Merge pull request #8 from razvandimescu/feat/windows-support

Add Windows support (Phase 1)
This commit was merged in pull request #8.
This commit is contained in:
Razvan Dimescu
2026-03-22 08:38:10 +02:00
committed by GitHub
5 changed files with 140 additions and 21 deletions

View File

@@ -26,3 +26,14 @@ jobs:
run: cargo test run: cargo test
- name: audit - name: audit
run: cargo install cargo-audit && cargo 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

View File

@@ -25,6 +25,9 @@ jobs:
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
name: numa-linux-aarch64 name: numa-linux-aarch64
- target: x86_64-pc-windows-msvc
os: windows-latest
name: numa-windows-x86_64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
@@ -51,13 +54,21 @@ jobs:
if: matrix.target == 'aarch64-unknown-linux-musl' if: matrix.target == 'aarch64-unknown-linux-musl'
run: cross build --release --target ${{ matrix.target }} run: cross build --release --target ${{ matrix.target }}
- name: Package - name: Package (Unix)
if: runner.os != 'Windows'
run: | run: |
cd target/${{ matrix.target }}/release cd target/${{ matrix.target }}/release
tar czf ../../../${{ matrix.name }}.tar.gz numa tar czf ../../../${{ matrix.name }}.tar.gz numa
cd ../../.. cd ../../..
sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 || shasum -a 256 ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -65,6 +76,8 @@ jobs:
path: | path: |
${{ matrix.name }}.tar.gz ${{ matrix.name }}.tar.gz
${{ matrix.name }}.tar.gz.sha256 ${{ matrix.name }}.tar.gz.sha256
${{ matrix.name }}.zip
${{ matrix.name }}.zip.sha256
release: release:
needs: build needs: build
@@ -80,4 +93,5 @@ jobs:
generate_release_notes: true generate_release_notes: true
files: | files: |
*.tar.gz *.tar.gz
*.zip
*.sha256 *.sha256

View File

@@ -21,9 +21,25 @@ pub mod tls;
pub type Error = Box<dyn std::error::Error + Send + Sync>; pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
/// Shared config directory: ~/.config/numa/ /// Shared config directory for persistent data (services.json, etc).
/// Handles sudo (uses SUDO_USER) and launchd (falls back to /usr/local/var/numa/). /// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
/// Windows: %APPDATA%\numa
pub fn config_dir() -> std::path::PathBuf { 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 // When run via sudo, SUDO_USER has the real user
if let Ok(user) = std::env::var("SUDO_USER") { if let Ok(user) = std::env::var("SUDO_USER") {
let home = if cfg!(target_os = "macos") { let home = if cfg!(target_os = "macos") {
@@ -37,7 +53,6 @@ pub fn config_dir() -> std::path::PathBuf {
// Normal user (not root) // Normal user (not root)
if let Ok(home) = std::env::var("HOME") { if let Ok(home) = std::env::var("HOME") {
let path = std::path::PathBuf::from(&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") { if !home.starts_with("/var/root") && !home.starts_with("/root") {
return path.join(".config").join("numa"); 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 // Running as root daemon (launchd/systemd) — use system-wide path
std::path::PathBuf::from("/usr/local/var/numa") 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")
}
}

View File

@@ -24,13 +24,25 @@ pub fn discover_system_dns() -> SystemDnsInfo {
{ {
discover_macos() discover_macos()
} }
#[cfg(not(target_os = "macos"))] #[cfg(target_os = "linux")]
{ {
SystemDnsInfo { SystemDnsInfo {
default_upstream: detect_upstream_linux_or_backup(), default_upstream: detect_upstream_linux_or_backup(),
forwarding_rules: Vec::new(), 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")] #[cfg(target_os = "macos")]
@@ -156,7 +168,7 @@ fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
/// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf /// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf
/// only has loopback (meaning numa install already ran). /// only has loopback (meaning numa install already ran).
#[cfg(not(target_os = "macos"))] #[cfg(target_os = "linux")]
fn detect_upstream_linux_or_backup() -> Option<String> { fn detect_upstream_linux_or_backup() -> Option<String> {
// Try /etc/resolv.conf first // Try /etc/resolv.conf first
if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") { if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") {
@@ -177,7 +189,7 @@ fn detect_upstream_linux_or_backup() -> Option<String> {
None None
} }
#[cfg(not(target_os = "macos"))] #[cfg(target_os = "linux")]
fn read_upstream_from_file(path: &str) -> Option<String> { fn read_upstream_from_file(path: &str) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?; let text = std::fs::read_to_string(path).ok()?;
for line in text.lines() { for line in text.lines() {
@@ -193,6 +205,56 @@ fn read_upstream_from_file(path: &str) -> Option<String> {
None 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. /// Find the upstream for a domain by checking forwarding rules.
/// Returns None if no rule matches (use default upstream). /// Returns None if no rule matches (use default upstream).
/// Zero-allocation on the hot path — dot_suffix is pre-computed. /// 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). /// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
pub fn restart_service() -> Result<(), String> { pub fn restart_service() -> Result<(), String> {
// Show version of the binary that will be running after restart #[cfg(any(target_os = "macos", target_os = "linux"))]
let version = match std::process::Command::new("/usr/local/bin/numa") let version = {
.arg("--version") match std::process::Command::new("/usr/local/bin/numa")
.output() .arg("--version")
{ .output()
Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), {
Err(_) => "unknown".to_string(), Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
Err(_) => "unknown".to_string(),
}
}; };
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -769,7 +833,7 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
// --- CA trust management --- // --- CA trust management ---
fn trust_ca() -> Result<(), String> { 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() { if !ca_path.exists() {
return Err("CA not generated yet — start numa first to create certificates".into()); 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")))] #[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(()) Ok(())
} }
fn untrust_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")] #[cfg(target_os = "macos")]
{ {

View File

@@ -10,14 +10,11 @@ use time::{Duration, OffsetDateTime};
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year 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. /// Build a TLS config with a cert covering all provided service names.
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
/// so we list each service explicitly as a SAN. /// so we list each service explicitly as a SAN.
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> { pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
let dir = std::path::PathBuf::from(TLS_DIR); let dir = crate::data_dir();
let (ca_cert, ca_key) = ensure_ca(&dir)?; let (ca_cert, ca_key) = ensure_ca(&dir)?;
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;