add Windows support (Phase 1)

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) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-22 08:13:53 +02:00
parent 02e83ccd72
commit 5495107c9e
5 changed files with 129 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -21,9 +21,25 @@ pub mod tls;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
/// 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")
}
}

View File

@@ -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<ForwardingRule> {
/// 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<String> {
// 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<String> {
None
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
fn read_upstream_from_file(path: &str) -> Option<String> {
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<String> {
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")]
{

View File

@@ -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<Arc<ServerConfig>> {
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)?;