Merge pull request #8 from razvandimescu/feat/windows-support
Add Windows support (Phase 1)
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(':').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 = {
|
||||||
|
match std::process::Command::new("/usr/local/bin/numa")
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
||||||
Err(_) => "unknown".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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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