From 5495107c9ec8ec8f6defef64d8325d14447bb016 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 08:13:53 +0200 Subject: [PATCH] add Windows support (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-platform paths: config_dir() uses %APPDATA%, data_dir() uses %PROGRAMDATA% on Windows. TLS cert directory uses data_dir() instead of hardcoded /usr/local/var/numa. Windows DNS discovery via ipconfig. Fixed cfg gates from not(macos) to explicit linux to prevent Linux code compiling on Windows. Added Windows target to CI and release workflows with zip packaging. System integration (numa install/service) not yet supported on Windows — users run numa.exe manually. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 11 ++++++ .github/workflows/release.yml | 16 +++++++- src/lib.rs | 38 ++++++++++++++++-- src/system_dns.rs | 72 ++++++++++++++++++++++++++++++++--- src/tls.rs | 5 +-- 5 files changed, 129 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d792c84..f306f15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,14 @@ jobs: run: cargo test - name: audit run: cargo install cargo-audit && cargo audit + + check-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build + - name: clippy + run: cargo clippy -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bae6950..c694bc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: - target: aarch64-unknown-linux-musl os: ubuntu-latest name: numa-linux-aarch64 + - target: x86_64-pc-windows-msvc + os: windows-latest + name: numa-windows-x86_64 runs-on: ${{ matrix.os }} steps: @@ -51,13 +54,21 @@ jobs: if: matrix.target == 'aarch64-unknown-linux-musl' run: cross build --release --target ${{ matrix.target }} - - name: Package + - name: Package (Unix) + if: runner.os != 'Windows' run: | cd target/${{ matrix.target }}/release tar czf ../../../${{ matrix.name }}.tar.gz numa cd ../../.. sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 || shasum -a 256 ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path "target/${{ matrix.target }}/release/numa.exe" -DestinationPath "${{ matrix.name }}.zip" + (Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -65,6 +76,8 @@ jobs: path: | ${{ matrix.name }}.tar.gz ${{ matrix.name }}.tar.gz.sha256 + ${{ matrix.name }}.zip + ${{ matrix.name }}.zip.sha256 release: needs: build @@ -80,4 +93,5 @@ jobs: generate_release_notes: true files: | *.tar.gz + *.zip *.sha256 diff --git a/src/lib.rs b/src/lib.rs index 1d41c04..dc4ce2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,25 @@ pub mod tls; pub type Error = Box; pub type Result = std::result::Result; -/// Shared config directory: ~/.config/numa/ -/// Handles sudo (uses SUDO_USER) and launchd (falls back to /usr/local/var/numa/). +/// Shared config directory for persistent data (services.json, etc). +/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon) +/// Windows: %APPDATA%\numa pub fn config_dir() -> std::path::PathBuf { + #[cfg(windows)] + { + std::path::PathBuf::from( + std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), + ) + .join("numa") + } + #[cfg(not(windows))] + { + config_dir_unix() + } +} + +#[cfg(not(windows))] +fn config_dir_unix() -> std::path::PathBuf { // When run via sudo, SUDO_USER has the real user if let Ok(user) = std::env::var("SUDO_USER") { let home = if cfg!(target_os = "macos") { @@ -37,7 +53,6 @@ pub fn config_dir() -> std::path::PathBuf { // Normal user (not root) if let Ok(home) = std::env::var("HOME") { let path = std::path::PathBuf::from(&home); - // /var/root on macOS is read-only (SIP), use /usr/local/var/numa instead if !home.starts_with("/var/root") && !home.starts_with("/root") { return path.join(".config").join("numa"); } @@ -46,3 +61,20 @@ pub fn config_dir() -> std::path::PathBuf { // Running as root daemon (launchd/systemd) — use system-wide path std::path::PathBuf::from("/usr/local/var/numa") } + +/// System-wide data directory for TLS certs. +/// Unix: /usr/local/var/numa +/// Windows: %PROGRAMDATA%\numa +pub fn data_dir() -> std::path::PathBuf { + #[cfg(windows)] + { + std::path::PathBuf::from( + std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), + ) + .join("numa") + } + #[cfg(not(windows))] + { + std::path::PathBuf::from("/usr/local/var/numa") + } +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 6b63c48..3b7328c 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -24,13 +24,25 @@ pub fn discover_system_dns() -> SystemDnsInfo { { discover_macos() } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] { SystemDnsInfo { default_upstream: detect_upstream_linux_or_backup(), forwarding_rules: Vec::new(), } } + #[cfg(windows)] + { + discover_windows() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + log::debug!("no conditional forwarding rules discovered"); + SystemDnsInfo { + default_upstream: None, + forwarding_rules: Vec::new(), + } + } } #[cfg(target_os = "macos")] @@ -156,7 +168,7 @@ fn make_rule(domain: &str, nameserver: &str) -> Option { /// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf /// only has loopback (meaning numa install already ran). -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn detect_upstream_linux_or_backup() -> Option { // Try /etc/resolv.conf first if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") { @@ -177,7 +189,7 @@ fn detect_upstream_linux_or_backup() -> Option { None } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn read_upstream_from_file(path: &str) -> Option { let text = std::fs::read_to_string(path).ok()?; for line in text.lines() { @@ -193,6 +205,56 @@ fn read_upstream_from_file(path: &str) -> Option { None } +// --- Windows implementation --- + +#[cfg(windows)] +fn discover_windows() -> SystemDnsInfo { + use log::{debug, warn}; + + let output = match std::process::Command::new("ipconfig").arg("/all").output() { + Ok(o) => o, + Err(e) => { + warn!("failed to run ipconfig /all: {}", e); + return SystemDnsInfo { + default_upstream: None, + forwarding_rules: Vec::new(), + }; + } + }; + + let text = String::from_utf8_lossy(&output.stdout); + let mut upstream = None; + + for line in text.lines() { + let trimmed = line.trim(); + // Match "DNS Servers" line (English) or similar localized variants + if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { + if let Some(ip) = trimmed.split(':').last() { + let ip = ip.trim(); + if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" { + upstream = Some(ip.to_string()); + break; + } + } + } + // Continuation lines (indented IPs after DNS Servers line) + if upstream.is_none() && trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()) { + // Skip continuation lines — we only need the first DNS server + } + } + + if let Some(ref ns) = upstream { + info!("detected Windows upstream: {}", ns); + } else { + debug!("no DNS servers found in ipconfig output"); + } + + SystemDnsInfo { + default_upstream: upstream, + forwarding_rules: Vec::new(), + } +} + /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. @@ -769,7 +831,7 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> { // --- CA trust management --- fn trust_ca() -> Result<(), String> { - let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + let ca_path = crate::data_dir().join("ca.pem"); if !ca_path.exists() { return Err("CA not generated yet — start numa first to create certificates".into()); } @@ -816,7 +878,7 @@ fn trust_ca() -> Result<(), String> { } fn untrust_ca() -> Result<(), String> { - let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + let ca_path = crate::data_dir().join("ca.pem"); #[cfg(target_os = "macos")] { diff --git a/src/tls.rs b/src/tls.rs index 5fdada5..5118390 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -10,14 +10,11 @@ use time::{Duration, OffsetDateTime}; const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CERT_VALIDITY_DAYS: i64 = 365; // 1 year -/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree. -pub const TLS_DIR: &str = "/usr/local/var/numa"; - /// Build a TLS config with a cert covering all provided service names. /// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// so we list each service explicitly as a SAN. pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result> { - let dir = std::path::PathBuf::from(TLS_DIR); + let dir = crate::data_dir(); let (ca_cert, ca_key) = ensure_ca(&dir)?; let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;