Add Windows support (Phase 1) #8
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(':').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")]
|
||||
{
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user