Compare commits

3 Commits

Author SHA1 Message Date
DaZuo0122
5a23175a83 Update: Build instructions for just 2026-02-02 13:00:19 +08:00
DaZuo0122
57492ab654 Add: Justfile to replace cmake and make 2026-02-02 12:32:56 +08:00
DaZuo0122
7054ff77a7 Fix: http/3 alpn bugs 2026-01-18 23:05:41 +08:00
9 changed files with 289 additions and 112 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
/data
/dist

View File

@@ -1,41 +0,0 @@
cmake_minimum_required(VERSION 3.20)
project(wtfnet LANGUAGES NONE)
set(CARGO_CMD cargo)
set(CARGO_TARGET_DIR "${CMAKE_BINARY_DIR}/cargo-target")
set(BIN_NAME "wtfn${CMAKE_EXECUTABLE_SUFFIX}")
set(BIN_PATH "${CARGO_TARGET_DIR}/release/${BIN_NAME}")
file(READ "${CMAKE_SOURCE_DIR}/crates/wtfnet-cli/Cargo.toml" CLI_TOML)
string(REGEX MATCH "version = \"([0-9]+\\.[0-9]+\\.[0-9]+)\"" CLI_VERSION_MATCH "${CLI_TOML}")
if(CMAKE_MATCH_1)
set(PACKAGE_VERSION "${CMAKE_MATCH_1}")
else()
set(PACKAGE_VERSION "0.1.0")
endif()
add_custom_command(
OUTPUT "${BIN_PATH}"
COMMAND "${CMAKE_COMMAND}" -E env CARGO_TARGET_DIR="${CARGO_TARGET_DIR}"
"${CARGO_CMD}" build --release --workspace --bin wtfn
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Building wtfn with cargo"
VERBATIM
)
add_custom_target(wtfnet_build ALL DEPENDS "${BIN_PATH}")
install(PROGRAMS "${BIN_PATH}" DESTINATION bin)
install(DIRECTORY "${CMAKE_SOURCE_DIR}/data" DESTINATION share/wtfnet)
add_dependencies(install wtfnet_build)
set(CPACK_PACKAGE_NAME "wtfnet")
set(CPACK_PACKAGE_VERSION "${PACKAGE_VERSION}")
set(CPACK_PACKAGE_FILE_NAME "wtfnet-${PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
if(WIN32)
set(CPACK_GENERATOR "ZIP")
else()
set(CPACK_GENERATOR "TGZ")
endif()
include(CPack)

2
Cargo.lock generated
View File

@@ -3235,7 +3235,7 @@ dependencies = [
[[package]]
name = "wtfnet-cli"
version = "0.1.0"
version = "0.4.0"
dependencies = [
"clap",
"serde",

View File

@@ -1,18 +0,0 @@
BUILD_DIR ?= build
.PHONY: build configure package install clean
configure:
cmake -S . -B $(BUILD_DIR)
build: configure
cmake --build $(BUILD_DIR)
package: build
cmake --build $(BUILD_DIR) --target package
install: build
cmake --build $(BUILD_DIR) --target install
clean:
cmake -E rm -rf $(BUILD_DIR)

View File

@@ -85,20 +85,33 @@ Lookup order:
2) `data/` next to the CLI binary
3) `data/` in the current working directory
## Build and package
## Build
### Only build binary
```bash
cmake -S . -B build
cmake --build build
cmake --build build --target package
cargo build --release
```
Install:
### Build and package
1. Prepare GeoLite2 databases (required `GeoLite2-ASN.mmdb` and `GeoLite2-Country.mmdb` ):
```bash
cmake --build build --target install
# Place your mmdb files under data/
mkdir data
```
> **Note**: This step requires `python3` and `just`.
2. Use `just` to run build and package command (Note: you don't need bash environment on windows):
```bash
# You will find package under dist/, zip file on windows, tar.gz file on linux
just release
```
## HTTP/3 (experimental)
HTTP/3 support is feature-gated and incomplete. Do not enable it in production builds yet.
HTTP/3 support is feature-gated and best-effort. Enable it only when you want to test QUIC
connectivity.
To enable locally for testing:
```bash

View File

@@ -21,6 +21,8 @@ use quinn::ClientConfig as QuinnClientConfig;
#[cfg(feature = "http3")]
use quinn::Endpoint;
#[cfg(feature = "http3")]
use quinn::crypto::rustls::QuicClientConfig;
#[cfg(feature = "http3")]
use webpki_roots::TLS_SERVER_ROOTS;
#[derive(Debug, Error)]
@@ -468,26 +470,54 @@ async fn http3_request(
let port = parsed
.port_or_known_default()
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
let ip = resolved_ips
.first()
.and_then(|value| value.parse::<IpAddr>().ok())
.ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?;
let quinn_config = build_quinn_config()?;
let candidates = resolved_ips
.iter()
.filter_map(|value| value.parse::<IpAddr>().ok())
.collect::<Vec<_>>();
if candidates.is_empty() {
return Err(HttpError::Request("no resolved IPs for http3".to_string()));
}
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())
.map_err(|err| HttpError::Request(err.to_string()))?;
endpoint.set_default_client_config(quinn_config);
let mut endpoint_guard = None;
let mut connection = None;
let mut connect_ms = None;
for ip in candidates {
let bind_addr = match ip {
IpAddr::V4(_) => "0.0.0.0:0",
IpAddr::V6(_) => "[::]:0",
};
let mut endpoint = Endpoint::client(bind_addr.parse().unwrap())
.map_err(|err| HttpError::Request(err.to_string()))?;
endpoint.set_default_client_config(quinn_config.clone());
let connect_start = Instant::now();
let connecting = match endpoint.connect(SocketAddr::new(ip, port), host) {
Ok(connecting) => connecting,
Err(err) => {
warnings.push(format!("http3 connect failed to {ip}: {err}"));
continue;
}
};
match timeout(Duration::from_millis(opts.timeout_ms), connecting).await {
Ok(Ok(conn)) => {
connect_ms = Some(connect_start.elapsed().as_millis());
connection = Some(conn);
endpoint_guard = Some(endpoint);
break;
}
Ok(Err(err)) => {
warnings.push(format!("http3 connect failed to {ip}: {err}"));
}
Err(_) => {
warnings.push(format!("http3 connect to {ip} timed out"));
}
}
}
let connect_start = Instant::now();
let connecting = endpoint
.connect(SocketAddr::new(ip, port), host)
.map_err(|err| HttpError::Request(err.to_string()))?;
let connection = timeout(Duration::from_millis(opts.timeout_ms), connecting)
.await
.map_err(|_| HttpError::Request("http3 connect timed out".to_string()))?
.map_err(|err| HttpError::Request(err.to_string()))?;
let connect_ms = connect_start.elapsed().as_millis();
let connection = connection.ok_or_else(|| {
HttpError::Request("http3 connect failed for all resolved IPs".to_string())
})?;
let connect_ms = connect_ms.unwrap_or_default();
let conn = h3_quinn::Connection::new(connection);
let (mut driver, mut send_request) = h3::client::new(conn)
@@ -563,30 +593,30 @@ async fn http3_request(
warnings.push("http3 timing for tls/connect is best-effort".to_string());
Ok((
HttpReport {
url: url.to_string(),
final_url: Some(final_url),
method: match opts.method {
HttpMethod::Head => "HEAD".to_string(),
HttpMethod::Get => "GET".to_string(),
},
status: Some(status.as_u16()),
http_version: Some("HTTP/3".to_string()),
resolved_ips: resolved_ips.to_vec(),
headers,
body,
warnings: Vec::new(),
timing: HttpTiming {
total_ms,
dns_ms: Some(dns_ms),
connect_ms: Some(connect_ms),
tls_ms: None,
ttfb_ms: Some(ttfb_ms),
},
let _endpoint_guard = endpoint_guard;
let report = HttpReport {
url: url.to_string(),
final_url: Some(final_url),
method: match opts.method {
HttpMethod::Head => "HEAD".to_string(),
HttpMethod::Get => "GET".to_string(),
},
warnings,
))
status: Some(status.as_u16()),
http_version: Some("HTTP/3".to_string()),
resolved_ips: resolved_ips.to_vec(),
headers,
body,
warnings: Vec::new(),
timing: HttpTiming {
total_ms,
dns_ms: Some(dns_ms),
connect_ms: Some(connect_ms),
tls_ms: None,
ttfb_ms: Some(ttfb_ms),
},
};
Ok((report, warnings))
}
#[cfg(feature = "http3")]
@@ -594,10 +624,14 @@ fn build_quinn_config() -> Result<QuinnClientConfig, HttpError> {
let mut roots = quinn::rustls::RootCertStore::empty();
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
let mut client_config =
QuinnClientConfig::with_root_certificates(Arc::new(roots)).map_err(|err| {
HttpError::Request(format!("quinn config error: {err}"))
})?;
let mut crypto = quinn::rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
crypto.alpn_protocols = vec![b"h3".to_vec()];
let mut client_config = QuinnClientConfig::new(Arc::new(
QuicClientConfig::try_from(crypto)
.map_err(|err| HttpError::Request(format!("quinn config error: {err}")))?,
));
let mut transport = quinn::TransportConfig::default();
transport.keep_alive_interval(Some(Duration::from_secs(5)));
client_config.transport_config(Arc::new(transport));

View File

@@ -22,7 +22,7 @@ This document tracks current implementation status against the original design i
- DNS watch uses `pnet` and is feature-gated as best-effort.
## Gaps vs design (as of now)
- HTTP/3 is feature-gated and incomplete; not enabled in default builds.
- HTTP/3 is feature-gated and best-effort; not enabled in default builds.
- TLS verification is rustls-based (no OS-native verifier).
- DNS leak DoH detection is heuristic and currently optional.

13
justfile Normal file
View File

@@ -0,0 +1,13 @@
# justfile (cross-platform, no bash)
python := env_var_or_default("PYTHON", if os() == "windows" { "python" } else { "python3" })
dist_dir := "dist"
stage_root := "target/release-package"
default:
@just --list
release bin='' target='':
{{python}} scripts/release_meta.py --bin "{{bin}}" --target "{{target}}" --dist-dir "{{dist_dir}}" --stage-root "{{stage_root}}"
clean-dist:
{{python}} -c "import shutil; shutil.rmtree('dist', ignore_errors=True); shutil.rmtree('target/release-package', ignore_errors=True)"

175
scripts/release_meta.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from typing import Any
def run(cmd: list[str], *, capture: bool = False) -> str:
if capture:
return subprocess.check_output(cmd, text=True).strip()
subprocess.check_call(cmd)
return ""
def cargo_metadata() -> dict[str, Any]:
out = run(["cargo", "metadata", "--no-deps", "--format-version", "1"], capture=True)
return json.loads(out)
def rustc_host_triple() -> str:
v = run(["rustc", "-vV"], capture=True)
for line in v.splitlines():
if line.startswith("host: "):
return line.split("host: ", 1)[1].strip()
raise RuntimeError("Could not determine host target triple from `rustc -vV`")
def is_windows_host() -> bool:
# Works for normal Windows Python and most MSYS/Cygwin Pythons too.
sp = sys.platform.lower()
ps = platform.system().lower()
return (
os.name == "nt"
or sp.startswith("win")
or sp.startswith("cygwin")
or sp.startswith("msys")
or "windows" in ps
or "cygwin" in ps
or "msys" in ps
)
def exe_suffix_for_target(target_triple: str) -> str:
return ".exe" if "windows" in target_triple else ""
def find_bin_targets(meta: dict[str, Any]) -> list[tuple[str, str, str]]:
bins: list[tuple[str, str, str]] = []
for p in meta.get("packages", []):
for t in p.get("targets", []):
if "bin" in t.get("kind", []):
bins.append((p["name"], p["version"], t["name"]))
bins.sort(key=lambda x: (x[0], x[2], x[1])) # stable deterministic choice
return bins
def find_owner_package_for_bin(meta: dict[str, Any], bin_name: str) -> tuple[str, str]:
for p in meta.get("packages", []):
for t in p.get("targets", []):
if t.get("name") == bin_name and "bin" in t.get("kind", []):
return p["name"], p["version"]
raise RuntimeError(f"Could not find a package providing bin '{bin_name}'")
def stage_and_archive(
*,
pkg_name: str,
pkg_version: str,
bin_path: Path,
data_dir: Path,
dist_dir: Path,
stage_root: Path,
target_triple_for_name: str,
) -> Path:
pkg_base = f"{pkg_name}-v{pkg_version}-{target_triple_for_name}"
stage_dir = stage_root / pkg_base
stage_data_dir = stage_dir / "data"
if stage_root.exists():
shutil.rmtree(stage_root)
stage_data_dir.mkdir(parents=True, exist_ok=True)
dist_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(bin_path, stage_dir / bin_path.name)
mmdbs = sorted(data_dir.glob("*.mmdb")) if data_dir.exists() else []
if mmdbs:
for f in mmdbs:
shutil.copy2(f, stage_data_dir / f.name)
else:
print("WARN: no ./data/*.mmdb found; packaging binary only.", file=sys.stderr)
if is_windows_host():
out = dist_dir / f"{pkg_base}.zip"
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in stage_dir.rglob("*"):
if p.is_file():
z.write(p, arcname=str(Path(pkg_base) / p.relative_to(stage_dir)))
return out
else:
out = dist_dir / f"{pkg_base}.tar.gz"
with tarfile.open(out, "w:gz") as tf:
tf.add(stage_dir, arcname=pkg_base)
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Build and package Rust binary + data/*.mmdb")
ap.add_argument("--bin", default="", help="Binary target name (optional)")
ap.add_argument("--target", default="", help="Cargo target triple (optional)")
ap.add_argument("--dist-dir", default="dist", help="Output directory for archives")
ap.add_argument("--stage-root", default="target/release-package", help="Staging directory root")
ap.add_argument("--data-dir", default="data", help="Directory containing .mmdb files")
args = ap.parse_args()
meta = cargo_metadata()
bins = find_bin_targets(meta)
if not bins:
print("ERROR: no binary targets found in workspace.", file=sys.stderr)
return 2
bin_name = args.bin.strip()
if not bin_name:
_, _, bin_name = bins[0]
print(f"INFO: --bin not provided; defaulting to '{bin_name}'", file=sys.stderr)
pkg_name, pkg_version = find_owner_package_for_bin(meta, bin_name)
host_triple = rustc_host_triple()
target_triple_for_name = args.target.strip() or host_triple
# Build only the owning package
build_cmd = ["cargo", "build", "-p", pkg_name, "--release"]
if args.target.strip():
build_cmd += ["--target", args.target.strip()]
run(build_cmd)
# Locate binary
exe_suffix = exe_suffix_for_target(target_triple_for_name)
bin_dir = Path("target") / (args.target.strip() if args.target.strip() else "release") / "release" \
if args.target.strip() else Path("target") / "release"
if args.target.strip():
bin_dir = Path("target") / args.target.strip() / "release"
bin_path = bin_dir / f"{bin_name}{exe_suffix}"
if not bin_path.exists():
print(f"ERROR: built binary not found: {bin_path}", file=sys.stderr)
print("Hint: pass the correct bin target name: just release bin=<name>", file=sys.stderr)
return 3
out = stage_and_archive(
pkg_name=pkg_name,
pkg_version=pkg_version,
bin_path=bin_path,
data_dir=Path(args.data_dir),
dist_dir=Path(args.dist_dir),
stage_root=Path(args.stage_root),
target_triple_for_name=target_triple_for_name,
)
print(f"Created: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())