diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 681b93c..f306f15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,16 @@ jobs: run: cargo clippy -- -D warnings - name: test 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1971c6b..057a8d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,12 +19,15 @@ jobs: - target: aarch64-apple-darwin os: macos-latest name: numa-macos-aarch64 - - target: x86_64-unknown-linux-gnu + - target: x86_64-unknown-linux-musl os: ubuntu-latest name: numa-linux-x86_64 - - target: aarch64-unknown-linux-gnu + - 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: @@ -35,23 +38,36 @@ jobs: with: targets: ${{ matrix.target }} - - name: Install cross-compilation tools - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Install musl tools (x86_64) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools - - name: Build + - name: Install cross (aarch64) + if: matrix.target == 'aarch64-unknown-linux-musl' + run: cargo install cross + + - name: Build (native) + if: matrix.target != 'aarch64-unknown-linux-musl' run: cargo build --release --target ${{ matrix.target }} - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - name: Package + - name: Build (cross) + if: matrix.target == 'aarch64-unknown-linux-musl' + run: cross build --release --target ${{ matrix.target }} + + - 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 + 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 @@ -60,9 +76,24 @@ jobs: path: | ${{ matrix.name }}.tar.gz ${{ matrix.name }}.tar.gz.sha256 + ${{ matrix.name }}.zip + ${{ matrix.name }}.zip.sha256 + + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} release: - needs: build + needs: [build, publish] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -75,4 +106,5 @@ jobs: generate_release_notes: true files: | *.tar.gz + *.zip *.sha256 diff --git a/Cargo.lock b/Cargo.lock index da61725..3d45147 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -384,6 +393,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -514,6 +529,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -575,6 +609,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -621,7 +656,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -932,8 +967,9 @@ dependencies = [ [[package]] name = "numa" -version = "0.1.0" +version = "0.4.0" dependencies = [ + "arc-swap", "axum", "env_logger", "futures", @@ -944,9 +980,9 @@ dependencies = [ "rcgen", "reqwest", "rustls", - "rustls-pemfile", "serde", "serde_json", + "socket2 0.5.10", "time", "tokio", "tokio-rustls", @@ -1063,7 +1099,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -1100,7 +1136,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -1201,6 +1237,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "h2", "http", "http-body", "http-body-util", @@ -1275,15 +1312,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1296,9 +1324,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -1417,6 +1445,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1576,7 +1614,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index e88dfdb..303d6ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.1.0" +version = "0.4.0" authors = ["razvandimescu "] edition = "2021" description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done." @@ -17,13 +17,14 @@ serde_json = "1" toml = "0.8" log = "0.4" env_logger = "0.11" -reqwest = { version = "0.12", features = ["rustls-tls", "gzip"], default-features = false } +reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false } hyper = { version = "1", features = ["client", "http1", "server"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" +socket2 = { version = "0.5", features = ["all"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" tokio-rustls = "0.26" -rustls-pemfile = "2" +arc-swap = "1" diff --git a/Makefile b/Makefile index 5b0165c..d25d697 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: all build lint fmt check test clean deploy +.PHONY: all build lint fmt check audit test clean deploy all: lint build build: cargo build -lint: fmt check +lint: fmt check audit fmt: cargo fmt --check @@ -13,6 +13,9 @@ fmt: check: cargo clippy -- -D warnings +audit: + cargo audit + test: cargo test diff --git a/README.md b/README.md index 600ee17..9da613c 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,23 @@ # Numa +[![CI](https://github.com/razvandimescu/numa/actions/workflows/ci.yml/badge.svg)](https://github.com/razvandimescu/numa/actions) +[![crates.io](https://img.shields.io/crates/v/numa.svg)](https://crates.io/crates/numa) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + **DNS you own. Everywhere you go.** A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. -Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. +Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. One ~8MB binary, no PHP, no web server, no database — everything is embedded. ![Numa dashboard](assets/hero-demo.gif) ## Quick Start ```bash -# Install +# Install (pick one) +brew install razvandimescu/tap/numa +cargo install numa curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh # Run (port 53 requires root) @@ -33,11 +39,14 @@ sudo ./target/release/numa ## Why Numa +- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with auto TLS, a REST API, LAN discovery, and auto-revert. +- **Path-based routing** — `app.numa/api → :5001`, `app.numa/auth → :5002`. Route URL paths to different backends with optional prefix stripping. Like nginx location blocks, zero config files. +- **LAN service discovery** — Numa instances on the same network find each other automatically via mDNS. Access a teammate's `api.numa` from your machine. Opt-in via `[lan] enabled = true`. +- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 25+ endpoints. Built-in diagnostics: `curl localhost:5380/diagnose/example.com` tells you exactly how any domain resolves. +- **DNS-over-HTTPS** — upstream queries encrypted via DoH. Your ISP sees HTTPS traffic, not DNS queries. Set `address = "https://9.9.9.9/dns-query"` in `[upstream]` or any DoH provider. - **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports. -- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with a dashboard and auto-revert. -- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 22 endpoints. - **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. -- **Live dashboard** — real-time stats, query log, blocking controls, service management. +- **Live dashboard** — real-time stats, query log, blocking controls, service management. LAN accessibility badges show which services are reachable from other devices. - **macOS + Linux** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service. ## Local Service Proxy @@ -55,6 +64,18 @@ open http://frontend.numa # → proxied to localhost:5173 - **HTTPS with green lock** — auto-generated local CA + per-service TLS certs - **WebSocket** — Vite/webpack HMR works through the proxy - **Health checks** — dashboard shows green/red status per service +- **LAN sharing** — services bound to `0.0.0.0` are automatically discoverable by other Numa instances on the network. Dashboard shows "LAN" or "local only" per service. +- **Path-based routing** — route URL paths to different backends: + ```toml + [[services]] + name = "app" + target_port = 3000 + routes = [ + { path = "/api", port = 5001 }, + { path = "/auth", port = 5002, strip = true }, + ] + ``` + `app.numa/api/users → :5001/api/users`, `app.numa/auth/login → :5002/login` (stripped) - **Persistent** — services survive restarts - Or configure in `numa.toml`: @@ -64,17 +85,61 @@ name = "frontend" target_port = 5173 ``` +## LAN Service Discovery + +Run Numa on multiple machines. They find each other automatically: + +``` +Machine A (192.168.1.5) Machine B (192.168.1.20) +┌──────────────────────┐ ┌──────────────────────┐ +│ Numa │ mDNS │ Numa │ +│ services: │◄───────────►│ services: │ +│ - api (port 8000) │ discovery │ - grafana (3000) │ +│ - frontend (5173) │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +From Machine B: +```bash +dig @127.0.0.1 api.numa # → 192.168.1.5 +curl http://api.numa # → proxied to Machine A's port 8000 +``` + +Enable LAN discovery: +```bash +numa lan on +``` +Or in `numa.toml`: +```toml +[lan] +enabled = true +``` +Uses standard mDNS (`_numa._tcp.local` on port 5353) — compatible with Bonjour/Avahi, silently dropped by corporate firewalls instead of triggering IPS alerts. + +**Hub mode** — don't want to install Numa on every machine? Run one instance as a shared DNS server and point other devices to it: + +```bash +# On the hub machine, bind to LAN interface +[server] +bind_addr = "0.0.0.0:53" + +# On other devices, set DNS to the hub's IP +# They get .numa resolution, ad blocking, caching — zero install +``` + ## How It Compares | | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa | |---|---|---|---|---|---| -| Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains | -| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary | -| Developer overrides | No | No | No | No | REST API + auto-expiry | | Local service proxy | No | No | No | No | `.numa` + HTTPS + WS | -| Data stays local | Yes | Yes | Cloud | Cloud | 100% local | +| Path-based routing | No | No | No | No | Prefix match + strip | +| LAN service discovery | No | No | No | No | mDNS, opt-in | +| Developer overrides | No | No | No | No | REST API + auto-expiry | +| Encrypted upstream (DoH) | No (needs cloudflared) | Yes | Cloud only | Cloud only | Native, single binary | +| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary | | Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box | -| Self-sovereign DNS | No | No | No | No | pkarr/DHT roadmap | +| Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains | +| Data stays local | Yes | Yes | Cloud | Cloud | 100% local | ## How It Works @@ -82,7 +147,7 @@ target_port = 5173 Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream ``` -No DNS libraries. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning. +No DNS libraries — no `hickory-dns`, no `trust-dns`. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning. [Configuration reference](numa.toml) @@ -93,6 +158,9 @@ No DNS libraries. The wire protocol — headers, labels, compression pointers, r - [x] Ad blocking — 385K+ domains, live dashboard, allowlist - [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery - [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket +- [x] Path-based routing — URL prefix routing with optional strip, REST API +- [x] LAN service discovery — mDNS auto-discovery (opt-in), cross-machine DNS + proxy +- [x] DNS-over-HTTPS — encrypted upstream via DoH (Quad9, Cloudflare, any provider) - [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes) - [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served diff --git a/assets/hero-demo.gif b/assets/hero-demo.gif index 40326fa..e85f58a 100644 Binary files a/assets/hero-demo.gif and b/assets/hero-demo.gif differ diff --git a/numa.toml b/numa.toml index faa455d..09e8523 100644 --- a/numa.toml +++ b/numa.toml @@ -1,13 +1,22 @@ [server] bind_addr = "0.0.0.0:53" api_port = 5380 +# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access # [upstream] -# address = "" # auto-detect from system resolver (default) -# address = "9.9.9.9" # or set explicitly -# port = 53 +# address = "" # auto-detect from system resolver (default) +# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) +# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH +# address = "9.9.9.9" # plain UDP +# port = 53 # only used for plain UDP # timeout_ms = 3000 +# [blocking] +# enabled = true # set to false to disable ad blocking +# refresh_hours = 24 +# lists = ["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt"] +# allowlist = ["example.com"] # domains to never block + [cache] max_entries = 10000 min_ttl = 60 @@ -18,6 +27,7 @@ enabled = true port = 80 tls_port = 443 tld = "numa" +# bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled # Pre-configured services (numa.numa is always added automatically) # [[services]] @@ -40,3 +50,9 @@ tld = "numa" # record_type = "A" # value = "127.0.0.1" # ttl = 60 + +# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled) +# [lan] +# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) +# broadcast_interval_secs = 30 +# peer_timeout_secs = 90 diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh index 2e875db..bb84ead 100755 --- a/scripts/record-demo.sh +++ b/scripts/record-demo.sh @@ -8,8 +8,10 @@ # 1. Opens the dashboard in Chrome --app mode (clean, no address bar) # 2. Generates DNS traffic (forward, cache hit, blocked) # 3. Types "peekm" / "6419" into the Local Services form on camera -# 4. Opens peekm.numa to show the proxy working -# 5. Records via ffmpeg and converts to optimized GIF +# 4. Shows LAN accessibility badge ("local only" / "LAN") +# 5. Checks a blocked domain +# 6. Opens peekm.numa to show the proxy working +# 7. Records via ffmpeg and converts to optimized GIF set -euo pipefail @@ -228,18 +230,10 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1 dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 sleep 3 -# --------------- Scene 2: Check Domain blocker (3-6s) --------------- -log "Scene 2: Check Domain — blocked tracker..." -type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 -sleep 0.3 -# Click Check button -run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" -sleep 2 +# --------------- Scene 2: Add peekm service via UI (3-7s) --------------- +log "Scene 2: Adding peekm.numa service..." -# --------------- Scene 3: Add peekm service via UI (6-10s) --------------- -log "Scene 3: Adding peekm.numa service..." - -# Scroll to Local Services form +# Services panel is now first — scroll to it run_js " var svcPanel = document.getElementById('serviceForm'); if (svcPanel) svcPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); @@ -251,20 +245,34 @@ sleep 0.2 type_into "#svcPort" "6419" 0.1 sleep 0.3 -# Click "Add Service" +# Click "Add Service" — LAN badge ("local only" or "LAN") will appear run_js "document.querySelector('#serviceForm .btn-add').click();" -sleep 1.5 +sleep 2 -# --------------- Scene 4: Open peekm.numa (10-14s) --------------- -log "Scene 4: Opening peekm.numa in browser..." +# --------------- Scene 3: Open peekm.numa (7-11s) --------------- +log "Scene 3: Opening peekm.numa in browser..." open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true sleep 4 -# --------------- Scene 5: Back to dashboard (14-17s) --------------- -log "Scene 5: Back to dashboard — LOCAL queries visible..." +# --------------- Scene 4: Back to dashboard (11-14s) --------------- +log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..." osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true sleep 3 +# --------------- Scene 5: Check Domain blocker (14-17s) --------------- +log "Scene 5: Check Domain — blocked tracker..." +# Scroll down to blocking panel +run_js " + var blockPanel = document.getElementById('blockingPanel'); + if (blockPanel) blockPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); +" +sleep 0.5 +type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 +sleep 0.3 +# Click Check button +run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" +sleep 2 + # --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- log "Scene 6: dig proof overlay..." DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) diff --git a/site/dashboard.html b/site/dashboard.html index ccbb4b5..348960c 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -382,6 +382,15 @@ body { } .health-dot.up { background: var(--emerald); } .health-dot.down { background: var(--rose); } +.lan-badge { + font-family: var(--font-mono); + font-size: 0.58rem; + padding: 1px 5px; + border-radius: 3px; + margin-left: 0.3rem; +} +.lan-badge.shared { background: rgba(82, 122, 82, 0.12); color: var(--emerald); } +.lan-badge.local-only { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); } /* Override form */ .override-form { @@ -568,22 +577,27 @@ body { - -
+ +
-
- Local Services -
Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.
-
+ Blocking +
-
+
- - + +
- -
-
-
No services configured
-
+ +
+
@@ -651,6 +661,7 @@ body {
- Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f + Config: + · Data: + · Upstream: + · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f · GitHub
diff --git a/src/api.rs b/src/api.rs index 9c25377..2df9d73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -9,7 +9,7 @@ use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use crate::ctx::ServerCtx; -use crate::forward::forward_query; +use crate::forward::{forward_query, Upstream}; use crate::query_log::QueryLogFilter; use crate::question::QueryType; use crate::stats::QueryPath; @@ -46,6 +46,10 @@ pub fn router(ctx: Arc) -> Router { .route("/services", get(list_services)) .route("/services", post(create_service)) .route("/services/{name}", delete(remove_service)) + .route("/services/{name}/routes", get(list_routes)) + .route("/services/{name}/routes", post(add_route)) + .route("/services/{name}/routes", delete(remove_route)) + .route("/ca.pem", get(serve_ca)) .with_state(ctx) } @@ -126,10 +130,20 @@ struct QueryLogResponse { #[derive(Serialize)] struct StatsResponse { uptime_secs: u64, + upstream: String, + config_path: String, + data_dir: String, queries: QueriesStats, cache: CacheStats, overrides: OverrideStats, blocking: BlockingStatsResponse, + lan: LanStatsResponse, +} + +#[derive(Serialize)] +struct LanStatsResponse { + enabled: bool, + peers: usize, } #[derive(Serialize)] @@ -341,8 +355,9 @@ async fn diagnose( } // Check upstream (async, no locks held) + let upstream = ctx.upstream.lock().unwrap().clone(); let (upstream_matched, upstream_detail) = - forward_query_for_diagnose(&domain_lower, ctx.upstream, ctx.timeout).await; + forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await; steps.push(DiagnoseStep { source: "upstream".to_string(), matched: upstream_matched, @@ -358,7 +373,7 @@ async fn diagnose( async fn forward_query_for_diagnose( domain: &str, - upstream: std::net::SocketAddr, + upstream: &Upstream, timeout: std::time::Duration, ) -> (bool, String) { use crate::packet::DnsPacket; @@ -434,8 +449,13 @@ async fn stats(State(ctx): State>) -> Json { let override_count = ctx.overrides.lock().unwrap().active_count(); let bl_stats = ctx.blocklist.lock().unwrap().stats(); + let upstream = ctx.upstream.lock().unwrap().to_string(); + Json(StatsResponse { uptime_secs: snap.uptime_secs, + upstream, + config_path: ctx.config_path.clone(), + data_dir: ctx.data_dir.to_string_lossy().to_string(), queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, @@ -458,6 +478,10 @@ async fn stats(State(ctx): State>) -> Json { domains_loaded: bl_stats.domains_loaded, allowlist_size: bl_stats.allowlist_size, }, + lan: LanStatsResponse { + enabled: ctx.lan_enabled, + peers: ctx.lan_peers.lock().unwrap().list().len(), + }, }) } @@ -590,6 +614,10 @@ struct ServiceResponse { target_port: u16, url: String, healthy: bool, + lan_accessible: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + routes: Vec, + source: String, } #[derive(Deserialize)] @@ -604,27 +632,57 @@ async fn list_services(State(ctx): State>) -> Json = entries + let lan_ip = crate::lan::detect_lan_ip(); + + let check_futures: Vec<_> = entries .iter() - .map(|(_, port)| check_health(*port)) + .map(|(_, port, _, _)| { + let port = *port; + let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port)); + async move { + let healthy = check_tcp(localhost).await; + let lan_accessible = match lan_addr { + Some(addr) => check_tcp(addr).await, + None => false, + }; + (healthy, lan_accessible) + } + }) .collect(); - let health_results = futures::future::join_all(health_futures).await; + let check_results = futures::future::join_all(check_futures).await; let results: Vec<_> = entries .into_iter() - .zip(health_results) - .map(|((name, port), healthy)| ServiceResponse { - url: format!("http://{}.{}", name, tld), - name, - target_port: port, - healthy, - }) + .zip(check_results) + .map( + |((name, port, routes, source), (healthy, lan_accessible))| ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: port, + healthy, + lan_accessible, + routes, + source, + }, + ) .collect(); Json(results) } @@ -653,9 +711,21 @@ async fn create_service( } let tld = &ctx.proxy_tld; + let is_new = !ctx.services.lock().unwrap().has_name(&name); ctx.services.lock().unwrap().insert(&name, req.target_port); + if is_new { + crate::tls::regenerate_tls(&ctx); + } - let healthy = check_health(req.target_port).await; + let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port)); + let lan_addr = + crate::lan::detect_lan_ip().map(|ip| std::net::SocketAddr::new(ip.into(), req.target_port)); + let (healthy, lan_accessible) = tokio::join!(check_tcp(localhost), async { + match lan_addr { + Some(a) => check_tcp(a).await, + None => false, + } + }); Ok(( StatusCode::CREATED, Json(ServiceResponse { @@ -663,6 +733,9 @@ async fn create_service( name, target_port: req.target_port, healthy, + lan_accessible, + routes: Vec::new(), + source: "api".to_string(), }), )) } @@ -671,18 +744,105 @@ async fn remove_service(State(ctx): State>, Path(name): Path bool { +// --- Route handlers --- + +#[derive(Deserialize)] +struct AddRouteRequest { + path: String, + port: u16, + #[serde(default)] + strip: bool, +} + +#[derive(Deserialize)] +struct RemoveRouteRequest { + path: String, +} + +async fn list_routes( + State(ctx): State>, + Path(name): Path, +) -> Result>, StatusCode> { + let store = ctx.services.lock().unwrap(); + match store.lookup(&name) { + Some(entry) => Ok(Json(entry.routes.clone())), + None => Err(StatusCode::NOT_FOUND), + } +} + +async fn add_route( + State(ctx): State>, + Path(name): Path, + Json(req): Json, +) -> Result { + if req.path.is_empty() || !req.path.starts_with('/') { + return Err((StatusCode::BAD_REQUEST, "path must start with /".into())); + } + if req.path.contains("/../") || req.path.ends_with("/..") || req.path.contains("%") { + return Err(( + StatusCode::BAD_REQUEST, + "path must not contain '..' or percent-encoding".into(), + )); + } + if req.port == 0 { + return Err((StatusCode::BAD_REQUEST, "port must be > 0".into())); + } + let mut store = ctx.services.lock().unwrap(); + if store.add_route(&name, req.path, req.port, req.strip) { + Ok(StatusCode::CREATED) + } else { + Err(( + StatusCode::NOT_FOUND, + format!("service '{}' not found", name), + )) + } +} + +async fn remove_route( + State(ctx): State>, + Path(name): Path, + Json(req): Json, +) -> StatusCode { + let mut store = ctx.services.lock().unwrap(); + if store.remove_route(&name, &req.path) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +async fn serve_ca(State(ctx): State>) -> Result { + let ca_path = ctx.data_dir.join("ca.pem"); + let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::NOT_FOUND)?; + Ok(( + [ + (header::CONTENT_TYPE, "application/x-pem-file"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"numa-ca.pem\"", + ), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + bytes, + )) +} + +async fn check_tcp(addr: std::net::SocketAddr) -> bool { tokio::time::timeout( std::time::Duration::from_millis(100), - tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), + tokio::net::TcpStream::connect(addr), ) .await .map(|r| r.is_ok()) diff --git a/src/buffer.rs b/src/buffer.rs index 0c358e7..212bf92 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -21,6 +21,13 @@ impl BytePacketBuffer { } } + pub fn from_bytes(data: &[u8]) -> Self { + let mut buf = Self::new(); + let len = data.len().min(BUF_SIZE); + buf.buf[..len].copy_from_slice(&data[..len]); + buf + } + pub fn pos(&self) -> usize { self.pos } diff --git a/src/config.rs b/src/config.rs index 8359983..f0cd811 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,8 @@ pub struct Config { pub proxy: ProxyConfig, #[serde(default)] pub services: Vec, + #[serde(default)] + pub lan: LanConfig, } #[derive(Deserialize)] @@ -33,6 +35,8 @@ pub struct ServerConfig { pub bind_addr: String, #[serde(default = "default_api_port")] pub api_port: u16, + #[serde(default = "default_api_bind_addr")] + pub api_bind_addr: String, } impl Default for ServerConfig { @@ -40,10 +44,15 @@ impl Default for ServerConfig { ServerConfig { bind_addr: default_bind_addr(), api_port: default_api_port(), + api_bind_addr: default_api_bind_addr(), } } } +fn default_api_bind_addr() -> String { + "127.0.0.1".to_string() +} + fn default_bind_addr() -> String { "0.0.0.0:53".to_string() } @@ -170,6 +179,8 @@ pub struct ProxyConfig { pub tls_port: u16, #[serde(default = "default_proxy_tld")] pub tld: String, + #[serde(default = "default_proxy_bind_addr")] + pub bind_addr: String, } impl Default for ProxyConfig { @@ -179,10 +190,15 @@ impl Default for ProxyConfig { port: default_proxy_port(), tls_port: default_proxy_tls_port(), tld: default_proxy_tld(), + bind_addr: default_proxy_bind_addr(), } } } +fn default_proxy_bind_addr() -> String { + "127.0.0.1".to_string() +} + fn default_proxy_enabled() -> bool { true } @@ -200,15 +216,162 @@ fn default_proxy_tld() -> String { pub struct ServiceConfig { pub name: String, pub target_port: u16, + #[serde(default)] + pub routes: Vec, } -pub fn load_config(path: &str) -> Result { - if !Path::new(path).exists() { - return Ok(Config::default()); +#[derive(Deserialize, Clone)] +pub struct LanConfig { + #[serde(default = "default_lan_enabled")] + pub enabled: bool, + #[serde(default = "default_lan_broadcast_interval")] + pub broadcast_interval_secs: u64, + #[serde(default = "default_lan_peer_timeout")] + pub peer_timeout_secs: u64, +} + +impl Default for LanConfig { + fn default() -> Self { + LanConfig { + enabled: default_lan_enabled(), + broadcast_interval_secs: default_lan_broadcast_interval(), + peer_timeout_secs: default_lan_peer_timeout(), + } } - let contents = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&contents)?; - Ok(config) +} + +fn default_lan_enabled() -> bool { + false +} +fn default_lan_broadcast_interval() -> u64 { + 30 +} +fn default_lan_peer_timeout() -> u64 { + 90 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lan_disabled_by_default() { + assert!(!LanConfig::default().enabled); + } + + #[test] + fn api_binds_localhost_by_default() { + assert_eq!(ServerConfig::default().api_bind_addr, "127.0.0.1"); + } + + #[test] + fn proxy_binds_localhost_by_default() { + assert_eq!(ProxyConfig::default().bind_addr, "127.0.0.1"); + } + + #[test] + fn empty_toml_gives_defaults() { + let config: Config = toml::from_str("").unwrap(); + assert!(!config.lan.enabled); + assert_eq!(config.server.api_bind_addr, "127.0.0.1"); + assert_eq!(config.proxy.bind_addr, "127.0.0.1"); + assert_eq!(config.server.api_port, ServerConfig::default().api_port); + } + + #[test] + fn lan_enabled_parses() { + let config: Config = toml::from_str("[lan]\nenabled = true").unwrap(); + assert!(config.lan.enabled); + } + + #[test] + fn custom_bind_addrs_parse() { + let toml = r#" + [server] + api_bind_addr = "0.0.0.0" + [proxy] + bind_addr = "0.0.0.0" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.server.api_bind_addr, "0.0.0.0"); + assert_eq!(config.proxy.bind_addr, "0.0.0.0"); + } + + #[test] + fn service_routes_parse_from_toml() { + let toml = r#" + [[services]] + name = "app" + target_port = 3000 + routes = [ + { path = "/api", port = 4000, strip = true }, + { path = "/static", port = 5000 }, + ] + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].routes.len(), 2); + assert!(config.services[0].routes[0].strip); + assert!(!config.services[0].routes[1].strip); // default false + } +} + +pub struct ConfigLoad { + pub config: Config, + pub path: String, + pub found: bool, +} + +fn resolve_path(path: &str) -> String { + // canonicalize gives the real absolute path for existing files; + // for non-existent files, build an absolute path manually + std::fs::canonicalize(path) + .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path))) + .unwrap_or_else(|_| Path::new(path).to_path_buf()) + .to_string_lossy() + .to_string() +} + +pub fn load_config(path: &str) -> Result { + // Try the given path first, then well-known locations (for service mode where cwd is /) + let candidates: Vec = { + let p = Path::new(path); + let mut v = vec![p.to_path_buf()]; + if p.is_relative() { + let filename = p.file_name().unwrap_or(p.as_os_str()); + v.push(crate::config_dir().join(filename)); + v.push(crate::data_dir().join(filename)); + } + v + }; + + for candidate in &candidates { + match std::fs::read_to_string(candidate) { + Ok(contents) => { + let resolved = resolve_path(&candidate.to_string_lossy()); + let config: Config = toml::from_str(&contents)?; + return Ok(ConfigLoad { + config, + path: resolved, + found: true, + }); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } + } + + // Show config_dir candidate as the "expected" path — it's actionable + let display_path = candidates + .get(1) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| resolve_path(path)); + log::info!("config not found, using defaults (create {})", display_path); + Ok(ConfigLoad { + config: Config::default(), + path: display_path, + found: false, + }) } pub type ZoneMap = HashMap>>; diff --git a/src/ctx.rs b/src/ctx.rs index cf485bd..925ab4a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,16 +1,20 @@ use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime}; +use arc_swap::ArcSwap; use log::{debug, error, info, warn}; +use rustls::ServerConfig; use tokio::net::UdpSocket; use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::DnsCache; use crate::config::ZoneMap; -use crate::forward::forward_query; +use crate::forward::{forward_query, Upstream}; use crate::header::ResultCode; +use crate::lan::PeerStore; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; use crate::query_log::{QueryLog, QueryLogEntry}; @@ -29,11 +33,21 @@ pub struct ServerCtx { pub blocklist: Mutex, pub query_log: Mutex, pub services: Mutex, + pub lan_peers: Mutex, pub forwarding_rules: Vec, - pub upstream: SocketAddr, + pub upstream: Mutex, + pub upstream_auto: bool, + pub upstream_port: u16, + pub lan_ip: Mutex, pub timeout: Duration, pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation + pub lan_enabled: bool, + pub config_path: String, + pub config_found: bool, + pub config_dir: PathBuf, + pub data_dir: PathBuf, + pub tls_config: Option>, } pub async fn handle_query( @@ -67,16 +81,37 @@ pub async fn handle_query( } else if !ctx.proxy_tld_suffix.is_empty() && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) { + // Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP + let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname); + let resolve_ip = { + let local = ctx.services.lock().unwrap(); + if local.lookup(service_name).is_some() { + std::net::Ipv4Addr::LOCALHOST + } else { + let mut peers = ctx.lan_peers.lock().unwrap(); + peers + .lookup(service_name) + .and_then(|(ip, _)| match ip { + std::net::IpAddr::V4(v4) => Some(v4), + _ => None, + }) + .unwrap_or(std::net::Ipv4Addr::LOCALHOST) + } + }; let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { domain: qname.clone(), - addr: std::net::Ipv6Addr::LOCALHOST, + addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST { + std::net::Ipv6Addr::LOCALHOST + } else { + resolve_ip.to_ipv6_mapped() + }, ttl: 300, }), _ => resp.answers.push(DnsRecord::A { domain: qname.clone(), - addr: std::net::Ipv4Addr::LOCALHOST, + addr: resolve_ip, ttl: 300, }), } @@ -108,9 +143,11 @@ pub async fn handle_query( (resp, QueryPath::Cached) } else { let upstream = - crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) - .unwrap_or(ctx.upstream); - match forward_query(&query, upstream, ctx.timeout).await { + match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { + Some(addr) => Upstream::Udp(addr), + None => ctx.upstream.lock().unwrap().clone(), + }; + match forward_query(&query, &upstream, ctx.timeout).await { Ok(resp) => { ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); (resp, QueryPath::Forwarded) diff --git a/src/forward.rs b/src/forward.rs index ff5c14f..2e11a4b 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::net::SocketAddr; use std::time::Duration; @@ -8,7 +9,46 @@ use crate::buffer::BytePacketBuffer; use crate::packet::DnsPacket; use crate::Result; +#[derive(Clone)] +pub enum Upstream { + Udp(SocketAddr), + Doh { + url: String, + client: reqwest::Client, + }, +} + +impl PartialEq for Upstream { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Udp(a), Self::Udp(b)) => a == b, + (Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b, + _ => false, + } + } +} + +impl fmt::Display for Upstream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Upstream::Udp(addr) => write!(f, "{}", addr), + Upstream::Doh { url, .. } => f.write_str(url), + } + } +} + pub async fn forward_query( + query: &DnsPacket, + upstream: &Upstream, + timeout_duration: Duration, +) -> Result { + match upstream { + Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await, + Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await, + } +} + +async fn forward_udp( query: &DnsPacket, upstream: SocketAddr, timeout_duration: Duration, @@ -33,3 +73,174 @@ pub async fn forward_query( DnsPacket::from_buffer(&mut recv_buffer) } + +async fn forward_doh( + query: &DnsPacket, + url: &str, + client: &reqwest::Client, + timeout_duration: Duration, +) -> Result { + let mut send_buffer = BytePacketBuffer::new(); + query.write(&mut send_buffer)?; + + let resp = timeout( + timeout_duration, + client + .post(url) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(send_buffer.filled().to_vec()) + .send(), + ) + .await?? + .error_for_status()?; + + let bytes = resp.bytes().await?; + log::debug!("DoH response: {} bytes", bytes.len()); + + let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes); + DnsPacket::from_buffer(&mut recv_buffer) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::future::IntoFuture; + + use crate::header::ResultCode; + use crate::question::{DnsQuestion, QueryType}; + use crate::record::DnsRecord; + + #[test] + fn upstream_display_udp() { + let u = Upstream::Udp("9.9.9.9:53".parse().unwrap()); + assert_eq!(u.to_string(), "9.9.9.9:53"); + } + + #[test] + fn upstream_display_doh() { + let u = Upstream::Doh { + url: "https://dns.quad9.net/dns-query".to_string(), + client: reqwest::Client::new(), + }; + assert_eq!(u.to_string(), "https://dns.quad9.net/dns-query"); + } + + fn make_query() -> DnsPacket { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new("example.com".to_string(), QueryType::A)); + q + } + + fn make_response(query: &DnsPacket) -> DnsPacket { + let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); + resp.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: "93.184.216.34".parse().unwrap(), + ttl: 300, + }); + resp + } + + fn to_wire(pkt: &DnsPacket) -> Vec { + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + buf.filled().to_vec() + } + + #[tokio::test] + async fn doh_mock_server_resolves() { + let query = make_query(); + let response_bytes = to_wire(&make_response(&query)); + + let app = axum::Router::new().route( + "/dns-query", + axum::routing::post(move || { + let body = response_bytes.clone(); + async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/dns-message")], + body, + ) + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, app).into_future()); + + let upstream = Upstream::Doh { + url: format!("http://{}/dns-query", addr), + client: reqwest::Client::new(), + }; + + let result = forward_query(&query, &upstream, Duration::from_secs(2)) + .await + .expect("DoH forward should succeed"); + + assert_eq!(result.header.id, 0xABCD); + assert!(result.header.response); + assert_eq!(result.header.rescode, ResultCode::NOERROR); + assert_eq!(result.answers.len(), 1); + match &result.answers[0] { + DnsRecord::A { domain, addr, ttl } => { + assert_eq!(domain, "example.com"); + assert_eq!( + *addr, + "93.184.216.34".parse::().unwrap() + ); + assert_eq!(*ttl, 300); + } + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn doh_http_error_propagates() { + let app = axum::Router::new().route( + "/dns-query", + axum::routing::post(|| async { + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "bad") + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, app).into_future()); + + let upstream = Upstream::Doh { + url: format!("http://{}/dns-query", addr), + client: reqwest::Client::new(), + }; + + let result = forward_query(&make_query(), &upstream, Duration::from_secs(2)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn doh_timeout() { + let app = axum::Router::new().route( + "/dns-query", + axum::routing::post(|| async { + tokio::time::sleep(Duration::from_secs(10)).await; + "never" + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, app).into_future()); + + let upstream = Upstream::Doh { + url: format!("http://{}/dns-query", addr), + client: reqwest::Client::new(), + }; + + let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await; + assert!(result.is_err()); + } +} diff --git a/src/lan.rs b/src/lan.rs new file mode 100644 index 0000000..db210e9 --- /dev/null +++ b/src/lan.rs @@ -0,0 +1,470 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use log::{debug, info, warn}; + +use crate::buffer::BytePacketBuffer; +use crate::config::LanConfig; +use crate::ctx::ServerCtx; +use crate::header::DnsHeader; +use crate::question::{DnsQuestion, QueryType}; + +// --- Constants --- + +const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; +const SERVICE_TYPE: &str = "_numa._tcp.local"; +const MDNS_TTL: u32 = 120; + +// --- Peer Store --- + +pub struct PeerStore { + peers: HashMap, + timeout: Duration, +} + +impl PeerStore { + pub fn new(timeout_secs: u64) -> Self { + PeerStore { + peers: HashMap::new(), + timeout: Duration::from_secs(timeout_secs), + } + } + + /// Returns true if a previously-unseen name was inserted. + pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) -> bool { + let now = Instant::now(); + let mut changed = false; + for (name, port) in services { + let key = name.to_lowercase(); + if !self.peers.contains_key(&key) { + changed = true; + } + self.peers.insert(key, (host, *port, now)); + } + changed + } + + pub fn lookup(&mut self, name: &str) -> Option<(IpAddr, u16)> { + let key = name.to_lowercase(); + let entry = self.peers.get(&key)?; + if entry.2.elapsed() > self.timeout { + self.peers.remove(&key); + return None; + } + Some((entry.0, entry.1)) + } + + pub fn list(&mut self) -> Vec<(String, IpAddr, u16, u64)> { + let now = Instant::now(); + self.peers + .retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); + self.peers + .iter() + .map(|(name, (ip, port, seen))| { + ( + name.clone(), + *ip, + *port, + now.duration_since(*seen).as_secs(), + ) + }) + .collect() + } + + pub fn names(&mut self) -> Vec { + let now = Instant::now(); + self.peers + .retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); + self.peers.keys().cloned().collect() + } + + pub fn clear(&mut self) { + self.peers.clear(); + } +} + +// --- mDNS Discovery --- + +pub fn detect_lan_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + match socket.local_addr().ok()? { + SocketAddr::V4(addr) => Some(*addr.ip()), + _ => None, + } +} + +fn get_hostname() -> String { + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "numa".to_string()) +} + +/// Generate a per-process instance ID for self-filtering on multi-instance hosts +fn instance_id() -> String { + format!( + "{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + % 1_000_000 + ) +} + +pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { + let interval = Duration::from_secs(config.broadcast_interval_secs); + let local_ip = *ctx.lan_ip.lock().unwrap(); + let hostname = get_hostname(); + let our_instance_id = instance_id(); + + info!( + "LAN discovery via mDNS on {}:{}, local IP {}, instance {}._numa._tcp.local", + MDNS_ADDR, MDNS_PORT, local_ip, hostname + ); + + let std_socket = match create_mdns_socket() { + Ok(s) => s, + Err(e) => { + warn!( + "LAN: could not bind mDNS socket: {} — LAN discovery disabled", + e + ); + return; + } + }; + let socket = match tokio::net::UdpSocket::from_std(std_socket) { + Ok(s) => s, + Err(e) => { + warn!("LAN: tokio socket conversion failed: {}", e); + return; + } + }; + let socket = Arc::new(socket); + let dest = SocketAddr::new(IpAddr::V4(MDNS_ADDR), MDNS_PORT); + + // Spawn sender: announce our services periodically + let sender_ctx = Arc::clone(&ctx); + let sender_socket = Arc::clone(&socket); + let sender_hostname = hostname.clone(); + let sender_instance_id = our_instance_id.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + let services: Vec<(String, u16)> = { + let store = sender_ctx.services.lock().unwrap(); + store + .list() + .iter() + .map(|e| (e.name.clone(), e.target_port)) + .collect() + }; + if services.is_empty() { + continue; + } + let current_ip = *sender_ctx.lan_ip.lock().unwrap(); + if let Ok(pkt) = + build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) + { + let _ = sender_socket.send_to(pkt.filled(), dest).await; + } + } + }); + + // Send initial browse query + if let Ok(pkt) = build_browse_query() { + let _ = socket.send_to(pkt.filled(), dest).await; + } + + // Receiver loop: parse mDNS responses for _numa._tcp + let mut buf = vec![0u8; 4096]; + loop { + let (len, _src) = match socket.recv_from(&mut buf).await { + Ok(r) => r, + Err(e) => { + debug!("mDNS recv error: {}", e); + continue; + } + }; + + let data = &buf[..len]; + if let Some(ann) = parse_mdns_response(data) { + // Skip our own announcements via instance ID (works on multi-instance same-host) + if ann.instance_id.as_deref() == Some(our_instance_id.as_str()) { + continue; + } + if !ann.services.is_empty() { + let changed = ctx + .lan_peers + .lock() + .unwrap() + .update(ann.peer_ip, &ann.services); + if changed { + crate::tls::regenerate_tls(&ctx); + } + debug!( + "LAN: {} services from {} (mDNS)", + ann.services.len(), + ann.peer_ip + ); + } + } + } +} + +// --- mDNS Packet Building --- + +fn build_browse_query() -> crate::Result { + let mut buf = BytePacketBuffer::new(); + + let mut header = DnsHeader::new(); + header.questions = 1; + header.write(&mut buf)?; + + DnsQuestion::new(SERVICE_TYPE.to_string(), QueryType::PTR).write(&mut buf)?; + + Ok(buf) +} + +fn build_announcement( + hostname: &str, + ip: Ipv4Addr, + services: &[(String, u16)], + inst_id: &str, +) -> crate::Result { + let mut buf = BytePacketBuffer::new(); + let instance_name = format!("{}._numa._tcp.local", hostname); + let host_local = format!("{}.local", hostname); + + let mut header = DnsHeader::new(); + header.response = true; + header.authoritative_answer = true; + header.answers = 4; // PTR + SRV + TXT + A + header.write(&mut buf)?; + + // PTR: _numa._tcp.local → ._numa._tcp.local + write_record_header(&mut buf, SERVICE_TYPE, QueryType::PTR.to_num(), 1, MDNS_TTL)?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + buf.write_qname(&instance_name)?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // SRV: ._numa._tcp.local → .local + // Port in SRV is informational; actual service ports are in TXT + write_record_header( + &mut buf, + &instance_name, + QueryType::SRV.to_num(), + 0x8001, + MDNS_TTL, + )?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + buf.write_u16(0)?; // priority + buf.write_u16(0)?; // weight + buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display + buf.write_qname(&host_local)?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // TXT: services + instance ID for self-filtering + write_record_header( + &mut buf, + &instance_name, + QueryType::TXT.to_num(), + 0x8001, + MDNS_TTL, + )?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + let svc_str = services + .iter() + .map(|(name, port)| format!("{}:{}", name, port)) + .collect::>() + .join(","); + write_txt_string(&mut buf, &format!("services={}", svc_str))?; + write_txt_string(&mut buf, &format!("id={}", inst_id))?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // A: .local → IP + write_record_header( + &mut buf, + &host_local, + QueryType::A.to_num(), + 0x8001, + MDNS_TTL, + )?; + buf.write_u16(4)?; + for &b in &ip.octets() { + buf.write_u8(b)?; + } + + Ok(buf) +} + +fn write_record_header( + buf: &mut BytePacketBuffer, + name: &str, + rtype: u16, + class: u16, + ttl: u32, +) -> crate::Result<()> { + buf.write_qname(name)?; + buf.write_u16(rtype)?; + buf.write_u16(class)?; + buf.write_u32(ttl)?; + Ok(()) +} + +fn patch_rdlen( + buf: &mut BytePacketBuffer, + rdlen_pos: usize, + rdata_start: usize, +) -> crate::Result<()> { + let rdlen = (buf.pos() - rdata_start) as u16; + buf.set_u16(rdlen_pos, rdlen) +} + +fn write_txt_string(buf: &mut BytePacketBuffer, s: &str) -> crate::Result<()> { + let bytes = s.as_bytes(); + for chunk in bytes.chunks(255) { + buf.write_u8(chunk.len() as u8)?; + for &b in chunk { + buf.write_u8(b)?; + } + } + Ok(()) +} + +// --- mDNS Packet Parsing --- + +struct MdnsAnnouncement { + services: Vec<(String, u16)>, + peer_ip: IpAddr, + instance_id: Option, +} + +fn parse_mdns_response(data: &[u8]) -> Option { + if data.len() < 12 { + return None; + } + + let mut buf = BytePacketBuffer::new(); + buf.buf[..data.len()].copy_from_slice(data); + + let mut header = DnsHeader::new(); + header.read(&mut buf).ok()?; + + if !header.response || header.answers == 0 { + return None; + } + + // Skip questions + for _ in 0..header.questions { + let mut q = DnsQuestion::new(String::new(), QueryType::UNKNOWN(0)); + q.read(&mut buf).ok()?; + } + + let total = header.answers + header.authoritative_entries + header.resource_entries; + let mut txt_services: Option> = None; + let mut peer_instance_id: Option = None; + let mut a_ip: Option = None; + let mut name = String::with_capacity(64); + + for _ in 0..total { + if buf.pos() >= data.len() { + break; + } + + name.clear(); + if buf.read_qname(&mut name).is_err() { + break; + } + + let rtype = buf.read_u16().unwrap_or(0); + let _rclass = buf.read_u16().unwrap_or(0); + let _ttl = buf.read_u32().unwrap_or(0); + let rdlength = buf.read_u16().unwrap_or(0) as usize; + let rdata_start = buf.pos(); + + match rtype { + t if t == QueryType::TXT.to_num() && name.contains("_numa._tcp") => { + let mut pos = rdata_start; + while pos < rdata_start + rdlength && pos < data.len() { + let txt_len = data[pos] as usize; + pos += 1; + if pos + txt_len > data.len() { + break; + } + if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { + if let Some(val) = txt.strip_prefix("services=") { + let svcs: Vec<(String, u16)> = val + .split(',') + .filter_map(|s| { + let mut parts = s.splitn(2, ':'); + let svc_name = parts.next()?.to_string(); + let port = parts.next()?.parse().ok()?; + Some((svc_name, port)) + }) + .collect(); + if !svcs.is_empty() { + txt_services = Some(svcs); + } + } else if let Some(id) = txt.strip_prefix("id=") { + peer_instance_id = Some(id.to_string()); + } + } + pos += txt_len; + } + } + t if t == QueryType::A.to_num() && rdlength == 4 && rdata_start + 4 <= data.len() => { + a_ip = Some(IpAddr::V4(Ipv4Addr::new( + data[rdata_start], + data[rdata_start + 1], + data[rdata_start + 2], + data[rdata_start + 3], + ))); + } + _ => {} + } + + buf.seek(rdata_start + rdlength).ok(); + } + + let services = txt_services?; + // Trust the A record IP if present, otherwise this isn't a complete announcement + let peer_ip = a_ip?; + + Some(MdnsAnnouncement { + services, + peer_ip, + instance_id: peer_instance_id, + }) +} + +fn create_mdns_socket() -> std::io::Result { + let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT); + let socket = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::DGRAM, + Some(socket2::Protocol::UDP), + )?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + socket.set_nonblocking(true)?; + socket.bind(&socket2::SockAddr::from(addr))?; + socket.join_multicast_v4(&MDNS_ADDR, &Ipv4Addr::UNSPECIFIED)?; + Ok(socket.into()) +} diff --git a/src/lib.rs b/src/lib.rs index ad9355e..dc4ce2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod ctx; pub mod forward; pub mod header; +pub mod lan; pub mod override_store; pub mod packet; pub mod proxy; @@ -20,9 +21,25 @@ pub mod tls; pub type Error = Box; pub type Result = std::result::Result; -/// 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") { @@ -36,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"); } @@ -45,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") + } +} diff --git a/src/main.rs b/src/main.rs index 9c1a543..8e23e78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,14 +2,16 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; +use arc_swap::ArcSwap; use log::{error, info}; use tokio::net::UdpSocket; use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; use numa::buffer::BytePacketBuffer; use numa::cache::DnsCache; -use numa::config::{build_zone_map, load_config}; +use numa::config::{build_zone_map, load_config, ConfigLoad}; use numa::ctx::{handle_query, ServerCtx}; +use numa::forward::Upstream; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; use numa::service_store::ServiceStore; @@ -50,6 +52,20 @@ async fn main() -> numa::Result<()> { } }; } + "lan" => { + let sub = std::env::args().nth(2).unwrap_or_default(); + let config_path = std::env::args() + .nth(3) + .unwrap_or_else(|| "numa.toml".to_string()); + return match sub.as_str() { + "on" => set_lan_enabled(true, &config_path), + "off" => set_lan_enabled(false, &config_path), + _ => { + eprintln!("Usage: numa lan [config-path]"); + Ok(()) + } + }; + } "version" | "--version" | "-V" => { eprintln!("numa {}", env!("CARGO_PKG_VERSION")); return Ok(()); @@ -65,6 +81,8 @@ async fn main() -> numa::Result<()> { eprintln!(" service stop Uninstall the system service"); eprintln!(" service restart Restart the service with updated binary"); eprintln!(" service status Check if the service is running"); + eprintln!(" lan on Enable LAN service discovery (mDNS)"); + eprintln!(" lan off Disable LAN service discovery"); eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); @@ -80,20 +98,41 @@ async fn main() -> numa::Result<()> { } else { arg1 // treat as config path for backwards compatibility }; - let config = load_config(&config_path)?; + let ConfigLoad { + config, + path: resolved_config_path, + found: config_found, + } = load_config(&config_path)?; // Discover system DNS in a single pass (upstream + forwarding rules) let system_dns = discover_system_dns(); let upstream_addr = if config.upstream.address.is_empty() { - system_dns.default_upstream.unwrap_or_else(|| { - info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)"); - "9.9.9.9".to_string() - }) + system_dns + .default_upstream + .or_else(numa::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| { + info!("could not detect system DNS, falling back to Quad9 DoH"); + "https://dns.quad9.net/dns-query".to_string() + }) } else { config.upstream.address.clone() }; - let upstream: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; + + let upstream: Upstream = if upstream_addr.starts_with("https://") { + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + Upstream::Doh { + url: upstream_addr, + client, + } + } else { + let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; + Upstream::Udp(addr) + }; + let upstream_label = upstream.to_string(); let api_port = config.server.api_port; let mut blocklist = BlocklistStore::new(); @@ -106,14 +145,28 @@ async fn main() -> numa::Result<()> { // Build service store: config services + persisted user services let mut service_store = ServiceStore::new(); - service_store.insert_from_config("numa", config.server.api_port); + service_store.insert_from_config("numa", config.server.api_port, Vec::new()); for svc in &config.services { - service_store.insert_from_config(&svc.name, svc.target_port); + service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); } service_store.load_persisted(); let forwarding_rules = system_dns.forwarding_rules; + // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) + let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { + let service_names = service_store.names(); + match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { + Ok(tls_config) => Some(ArcSwap::from(tls_config)), + Err(e) => { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + None + } + } + } else { + None + }; + let ctx = Arc::new(ServerCtx { socket: UdpSocket::bind(&config.server.bind_addr).await?, zone_map: build_zone_map(&config.zones)?, @@ -127,8 +180,12 @@ async fn main() -> numa::Result<()> { blocklist: Mutex::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), services: Mutex::new(service_store), + lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), forwarding_rules, - upstream, + upstream: Mutex::new(upstream), + upstream_auto: config.upstream.address.is_empty(), + upstream_port: config.upstream.port, + lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), timeout: Duration::from_millis(config.upstream.timeout_ms), proxy_tld_suffix: if config.proxy.tld.is_empty() { String::new() @@ -136,40 +193,127 @@ async fn main() -> numa::Result<()> { format!(".{}", config.proxy.tld) }, proxy_tld: config.proxy.tld.clone(), + lan_enabled: config.lan.enabled, + config_path: resolved_config_path, + config_found, + config_dir: numa::config_dir(), + data_dir: numa::data_dir(), + tls_config: initial_tls, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); - eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m"); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;163;152;136mv{}\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m", env!("CARGO_PKG_VERSION")); - eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDashboard\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mUpstream\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", upstream); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mZones\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} records", zone_count)); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries)); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", - if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() }); - if config.proxy.enabled { - let schemes = if config.proxy.tls_port > 0 { - format!( + + // Build banner rows, then size the box to fit the longest value + let api_url = format!("http://localhost:{}", api_port); + let proxy_label = if config.proxy.enabled { + if config.proxy.tls_port > 0 { + Some(format!( "http://:{} https://:{}", config.proxy.port, config.proxy.tls_port - ) + )) } else { - format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port) - }; - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes); + Some(format!( + "http://*.{} on :{}", + config.proxy.tld, config.proxy.port + )) + } + } else { + None + }; + let config_label = if ctx.config_found { + ctx.config_path.clone() + } else { + format!("{} (defaults)", ctx.config_path) + }; + let data_label = ctx.data_dir.display().to_string(); + let services_label = ctx.config_dir.join("services.json").display().to_string(); + + // label (10) + value + padding (2) = inner width; minimum 40 for the title row + let val_w = [ + config.server.bind_addr.len(), + api_url.len(), + upstream_label.len(), + config_label.len(), + data_label.len(), + services_label.len(), + ] + .into_iter() + .chain(proxy_label.as_ref().map(|s| s.len())) + .max() + .unwrap_or(30); + let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title + + let o = "\x1b[38;2;192;98;58m"; // orange + let g = "\x1b[38;2;107;124;78m"; // green + let d = "\x1b[38;2;163;152;136m"; // dim + let r = "\x1b[0m"; // reset + let b = "\x1b[1;38;2;192;98;58m"; // bold orange + let it = "\x1b[3;38;2;163;152;136m"; // italic dim + + let bar_top = "═".repeat(w); + let bar_mid = "─".repeat(w); + let row = |label: &str, color: &str, value: &str| { + eprintln!( + "{o} ║{r} {color}{:<9}{r} {: numa::Result<()> { // Spawn HTTP API server let api_ctx = Arc::clone(&ctx); - let api_addr: SocketAddr = format!("0.0.0.0:{}", api_port).parse()?; + let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?; tokio::spawn(async move { let app = numa::api::router(api_ctx); let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap(); @@ -202,37 +346,50 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value + let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled { + std::net::Ipv4Addr::UNSPECIFIED + } else { + config + .proxy + .bind_addr + .parse() + .unwrap_or(std::net::Ipv4Addr::LOCALHOST) + }; + // Spawn HTTP reverse proxy for .numa domains if config.proxy.enabled { let proxy_ctx = Arc::clone(&ctx); let proxy_port = config.proxy.port; tokio::spawn(async move { - numa::proxy::start_proxy(proxy_ctx, proxy_port).await; + numa::proxy::start_proxy(proxy_ctx, proxy_port, proxy_bind).await; }); } // Spawn HTTPS reverse proxy with TLS termination - if config.proxy.enabled && config.proxy.tls_port > 0 { - let service_names: Vec = ctx - .services - .lock() - .unwrap() - .list() - .iter() - .map(|e| e.name.clone()) - .collect(); - match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { - Ok(tls_config) => { - let proxy_ctx = Arc::clone(&ctx); - let tls_port = config.proxy.tls_port; - tokio::spawn(async move { - numa::proxy::start_proxy_tls(proxy_ctx, tls_port, tls_config).await; - }); - } - Err(e) => { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); - } - } + if config.proxy.enabled && config.proxy.tls_port > 0 && ctx.tls_config.is_some() { + let proxy_ctx = Arc::clone(&ctx); + let tls_port = config.proxy.tls_port; + tokio::spawn(async move { + numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; + }); + } + + // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) + { + let watch_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + network_watch_loop(watch_ctx).await; + }); + } + + // Spawn LAN service discovery + if config.lan.enabled { + let lan_ctx = Arc::clone(&ctx); + let lan_config = config.lan.clone(); + tokio::spawn(async move { + numa::lan::start_lan_discovery(lan_ctx, &lan_config).await; + }); } // UDP DNS listener @@ -250,6 +407,124 @@ async fn main() -> numa::Result<()> { } } +async fn network_watch_loop(ctx: Arc) { + let mut tick: u64 = 0; + + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.tick().await; // skip immediate tick + + loop { + interval.tick().await; + tick += 1; + let mut changed = false; + + // Check LAN IP change (every 5s — cheap, one UDP socket call) + if let Some(new_ip) = numa::lan::detect_lan_ip() { + let mut current_ip = ctx.lan_ip.lock().unwrap(); + if new_ip != *current_ip { + info!("LAN IP changed: {} → {}", current_ip, new_ip); + *current_ip = new_ip; + changed = true; + } + } + + // Re-detect upstream every 30s or on LAN IP change (UDP only — + // DoH upstreams are explicitly configured via URL, not auto-detected) + if ctx.upstream_auto + && matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_)) + && (changed || tick.is_multiple_of(6)) + { + let dns_info = numa::system_dns::discover_system_dns(); + let new_addr = dns_info + .default_upstream + .or_else(numa::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| "9.9.9.9".to_string()); + if let Ok(new_sock) = + format!("{}:{}", new_addr, ctx.upstream_port).parse::() + { + let new_upstream = Upstream::Udp(new_sock); + let mut upstream = ctx.upstream.lock().unwrap(); + if *upstream != new_upstream { + info!("upstream changed: {} → {}", upstream, new_upstream); + *upstream = new_upstream; + changed = true; + } + } + } + + // Flush stale LAN peers on any network change + if changed { + ctx.lan_peers.lock().unwrap().clear(); + info!("flushed LAN peers after network change"); + } + } +} + +fn set_lan_enabled(enabled: bool, path: &str) -> numa::Result<()> { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::write(path, format!("[lan]\nenabled = {}\n", enabled))?; + print_lan_status(enabled); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + + // Track current TOML section while scanning lines + let mut in_lan = false; + let mut found = false; + let mut lines: Vec = contents + .lines() + .map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lan = trimmed == "[lan]"; + } + if in_lan && !found { + if let Some((key, _)) = trimmed.split_once('=') { + if key.trim() == "enabled" { + found = true; + let indent = &line[..line.len() - trimmed.len()]; + return format!("{}enabled = {}", indent, enabled); + } + } + } + line.to_string() + }) + .collect(); + + if !found { + if let Some(i) = lines.iter().position(|l| l.trim() == "[lan]") { + lines.insert(i + 1, format!("enabled = {}", enabled)); + } else { + lines.push(String::new()); + lines.push("[lan]".to_string()); + lines.push(format!("enabled = {}", enabled)); + } + } + + let mut result = lines.join("\n"); + if !result.ends_with('\n') { + result.push('\n'); + } + std::fs::write(path, result)?; + print_lan_status(enabled); + Ok(()) +} + +fn print_lan_status(enabled: bool) { + let label = if enabled { "enabled" } else { "disabled" }; + let color = if enabled { "32" } else { "33" }; + eprintln!( + "\x1b[1;38;2;192;98;58mNuma\x1b[0m — LAN discovery \x1b[{}m{}\x1b[0m", + color, label + ); + if enabled { + eprintln!(" Restart Numa to start mDNS discovery"); + } +} + async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { let downloaded = download_blocklists(lists).await; diff --git a/src/proxy.rs b/src/proxy.rs index 761a0d1..ce592f7 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use axum::body::Body; @@ -11,7 +11,6 @@ use hyper::StatusCode; use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; use log::{debug, error, info, warn}; -use rustls::ServerConfig; use tokio::io::copy_bidirectional; use tokio_rustls::TlsAcceptor; @@ -25,8 +24,8 @@ struct ProxyState { client: HttpClient, } -pub async fn start_proxy(ctx: Arc, port: u16) { - let addr: SocketAddr = ([0, 0, 0, 0], port).into(); +pub async fn start_proxy(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { + let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -50,8 +49,8 @@ pub async fn start_proxy(ctx: Arc, port: u16) { axum::serve(listener, app).await.unwrap(); } -pub async fn start_proxy_tls(ctx: Arc, port: u16, tls_config: Arc) { - let addr: SocketAddr = ([0, 0, 0, 0], port).into(); +pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { + let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -64,11 +63,17 @@ pub async fn start_proxy_tls(ctx: Arc, port: u16, tls_config: Arc, port: u16, tls_config: Arc, req: Request) -> axum::r } }; - let target_port = { + let request_path = req.uri().path().to_string(); + + let (target_host, target_port, rewritten_path) = { let store = state.ctx.services.lock().unwrap(); - match store.lookup(&service_name) { - Some(entry) => entry.target_port, - None => { + if let Some(entry) = store.lookup(&service_name) { + let (port, path) = entry.resolve_route(&request_path); + ("localhost".to_string(), port, path) + } else { + let mut peers = state.ctx.lan_peers.lock().unwrap(); + match peers.lookup(&service_name) { + Some((ip, port)) => (ip.to_string(), port, request_path.clone()), + None => { return ( StatusCode::NOT_FOUND, [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], @@ -259,18 +274,22 @@ pre .str {{ color: #d48a5a }} ), ) .into_response() + } } } }; - let path_and_query = req + let query_string = req .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query) - .parse() - .unwrap(); + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let target_uri: hyper::Uri = format!( + "http://{}:{}{}{}", + target_host, target_port, rewritten_path, query_string + ) + .parse() + .unwrap(); // Check for upgrade request (WebSocket, etc.) let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); diff --git a/src/service_store.rs b/src/service_store.rs index 26b2daf..f2c72c7 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use log::{info, warn}; @@ -8,12 +8,56 @@ use serde::{Deserialize, Serialize}; pub struct ServiceEntry { pub name: String, pub target_port: u16, + #[serde(default)] + pub routes: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RouteEntry { + pub path: String, + pub port: u16, + #[serde(default)] + pub strip: bool, +} + +impl ServiceEntry { + /// Resolve backend port and (possibly rewritten) path for a request + pub fn resolve_route(&self, request_path: &str) -> (u16, String) { + // Longest prefix match + let matched = self + .routes + .iter() + .filter(|r| { + request_path == r.path + || (request_path.starts_with(&r.path) + && (r.path.ends_with('/') + || request_path.as_bytes().get(r.path.len()) == Some(&b'/'))) + }) + .max_by_key(|r| r.path.len()); + + match matched { + Some(route) => { + let path = if route.strip { + let stripped = &request_path[route.path.len()..]; + if stripped.is_empty() || !stripped.starts_with('/') { + format!("/{}", stripped.trim_start_matches('/')) + } else { + stripped.to_string() + } + } else { + request_path.to_string() + }; + (route.port, path) + } + None => (self.target_port, request_path.to_string()), + } + } } pub struct ServiceStore { entries: HashMap, /// Services defined in numa.toml (not persisted to user file) - config_services: std::collections::HashSet, + config_services: HashSet, persist_path: PathBuf, } @@ -28,13 +72,13 @@ impl ServiceStore { let persist_path = dirs_path(); ServiceStore { entries: HashMap::new(), - config_services: std::collections::HashSet::new(), + config_services: HashSet::new(), persist_path, } } /// Insert a service from numa.toml config (not persisted) - pub fn insert_from_config(&mut self, name: &str, target_port: u16) { + pub fn insert_from_config(&mut self, name: &str, target_port: u16, routes: Vec) { let key = name.to_lowercase(); self.config_services.insert(key.clone()); self.entries.insert( @@ -42,6 +86,7 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes, }, ); } @@ -54,11 +99,37 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes: Vec::new(), }, ); self.save(); } + pub fn add_route(&mut self, service: &str, path: String, port: u16, strip: bool) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + entry.routes.retain(|r| r.path != path); + entry.routes.push(RouteEntry { path, port, strip }); + self.save(); + true + } else { + false + } + } + + pub fn remove_route(&mut self, service: &str, path: &str) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + let before = entry.routes.len(); + entry.routes.retain(|r| r.path != path); + if entry.routes.len() < before { + self.save(); + return true; + } + } + false + } + pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> { self.entries.get(&name.to_lowercase()) } @@ -72,12 +143,26 @@ impl ServiceStore { removed } + /// Names are always stored lowercased, so callers must pass lowercase keys. + pub fn is_config_service(&self, name: &str) -> bool { + self.config_services.contains(name) + } + pub fn list(&self) -> Vec<&ServiceEntry> { let mut entries: Vec<_> = self.entries.values().collect(); entries.sort_by(|a, b| a.name.cmp(&b.name)); entries } + pub fn names(&self) -> Vec { + self.entries.keys().cloned().collect() + } + + /// Returns true if the name is new (not already registered). + pub fn has_name(&self, name: &str) -> bool { + self.entries.contains_key(&name.to_lowercase()) + } + /// Load user-defined services from ~/.config/numa/services.json pub fn load_persisted(&mut self) { if !self.persist_path.exists() { @@ -133,3 +218,157 @@ impl ServiceStore { fn dirs_path() -> PathBuf { crate::config_dir().join("services.json") } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn entry(port: u16, routes: Vec) -> ServiceEntry { + ServiceEntry { + name: "app".into(), + target_port: port, + routes, + } + } + + fn route(path: &str, port: u16, strip: bool) -> RouteEntry { + RouteEntry { + path: path.into(), + port, + strip, + } + } + + fn test_store() -> ServiceStore { + ServiceStore { + entries: HashMap::new(), + config_services: HashSet::new(), + persist_path: PathBuf::from("/dev/null"), + } + } + + // --- resolve_route --- + + #[test] + fn no_routes_returns_default_port() { + let e = entry(3000, vec![]); + assert_eq!(e.resolve_route("/anything"), (3000, "/anything".into())); + } + + #[test] + fn exact_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api"), (4000, "/api".into())); + } + + #[test] + fn prefix_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/api/users".into())); + } + + #[test] + fn segment_boundary_rejects_partial() { + let e = entry(3000, vec![route("/api", 4000, false)]); + // /apiary must NOT match /api — different segment + assert_eq!(e.resolve_route("/apiary"), (3000, "/apiary".into())); + } + + #[test] + fn segment_boundary_rejects_apikey() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/apikey"), (3000, "/apikey".into())); + } + + #[test] + fn longest_prefix_wins() { + let e = entry( + 3000, + vec![route("/api", 4000, false), route("/api/v2", 5000, false)], + ); + assert_eq!( + e.resolve_route("/api/v2/users"), + (5000, "/api/v2/users".into()) + ); + // shorter prefix still works for non-v2 paths + assert_eq!( + e.resolve_route("/api/v1/users"), + (4000, "/api/v1/users".into()) + ); + } + + #[test] + fn strip_removes_prefix() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/users".into())); + } + + #[test] + fn strip_exact_path_gives_root() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api"), (4000, "/".into())); + } + + #[test] + fn trailing_slash_route_matches() { + let e = entry(3000, vec![route("/app/", 4000, false)]); + assert_eq!( + e.resolve_route("/app/dashboard"), + (4000, "/app/dashboard".into()) + ); + } + + // --- ServiceStore: add_route / remove_route --- + + #[test] + fn add_route_to_existing_service() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(store.add_route("app", "/api".into(), 4000, false)); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].path, "/api"); + } + + #[test] + fn add_route_to_missing_service_returns_false() { + let mut store = test_store(); + assert!(!store.add_route("ghost", "/api".into(), 4000, false)); + } + + #[test] + fn add_route_deduplicates_by_path() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + store.add_route("app", "/api".into(), 4000, false); + store.add_route("app", "/api".into(), 5000, true); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].port, 5000); + assert!(entry.routes[0].strip); + } + + #[test] + fn remove_route_returns_true_when_found() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![route("/api", 4000, false)]); + assert!(store.remove_route("app", "/api")); + assert!(store.lookup("app").unwrap().routes.is_empty()); + } + + #[test] + fn remove_route_returns_false_when_missing() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(!store.remove_route("app", "/nope")); + } + + #[test] + fn lookup_is_case_insensitive() { + let mut store = test_store(); + store.insert_from_config("MyApp", 3000, vec![]); + assert!(store.lookup("myapp").is_some()); + assert!(store.lookup("MYAPP").is_some()); + } +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 6b63c48..57559b5 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -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 { /// 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 { // 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 { None } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn read_upstream_from_file(path: &str) -> Option { let text = std::fs::read_to_string(path).ok()?; for line in text.lines() { @@ -193,6 +205,103 @@ fn read_upstream_from_file(path: &str) -> Option { None } +/// Detect DNS server from DHCP lease — fallback when scutil/resolv.conf only shows 127.0.0.1. +/// On macOS: parses `ipconfig getpacket en0` for domain_name_server. +/// On Linux/Windows: returns None (not implemented yet). +pub fn detect_dhcp_dns() -> Option { + #[cfg(target_os = "macos")] + { + detect_dhcp_dns_macos() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +#[cfg(target_os = "macos")] +fn detect_dhcp_dns_macos() -> Option { + // Try common interfaces + for iface in &["en0", "en1"] { + let output = std::process::Command::new("ipconfig") + .args(["getpacket", iface]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("domain_name_server") { + // Format: "domain_name_server (ip_mult): {213.154.124.25, 1.0.0.1}" + if let Some(braces) = line.split('{').nth(1) { + let inner = braces.trim_end_matches('}').trim(); + // Take the first non-loopback DNS server + for addr in inner.split(',') { + let addr = addr.trim(); + if !addr.is_empty() + && addr != "127.0.0.1" + && addr != "0.0.0.0" + && addr.parse::().is_ok() + { + log::info!("detected DHCP DNS: {}", addr); + return Some(addr.to_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 +531,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 +880,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 +920,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")] { diff --git a/src/tls.rs b/src/tls.rs index 5fdada5..966b1f1 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,7 +1,10 @@ +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use log::{info, warn}; + +use crate::ctx::ServerCtx; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; @@ -10,14 +13,31 @@ 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"; +/// Collect all service + LAN peer names and regenerate the TLS cert. +pub fn regenerate_tls(ctx: &ServerCtx) { + let tls = match &ctx.tls_config { + Some(t) => t, + None => return, + }; + + let mut names: HashSet = ctx.services.lock().unwrap().names().into_iter().collect(); + names.extend(ctx.lan_peers.lock().unwrap().names()); + let names: Vec = names.into_iter().collect(); + + match build_tls_config(&ctx.proxy_tld, &names) { + Ok(new_config) => { + tls.store(new_config); + info!("TLS cert regenerated for {} services", names.len()); + } + Err(e) => warn!("TLS regeneration failed: {}", e), + } +} /// 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> { - 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)?;