24 Commits

Author SHA1 Message Date
Razvan Dimescu
c1d425069f bump version to 0.5.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:41:07 +02:00
Razvan Dimescu
d274500308 feat: DNS-over-HTTPS (DoH) upstream forwarding (#14)
* feat: DNS-over-HTTPS upstream forwarding

Encrypt upstream queries via DoH — ISPs see HTTPS traffic on port 443,
not plaintext DNS on port 53. URL scheme determines transport:
https:// = DoH, bare IP = plain UDP. Falls back to Quad9 DoH when
system resolver cannot be detected.

- Upstream enum (Udp/Doh) with Display and PartialEq
- BytePacketBuffer::from_bytes constructor
- reqwest http2 feature for DoH server compatibility
- network_watch_loop guards against DoH→UDP silent downgrade
- 5 new tests (mock DoH server, HTTP errors, timeout)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add DoH to README — Why Numa, comparison table, roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 00:39:58 +02:00
Razvan Dimescu
9c313ef06a docs: reorder README for launch — lead with unique features, add install methods
Comparison table and "Why Numa" reordered so unique capabilities (service proxy,
path routing, LAN discovery) appear first. Added brew/cargo install to Quick Start.
Removed unshipped "Self-sovereign DNS" row from comparison table. Named hickory-dns
and trust-dns in "How It Works" to signal deliberate architectural choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:16:50 +02:00
Razvan Dimescu
0d25fae4cf Merge pull request #13 from razvandimescu/fix/tls-hot-reload
fix: TLS cert hot-reload when services change
2026-03-23 19:46:05 +02:00
Razvan Dimescu
1ae2e23bb6 fix: regenerate TLS cert when services change (hot-reload via ArcSwap)
HTTPS proxy certs were generated once at startup. Services added at
runtime via API or LAN discovery got "not secure" in the browser
because their SAN wasn't in the cert. Now the cert is regenerated
on every service add/remove and swapped atomically via ArcSwap.
In-flight connections are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:06 +02:00
Razvan Dimescu
fe784addd2 release: auto-publish to crates.io on tag push
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:41:21 +02:00
Razvan Dimescu
a3a218ba5e numa.toml: add commented [blocking] section for discoverability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:02:43 +02:00
Razvan Dimescu
e4594c7955 bump version to 0.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:57:53 +02:00
Razvan Dimescu
b85f599b8f Merge pull request #12 from razvandimescu/feat/community-feedback-improvements
LAN opt-in, mDNS, security hardening, path routing
2026-03-23 13:55:19 +02:00
Razvan Dimescu
03c164e339 dynamic banner width, hoist HTML escaper, cache CA, restore log path
- banner box width adapts to longest value (fixes overflow with long paths)
- hoist h() HTML escape function to script top, remove 3 local copies
- serve_ca: add Cache-Control: public, max-age=86400
- restore log path in dashboard footer alongside new config/data fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:29:18 +02:00
Razvan Dimescu
2fce82e36c config visibility, PR review fixes, XSS hardening
Config visibility:
- startup banner shows config path, data dir, services path
- config search: ./numa.toml → ~/.config/numa/ → /usr/local/var/numa/
- /stats API exposes config_path and data_dir, dashboard footer renders them
- GET /ca.pem endpoint serves CA cert for cross-device TLS trust
- load_config returns ConfigLoad with found flag, warns on not-found
- ServerCtx stores PathBuf for config_dir/data_dir, string conversion at boundaries

PR review fixes:
- add explicit parens in resolve_route operator precedence (service_store.rs)
- hostname portability: drop -s flag, trim domain with split('.') (lan.rs)
- serve_ca uses spawn_blocking instead of sync fs::read in async handler
- load_config: remove TOCTOU exists() check, read directly and handle NotFound

XSS hardening:
- HTML-escape all user-controlled interpolations in dashboard (service names,
  route paths, ports, URLs, block check domain/reason)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:24:21 +02:00
Razvan Dimescu
53ae4d1404 address PR review: SRV port, drop spike, percent-encoded paths
- SRV record uses first service's port (was 0, confused dns-sd -L)
- Remove examples/mdns_coexist.rs (served its purpose as spike)
- Reject percent-encoding in route paths (defense-in-depth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:21:09 +02:00
Razvan Dimescu
4748a4a4bb dashboard: show LAN status in Local Services panel header
- Add lan_enabled to ServerCtx
- Add lan field to /stats API (enabled, peer count)
- Dashboard shows "LAN off" (dim) or "LAN on · N peers" (green)
- Tooltip shows enable command or mDNS service type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:16:52 +02:00
Razvan Dimescu
607470472d README: add numa lan on command to LAN discovery section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:12:53 +02:00
Razvan Dimescu
0dd7700665 simplify set_lan_enabled: fix config path, TOCTOU, double iteration
- Accept config path parameter (consistent with main's resolution)
- Read first, match on NotFound (eliminates TOCTOU race)
- Single position() call replaces any() + position()
- Precise key matching via split_once('=')
- Preserve original indentation on replacement
- Extract print_lan_status helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:59:35 +02:00
Razvan Dimescu
dddc10336c add numa lan on/off CLI command, update README
- numa lan on/off toggles LAN discovery in numa.toml
- Writes [lan] section if missing, updates enabled if present
- Colored output with restart hint
- README: add lan on/off to help text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:30:22 +02:00
Razvan Dimescu
4e723e8ee7 update README: mDNS, path routing, security defaults, opt-in LAN
- LAN discovery section: multicast → mDNS, add opt-in config example
- Add path-based routing to Why Numa, Local Service Proxy, comparison table, roadmap
- Update developer overrides: 25+ endpoints, mention /diagnose
- Comparison table: add path-based routing row
- Diagram: multicast → mDNS label

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:14:18 +02:00
Razvan Dimescu
03ca0bcb28 dashboard: route CRUD, source-aware service controls, XSS fix
- Add inline route management (+ route / x) per service in dashboard
- Expose service source (config vs api) in API response
- Only show service delete button for API-created services
- Pre-fill route port with service target_port
- Fix XSS in route path onclick handlers
- Skip renderServices refresh while route form is open (editingRoute guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:58:31 +02:00
Razvan Dimescu
c021d5a0c8 add unit tests for route matching, config defaults, and service store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 07:49:22 +02:00
Razvan Dimescu
ed12659b26 fmt: fix proxy.rs formatting for CI rustfmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 07:13:58 +02:00
Razvan Dimescu
eaab406515 simplify: unify route structs, fix prefix collision, lint fixes
- Unify RouteConfig/RouteEntry/RouteResponse into single RouteEntry
- Fix prefix collision: /api no longer matches /apiary (segment boundary check)
- Add path traversal rejection in route API
- Extract MdnsAnnouncement struct (clippy type_complexity)
- cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 06:57:57 +02:00
Razvan Dimescu
9992418908 LAN opt-in, mDNS migration, security hardening, path-based routing
- LAN discovery disabled by default (opt-in via [lan] enabled = true)
- Replace custom JSON multicast (239.255.70.78:5390) with standard mDNS
  (_numa._tcp.local on 224.0.0.251:5353) using existing DNS parser
- Instance ID in TXT record for multi-instance self-filtering
- API and proxy bind to 127.0.0.1 by default (0.0.0.0 when LAN enabled)
- Path-based routing: longest prefix match with optional prefix stripping
  via [[services]] routes = [{path, port, strip?}]
- REST API: GET/POST/DELETE /services/{name}/routes
- Dashboard shows route lines per service when configured
- Segment-boundary route matching (prevents /api matching /apiary)
- Route path validation (rejects path traversal)

Closes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 06:56:31 +02:00
Razvan Dimescu
0a43feaf1a Merge pull request #10 from razvandimescu/fix/fast-network-detect
Reduce network change detection to 5s
2026-03-22 21:47:25 +02:00
Razvan Dimescu
1bf11190d5 reduce network change detection to 5s with tiered polling
LAN IP checked every 5s (cheap UDP socket call). Full upstream
re-detection runs every 30s as safety net, or immediately when
LAN IP changes. Reduces worst-case network switch recovery from
30s to 5s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:36:03 +02:00
16 changed files with 1605 additions and 240 deletions

View File

@@ -79,8 +79,21 @@ jobs:
${{ matrix.name }}.zip ${{ matrix.name }}.zip
${{ matrix.name }}.zip.sha256 ${{ 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: release:
needs: build needs: [build, publish]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4

39
Cargo.lock generated
View File

@@ -67,6 +67,15 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.6.2" version = "0.6.2"
@@ -384,6 +393,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -514,6 +529,25 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -575,6 +609,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -932,8 +967,9 @@ dependencies = [
[[package]] [[package]]
name = "numa" name = "numa"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"arc-swap",
"axum", "axum",
"env_logger", "env_logger",
"futures", "futures",
@@ -1201,6 +1237,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "numa" name = "numa"
version = "0.3.1" version = "0.5.0"
authors = ["razvandimescu <razvan@dimescu.com>"] authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021" edition = "2021"
description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done." description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done."
@@ -17,7 +17,7 @@ serde_json = "1"
toml = "0.8" toml = "0.8"
log = "0.4" log = "0.4"
env_logger = "0.11" 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 = { version = "1", features = ["client", "http1", "server"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1" http-body-util = "0.1"
@@ -27,3 +27,4 @@ rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
time = "0.3" time = "0.3"
rustls = "0.23" rustls = "0.23"
tokio-rustls = "0.26" tokio-rustls = "0.26"
arc-swap = "1"

View File

@@ -15,7 +15,9 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by
## Quick Start ## Quick Start
```bash ```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 curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
# Run (port 53 requires root) # Run (port 53 requires root)
@@ -37,10 +39,12 @@ sudo ./target/release/numa
## Why 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. - **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.
- **LAN service discovery** — Numa instances on the same network find each other automatically via multicast. Access a teammate's `api.numa` from your machine, zero config.
- **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. - **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver.
- **Live dashboard** — real-time stats, query log, blocking controls, service management. LAN accessibility badges show which services are reachable from other devices. - **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. - **macOS + Linux** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service.
@@ -61,6 +65,17 @@ open http://frontend.numa # → proxied to localhost:5173
- **WebSocket** — Vite/webpack HMR works through the proxy - **WebSocket** — Vite/webpack HMR works through the proxy
- **Health checks** — dashboard shows green/red status per service - **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. - **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 - **Persistent** — services survive restarts
- Or configure in `numa.toml`: - Or configure in `numa.toml`:
@@ -77,7 +92,7 @@ Run Numa on multiple machines. They find each other automatically:
``` ```
Machine A (192.168.1.5) Machine B (192.168.1.20) Machine A (192.168.1.5) Machine B (192.168.1.20)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Numa │ multicast │ Numa │ │ Numa │ mDNS │ Numa │
│ services: │◄───────────►│ services: │ │ services: │◄───────────►│ services: │
│ - api (port 8000) │ discovery │ - grafana (3000) │ │ - api (port 8000) │ discovery │ - grafana (3000) │
│ - frontend (5173) │ │ │ │ - frontend (5173) │ │ │
@@ -90,7 +105,16 @@ dig @127.0.0.1 api.numa # → 192.168.1.5
curl http://api.numa # → proxied to Machine A's port 8000 curl http://api.numa # → proxied to Machine A's port 8000
``` ```
No configuration needed. Multicast announcements on `239.255.70.78:5390`, configurable via `[lan]` in `numa.toml`. 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: **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:
@@ -107,14 +131,15 @@ bind_addr = "0.0.0.0:53"
| | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa | | | 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 | | Local service proxy | No | No | No | No | `.numa` + HTTPS + WS |
| LAN service discovery | No | No | No | No | Multicast, zero config | | Path-based routing | No | No | No | No | Prefix match + strip |
| Data stays local | Yes | Yes | Cloud | Cloud | 100% local | | 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 | | 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 ## How It Works
@@ -122,7 +147,7 @@ bind_addr = "0.0.0.0:53"
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream 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) [Configuration reference](numa.toml)
@@ -133,7 +158,9 @@ No DNS libraries. The wire protocol — headers, labels, compression pointers, r
- [x] Ad blocking — 385K+ domains, live dashboard, allowlist - [x] Ad blocking — 385K+ domains, live dashboard, allowlist
- [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery - [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery
- [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket - [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket
- [x] LAN service discovery — multicast auto-discovery, cross-machine DNS + proxy - [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) - [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes)
- [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served - [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served

View File

@@ -1,13 +1,22 @@
[server] [server]
bind_addr = "0.0.0.0:53" bind_addr = "0.0.0.0:53"
api_port = 5380 api_port = 5380
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
# [upstream] # [upstream]
# address = "" # auto-detect from system resolver (default) # address = "" # auto-detect from system resolver (default)
# address = "9.9.9.9" # or set explicitly # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
# port = 53 # 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 # 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] [cache]
max_entries = 10000 max_entries = 10000
min_ttl = 60 min_ttl = 60
@@ -18,6 +27,7 @@ enabled = true
port = 80 port = 80
tls_port = 443 tls_port = 443
tld = "numa" 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) # Pre-configured services (numa.numa is always added automatically)
# [[services]] # [[services]]
@@ -40,3 +50,9 @@ tld = "numa"
# record_type = "A" # record_type = "A"
# value = "127.0.0.1" # value = "127.0.0.1"
# ttl = 60 # 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

View File

@@ -580,10 +580,11 @@ body {
<!-- Local services --> <!-- Local services -->
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div style="flex:1;">
<span class="panel-title">Local Services</span> <span class="panel-title">Local Services</span>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.</div> <div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.</div>
</div> </div>
<span id="lanToggle" style="font-family:var(--font-mono);font-size:0.68rem;cursor:default;user-select:none;" title=""></span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<form class="override-form" id="serviceForm" onsubmit="return addService(event)"> <form class="override-form" id="serviceForm" onsubmit="return addService(event)">
@@ -660,6 +661,7 @@ body {
<script> <script>
const API = ''; const API = '';
const h = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let prevTotal = null; let prevTotal = null;
let lastLogEntries = []; let lastLogEntries = [];
let prevTime = null; let prevTime = null;
@@ -874,6 +876,24 @@ async function refresh() {
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs);
document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || '';
// LAN status indicator
const lanEl = document.getElementById('lanToggle');
if (stats.lan) {
if (!stats.lan.enabled) {
lanEl.style.color = 'var(--text-dim)';
lanEl.textContent = 'LAN off';
lanEl.title = 'Enable with: numa lan on';
} else {
const pc = stats.lan.peers || 0;
lanEl.style.color = pc > 0 ? 'var(--emerald)' : 'var(--teal)';
lanEl.textContent = `LAN on · ${pc} peer${pc !== 1 ? 's' : ''}`;
lanEl.title = 'mDNS discovery active (_numa._tcp.local)';
}
}
document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('overrideCount').textContent = stats.overrides.active;
document.getElementById('blockedCount').textContent = formatNumber(q.blocked); document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
const bl = stats.blocking; const bl = stats.blocking;
@@ -989,14 +1009,14 @@ async function checkDomain(event) {
if (result.blocked) { if (result.blocked) {
el.style.background = 'rgba(181, 68, 58, 0.1)'; el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)'; el.style.color = 'var(--rose)';
el.innerHTML = `<strong>Blocked</strong> — ${result.reason}` + el.innerHTML = `<strong>Blocked</strong> — ${h(result.reason)}` +
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : '') + (result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '') +
` <button class="btn-delete" onclick="allowDomain('${domain}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`; ` <button class="btn-delete" onclick="allowDomain('${h(domain)}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
} else { } else {
el.style.background = 'rgba(82, 122, 82, 0.1)'; el.style.background = 'rgba(82, 122, 82, 0.1)';
el.style.color = 'var(--emerald)'; el.style.color = 'var(--emerald)';
el.innerHTML = `<strong>Allowed</strong> — ${result.reason}` + el.innerHTML = `<strong>Allowed</strong> — ${h(result.reason)}` +
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : ''); (result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '');
} }
} catch (err) { } catch (err) {
el.style.display = 'block'; el.style.display = 'block';
@@ -1086,7 +1106,10 @@ async function removeAllowlistDomain(domain) {
} catch (err) {} } catch (err) {}
} }
let editingRoute = false;
function renderServices(entries) { function renderServices(entries) {
if (editingRoute) return;
const el = document.getElementById('servicesList'); const el = document.getElementById('servicesList');
if (!entries.length) { if (!entries.length) {
el.innerHTML = '<div class="empty-state">No services configured</div>'; el.innerHTML = '<div class="empty-state">No services configured</div>';
@@ -1098,18 +1121,69 @@ function renderServices(entries) {
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>' ? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>') : '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
: ''; : '';
const routeLines = (e.routes || []).map(r =>
`<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` +
`<span style="display:inline-block;min-width:60px;">${h(r.path)}</span> ` +
`&rarr; :${parseInt(r.port)||0}` +
(r.strip ? ` <span style="opacity:0.6;">(strip)</span>` : '') +
(e.name === 'numa' ? '' : ` <button class="btn-delete" onclick="deleteRoute('${h(e.name)}','${h(r.path)}')" title="Remove route" style="font-size:0.65rem;padding:0 0.25rem;min-width:auto;opacity:0.5;">&times;</button>`) +
`</div>`
).join('');
const deletable = e.source !== 'config' && e.name !== 'numa';
const name = h(e.name);
return ` return `
<div class="service-item"> <div class="service-item">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span> <span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
<div class="service-info"> <div class="service-info">
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div> <div class="service-name"><a href="${h(e.url)}" target="_blank">${name}.numa</a>${lanBadge}</div>
<div class="service-port">localhost:${e.target_port} &rarr; proxied</div> <div class="service-port">localhost:${parseInt(e.target_port)||0} &rarr; proxied</div>
${routeLines}
${e.name === 'numa' ? '' : `<div style="margin-top:0.3rem;"><button onclick="toggleRouteForm('${name}')" style="font-size:0.7rem;padding:0.1rem 0.4rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">+ route</button><div id="routeForm-${name}" style="display:none;margin-top:0.3rem;"><div style="display:flex;gap:0.3rem;align-items:center;"><input type="text" id="routePath-${name}" placeholder="/path" style="flex:2;padding:0.25rem 0.4rem;font-size:0.75rem;"><input type="number" id="routePort-${name}" value="${parseInt(e.target_port)||0}" min="1" max="65535" style="flex:1;padding:0.25rem 0.4rem;font-size:0.75rem;"><label style="font-size:0.7rem;color:var(--text-dim);display:flex;align-items:center;gap:0.2rem;"><input type="checkbox" id="routeStrip-${name}">strip</label><button onclick="addRoute('${name}')" style="font-size:0.7rem;padding:0.2rem 0.5rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">add</button></div><div class="override-error" id="routeError-${name}" style="display:none;font-size:0.7rem;"></div></div></div>`}
</div> </div>
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`} ${deletable ? `<button class="btn-delete" onclick="deleteService('${name}')" title="Remove service">&times;</button>` : ''}
</div> </div>
`}).join(''); `}).join('');
} }
function toggleRouteForm(name) {
const el = document.getElementById('routeForm-' + name);
const opening = el.style.display === 'none';
el.style.display = opening ? 'block' : 'none';
editingRoute = opening;
}
async function addRoute(name) {
const errEl = document.getElementById('routeError-' + name);
errEl.style.display = 'none';
try {
const path = document.getElementById('routePath-' + name).value.trim();
const port = parseInt(document.getElementById('routePort-' + name).value) || 0;
const strip = document.getElementById('routeStrip-' + name).checked;
const res = await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, port, strip }),
});
if (!res.ok) throw new Error(await res.text());
editingRoute = false;
refresh();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
}
}
async function deleteRoute(name, path) {
try {
await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
refresh();
} catch (err) { /* next refresh will update */ }
}
async function addService(event) { async function addService(event) {
event.preventDefault(); event.preventDefault();
const errEl = document.getElementById('serviceError'); const errEl = document.getElementById('serviceError');
@@ -1151,8 +1225,10 @@ setInterval(refresh, 2000);
</script> </script>
<div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);"> <div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span> Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Logs: <span id="logPath" style="user-select:all;">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span> · Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a> · <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
</div> </div>

View File

@@ -9,7 +9,7 @@ use axum::{Json, Router};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ctx::ServerCtx; use crate::ctx::ServerCtx;
use crate::forward::forward_query; use crate::forward::{forward_query, Upstream};
use crate::query_log::QueryLogFilter; use crate::query_log::QueryLogFilter;
use crate::question::QueryType; use crate::question::QueryType;
use crate::stats::QueryPath; use crate::stats::QueryPath;
@@ -46,6 +46,10 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/services", get(list_services)) .route("/services", get(list_services))
.route("/services", post(create_service)) .route("/services", post(create_service))
.route("/services/{name}", delete(remove_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) .with_state(ctx)
} }
@@ -127,10 +131,19 @@ struct QueryLogResponse {
struct StatsResponse { struct StatsResponse {
uptime_secs: u64, uptime_secs: u64,
upstream: String, upstream: String,
config_path: String,
data_dir: String,
queries: QueriesStats, queries: QueriesStats,
cache: CacheStats, cache: CacheStats,
overrides: OverrideStats, overrides: OverrideStats,
blocking: BlockingStatsResponse, blocking: BlockingStatsResponse,
lan: LanStatsResponse,
}
#[derive(Serialize)]
struct LanStatsResponse {
enabled: bool,
peers: usize,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -342,9 +355,9 @@ async fn diagnose(
} }
// Check upstream (async, no locks held) // Check upstream (async, no locks held)
let upstream = *ctx.upstream.lock().unwrap(); let upstream = ctx.upstream.lock().unwrap().clone();
let (upstream_matched, upstream_detail) = let (upstream_matched, upstream_detail) =
forward_query_for_diagnose(&domain_lower, upstream, ctx.timeout).await; forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await;
steps.push(DiagnoseStep { steps.push(DiagnoseStep {
source: "upstream".to_string(), source: "upstream".to_string(),
matched: upstream_matched, matched: upstream_matched,
@@ -360,7 +373,7 @@ async fn diagnose(
async fn forward_query_for_diagnose( async fn forward_query_for_diagnose(
domain: &str, domain: &str,
upstream: std::net::SocketAddr, upstream: &Upstream,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> (bool, String) { ) -> (bool, String) {
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
@@ -441,6 +454,8 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
Json(StatsResponse { Json(StatsResponse {
uptime_secs: snap.uptime_secs, uptime_secs: snap.uptime_secs,
upstream, upstream,
config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(),
queries: QueriesStats { queries: QueriesStats {
total: snap.total, total: snap.total,
forwarded: snap.forwarded, forwarded: snap.forwarded,
@@ -463,6 +478,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
domains_loaded: bl_stats.domains_loaded, domains_loaded: bl_stats.domains_loaded,
allowlist_size: bl_stats.allowlist_size, allowlist_size: bl_stats.allowlist_size,
}, },
lan: LanStatsResponse {
enabled: ctx.lan_enabled,
peers: ctx.lan_peers.lock().unwrap().list().len(),
},
}) })
} }
@@ -596,6 +615,9 @@ struct ServiceResponse {
url: String, url: String,
healthy: bool, healthy: bool,
lan_accessible: bool, lan_accessible: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
routes: Vec<crate::service_store::RouteEntry>,
source: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -610,7 +632,19 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
store store
.list() .list()
.into_iter() .into_iter()
.map(|e| (e.name.clone(), e.target_port)) .map(|e| {
let source = if store.is_config_service(&e.name) {
"config"
} else {
"api"
};
(
e.name.clone(),
e.target_port,
e.routes.clone(),
source.to_string(),
)
})
.collect() .collect()
}; };
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
@@ -619,7 +653,7 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
let check_futures: Vec<_> = entries let check_futures: Vec<_> = entries
.iter() .iter()
.map(|(_, port)| { .map(|(_, port, _, _)| {
let port = *port; let port = *port;
let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], 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)); let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port));
@@ -639,12 +673,14 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
.into_iter() .into_iter()
.zip(check_results) .zip(check_results)
.map( .map(
|((name, port), (healthy, lan_accessible))| ServiceResponse { |((name, port, routes, source), (healthy, lan_accessible))| ServiceResponse {
url: format!("http://{}.{}", name, tld), url: format!("http://{}.{}", name, tld),
name, name,
target_port: port, target_port: port,
healthy, healthy,
lan_accessible, lan_accessible,
routes,
source,
}, },
) )
.collect(); .collect();
@@ -675,7 +711,11 @@ async fn create_service(
} }
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
let is_new = !ctx.services.lock().unwrap().has_name(&name);
ctx.services.lock().unwrap().insert(&name, req.target_port); ctx.services.lock().unwrap().insert(&name, req.target_port);
if is_new {
crate::tls::regenerate_tls(&ctx);
}
let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port)); let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port));
let lan_addr = let lan_addr =
@@ -694,6 +734,8 @@ async fn create_service(
target_port: req.target_port, target_port: req.target_port,
healthy, healthy,
lan_accessible, lan_accessible,
routes: Vec::new(),
source: "api".to_string(),
}), }),
)) ))
} }
@@ -702,14 +744,101 @@ async fn remove_service(State(ctx): State<Arc<ServerCtx>>, Path(name): Path<Stri
if name.eq_ignore_ascii_case("numa") { if name.eq_ignore_ascii_case("numa") {
return StatusCode::FORBIDDEN; return StatusCode::FORBIDDEN;
} }
let mut store = ctx.services.lock().unwrap(); let removed = ctx.services.lock().unwrap().remove(&name);
if store.remove(&name) { if removed {
crate::tls::regenerate_tls(&ctx);
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} else { } else {
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
} }
} }
// --- 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<Arc<ServerCtx>>,
Path(name): Path<String>,
) -> Result<Json<Vec<crate::service_store::RouteEntry>>, 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<Arc<ServerCtx>>,
Path(name): Path<String>,
Json(req): Json<AddRouteRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
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<Arc<ServerCtx>>,
Path(name): Path<String>,
Json(req): Json<RemoveRouteRequest>,
) -> 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<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
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 { async fn check_tcp(addr: std::net::SocketAddr) -> bool {
tokio::time::timeout( tokio::time::timeout(
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),

View File

@@ -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 { pub fn pos(&self) -> usize {
self.pos self.pos
} }

View File

@@ -35,6 +35,8 @@ pub struct ServerConfig {
pub bind_addr: String, pub bind_addr: String,
#[serde(default = "default_api_port")] #[serde(default = "default_api_port")]
pub api_port: u16, pub api_port: u16,
#[serde(default = "default_api_bind_addr")]
pub api_bind_addr: String,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -42,10 +44,15 @@ impl Default for ServerConfig {
ServerConfig { ServerConfig {
bind_addr: default_bind_addr(), bind_addr: default_bind_addr(),
api_port: default_api_port(), 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 { fn default_bind_addr() -> String {
"0.0.0.0:53".to_string() "0.0.0.0:53".to_string()
} }
@@ -172,6 +179,8 @@ pub struct ProxyConfig {
pub tls_port: u16, pub tls_port: u16,
#[serde(default = "default_proxy_tld")] #[serde(default = "default_proxy_tld")]
pub tld: String, pub tld: String,
#[serde(default = "default_proxy_bind_addr")]
pub bind_addr: String,
} }
impl Default for ProxyConfig { impl Default for ProxyConfig {
@@ -181,10 +190,15 @@ impl Default for ProxyConfig {
port: default_proxy_port(), port: default_proxy_port(),
tls_port: default_proxy_tls_port(), tls_port: default_proxy_tls_port(),
tld: default_proxy_tld(), 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 { fn default_proxy_enabled() -> bool {
true true
} }
@@ -202,16 +216,14 @@ fn default_proxy_tld() -> String {
pub struct ServiceConfig { pub struct ServiceConfig {
pub name: String, pub name: String,
pub target_port: u16, pub target_port: u16,
#[serde(default)]
pub routes: Vec<crate::service_store::RouteEntry>,
} }
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
pub struct LanConfig { pub struct LanConfig {
#[serde(default = "default_lan_enabled")] #[serde(default = "default_lan_enabled")]
pub enabled: bool, pub enabled: bool,
#[serde(default = "default_lan_multicast_group")]
pub multicast_group: String,
#[serde(default = "default_lan_port")]
pub port: u16,
#[serde(default = "default_lan_broadcast_interval")] #[serde(default = "default_lan_broadcast_interval")]
pub broadcast_interval_secs: u64, pub broadcast_interval_secs: u64,
#[serde(default = "default_lan_peer_timeout")] #[serde(default = "default_lan_peer_timeout")]
@@ -222,8 +234,6 @@ impl Default for LanConfig {
fn default() -> Self { fn default() -> Self {
LanConfig { LanConfig {
enabled: default_lan_enabled(), enabled: default_lan_enabled(),
multicast_group: default_lan_multicast_group(),
port: default_lan_port(),
broadcast_interval_secs: default_lan_broadcast_interval(), broadcast_interval_secs: default_lan_broadcast_interval(),
peer_timeout_secs: default_lan_peer_timeout(), peer_timeout_secs: default_lan_peer_timeout(),
} }
@@ -231,13 +241,7 @@ impl Default for LanConfig {
} }
fn default_lan_enabled() -> bool { fn default_lan_enabled() -> bool {
true false
}
fn default_lan_multicast_group() -> String {
"239.255.70.78".to_string()
}
fn default_lan_port() -> u16 {
5390
} }
fn default_lan_broadcast_interval() -> u64 { fn default_lan_broadcast_interval() -> u64 {
30 30
@@ -246,13 +250,128 @@ fn default_lan_peer_timeout() -> u64 {
90 90
} }
pub fn load_config(path: &str) -> Result<Config> { #[cfg(test)]
if !Path::new(path).exists() { mod tests {
return Ok(Config::default()); use super::*;
#[test]
fn lan_disabled_by_default() {
assert!(!LanConfig::default().enabled);
} }
let contents = std::fs::read_to_string(path)?;
#[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<ConfigLoad> {
// Try the given path first, then well-known locations (for service mode where cwd is /)
let candidates: Vec<std::path::PathBuf> = {
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)?; let config: Config = toml::from_str(&contents)?;
Ok(config) 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<String, HashMap<QueryType, Vec<DnsRecord>>>; pub type ZoneMap = HashMap<String, HashMap<QueryType, Vec<DnsRecord>>>;

View File

@@ -1,15 +1,18 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant, SystemTime};
use arc_swap::ArcSwap;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rustls::ServerConfig;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use crate::blocklist::BlocklistStore; use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer; use crate::buffer::BytePacketBuffer;
use crate::cache::DnsCache; use crate::cache::DnsCache;
use crate::config::ZoneMap; use crate::config::ZoneMap;
use crate::forward::forward_query; use crate::forward::{forward_query, Upstream};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::lan::PeerStore; use crate::lan::PeerStore;
use crate::override_store::OverrideStore; use crate::override_store::OverrideStore;
@@ -32,13 +35,19 @@ pub struct ServerCtx {
pub services: Mutex<ServiceStore>, pub services: Mutex<ServiceStore>,
pub lan_peers: Mutex<PeerStore>, pub lan_peers: Mutex<PeerStore>,
pub forwarding_rules: Vec<ForwardingRule>, pub forwarding_rules: Vec<ForwardingRule>,
pub upstream: Mutex<SocketAddr>, pub upstream: Mutex<Upstream>,
pub upstream_auto: bool, pub upstream_auto: bool,
pub upstream_port: u16, pub upstream_port: u16,
pub lan_ip: Mutex<std::net::Ipv4Addr>, pub lan_ip: Mutex<std::net::Ipv4Addr>,
pub timeout: Duration, pub timeout: Duration,
pub proxy_tld: String, pub proxy_tld: String,
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation 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<ArcSwap<ServerConfig>>,
} }
pub async fn handle_query( pub async fn handle_query(
@@ -134,9 +143,11 @@ pub async fn handle_query(
(resp, QueryPath::Cached) (resp, QueryPath::Cached)
} else { } else {
let upstream = let upstream =
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
.unwrap_or_else(|| *ctx.upstream.lock().unwrap()); Some(addr) => Upstream::Udp(addr),
match forward_query(&query, upstream, ctx.timeout).await { None => ctx.upstream.lock().unwrap().clone(),
};
match forward_query(&query, &upstream, ctx.timeout).await {
Ok(resp) => { Ok(resp) => {
ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); ctx.cache.lock().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded) (resp, QueryPath::Forwarded)

View File

@@ -1,3 +1,4 @@
use std::fmt;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::Duration; use std::time::Duration;
@@ -8,7 +9,46 @@ use crate::buffer::BytePacketBuffer;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::Result; 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( pub async fn forward_query(
query: &DnsPacket,
upstream: &Upstream,
timeout_duration: Duration,
) -> Result<DnsPacket> {
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, query: &DnsPacket,
upstream: SocketAddr, upstream: SocketAddr,
timeout_duration: Duration, timeout_duration: Duration,
@@ -33,3 +73,174 @@ pub async fn forward_query(
DnsPacket::from_buffer(&mut recv_buffer) DnsPacket::from_buffer(&mut recv_buffer)
} }
async fn forward_doh(
query: &DnsPacket,
url: &str,
client: &reqwest::Client,
timeout_duration: Duration,
) -> Result<DnsPacket> {
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<u8> {
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::<std::net::Ipv4Addr>().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());
}
}

View File

@@ -1,13 +1,22 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use log::{debug, info, warn}; use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use crate::buffer::BytePacketBuffer;
use crate::config::LanConfig; use crate::config::LanConfig;
use crate::ctx::ServerCtx; 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 --- // --- Peer Store ---
@@ -24,11 +33,18 @@ impl PeerStore {
} }
} }
pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) { /// 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 now = Instant::now();
let mut changed = false;
for (name, port) in services { for (name, port) in services {
self.peers.insert(name.to_lowercase(), (host, *port, now)); 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)> { pub fn lookup(&mut self, name: &str) -> Option<(IpAddr, u16)> {
@@ -58,25 +74,19 @@ impl PeerStore {
.collect() .collect()
} }
pub fn names(&mut self) -> Vec<String> {
let now = Instant::now();
self.peers
.retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout);
self.peers.keys().cloned().collect()
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.peers.clear(); self.peers.clear();
} }
} }
// --- Multicast --- // --- mDNS Discovery ---
#[derive(Serialize, Deserialize)]
struct Announcement {
instance_id: u64,
host: String,
services: Vec<AnnouncedService>,
}
#[derive(Serialize, Deserialize)]
struct AnnouncedService {
name: String,
port: u16,
}
pub fn detect_lan_ip() -> Option<Ipv4Addr> { pub fn detect_lan_ip() -> Option<Ipv4Addr> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
@@ -87,44 +97,45 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
} }
} }
pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) { fn get_hostname() -> String {
let multicast_group: Ipv4Addr = match config.multicast_group.parse::<Ipv4Addr>() { std::process::Command::new("hostname")
Ok(g) if g.is_multicast() => g, .output()
Ok(g) => { .ok()
warn!("LAN: {} is not a multicast address (224.0.0.0/4)", g); .and_then(|o| String::from_utf8(o.stdout).ok())
return; .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
} .filter(|h| !h.is_empty())
Err(e) => { .unwrap_or_else(|| "numa".to_string())
warn!( }
"LAN: invalid multicast group {}: {}",
config.multicast_group, e
);
return;
}
};
let port = config.port;
let interval = Duration::from_secs(config.broadcast_interval_secs);
let instance_id: u64 = { /// Generate a per-process instance ID for self-filtering on multi-instance hosts
let pid = std::process::id() as u64; fn instance_id() -> String {
let ts = std::time::SystemTime::now() format!(
"{}:{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_nanos() as u64; .as_nanos()
pid ^ ts % 1_000_000
}; )
}
pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
let interval = Duration::from_secs(config.broadcast_interval_secs);
let local_ip = *ctx.lan_ip.lock().unwrap(); let local_ip = *ctx.lan_ip.lock().unwrap();
let hostname = get_hostname();
let our_instance_id = instance_id();
info!( info!(
"LAN discovery on {}:{}, local IP {}, instance {:016x}", "LAN discovery via mDNS on {}:{}, local IP {}, instance {}._numa._tcp.local",
multicast_group, port, local_ip, instance_id MDNS_ADDR, MDNS_PORT, local_ip, hostname
); );
// Create socket with SO_REUSEADDR for multicast let std_socket = match create_mdns_socket() {
let std_socket = match create_multicast_socket(multicast_group, port) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
warn!( warn!(
"LAN: could not bind multicast socket: {} — LAN discovery disabled", "LAN: could not bind mDNS socket: {} — LAN discovery disabled",
e e
); );
return; return;
@@ -138,81 +149,312 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
} }
}; };
let socket = Arc::new(socket); let socket = Arc::new(socket);
let dest = SocketAddr::new(IpAddr::V4(MDNS_ADDR), MDNS_PORT);
// Spawn sender // Spawn sender: announce our services periodically
let sender_ctx = Arc::clone(&ctx); let sender_ctx = Arc::clone(&ctx);
let sender_socket = Arc::clone(&socket); let sender_socket = Arc::clone(&socket);
let dest = SocketAddr::new(IpAddr::V4(multicast_group), port); let sender_hostname = hostname.clone();
let sender_instance_id = our_instance_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval); let mut ticker = tokio::time::interval(interval);
loop { loop {
ticker.tick().await; ticker.tick().await;
let services: Vec<AnnouncedService> = { let services: Vec<(String, u16)> = {
let store = sender_ctx.services.lock().unwrap(); let store = sender_ctx.services.lock().unwrap();
store store
.list() .list()
.iter() .iter()
.map(|e| AnnouncedService { .map(|e| (e.name.clone(), e.target_port))
name: e.name.clone(),
port: e.target_port,
})
.collect() .collect()
}; };
if services.is_empty() { if services.is_empty() {
continue; continue;
} }
let current_ip = sender_ctx.lan_ip.lock().unwrap().to_string(); let current_ip = *sender_ctx.lan_ip.lock().unwrap();
let announcement = Announcement { if let Ok(pkt) =
instance_id, build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
host: current_ip, {
services, let _ = sender_socket.send_to(pkt.filled(), dest).await;
};
if let Ok(json) = serde_json::to_vec(&announcement) {
let _ = sender_socket.send_to(&json, dest).await;
} }
} }
}); });
// Receiver loop // 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]; let mut buf = vec![0u8; 4096];
loop { loop {
let (len, src) = match socket.recv_from(&mut buf).await { let (len, _src) = match socket.recv_from(&mut buf).await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
debug!("LAN recv error: {}", e); debug!("mDNS recv error: {}", e);
continue; continue;
} }
}; };
let announcement: Announcement = match serde_json::from_slice(&buf[..len]) {
Ok(a) => a, let data = &buf[..len];
Err(_) => continue, if let Some(ann) = parse_mdns_response(data) {
}; // Skip our own announcements via instance ID (works on multi-instance same-host)
// Skip self-announcements if ann.instance_id.as_deref() == Some(our_instance_id.as_str()) {
if announcement.instance_id == instance_id {
continue; continue;
} }
let peer_ip: IpAddr = match announcement.host.parse() { if !ann.services.is_empty() {
Ok(ip) => ip, let changed = ctx
Err(_) => continue, .lan_peers
}; .lock()
let services: Vec<(String, u16)> = announcement .unwrap()
.services .update(ann.peer_ip, &ann.services);
.iter() if changed {
.map(|s| (s.name.clone(), s.port)) crate::tls::regenerate_tls(&ctx);
.collect(); }
let count = services.len();
ctx.lan_peers.lock().unwrap().update(peer_ip, &services);
debug!( debug!(
"LAN: {} services from {} (via {})", "LAN: {} services from {} (mDNS)",
count, announcement.host, src ann.services.len(),
ann.peer_ip
); );
} }
}
}
} }
fn create_multicast_socket(group: Ipv4Addr, port: u16) -> std::io::Result<std::net::UdpSocket> { // --- mDNS Packet Building ---
use std::net::SocketAddrV4;
let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port); fn build_browse_query() -> crate::Result<BytePacketBuffer> {
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<BytePacketBuffer> {
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 → <hostname>._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: <instance>._numa._tcp.local → <hostname>.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::<Vec<_>>()
.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: <hostname>.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<String>,
}
fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
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<Vec<(String, u16)>> = None;
let mut peer_instance_id: Option<String> = None;
let mut a_ip: Option<IpAddr> = 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<std::net::UdpSocket> {
let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT);
let socket = socket2::Socket::new( let socket = socket2::Socket::new(
socket2::Domain::IPV4, socket2::Domain::IPV4,
socket2::Type::DGRAM, socket2::Type::DGRAM,
@@ -223,6 +465,6 @@ fn create_multicast_socket(group: Ipv4Addr, port: u16) -> std::io::Result<std::n
socket.set_reuse_port(true)?; socket.set_reuse_port(true)?;
socket.set_nonblocking(true)?; socket.set_nonblocking(true)?;
socket.bind(&socket2::SockAddr::from(addr))?; socket.bind(&socket2::SockAddr::from(addr))?;
socket.join_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED)?; socket.join_multicast_v4(&MDNS_ADDR, &Ipv4Addr::UNSPECIFIED)?;
Ok(socket.into()) Ok(socket.into())
} }

View File

@@ -2,14 +2,16 @@ use std::net::SocketAddr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use arc_swap::ArcSwap;
use log::{error, info}; use log::{error, info};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
use numa::buffer::BytePacketBuffer; use numa::buffer::BytePacketBuffer;
use numa::cache::DnsCache; 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::ctx::{handle_query, ServerCtx};
use numa::forward::Upstream;
use numa::override_store::OverrideStore; use numa::override_store::OverrideStore;
use numa::query_log::QueryLog; use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; 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 <on|off> [config-path]");
Ok(())
}
};
}
"version" | "--version" | "-V" => { "version" | "--version" | "-V" => {
eprintln!("numa {}", env!("CARGO_PKG_VERSION")); eprintln!("numa {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
@@ -65,6 +81,8 @@ async fn main() -> numa::Result<()> {
eprintln!(" service stop Uninstall the system service"); eprintln!(" service stop Uninstall the system service");
eprintln!(" service restart Restart the service with updated binary"); eprintln!(" service restart Restart the service with updated binary");
eprintln!(" service status Check if the service is running"); 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!(" help Show this help");
eprintln!(); eprintln!();
eprintln!("Config path defaults to numa.toml"); eprintln!("Config path defaults to numa.toml");
@@ -80,7 +98,11 @@ async fn main() -> numa::Result<()> {
} else { } else {
arg1 // treat as config path for backwards compatibility 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) // Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns(); let system_dns = discover_system_dns();
@@ -90,13 +112,27 @@ async fn main() -> numa::Result<()> {
.default_upstream .default_upstream
.or_else(numa::system_dns::detect_dhcp_dns) .or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| { .unwrap_or_else(|| {
info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)"); info!("could not detect system DNS, falling back to Quad9 DoH");
"9.9.9.9".to_string() "https://dns.quad9.net/dns-query".to_string()
}) })
} else { } else {
config.upstream.address.clone() 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 api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new(); let mut blocklist = BlocklistStore::new();
@@ -109,14 +145,28 @@ async fn main() -> numa::Result<()> {
// Build service store: config services + persisted user services // Build service store: config services + persisted user services
let mut service_store = ServiceStore::new(); 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 { 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(); service_store.load_persisted();
let forwarding_rules = system_dns.forwarding_rules; 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 { let ctx = Arc::new(ServerCtx {
socket: UdpSocket::bind(&config.server.bind_addr).await?, socket: UdpSocket::bind(&config.server.bind_addr).await?,
zone_map: build_zone_map(&config.zones)?, zone_map: build_zone_map(&config.zones)?,
@@ -143,44 +193,127 @@ async fn main() -> numa::Result<()> {
format!(".{}", config.proxy.tld) format!(".{}", config.proxy.tld)
}, },
proxy_tld: config.proxy.tld.clone(), 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(); 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")); // Build banner rows, then size the box to fit the longest value
eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); let api_url = format!("http://localhost:{}", api_port);
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); let proxy_label = if config.proxy.enabled {
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); if config.proxy.tls_port > 0 {
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); Some(format!(
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!(
"http://:{} https://:{}", "http://:{} https://:{}",
config.proxy.port, config.proxy.tls_port config.proxy.port, config.proxy.tls_port
) ))
} else { } else {
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port) Some(format!(
"http://*.{} on :{}",
config.proxy.tld, config.proxy.port
))
}
} else {
None
}; };
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); 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} {:<vw$}{o}{r}",
label,
value,
vw = w - 12
);
};
// Title row: center within the box
let title = format!(
"{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}",
env!("CARGO_PKG_VERSION")
);
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
let title_pad = w.saturating_sub(title_visible_len);
eprintln!("\n{o}{bar_top}{r}");
eprint!("{o}{r} {title}");
eprintln!("{}{o}{r}", " ".repeat(title_pad));
eprintln!("{o}{bar_top}{r}");
row("DNS", g, &config.server.bind_addr);
row("API", g, &api_url);
row("Dashboard", g, &api_url);
row("Upstream", g, &upstream_label);
row("Zones", g, &format!("{} records", zone_count));
row(
"Cache",
g,
&format!("max {} entries", config.cache.max_entries),
);
row(
"Blocking",
g,
&if config.blocking.enabled {
format!("{} lists", config.blocking.lists.len())
} else {
"disabled".to_string()
},
);
if let Some(ref label) = proxy_label {
row("Proxy", g, label);
} }
if config.lan.enabled { if config.lan.enabled {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mLAN\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", row("LAN", g, "mDNS (_numa._tcp.local)");
format!("{}:{}", config.lan.multicast_group, config.lan.port));
} }
if !ctx.forwarding_rules.is_empty() { if !ctx.forwarding_rules.is_empty() {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", row(
format!("{} conditional rules", ctx.forwarding_rules.len())); "Routing",
g,
&format!("{} conditional rules", ctx.forwarding_rules.len()),
);
} }
eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n"); eprintln!("{o}{bar_mid}{r}");
row("Config", d, &config_label);
row("Data", d, &data_label);
row("Services", d, &services_label);
eprintln!("{o}{bar_top}{r}\n");
info!( info!(
"numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}", "numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}",
config.server.bind_addr, upstream, zone_count, config.cache.max_entries, api_port, config.server.bind_addr, upstream_label, zone_count, config.cache.max_entries, api_port,
); );
// Download blocklists on startup // Download blocklists on startup
@@ -205,7 +338,7 @@ async fn main() -> numa::Result<()> {
// Spawn HTTP API server // Spawn HTTP API server
let api_ctx = Arc::clone(&ctx); 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 { tokio::spawn(async move {
let app = numa::api::router(api_ctx); let app = numa::api::router(api_ctx);
let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap();
@@ -213,38 +346,34 @@ async fn main() -> numa::Result<()> {
axum::serve(listener, app).await.unwrap(); 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 // Spawn HTTP reverse proxy for .numa domains
if config.proxy.enabled { if config.proxy.enabled {
let proxy_ctx = Arc::clone(&ctx); let proxy_ctx = Arc::clone(&ctx);
let proxy_port = config.proxy.port; let proxy_port = config.proxy.port;
tokio::spawn(async move { 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 // Spawn HTTPS reverse proxy with TLS termination
if config.proxy.enabled && config.proxy.tls_port > 0 { if config.proxy.enabled && config.proxy.tls_port > 0 && ctx.tls_config.is_some() {
let service_names: Vec<String> = 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 proxy_ctx = Arc::clone(&ctx);
let tls_port = config.proxy.tls_port; let tls_port = config.proxy.tls_port;
tokio::spawn(async move { tokio::spawn(async move {
numa::proxy::start_proxy_tls(proxy_ctx, tls_port, tls_config).await; numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await;
}); });
} }
Err(e) => {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
}
}
}
// Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush)
{ {
@@ -279,14 +408,17 @@ async fn main() -> numa::Result<()> {
} }
async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) { async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
let mut interval = tokio::time::interval(Duration::from_secs(30)); let mut tick: u64 = 0;
let mut interval = tokio::time::interval(Duration::from_secs(5));
interval.tick().await; // skip immediate tick interval.tick().await; // skip immediate tick
loop { loop {
interval.tick().await; interval.tick().await;
tick += 1;
let mut changed = false; let mut changed = false;
// Check LAN IP change // Check LAN IP change (every 5s — cheap, one UDP socket call)
if let Some(new_ip) = numa::lan::detect_lan_ip() { if let Some(new_ip) = numa::lan::detect_lan_ip() {
let mut current_ip = ctx.lan_ip.lock().unwrap(); let mut current_ip = ctx.lan_ip.lock().unwrap();
if new_ip != *current_ip { if new_ip != *current_ip {
@@ -296,20 +428,24 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
} }
} }
// Check upstream change (only for auto-detected upstream) // Re-detect upstream every 30s or on LAN IP change (UDP only
if ctx.upstream_auto { // 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 dns_info = numa::system_dns::discover_system_dns();
// Use detected upstream, or try DHCP-provided DNS, or fall back to Quad9
let new_addr = dns_info let new_addr = dns_info
.default_upstream .default_upstream
.or_else(numa::system_dns::detect_dhcp_dns) .or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| "9.9.9.9".to_string()); .unwrap_or_else(|| "9.9.9.9".to_string());
if let Ok(new_upstream) = if let Ok(new_sock) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>() format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{ {
let new_upstream = Upstream::Udp(new_sock);
let mut upstream = ctx.upstream.lock().unwrap(); let mut upstream = ctx.upstream.lock().unwrap();
if new_upstream != *upstream { if *upstream != new_upstream {
info!("upstream changed: {} → {}", *upstream, new_upstream); info!("upstream changed: {} → {}", upstream, new_upstream);
*upstream = new_upstream; *upstream = new_upstream;
changed = true; changed = true;
} }
@@ -324,6 +460,71 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
} }
} }
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<String> = 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]) { async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
let downloaded = download_blocklists(lists).await; let downloaded = download_blocklists(lists).await;

View File

@@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use axum::body::Body; use axum::body::Body;
@@ -11,7 +11,6 @@ use hyper::StatusCode;
use hyper_util::client::legacy::Client; use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioExecutor;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rustls::ServerConfig;
use tokio::io::copy_bidirectional; use tokio::io::copy_bidirectional;
use tokio_rustls::TlsAcceptor; use tokio_rustls::TlsAcceptor;
@@ -25,8 +24,8 @@ struct ProxyState {
client: HttpClient, client: HttpClient,
} }
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16) { pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr) {
let addr: SocketAddr = ([0, 0, 0, 0], port).into(); let addr: SocketAddr = (bind_addr, port).into();
let listener = match tokio::net::TcpListener::bind(addr).await { let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
@@ -50,8 +49,8 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16) {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<ServerConfig>) { pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr) {
let addr: SocketAddr = ([0, 0, 0, 0], port).into(); let addr: SocketAddr = (bind_addr, port).into();
let listener = match tokio::net::TcpListener::bind(addr).await { let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
@@ -64,11 +63,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<Ser
}; };
info!("HTTPS proxy listening on {}", addr); info!("HTTPS proxy listening on {}", addr);
let acceptor = TlsAcceptor::from(tls_config); if ctx.tls_config.is_none() {
warn!("proxy: no TLS config — HTTPS proxy disabled");
return;
}
let client: HttpClient = Client::builder(TokioExecutor::new()) let client: HttpClient = Client::builder(TokioExecutor::new())
.http1_preserve_header_case(true) .http1_preserve_header_case(true)
.build_http(); .build_http();
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
let tls_holder = Arc::clone(&ctx);
let state = ProxyState { ctx, client }; let state = ProxyState { ctx, client };
let app = Router::new().fallback(any(proxy_handler)).with_state(state); let app = Router::new().fallback(any(proxy_handler)).with_state(state);
@@ -82,7 +87,10 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<Ser
} }
}; };
let acceptor = acceptor.clone(); // Load the latest TLS config on each connection (picks up new service certs)
// unwrap safe: guarded by is_none() check above
let acceptor =
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
let app = app.clone(); let app = app.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -135,14 +143,17 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
} }
}; };
let (target_host, target_port) = { let request_path = req.uri().path().to_string();
let (target_host, target_port, rewritten_path) = {
let store = state.ctx.services.lock().unwrap(); let store = state.ctx.services.lock().unwrap();
if let Some(entry) = store.lookup(&service_name) { if let Some(entry) = store.lookup(&service_name) {
("localhost".to_string(), entry.target_port) let (port, path) = entry.resolve_route(&request_path);
("localhost".to_string(), port, path)
} else { } else {
let mut peers = state.ctx.lan_peers.lock().unwrap(); let mut peers = state.ctx.lan_peers.lock().unwrap();
match peers.lookup(&service_name) { match peers.lookup(&service_name) {
Some((ip, port)) => (ip.to_string(), port), Some((ip, port)) => (ip.to_string(), port, request_path.clone()),
None => { None => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -268,13 +279,15 @@ pre .str {{ color: #d48a5a }}
} }
}; };
let path_and_query = req let query_string = req
.uri() .uri()
.path_and_query() .query()
.map(|pq| pq.as_str()) .map(|q| format!("?{}", q))
.unwrap_or("/"); .unwrap_or_default();
let target_uri: hyper::Uri = let target_uri: hyper::Uri = format!(
format!("http://{}:{}{}", target_host, target_port, path_and_query) "http://{}:{}{}{}",
target_host, target_port, rewritten_path, query_string
)
.parse() .parse()
.unwrap(); .unwrap();

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use log::{info, warn}; use log::{info, warn};
@@ -8,12 +8,56 @@ use serde::{Deserialize, Serialize};
pub struct ServiceEntry { pub struct ServiceEntry {
pub name: String, pub name: String,
pub target_port: u16, pub target_port: u16,
#[serde(default)]
pub routes: Vec<RouteEntry>,
}
#[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 { pub struct ServiceStore {
entries: HashMap<String, ServiceEntry>, entries: HashMap<String, ServiceEntry>,
/// Services defined in numa.toml (not persisted to user file) /// Services defined in numa.toml (not persisted to user file)
config_services: std::collections::HashSet<String>, config_services: HashSet<String>,
persist_path: PathBuf, persist_path: PathBuf,
} }
@@ -28,13 +72,13 @@ impl ServiceStore {
let persist_path = dirs_path(); let persist_path = dirs_path();
ServiceStore { ServiceStore {
entries: HashMap::new(), entries: HashMap::new(),
config_services: std::collections::HashSet::new(), config_services: HashSet::new(),
persist_path, persist_path,
} }
} }
/// Insert a service from numa.toml config (not persisted) /// 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<RouteEntry>) {
let key = name.to_lowercase(); let key = name.to_lowercase();
self.config_services.insert(key.clone()); self.config_services.insert(key.clone());
self.entries.insert( self.entries.insert(
@@ -42,6 +86,7 @@ impl ServiceStore {
ServiceEntry { ServiceEntry {
name: key, name: key,
target_port, target_port,
routes,
}, },
); );
} }
@@ -54,11 +99,37 @@ impl ServiceStore {
ServiceEntry { ServiceEntry {
name: key, name: key,
target_port, target_port,
routes: Vec::new(),
}, },
); );
self.save(); 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> { pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> {
self.entries.get(&name.to_lowercase()) self.entries.get(&name.to_lowercase())
} }
@@ -72,12 +143,26 @@ impl ServiceStore {
removed 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> { pub fn list(&self) -> Vec<&ServiceEntry> {
let mut entries: Vec<_> = self.entries.values().collect(); let mut entries: Vec<_> = self.entries.values().collect();
entries.sort_by(|a, b| a.name.cmp(&b.name)); entries.sort_by(|a, b| a.name.cmp(&b.name));
entries entries
} }
pub fn names(&self) -> Vec<String> {
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 /// Load user-defined services from ~/.config/numa/services.json
pub fn load_persisted(&mut self) { pub fn load_persisted(&mut self) {
if !self.persist_path.exists() { if !self.persist_path.exists() {
@@ -133,3 +218,157 @@ impl ServiceStore {
fn dirs_path() -> PathBuf { fn dirs_path() -> PathBuf {
crate::config_dir().join("services.json") crate::config_dir().join("services.json")
} }
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn entry(port: u16, routes: Vec<RouteEntry>) -> 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());
}
}

View File

@@ -1,7 +1,10 @@
use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use log::{info, warn}; use log::{info, warn};
use crate::ctx::ServerCtx;
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig; use rustls::ServerConfig;
@@ -10,6 +13,26 @@ 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
/// 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<String> = ctx.services.lock().unwrap().names().into_iter().collect();
names.extend(ctx.lan_peers.lock().unwrap().names());
let names: Vec<String> = 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. /// 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.