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:
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
|
|||||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
38
src/lib.rs
38
src/lib.rs
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(':').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.
|
/// 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.
|
||||||
@@ -769,7 +831,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());
|
||||||
}
|
}
|
||||||
@@ -816,7 +878,7 @@ fn trust_ca() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user