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..9d46ea3 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(':').next_back() { + 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().is_some_and(|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. @@ -422,13 +484,15 @@ pub fn uninstall_service() -> Result<(), String> { /// Restart the service (kill process, launchd/systemd auto-restarts with new binary). pub fn restart_service() -> Result<(), String> { - // Show version of the binary that will be running after restart - let version = match std::process::Command::new("/usr/local/bin/numa") - .arg("--version") - .output() - { - Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), - Err(_) => "unknown".to_string(), + #[cfg(any(target_os = "macos", target_os = "linux"))] + let version = { + match std::process::Command::new("/usr/local/bin/numa") + .arg("--version") + .output() + { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(_) => "unknown".to_string(), + } }; #[cfg(target_os = "macos")] @@ -769,7 +833,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()); } @@ -809,14 +873,15 @@ fn trust_ca() -> Result<(), String> { #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - return Err("CA trust not supported on this OS".into()); + Err("CA trust not supported on this OS".into()) } + #[cfg(any(target_os = "macos", target_os = "linux"))] Ok(()) } 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)?;