diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1971c6b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + name: numa-macos-x86_64 + - target: aarch64-apple-darwin + os: macos-latest + name: numa-macos-aarch64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: numa-linux-x86_64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: numa-linux-aarch64 + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Package + run: | + cd target/${{ matrix.target }}/release + tar czf ../../../${{ matrix.name }}.tar.gz numa + cd ../../.. + sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: | + ${{ matrix.name }}.tar.gz + ${{ matrix.name }}.tar.gz.sha256 + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + *.tar.gz + *.sha256 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9d0fd47 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +**Numa** — a portable DNS resolver with ad blocking, developer overrides, and a live dashboard. Built from scratch in Rust. Named after Numa Pompilius, the Roman king who established lasting institutions. + +Today: DNS forwarding/caching proxy with ad blocking, ephemeral overrides, live dashboard, and system DNS integration. +Next: Self-sovereign DNS via pkarr/Mainline DHT. +Vision: Incentivized resolver network with staking, challenge-based auditing, and token economics. + +## Build & Run + +```bash +cargo build # compile +sudo cargo run # run with default config (numa.toml) +sudo cargo run -- path/to/config # run with custom config path +RUST_LOG=debug sudo cargo run # verbose logging +make lint # clippy + rustfmt check +``` + +Test with: `dig @127.0.0.1 google.com` + +CLI commands: +```bash +numa help # show all commands +numa install # set system DNS to 127.0.0.1 +numa uninstall # restore original DNS +numa service start # install as persistent service (launchd/systemd) +numa service stop # uninstall service + restore DNS +numa service status # check service status +``` + +Dashboard: http://numa.numa (or http://localhost:5380) + +## Architecture + +``` +UDP :53 ──▶ handle_query() + │ + ├─ 1. Override Store (ephemeral, auto-expiry) + ├─ 2. .numa TLD (local service domains → 127.0.0.1) + ├─ 3. Blocklist (385K+ domains, subdomain matching) + ├─ 4. Local Zones (TOML config) + ├─ 5. Cache (TTL-aware, lazy eviction) + └─ 6. Upstream Forward (auto-detected from OS, conditional forwarding) + +HTTP :80 ──▶ Reverse proxy for .numa domains (WebSocket support) +HTTPS :443 ──▶ TLS reverse proxy (auto-generated local CA + wildcard *.numa cert) +HTTP :5380 ──▶ Axum REST API (22+ endpoints) + Dashboard +``` + +### Source Files + +``` +src/ + main.rs # startup: load config, bind UDP, spawn API + proxy, blocklist download, per-query task loop + lib.rs # module declarations, Error/Result type aliases + ctx.rs # ServerCtx shared state + handle_query() pipeline + api.rs # Axum REST server (22 endpoints, port 5380) + embedded dashboard + config.rs # TOML config loading with defaults (server, upstream, cache, blocking, proxy, zones) + proxy.rs # HTTP/HTTPS reverse proxy for .numa domains (port 80 + 443, WebSocket upgrade) + tls.rs # Local CA + wildcard cert generation (rcgen), rustls ServerConfig builder + service_store.rs # ServiceStore — name-to-port mappings, persisted to ~/.config/numa/services.json + blocklist.rs # BlocklistStore — HashSet, download, parse, subdomain matching, check + override_store.rs # OverrideStore — ephemeral domain overrides with auto-expiry + query_log.rs # ring buffer (VecDeque, 1000 entries) for recent queries + cache.rs # DnsCache — TTL-aware, lazy eviction every 1000 lookups + forward.rs # async UDP forwarding to upstream resolver + stats.rs # ServerStats counters + QueryPath enum (6 categories) + system_dns.rs # OS DNS discovery (scutil/resolv.conf), install/uninstall, service management + buffer.rs # BytePacketBuffer — 4096-byte DNS wire format I/O + header.rs # DnsHeader — 12-byte bitfield parsing/serialization + question.rs # DnsQuestion + QueryType enum (A, NS, CNAME, SOA, PTR, MX, TXT, AAAA, SRV, HTTPS) + record.rs # DnsRecord enum — wire format read/write per record type (filters UNKNOWN on write) + packet.rs # DnsPacket — header + questions + answers + authorities + resources +site/ + dashboard.html # live dashboard (embedded at compile time via include_str!) + index.html # landing page (Roman Stone theme) +``` + +## Config + +`numa.toml` at project root. Sections: `[server]`, `[upstream]`, `[cache]`, `[blocking]`, `[proxy]`, `[[services]]`, `[[zones]]`. Falls back to sensible defaults if file is missing. Upstream auto-detected from system resolver if not set. + +## REST API + +Dashboard: GET `/` (embedded HTML) +Override management: POST/GET/DELETE `/overrides`, POST `/overrides/environment` +Services: GET/POST `/services`, DELETE `/services/{name}` +Blocking: GET `/blocking/stats`, PUT `/blocking/toggle`, POST `/blocking/pause`, POST `/blocking/unpause`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}` +Diagnostics: GET `/diagnose/{domain}`, `/query-log`, `/stats`, `/cache`, `/health` +Cache: DELETE `/cache`, `/cache/{domain}` + +## Key Details + +- Rust 2021 edition, async via `tokio` (rt-multi-thread) +- Deps: tokio, axum, hyper, hyper-util, serde, serde_json, toml, log, env_logger, reqwest, futures, rcgen, rustls, tokio-rustls, time (zero DNS libraries) +- Shared config dir: `~/.config/numa/` via `config_dir()` in `lib.rs` (handles sudo correctly) +- TLS: auto-generated local CA + wildcard `*.numa` cert at `~/.config/numa/`. `numa install` trusts CA in OS keychain. +- Service persistence: user-added services saved to `~/.config/numa/services.json`, survives restarts +- Deploy workflow: `make deploy` (build release → copy → codesign → kill → launchd respawns) +- DNS buffer size: 4096 bytes (EDNS-compatible). UNKNOWN record types (e.g. OPT) filtered on serialization. +- `BytePacketBuffer::read_qname` handles label compression (pointer jumps) +- `type Error = Box` / `type Result` aliased in `lib.rs` +- Shared state via `Arc` with `std::sync::Mutex` (sub-microsecond holds, never across `.await`) +- Cache: TTL clamped between `min_ttl` and `max_ttl`, lazy eviction every 1000 queries +- Blocklist: parsed outside lock, swapped atomically. `is_blocked()` takes `&self` (read-only). +- Upstream: auto-detected from `scutil --dns` (macOS) or `/etc/resolv.conf` (Linux). Falls back to Quad9. +- Conditional forwarding: Tailscale/VPN domains auto-routed to correct upstream. +- macOS service: launchd plist with KeepAlive + RunAtLoad. Use `launchctl bootstrap/bootout` (not load/unload). +- Logging controlled via `RUST_LOG` env var. Default: `info` diff --git a/Cargo.lock b/Cargo.lock index b8a07dd..da61725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -61,12 +67,91 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -150,6 +235,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -165,12 +252,76 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -182,6 +333,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "env_filter" version = "1.0.0" @@ -217,6 +374,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -226,6 +393,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -233,6 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -241,6 +430,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -253,8 +476,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -563,6 +791,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -573,6 +811,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.183" @@ -615,6 +859,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -626,20 +886,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "numa" version = "0.1.0" dependencies = [ "axum", "env_logger", + "futures", + "http-body-util", + "hyper", + "hyper-util", "log", + "rcgen", "reqwest", + "rustls", + "rustls-pemfile", "serde", "serde_json", + "time", "tokio", + "tokio-rustls", "toml", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -652,6 +974,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -694,6 +1026,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -726,7 +1064,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -747,7 +1085,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -811,6 +1149,20 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "regex" version = "1.12.3" @@ -898,12 +1250,23 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustls" version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -912,6 +1275,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -928,6 +1300,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1026,6 +1399,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -1091,13 +1470,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1111,6 +1510,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1172,6 +1602,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1235,13 +1678,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -1616,6 +2064,33 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 32079a0..e88dfdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,13 @@ serde_json = "1" toml = "0.8" log = "0.4" env_logger = "0.11" -reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["rustls-tls", "gzip"], default-features = false } +hyper = { version = "1", features = ["client", "http1", "server"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } +http-body-util = "0.1" +futures = "0.3" +rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +time = "0.3" +rustls = "0.23" +tokio-rustls = "0.26" +rustls-pemfile = "2" diff --git a/Makefile b/Makefile index 540f041..c83f5aa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build lint fmt check test clean +.PHONY: all build lint fmt check test clean deploy all: lint build @@ -18,3 +18,11 @@ test: clean: cargo clean + +deploy: + cargo build --release + sudo cp target/release/numa /usr/local/bin/numa + sudo codesign -f -s - /usr/local/bin/numa + sudo kill $$(pgrep -f /usr/local/bin/numa) 2>/dev/null || true + @sleep 1 + @dig @127.0.0.1 google.com +short +time=3 > /dev/null && echo "Service restarted successfully" || echo "Warning: DNS not responding yet" diff --git a/README.md b/README.md index f5cb828..aa44da2 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ **DNS you own. Everywhere you go.** -Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. +Block ads and trackers. Override DNS for development. Name your local services. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. ## Why - **Ad blocking that travels with you** — 385K+ domains blocked out of the box. Works on any network: coffee shops, hotels, airports. - **Developer overrides** — point any hostname to any IP with auto-revert. No more editing `/etc/hosts`. +- **Local service proxy** — access `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. - **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. -- **Live dashboard** — real-time query stats, blocking controls, override management at `http://localhost:5380`. +- **Live dashboard** — real-time query stats, blocking controls, override management, local services at `http://numa.numa` (or `localhost:5380`). - **Single binary, zero config** — just run it. ## Quick Start @@ -32,7 +33,7 @@ docker run -p 53:53/udp -p 5380:5380 numa ### Try it -Open the dashboard: **http://localhost:5380** +Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) ```bash dig @127.0.0.1 google.com # ✓ resolves normally @@ -59,18 +60,51 @@ curl -X POST http://localhost:5380/overrides \ dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min) ``` +## Local Service Proxy + +Name your local dev services with `.numa` domains instead of remembering port numbers: + +```bash +# Register a service via API +curl -X POST http://localhost:5380/services \ + -H 'Content-Type: application/json' \ + -d '{"name":"frontend","target_port":5173}' + +# Now access it by name +open http://frontend.numa # → proxied to localhost:5173 +``` + +Or configure in `numa.toml`: +```toml +[[services]] +name = "frontend" +target_port = 5173 + +[[services]] +name = "api" +target_port = 8000 +``` + +- `numa.numa` is pre-configured — the dashboard itself, accessible without remembering the port +- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs. `sudo numa install` trusts the CA in your system keychain. +- WebSocket support — Vite/webpack HMR works through the proxy +- Health checks — dashboard shows green/red status for each service +- Services persist across restarts (`~/.config/numa/services.json`) +- Manage via dashboard UI or REST API + ## Resolution Pipeline ``` -Query → Overrides → Blocklist → Local Zones → Cache → Upstream → Respond +Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream → Respond ``` 1. **Overrides** — ephemeral, time-scoped redirects (highest priority) -2. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::` -3. **Local zones** — records defined in `[[zones]]` config -4. **Cache** — TTL-adjusted cached upstream responses (sub-ms) -5. **Forward** — query upstream resolver, cache the result -6. **SERVFAIL** — returned on upstream failure +2. **`.numa` TLD** — synthetic domains for local services → returns `127.0.0.1` +3. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::` +4. **Local zones** — records defined in `[[zones]]` config +5. **Cache** — TTL-adjusted cached upstream responses (sub-ms) +6. **Forward** — query upstream resolver, cache the result +7. **SERVFAIL** — returned on upstream failure ## Dashboard @@ -80,6 +114,7 @@ Live at `http://localhost:5380` when Numa is running: - Resolution path breakdown (forward / cached / local / override / blocked) - Scrolling query log with colored path tags - Active overrides with create/edit/delete +- Local services with health status and add/remove - Blocking controls: toggle on/off, pause 5 minutes, one-click allowlist - Cached domains list @@ -110,6 +145,15 @@ lists = [ refresh_hours = 24 allowlist = [] +[proxy] +enabled = true +port = 80 +tld = "numa" + +[[services]] +name = "frontend" +target_port = 5173 + [[zones]] domain = "mysite.local" record_type = "A" @@ -119,7 +163,7 @@ ttl = 60 ## HTTP API -REST API on port 5380 (18 endpoints): +REST API on port 5380 (22 endpoints): | Endpoint | Method | Description | |----------|--------|-------------| @@ -130,12 +174,16 @@ REST API on port 5380 (18 endpoints): | `/overrides/environment` | POST | Batch load overrides | | `/overrides/{domain}` | GET | Get specific override | | `/overrides/{domain}` | DELETE | Remove specific override | +| `/services` | GET | List local services (with health status) | +| `/services` | POST | Register a local service | +| `/services/{name}` | DELETE | Remove a local service | | `/blocking/stats` | GET | Blocklist stats (domains loaded, sources, enabled) | | `/blocking/toggle` | PUT | Enable/disable blocking | | `/blocking/pause` | POST | Pause blocking for N minutes | | `/blocking/allowlist` | GET | List allowlisted domains | | `/blocking/allowlist` | POST | Add domain to allowlist | | `/blocking/allowlist/{domain}` | DELETE | Remove from allowlist | +| `/blocking/check/{domain}` | GET | Check if domain is blocked | | `/diagnose/{domain}` | GET | Trace resolution path | | `/query-log` | GET | Recent queries (filterable) | | `/stats` | GET | Server statistics | @@ -151,6 +199,7 @@ REST API on port 5380 (18 endpoints): | Ad blocking | Yes | Yes | Limited | 385K+ domains | | Portable | No (Raspberry Pi) | Cloud only | Cloud only | Single binary | | Developer overrides | No | No | No | REST API + auto-expiry | +| Local service proxy | No | No | No | `.numa` domains + HTTPS + WebSocket | | Data stays local | Yes | Cloud | Cloud | 100% local | | Zero config | Complex setup | Yes | Yes | Works out of the box | | Self-sovereign DNS | No | No | No | pkarr/DHT roadmap | @@ -159,6 +208,8 @@ REST API on port 5380 (18 endpoints): **Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network. +**Name your local services** — `frontend.numa` instead of `localhost:5173`. CORS-friendly, HMR-compatible. + **Mock external services** — `Point api.stripe.com to localhost:8080 for 30 minutes` **Provision dev environments** — Create overrides for `db.dev`, `api.dev`, `cache.dev` @@ -176,6 +227,7 @@ Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies - [x] Ad blocking — 385K+ domains, dashboard, allowlist - [x] System DNS auto-discovery — Tailscale, VPN split-DNS - [x] System DNS auto-configuration — `numa install` / `numa uninstall` +- [x] Local service proxy — `.numa` domains with HTTP/HTTPS reverse proxy, auto TLS, WebSocket - [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [ ] Decentralized resolver network — staking, auditing, token economics diff --git a/com.numa.dns.plist b/com.numa.dns.plist new file mode 100644 index 0000000..67c90fa --- /dev/null +++ b/com.numa.dns.plist @@ -0,0 +1,25 @@ + + + + + Label + com.numa.dns + ProgramArguments + + /usr/local/bin/numa + + RunAtLoad + + KeepAlive + + StandardOutPath + /usr/local/var/log/numa.log + StandardErrorPath + /usr/local/var/log/numa.log + EnvironmentVariables + + RUST_LOG + info + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3771907 --- /dev/null +++ b/install.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Numa installer — detects OS/arch and downloads the latest release +# Usage: curl -sSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh +set -e + +REPO="razvandimescu/numa" +INSTALL_DIR="/usr/local/bin" + +# Detect OS +OS="$(uname -s)" +case "$OS" in + Darwin) OS_NAME="macos" ;; + Linux) OS_NAME="linux" ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac + +# Detect architecture +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH_NAME="x86_64" ;; + arm64|aarch64) ARCH_NAME="aarch64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +ASSET="numa-${OS_NAME}-${ARCH_NAME}.tar.gz" + +echo "" +echo " \033[1;38;2;192;98;58mNuma\033[0m installer" +echo "" +echo " OS: $OS_NAME" +echo " Arch: $ARCH_NAME" +echo "" + +# Get latest release tag +echo " Fetching latest release..." +TAG=$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + +if [ -z "$TAG" ]; then + echo " Error: could not find latest release." + echo " Check https://github.com/${REPO}/releases" + exit 1 +fi + +URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}" +echo " Downloading ${TAG}..." + +# Download and extract +TMP=$(mktemp -d) +curl -sSL "$URL" -o "$TMP/$ASSET" +tar xzf "$TMP/$ASSET" -C "$TMP" + +# Install +if [ -w "$INSTALL_DIR" ]; then + mv "$TMP/numa" "$INSTALL_DIR/numa" +else + echo " Installing to $INSTALL_DIR (requires sudo)..." + sudo mv "$TMP/numa" "$INSTALL_DIR/numa" +fi + +chmod +x "$INSTALL_DIR/numa" +rm -rf "$TMP" + +echo "" +echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)" +echo "" +echo " Get started:" +echo " sudo numa # start the DNS server" +echo " sudo numa install # set as system DNS" +echo " sudo numa service start # run as persistent service" +echo " open http://localhost:5380 # dashboard" +echo "" diff --git a/numa.service b/numa.service new file mode 100644 index 0000000..50b0909 --- /dev/null +++ b/numa.service @@ -0,0 +1,16 @@ +[Unit] +Description=Numa DNS — DNS you own, everywhere you go +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/numa +Restart=always +RestartSec=2 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=numa + +[Install] +WantedBy=multi-user.target diff --git a/numa.toml b/numa.toml index 27dc1f6..faa455d 100644 --- a/numa.toml +++ b/numa.toml @@ -2,16 +2,32 @@ bind_addr = "0.0.0.0:53" api_port = 5380 -[upstream] -address = "8.8.8.8" -port = 53 -timeout_ms = 3000 +# [upstream] +# address = "" # auto-detect from system resolver (default) +# address = "9.9.9.9" # or set explicitly +# port = 53 +# timeout_ms = 3000 [cache] max_entries = 10000 min_ttl = 60 max_ttl = 86400 +[proxy] +enabled = true +port = 80 +tls_port = 443 +tld = "numa" + +# Pre-configured services (numa.numa is always added automatically) +# [[services]] +# name = "frontend" +# target_port = 5173 +# +# [[services]] +# name = "api" +# target_port = 8000 + # Example zone records: # [[zones]] # domain = "dimescu.ro" diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh new file mode 100755 index 0000000..2e875db --- /dev/null +++ b/scripts/record-demo.sh @@ -0,0 +1,334 @@ +#!/bin/bash +# record-demo.sh — Records a hero GIF of the Numa dashboard. +# +# Prerequisites: ffmpeg, gifsicle (optional), numa running, python3 +# Usage: ./scripts/record-demo.sh [output.gif] +# +# The script: +# 1. Opens the dashboard in Chrome --app mode (clean, no address bar) +# 2. Generates DNS traffic (forward, cache hit, blocked) +# 3. Types "peekm" / "6419" into the Local Services form on camera +# 4. Opens peekm.numa to show the proxy working +# 5. Records via ffmpeg and converts to optimized GIF + +set -euo pipefail + +# --------------- Configuration --------------- +OUTPUT="${1:-assets/hero-demo.gif}" +PORT=5380 +RECORD_SECONDS=20 +VIEWPORT_W=1800 +VIEWPORT_H=1100 +FPS=12 +GIF_WIDTH=800 +MAX_GIF_SIZE_MB=5 +CDP_PORT=9223 + +# --------------- State --------------- +FFMPEG_PID="" +CHROME_PID="" +MOV_FILE="" +CHROME_DATA_DIR="" +CDP_HELPER="" + +# --------------- Helpers --------------- +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' +log() { echo -e "${GREEN}[demo]${NC} $1"; } +warn() { echo -e "${YELLOW}[demo]${NC} $1"; } +err() { echo -e "${RED}[demo]${NC} $1" >&2; } + +cleanup() { + log "Cleaning up..." + [ -n "$FFMPEG_PID" ] && kill "$FFMPEG_PID" 2>/dev/null || true + [ -n "$CHROME_PID" ] && kill "$CHROME_PID" 2>/dev/null && wait "$CHROME_PID" 2>/dev/null || true + [ -n "$MOV_FILE" ] && [ -f "$MOV_FILE" ] && rm -f "$MOV_FILE" + [ -n "$CDP_HELPER" ] && rm -f "$CDP_HELPER" + [ -n "$CHROME_DATA_DIR" ] && sleep 0.5 && rm -rf "$CHROME_DATA_DIR" + log "Done." +} +trap cleanup EXIT + +# --------------- CDP helper (Chrome DevTools Protocol) --------------- +CDP_HELPER=$(mktemp /tmp/numa-cdp-XXXXXX.py) +cat > "$CDP_HELPER" << 'PYTHON' +import json, socket, struct, os, sys, http.client, urllib.parse + +def cdp_eval(port, js): + conn = http.client.HTTPConnection('localhost', port, timeout=2) + conn.request('GET', '/json') + targets = json.loads(conn.getresponse().read()) + conn.close() + page = next((t for t in targets if t.get('type') == 'page'), None) + if not page: + return + ws_url = page.get('webSocketDebuggerUrl') + if not ws_url: + return + parsed = urllib.parse.urlparse(ws_url) + sock = socket.create_connection((parsed.hostname, parsed.port), timeout=5) + key = 'dGhlIHNhbXBsZSBub25jZQ==' + handshake = ( + f"GET {parsed.path} HTTP/1.1\r\n" + f"Host: {parsed.hostname}:{parsed.port}\r\n" + f"Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n" + ) + sock.sendall(handshake.encode()) + sock.recv(4096) + msg = json.dumps({"id": 1, "method": "Runtime.evaluate", + "params": {"expression": js}}).encode() + mask = os.urandom(4) + frame = bytearray([0x81]) + if len(msg) < 126: + frame.append(0x80 | len(msg)) + elif len(msg) < 65536: + frame.append(0x80 | 126) + frame.extend(struct.pack('>H', len(msg))) + else: + frame.append(0x80 | 127) + frame.extend(struct.pack('>Q', len(msg))) + frame.extend(mask) + frame.extend(bytes(b ^ mask[i % 4] for i, b in enumerate(msg))) + sock.sendall(bytes(frame)) + sock.recv(4096) + sock.close() + +if __name__ == '__main__': + try: + cdp_eval(int(sys.argv[1]), sys.argv[2]) + except Exception: + pass +PYTHON + +run_js() { + python3 "$CDP_HELPER" "$CDP_PORT" "$1" 2>/dev/null || true +} + +# Simulate typing into an input field character by character +type_into() { + local selector="$1" + local text="$2" + local delay="${3:-0.08}" + + # Focus the field + run_js "document.querySelector('$selector').focus();" + sleep 0.2 + + # Type each character + for (( i=0; i<${#text}; i++ )); do + local char="${text:$i:1}" + run_js " + var el = document.querySelector('$selector'); + el.value += '$char'; + el.dispatchEvent(new Event('input', {bubbles: true})); + " + sleep "$delay" + done +} + +# --------------- Dependency checks --------------- +for cmd in ffmpeg dig curl python3; do + if ! command -v "$cmd" &>/dev/null; then + err "$cmd is required but not found" + exit 1 + fi +done + +# Check numa is running +if ! dig @127.0.0.1 google.com +short +time=1 > /dev/null 2>&1; then + err "Numa is not running. Start it with: sudo numa" + exit 1 +fi +log "Numa is running." + +# Clean slate: remove peekm service if it exists from a previous run +curl -s -X DELETE "http://localhost:$PORT/services/peekm" > /dev/null 2>&1 || true + +# Pre-populate traffic so dashboard looks alive from frame 1 +log "Pre-populating DNS traffic..." +for domain in github.com google.com stackoverflow.com reddit.com cloudflare.com \ + fonts.googleapis.com api.github.com www.google.com cdn.jsdelivr.net; do + dig @127.0.0.1 "$domain" +short > /dev/null 2>&1 +done +# Blocked traffic +for domain in ads.doubleclick.net tracking.google.com ad.doubleclick.net \ + pixel.facebook.com analytics.google.com; do + dig @127.0.0.1 "$domain" +short > /dev/null 2>&1 +done +# Cache hits +for domain in github.com google.com stackoverflow.com; do + dig @127.0.0.1 "$domain" +short > /dev/null 2>&1 +done + +# --------------- Step 1: Open Chrome in --app mode --------------- +log "Opening dashboard in Chrome app mode (${VIEWPORT_W}x${VIEWPORT_H})..." +CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +CHROME_DATA_DIR=$(mktemp -d /tmp/numa-demo-chrome-XXXXXX) + +"$CHROME" \ + --app="http://localhost:$PORT" \ + --window-size=${VIEWPORT_W},${VIEWPORT_H} \ + --window-position=100,100 \ + --user-data-dir="$CHROME_DATA_DIR" \ + --remote-debugging-port=${CDP_PORT} \ + --no-first-run \ + --disable-extensions \ + --disable-infobars 2>/dev/null & +CHROME_PID=$! + +log "Waiting for page load..." +sleep 3 + +# Bring Chrome to front +osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true +sleep 0.5 + +# --------------- Step 2: Start screen recording --------------- +MOV_FILE=$(mktemp /tmp/numa-demo-XXXXXX.mov) + +SCREEN_LOGICAL_W=$(osascript -l JavaScript -e 'ObjC.import("AppKit"); $.NSScreen.mainScreen.frame.size.width') +SCREEN_LOGICAL_H=$(osascript -l JavaScript -e 'ObjC.import("AppKit"); $.NSScreen.mainScreen.frame.size.height') +log "Screen: ${SCREEN_LOGICAL_W}x${SCREEN_LOGICAL_H}" + +SCREEN_INDEX=$(ffmpeg -f avfoundation -list_devices true -i "" 2>&1 \ + | grep "Capture screen" | head -1 | sed 's/.*\[\([0-9]*\)\].*/\1/' || true) + +if [ -z "$SCREEN_INDEX" ]; then + err "No screen capture device found." + exit 1 +fi + +log "Recording ${RECORD_SECONDS}s..." +ffmpeg -y -loglevel warning \ + -f avfoundation -framerate 24 -capture_cursor 0 \ + -pixel_format uyvy422 \ + -probesize 50M \ + -i "${SCREEN_INDEX}:none" \ + -t "$RECORD_SECONDS" \ + -r 24 \ + -c:v libx264 -preset ultrafast -crf 18 \ + "$MOV_FILE" & +FFMPEG_PID=$! + +sleep 1 + +# Bring Chrome to front again +osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true +sleep 0.5 + +# --------------- Scene 1: Dashboard alive (0-3s) --------------- +# Dashboard is already showing pre-populated traffic from frame 1 +log "Scene 1: Dashboard with live traffic (3s)..." +# Trickle a few more queries for movement +dig @127.0.0.1 github.com +short > /dev/null 2>&1 +dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 +sleep 3 + +# --------------- Scene 2: Check Domain blocker (3-6s) --------------- +log "Scene 2: Check Domain — blocked tracker..." +type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 +sleep 0.3 +# Click Check button +run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" +sleep 2 + +# --------------- Scene 3: Add peekm service via UI (6-10s) --------------- +log "Scene 3: Adding peekm.numa service..." + +# Scroll to Local Services form +run_js " + var svcPanel = document.getElementById('serviceForm'); + if (svcPanel) svcPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); +" +sleep 0.5 + +type_into "#svcName" "peekm" 0.06 +sleep 0.2 +type_into "#svcPort" "6419" 0.1 +sleep 0.3 + +# Click "Add Service" +run_js "document.querySelector('#serviceForm .btn-add').click();" +sleep 1.5 + +# --------------- Scene 4: Open peekm.numa (10-14s) --------------- +log "Scene 4: Opening peekm.numa in browser..." +open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true +sleep 4 + +# --------------- Scene 5: Back to dashboard (14-17s) --------------- +log "Scene 5: Back to dashboard — LOCAL queries visible..." +osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true +sleep 3 + +# --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- +log "Scene 6: dig proof overlay..." +DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) +run_js " + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1a1814;color:#e8e0d4;padding:16px 28px;border-radius:10px;font-family:var(--font-mono);font-size:14px;z-index:99999;box-shadow:0 8px 32px rgba(0,0,0,0.3);border:1px solid rgba(192,98,58,0.3);white-space:pre;line-height:1.6;'; + overlay.innerHTML = '\$ dig @127.0.0.1 peekm.numa +short\n${DIG_RESULT}'; + document.body.appendChild(overlay); +" +sleep 3 + +# --------------- Step 6: Stop recording and convert --------------- +log "Stopping recording..." +kill "$FFMPEG_PID" 2>/dev/null || true +wait "$FFMPEG_PID" 2>/dev/null || true +FFMPEG_PID="" + +if [ ! -f "$MOV_FILE" ] || [ ! -s "$MOV_FILE" ]; then + err "Recording failed — no video captured." + err "Tip: grant Screen Recording permission to Terminal in System Settings > Privacy & Security" + exit 1 +fi + +# Compute crop region +CAPTURE_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$MOV_FILE") +CAPTURE_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$MOV_FILE") + +read -r CROP_W CROP_H CROP_X CROP_Y <<< "$(awk -v cw="$CAPTURE_W" -v ch="$CAPTURE_H" \ + -v sw="$SCREEN_LOGICAL_W" -v sh="$SCREEN_LOGICAL_H" \ + -v ww="$VIEWPORT_W" -v wh="$VIEWPORT_H" \ + 'BEGIN { + sx = cw / sw; sy = ch / sh + printf "%d %d %d %d", int(ww*sx), int(wh*sy), int(100*sx), int(100*sy) + }')" + +log "Capture: ${CAPTURE_W}x${CAPTURE_H}, crop: ${CROP_W}x${CROP_H}+${CROP_X}+${CROP_Y}" + +mkdir -p "$(dirname "$OUTPUT")" + +log "Converting to GIF (${GIF_WIDTH}px, ${FPS}fps)..." +ffmpeg -y -loglevel error \ + -i "$MOV_FILE" \ + -vf "crop=${CROP_W}:${CROP_H}:${CROP_X}:${CROP_Y},fps=${FPS},scale=${GIF_WIDTH}:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ + -loop 0 \ + "$OUTPUT" + +# Optimize with gifsicle if available +if command -v gifsicle &>/dev/null; then + log "Optimizing with gifsicle..." + gifsicle -O3 --lossy=60 --colors 128 "$OUTPUT" -o "$OUTPUT" +fi + +SIZE_BYTES=$(stat -f%z "$OUTPUT") +SIZE_MB=$(awk "BEGIN { printf \"%.1f\", $SIZE_BYTES / 1048576 }") +log "Hero GIF saved to $OUTPUT (${SIZE_MB}MB)" + +if awk "BEGIN { exit ($SIZE_MB > $MAX_GIF_SIZE_MB) ? 0 : 1 }"; then + warn "GIF is over ${MAX_GIF_SIZE_MB}MB. Consider reducing RECORD_SECONDS, FPS, or GIF_WIDTH." +fi + +# Clean up demo data +log "Cleaning up demo services..." +curl -s -X DELETE "http://localhost:$PORT/services/peekm" > /dev/null 2>&1 || true + +log "" +log "Add to README.md:" +log ' ![Numa dashboard](assets/hero-demo.gif)' diff --git a/site/dashboard.html b/site/dashboard.html index c39b7c3..ccbb4b5 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -232,7 +232,7 @@ body { /* Query log table */ .query-log { - max-height: 380px; + max-height: 600px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--bg-elevated) transparent; @@ -348,6 +348,41 @@ body { flex-shrink: 0; } +/* Service items */ +.service-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} +.service-item:last-child { border-bottom: none; } +.service-info { flex: 1; min-width: 0; } +.service-name { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; + color: var(--cyan); +} +.service-name a { + color: inherit; + text-decoration: none; +} +.service-name a:hover { text-decoration: underline; } +.service-port { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); +} +.health-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.health-dot.up { background: var(--emerald); } +.health-dot.down { background: var(--rose); } + /* Override form */ .override-form { display: flex; @@ -442,7 +477,7 @@ body {
DNS that governs itself
- +
@@ -497,7 +532,21 @@ body {
Recent Queries - +
+ + + +
@@ -519,18 +568,40 @@ body { + + + + + + + + @@ -1286,10 +1305,10 @@ footer .closing {
Zero — wire protocol parsed from scratch
Dependencies
-
6 runtime crates (tokio, axum, serde, serde_json, toml, log)
+
8 runtime crates (tokio, axum, hyper, serde, serde_json, toml, log, futures)
Packet Format
-
RFC 1035 compliant, 512-byte UDP
+
RFC 1035 compliant, 4096-byte UDP (EDNS)
Concurrency
Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)
@@ -1299,13 +1318,12 @@ footer .closing {
$ cargo install numa -$ sudo numa # bind to :53 +$ sudo numa # bind to :53, :80, :5380 $ dig @127.0.0.1 google.com # test resolution -$ curl localhost:5380/overrides # REST API -$ curl -X POST localhost:5380/overrides \ - -d '{"domain":"api.stripe.com", - "target":"127.0.0.1", - "duration_secs":1800}' # 30-min override +$ open http://numa.numa # dashboard +$ curl -X POST localhost:5380/services \ + -d '{"name":"frontend", + "target_port":5173}' # http://frontend.numa
@@ -1333,28 +1351,32 @@ footer .closing { Phase 2Ad & tracker blocking — 385K+ domains, live dashboard, one-click allowlist -
+
Phase 3 System integration — auto-discovery of OS DNS routing, one-command install
-
+
Phase 4 + Local service proxy — .numa domains, HTTP reverse proxy, WebSocket support +
+
+ Phase 5 pkarr spike — DHT resolution and publish endpoint
- Phase 5 + Phase 6 pkarr product — human-readable aliases, re-publish daemon, key management
- Phase 4 + Phase 7 Challenge and audit protocol for verifiable resolver behavior
- Phase 5 + Phase 8 Token economics, staking, and slashing mechanism
- Phase 6 + Phase 9 Decentralized resolver marketplace
diff --git a/src/api.rs b/src/api.rs index 148e725..9c25377 100644 --- a/src/api.rs +++ b/src/api.rs @@ -35,12 +35,17 @@ pub fn router(ctx: Arc) -> Router { .route("/blocking/stats", get(blocking_stats)) .route("/blocking/toggle", put(blocking_toggle)) .route("/blocking/pause", post(blocking_pause)) + .route("/blocking/unpause", post(blocking_unpause)) .route("/blocking/allowlist", get(blocking_allowlist)) .route("/blocking/allowlist", post(blocking_allowlist_add)) + .route("/blocking/check/{domain}", get(blocking_check)) .route( "/blocking/allowlist/{domain}", delete(blocking_allowlist_remove), ) + .route("/services", get(list_services)) + .route("/services", post(create_service)) + .route("/services/{name}", delete(remove_service)) .with_state(ctx) } @@ -532,6 +537,19 @@ async fn blocking_pause( Json(serde_json::json!({ "paused_minutes": req.minutes })) } +async fn blocking_unpause(State(ctx): State>) -> Json { + ctx.blocklist.lock().unwrap().unpause(); + Json(serde_json::json!({ "paused": false })) +} + +async fn blocking_check( + State(ctx): State>, + Path(domain): Path, +) -> Json { + let result = ctx.blocklist.lock().unwrap().check(&domain); + Json(result) +} + async fn blocking_allowlist(State(ctx): State>) -> Json> { let list = ctx.blocklist.lock().unwrap().allowlist(); Json(list) @@ -563,3 +581,110 @@ async fn blocking_allowlist_remove( StatusCode::NOT_FOUND } } + +// --- Service proxy handlers --- + +#[derive(Serialize)] +struct ServiceResponse { + name: String, + target_port: u16, + url: String, + healthy: bool, +} + +#[derive(Deserialize)] +struct CreateServiceRequest { + name: String, + target_port: u16, +} + +async fn list_services(State(ctx): State>) -> Json> { + let entries: Vec<_> = { + let store = ctx.services.lock().unwrap(); + store + .list() + .into_iter() + .map(|e| (e.name.clone(), e.target_port)) + .collect() + }; + let tld = &ctx.proxy_tld; + + // Run all health checks concurrently + let health_futures: Vec<_> = entries + .iter() + .map(|(_, port)| check_health(*port)) + .collect(); + let health_results = futures::future::join_all(health_futures).await; + + let results: Vec<_> = entries + .into_iter() + .zip(health_results) + .map(|((name, port), healthy)| ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: port, + healthy, + }) + .collect(); + Json(results) +} + +async fn create_service( + State(ctx): State>, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let name = req.name.to_lowercase(); + + // Validate name: alphanumeric + hyphens only, 1-63 chars + if name.is_empty() || name.len() > 63 { + return Err(( + StatusCode::BAD_REQUEST, + "name must be 1-63 characters".into(), + )); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(( + StatusCode::BAD_REQUEST, + "name must contain only alphanumeric characters and hyphens".into(), + )); + } + if req.target_port == 0 { + return Err((StatusCode::BAD_REQUEST, "target_port must be > 0".into())); + } + + let tld = &ctx.proxy_tld; + ctx.services.lock().unwrap().insert(&name, req.target_port); + + let healthy = check_health(req.target_port).await; + Ok(( + StatusCode::CREATED, + Json(ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: req.target_port, + healthy, + }), + )) +} + +async fn remove_service(State(ctx): State>, Path(name): Path) -> StatusCode { + if name.eq_ignore_ascii_case("numa") { + return StatusCode::FORBIDDEN; + } + let mut store = ctx.services.lock().unwrap(); + if store.remove(&name) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +async fn check_health(port: u16) -> bool { + tokio::time::timeout( + std::time::Duration::from_millis(100), + tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false) +} diff --git a/src/blocklist.rs b/src/blocklist.rs index 89ebaee..4319de9 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -12,6 +12,44 @@ pub struct BlocklistStore { last_refresh: Option, } +#[derive(serde::Serialize)] +pub struct BlockCheckResult { + pub blocked: bool, + pub reason: String, + pub matched_rule: Option, +} + +impl BlockCheckResult { + fn blocked(rule: &str, reason: &str) -> Self { + Self { + blocked: true, + reason: reason.to_string(), + matched_rule: Some(rule.to_string()), + } + } + fn allowed(rule: &str, reason: &str) -> Self { + Self { + blocked: false, + reason: reason.to_string(), + matched_rule: Some(rule.to_string()), + } + } + fn not_blocked() -> Self { + Self { + blocked: false, + reason: "not in blocklist".to_string(), + matched_rule: None, + } + } + fn disabled() -> Self { + Self { + blocked: false, + reason: "blocking is disabled".to_string(), + matched_rule: None, + } + } +} + pub struct BlocklistStats { pub enabled: bool, pub paused: bool, @@ -73,6 +111,36 @@ impl BlocklistStore { false } + /// Check if a domain is blocked and return the reason. + pub fn check(&self, domain: &str) -> BlockCheckResult { + let domain = domain.to_lowercase(); + + if !self.enabled { + return BlockCheckResult::disabled(); + } + + if self.allowlist.contains(&domain) { + return BlockCheckResult::allowed(&domain, "exact match in allowlist"); + } + + if self.domains.contains(&domain) { + return BlockCheckResult::blocked(&domain, "exact match in blocklist"); + } + + let mut d = domain.as_str(); + while let Some(dot) = d.find('.') { + d = &d[dot + 1..]; + if self.allowlist.contains(d) { + return BlockCheckResult::allowed(d, "parent domain in allowlist"); + } + if self.domains.contains(d) { + return BlockCheckResult::blocked(d, "parent domain in blocklist"); + } + } + + BlockCheckResult::not_blocked() + } + /// Atomically swap in a new domain set. Build the set outside the lock, /// then call this to swap — keeps lock hold time sub-microsecond. pub fn swap_domains(&mut self, domains: HashSet, sources: Vec) { @@ -93,6 +161,10 @@ impl BlocklistStore { self.paused_until = Some(Instant::now() + std::time::Duration::from_secs(seconds)); } + pub fn unpause(&mut self) { + self.paused_until = None; + } + pub fn is_paused(&self) -> bool { self.paused_until .map(|until| Instant::now() < until) @@ -165,6 +237,7 @@ pub fn parse_blocklist(text: &str) -> HashSet { pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .gzip(true) .build() .unwrap_or_default(); diff --git a/src/buffer.rs b/src/buffer.rs index c9378d0..0c358e7 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -1,7 +1,9 @@ use crate::Result; +const BUF_SIZE: usize = 4096; + pub struct BytePacketBuffer { - pub buf: [u8; 512], + pub buf: [u8; BUF_SIZE], pub pos: usize, } @@ -14,7 +16,7 @@ impl Default for BytePacketBuffer { impl BytePacketBuffer { pub fn new() -> BytePacketBuffer { BytePacketBuffer { - buf: [0; 512], + buf: [0; BUF_SIZE], pos: 0, } } @@ -38,7 +40,7 @@ impl BytePacketBuffer { } pub fn read(&mut self) -> Result { - if self.pos >= 512 { + if self.pos >= BUF_SIZE { return Err("End of buffer".into()); } let res = self.buf[self.pos]; @@ -47,14 +49,14 @@ impl BytePacketBuffer { } pub fn get(&self, pos: usize) -> Result { - if pos >= 512 { + if pos >= BUF_SIZE { return Err("End of buffer".into()); } Ok(self.buf[pos]) } pub fn get_range(&self, start: usize, len: usize) -> Result<&[u8]> { - if start + len > 512 { + if start + len > BUF_SIZE { return Err("End of buffer".into()); } Ok(&self.buf[start..start + len]) @@ -128,7 +130,7 @@ impl BytePacketBuffer { } pub fn write(&mut self, val: u8) -> Result<()> { - if self.pos >= 512 { + if self.pos >= BUF_SIZE { return Err("End of buffer".into()); } self.buf[self.pos] = val; @@ -172,7 +174,7 @@ impl BytePacketBuffer { } pub fn set(&mut self, pos: usize, val: u8) -> Result<()> { - if pos >= 512 { + if pos >= BUF_SIZE { return Err("End of buffer".into()); } self.buf[pos] = val; diff --git a/src/config.rs b/src/config.rs index 56beaec..8359983 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::question::QueryType; use crate::record::DnsRecord; use crate::Result; -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Config { #[serde(default)] pub server: ServerConfig, @@ -21,6 +21,10 @@ pub struct Config { pub blocking: BlockingConfig, #[serde(default)] pub zones: Vec, + #[serde(default)] + pub proxy: ProxyConfig, + #[serde(default)] + pub services: Vec, } #[derive(Deserialize)] @@ -69,7 +73,7 @@ impl Default for UpstreamConfig { } fn default_upstream_addr() -> String { - "8.8.8.8".to_string() + String::new() // empty = auto-detect from system resolver } fn default_upstream_port() -> u16 { 53 @@ -156,15 +160,51 @@ fn default_zone_ttl() -> u32 { 300 } +#[derive(Deserialize, Clone)] +pub struct ProxyConfig { + #[serde(default = "default_proxy_enabled")] + pub enabled: bool, + #[serde(default = "default_proxy_port")] + pub port: u16, + #[serde(default = "default_proxy_tls_port")] + pub tls_port: u16, + #[serde(default = "default_proxy_tld")] + pub tld: String, +} + +impl Default for ProxyConfig { + fn default() -> Self { + ProxyConfig { + enabled: default_proxy_enabled(), + port: default_proxy_port(), + tls_port: default_proxy_tls_port(), + tld: default_proxy_tld(), + } + } +} + +fn default_proxy_enabled() -> bool { + true +} +fn default_proxy_port() -> u16 { + 80 +} +fn default_proxy_tls_port() -> u16 { + 443 +} +fn default_proxy_tld() -> String { + "numa".to_string() +} + +#[derive(Deserialize, Clone)] +pub struct ServiceConfig { + pub name: String, + pub target_port: u16, +} + pub fn load_config(path: &str) -> Result { if !Path::new(path).exists() { - return Ok(Config { - server: ServerConfig::default(), - upstream: UpstreamConfig::default(), - cache: CacheConfig::default(), - blocking: BlockingConfig::default(), - zones: Vec::new(), - }); + return Ok(Config::default()); } let contents = std::fs::read_to_string(path)?; let config: Config = toml::from_str(&contents)?; diff --git a/src/ctx.rs b/src/ctx.rs index a407f83..18b00ec 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -14,7 +14,9 @@ use crate::header::ResultCode; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; use crate::query_log::{QueryLog, QueryLogEntry}; +use crate::question::QueryType; use crate::record::DnsRecord; +use crate::service_store::ServiceStore; use crate::stats::{QueryPath, ServerStats}; use crate::system_dns::ForwardingRule; @@ -26,9 +28,12 @@ pub struct ServerCtx { pub overrides: Mutex, pub blocklist: Mutex, pub query_log: Mutex, + pub services: Mutex, pub forwarding_rules: Vec, pub upstream: SocketAddr, pub timeout: Duration, + pub proxy_tld: String, + pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation } pub async fn handle_query( @@ -51,7 +56,7 @@ pub async fn handle_query( None => return Ok(()), }; - // Pipeline: overrides -> blocklist -> local zones -> cache -> upstream + // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path) = { let override_record = ctx.overrides.lock().unwrap().lookup(&qname); @@ -59,8 +64,24 @@ pub async fn handle_query( let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers.push(record); (resp, QueryPath::Overridden) + } else if !ctx.proxy_tld_suffix.is_empty() + && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) + { + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + match qtype { + QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { + domain: qname.clone(), + addr: std::net::Ipv6Addr::LOCALHOST, + ttl: 300, + }), + _ => resp.answers.push(DnsRecord::A { + domain: qname.clone(), + addr: std::net::Ipv4Addr::LOCALHOST, + ttl: 300, + }), + } + (resp, QueryPath::Local) } else if ctx.blocklist.lock().unwrap().is_blocked(&qname) { - use crate::question::QueryType; let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { diff --git a/src/lib.rs b/src/lib.rs index 60a175d..ad9355e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,11 +8,40 @@ pub mod forward; pub mod header; pub mod override_store; pub mod packet; +pub mod proxy; pub mod query_log; pub mod question; pub mod record; +pub mod service_store; pub mod stats; pub mod system_dns; +pub mod tls; pub type Error = Box; pub type Result = std::result::Result; + +/// Shared config directory: ~/.config/numa/ +/// Handles sudo (uses SUDO_USER) and launchd (falls back to /usr/local/var/numa/). +pub fn config_dir() -> std::path::PathBuf { + // When run via sudo, SUDO_USER has the real user + if let Ok(user) = std::env::var("SUDO_USER") { + let home = if cfg!(target_os = "macos") { + format!("/Users/{}", user) + } else { + format!("/home/{}", user) + }; + return std::path::PathBuf::from(home).join(".config").join("numa"); + } + + // Normal user (not root) + if let Ok(home) = std::env::var("HOME") { + let path = std::path::PathBuf::from(&home); + // /var/root on macOS is read-only (SIP), use /usr/local/var/numa instead + if !home.starts_with("/var/root") && !home.starts_with("/root") { + return path.join(".config").join("numa"); + } + } + + // Running as root daemon (launchd/systemd) — use system-wide path + std::path::PathBuf::from("/usr/local/var/numa") +} diff --git a/src/main.rs b/src/main.rs index 95d8719..9c1a543 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,12 @@ use numa::config::{build_zone_map, load_config}; use numa::ctx::{handle_query, ServerCtx}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; +use numa::service_store::ServiceStore; use numa::stats::ServerStats; -use numa::system_dns::{discover_forwarding_rules, install_system_dns, uninstall_system_dns}; +use numa::system_dns::{ + discover_system_dns, install_service, install_system_dns, restart_service, service_status, + uninstall_service, uninstall_system_dns, +}; #[tokio::main] async fn main() -> numa::Result<()> { @@ -32,14 +36,36 @@ async fn main() -> numa::Result<()> { eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); return uninstall_system_dns().map_err(|e| e.into()); } + "service" => { + let sub = std::env::args().nth(2).unwrap_or_default(); + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n"); + return match sub.as_str() { + "start" => install_service().map_err(|e| e.into()), + "stop" => uninstall_service().map_err(|e| e.into()), + "restart" => restart_service().map_err(|e| e.into()), + "status" => service_status().map_err(|e| e.into()), + _ => { + eprintln!("Usage: numa service "); + Ok(()) + } + }; + } + "version" | "--version" | "-V" => { + eprintln!("numa {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } "help" | "--help" | "-h" => { eprintln!("Usage: numa [command] [config-path]"); eprintln!(); eprintln!("Commands:"); - eprintln!(" (none) Start the DNS server (default)"); - eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)"); - eprintln!(" uninstall Restore original system DNS settings"); - eprintln!(" help Show this help"); + eprintln!(" (none) Start the DNS server (default)"); + eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)"); + eprintln!(" uninstall Restore original system DNS settings"); + eprintln!(" service start Install as system service (auto-start on boot)"); + eprintln!(" service stop Uninstall the system service"); + eprintln!(" service restart Restart the service with updated binary"); + eprintln!(" service status Check if the service is running"); + eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); return Ok(()); @@ -56,8 +82,18 @@ async fn main() -> numa::Result<()> { }; let config = load_config(&config_path)?; - let upstream: SocketAddr = - format!("{}:{}", config.upstream.address, config.upstream.port).parse()?; + // Discover system DNS in a single pass (upstream + forwarding rules) + let system_dns = discover_system_dns(); + + let upstream_addr = if config.upstream.address.is_empty() { + system_dns.default_upstream.unwrap_or_else(|| { + info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)"); + "9.9.9.9".to_string() + }) + } else { + config.upstream.address.clone() + }; + let upstream: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; let api_port = config.server.api_port; let mut blocklist = BlocklistStore::new(); @@ -68,8 +104,15 @@ async fn main() -> numa::Result<()> { blocklist.set_enabled(false); } - // Auto-discover conditional forwarding rules from OS (Tailscale, VPN, etc.) - let forwarding_rules = discover_forwarding_rules(); + // Build service store: config services + persisted user services + let mut service_store = ServiceStore::new(); + service_store.insert_from_config("numa", config.server.api_port); + for svc in &config.services { + service_store.insert_from_config(&svc.name, svc.target_port); + } + service_store.load_persisted(); + + let forwarding_rules = system_dns.forwarding_rules; let ctx = Arc::new(ServerCtx { socket: UdpSocket::bind(&config.server.bind_addr).await?, @@ -83,14 +126,21 @@ async fn main() -> numa::Result<()> { overrides: Mutex::new(OverrideStore::new()), blocklist: Mutex::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), + services: Mutex::new(service_store), forwarding_rules, upstream, timeout: Duration::from_millis(config.upstream.timeout_ms), + proxy_tld_suffix: if config.proxy.tld.is_empty() { + String::new() + } else { + format!(".{}", config.proxy.tld) + }, + proxy_tld: config.proxy.tld.clone(), }); 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;192;98;58m║\x1b[0m"); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;163;152;136mv{}\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m", env!("CARGO_PKG_VERSION")); eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr); eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); @@ -100,6 +150,17 @@ async fn main() -> numa::Result<()> { 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://:{}", + config.proxy.port, config.proxy.tls_port + ) + } else { + format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port) + }; + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes); + } 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", format!("{} conditional rules", ctx.forwarding_rules.len())); @@ -141,6 +202,39 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Spawn HTTP reverse proxy for .numa domains + if config.proxy.enabled { + let proxy_ctx = Arc::clone(&ctx); + let proxy_port = config.proxy.port; + tokio::spawn(async move { + numa::proxy::start_proxy(proxy_ctx, proxy_port).await; + }); + } + + // Spawn HTTPS reverse proxy with TLS termination + if config.proxy.enabled && config.proxy.tls_port > 0 { + let service_names: Vec = ctx + .services + .lock() + .unwrap() + .list() + .iter() + .map(|e| e.name.clone()) + .collect(); + match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { + Ok(tls_config) => { + let proxy_ctx = Arc::clone(&ctx); + let tls_port = config.proxy.tls_port; + tokio::spawn(async move { + numa::proxy::start_proxy_tls(proxy_ctx, tls_port, tls_config).await; + }); + } + Err(e) => { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + } + } + } + // UDP DNS listener #[allow(clippy::infinite_loop)] loop { diff --git a/src/packet.rs b/src/packet.rs index f6845aa..bca60c2 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -68,24 +68,33 @@ impl DnsPacket { } pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> { + // Filter out UNKNOWN records (e.g. EDNS OPT) that we can't re-serialize + let answers: Vec<_> = self.answers.iter().filter(|r| !r.is_unknown()).collect(); + let authorities: Vec<_> = self + .authorities + .iter() + .filter(|r| !r.is_unknown()) + .collect(); + let resources: Vec<_> = self.resources.iter().filter(|r| !r.is_unknown()).collect(); + let mut header = self.header.clone(); header.questions = self.questions.len() as u16; - header.answers = self.answers.len() as u16; - header.authoritative_entries = self.authorities.len() as u16; - header.resource_entries = self.resources.len() as u16; + header.answers = answers.len() as u16; + header.authoritative_entries = authorities.len() as u16; + header.resource_entries = resources.len() as u16; header.write(buffer)?; for question in &self.questions { question.write(buffer)?; } - for rec in &self.answers { + for rec in answers { rec.write(buffer)?; } - for rec in &self.authorities { + for rec in authorities { rec.write(buffer)?; } - for rec in &self.resources { + for rec in resources { rec.write(buffer)?; } diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..787bbf9 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,241 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::Router; +use http_body_util::BodyExt; +use hyper::StatusCode; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use log::{debug, error, info, warn}; +use rustls::ServerConfig; +use tokio::io::copy_bidirectional; +use tokio_rustls::TlsAcceptor; + +use crate::ctx::ServerCtx; + +type HttpClient = Client; + +#[derive(Clone)] +struct ProxyState { + ctx: Arc, + client: HttpClient, +} + +pub async fn start_proxy(ctx: Arc, port: u16) { + let addr: SocketAddr = ([0, 0, 0, 0], port).into(); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!( + "proxy: could not bind port {} ({}) — proxy disabled", + port, e + ); + return; + } + }; + info!("HTTP proxy listening on {}", addr); + + let client: HttpClient = Client::builder(TokioExecutor::new()) + .http1_preserve_header_case(true) + .build_http(); + + let state = ProxyState { ctx, client }; + + let app = Router::new().fallback(any(proxy_handler)).with_state(state); + + axum::serve(listener, app).await.unwrap(); +} + +pub async fn start_proxy_tls(ctx: Arc, port: u16, tls_config: Arc) { + let addr: SocketAddr = ([0, 0, 0, 0], port).into(); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!( + "proxy: could not bind TLS port {} ({}) — HTTPS proxy disabled", + port, e + ); + return; + } + }; + info!("HTTPS proxy listening on {}", addr); + + let acceptor = TlsAcceptor::from(tls_config); + let client: HttpClient = Client::builder(TokioExecutor::new()) + .http1_preserve_header_case(true) + .build_http(); + + let state = ProxyState { ctx, client }; + + let app = Router::new().fallback(any(proxy_handler)).with_state(state); + + loop { + let (tcp_stream, remote_addr) = match listener.accept().await { + Ok(conn) => conn, + Err(e) => { + error!("TLS accept error: {}", e); + continue; + } + }; + + let acceptor = acceptor.clone(); + let app = app.clone(); + + tokio::spawn(async move { + let tls_stream = match acceptor.accept(tcp_stream).await { + Ok(s) => s, + Err(e) => { + debug!("TLS handshake failed from {}: {}", remote_addr, e); + return; + } + }; + + let io = hyper_util::rt::TokioIo::new(tls_stream); + let svc = hyper_util::service::TowerToHyperService::new(app.into_service()); + + if let Err(e) = hyper::server::conn::http1::Builder::new() + .preserve_header_case(true) + .serve_connection(io, svc) + .with_upgrades() + .await + { + debug!("TLS connection error from {}: {}", remote_addr, e); + } + }); + } +} + +fn extract_host(req: &Request) -> Option { + req.headers() + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h).to_lowercase()) +} + +async fn proxy_handler(State(state): State, req: Request) -> axum::response::Response { + let hostname = match extract_host(&req) { + Some(h) => h, + None => { + return (StatusCode::BAD_REQUEST, "missing Host header").into_response(); + } + }; + + let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) { + Some(name) => name.to_string(), + None => { + return ( + StatusCode::BAD_GATEWAY, + format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname), + ) + .into_response() + } + }; + + let target_port = { + let store = state.ctx.services.lock().unwrap(); + match store.lookup(&service_name) { + Some(entry) => entry.target_port, + None => { + return ( + StatusCode::BAD_GATEWAY, + format!( + "unknown service: {}{}", + service_name, state.ctx.proxy_tld_suffix + ), + ) + .into_response() + } + } + }; + + let path_and_query = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query) + .parse() + .unwrap(); + + // Check for upgrade request (WebSocket, etc.) + let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); + + if is_upgrade { + return handle_upgrade(req, target_uri, state.client.clone()).await; + } + + // Regular HTTP proxy + let (mut parts, body) = req.into_parts(); + parts.uri = target_uri; + let proxied_req = Request::from_parts(parts, body); + + match state.client.request(proxied_req).await { + Ok(resp) => { + let (parts, body) = resp.into_parts(); + let body = Body::new(body.map_err(axum::Error::new)); + axum::response::Response::from_parts(parts, body) + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("proxy error: {}", e)).into_response(), + } +} + +async fn handle_upgrade( + mut req: Request, + target_uri: hyper::Uri, + client: HttpClient, +) -> axum::response::Response { + // Save the client-side upgrade future before forwarding + let client_upgrade = hyper::upgrade::on(&mut req); + + // Forward the request to backend + let (mut parts, body) = req.into_parts(); + parts.uri = target_uri; + let backend_req = Request::from_parts(parts, body); + + let mut backend_resp = match client.request(backend_req).await { + Ok(r) => r, + Err(e) => { + return (StatusCode::BAD_GATEWAY, format!("upgrade error: {}", e)).into_response() + } + }; + + if backend_resp.status() != StatusCode::SWITCHING_PROTOCOLS { + let (parts, body) = backend_resp.into_parts(); + let body = Body::new(body.map_err(axum::Error::new)); + return axum::response::Response::from_parts(parts, body); + } + + // Save response headers before consuming for upgrade + let resp_headers = backend_resp.headers().clone(); + let backend_upgrade = hyper::upgrade::on(&mut backend_resp); + + // Spawn bidirectional pipe once both sides are upgraded + tokio::spawn(async move { + let (client_io, backend_io) = match tokio::try_join!(client_upgrade, backend_upgrade) { + Ok((c, b)) => (c, b), + Err(e) => { + error!("proxy upgrade failed: {}", e); + return; + } + }; + + let mut client_rw = hyper_util::rt::TokioIo::new(client_io); + let mut backend_rw = hyper_util::rt::TokioIo::new(backend_io); + + match copy_bidirectional(&mut client_rw, &mut backend_rw).await { + Ok((up, down)) => debug!("ws proxy closed: {} up, {} down bytes", up, down), + Err(e) => debug!("ws proxy error: {}", e), + } + }); + + // Return 101 to client with the backend's upgrade headers + let mut resp = axum::response::Response::builder().status(StatusCode::SWITCHING_PROTOCOLS); + for (key, value) in &resp_headers { + resp = resp.header(key, value); + } + resp.body(Body::empty()).unwrap() +} diff --git a/src/question.rs b/src/question.rs index 4baebf4..30fe0ce 100644 --- a/src/question.rs +++ b/src/question.rs @@ -7,8 +7,13 @@ pub enum QueryType { A, // 1 NS, // 2 CNAME, // 5 + SOA, // 6 + PTR, // 12 MX, // 15 + TXT, // 16 AAAA, // 28 + SRV, // 33 + HTTPS, // 65 } impl QueryType { @@ -18,8 +23,13 @@ impl QueryType { QueryType::A => 1, QueryType::NS => 2, QueryType::CNAME => 5, + QueryType::SOA => 6, + QueryType::PTR => 12, QueryType::MX => 15, + QueryType::TXT => 16, QueryType::AAAA => 28, + QueryType::SRV => 33, + QueryType::HTTPS => 65, } } @@ -28,8 +38,13 @@ impl QueryType { 1 => QueryType::A, 2 => QueryType::NS, 5 => QueryType::CNAME, + 6 => QueryType::SOA, + 12 => QueryType::PTR, 15 => QueryType::MX, + 16 => QueryType::TXT, 28 => QueryType::AAAA, + 33 => QueryType::SRV, + 65 => QueryType::HTTPS, _ => QueryType::UNKNOWN(num), } } @@ -39,25 +54,30 @@ impl QueryType { QueryType::A => "A", QueryType::NS => "NS", QueryType::CNAME => "CNAME", + QueryType::SOA => "SOA", + QueryType::PTR => "PTR", QueryType::MX => "MX", + QueryType::TXT => "TXT", QueryType::AAAA => "AAAA", + QueryType::SRV => "SRV", + QueryType::HTTPS => "HTTPS", QueryType::UNKNOWN(_) => "UNKNOWN", } } pub fn parse_str(s: &str) -> Option { - if s.eq_ignore_ascii_case("A") { - Some(QueryType::A) - } else if s.eq_ignore_ascii_case("NS") { - Some(QueryType::NS) - } else if s.eq_ignore_ascii_case("CNAME") { - Some(QueryType::CNAME) - } else if s.eq_ignore_ascii_case("MX") { - Some(QueryType::MX) - } else if s.eq_ignore_ascii_case("AAAA") { - Some(QueryType::AAAA) - } else { - None + match s.to_ascii_uppercase().as_str() { + "A" => Some(QueryType::A), + "NS" => Some(QueryType::NS), + "CNAME" => Some(QueryType::CNAME), + "SOA" => Some(QueryType::SOA), + "PTR" => Some(QueryType::PTR), + "MX" => Some(QueryType::MX), + "TXT" => Some(QueryType::TXT), + "AAAA" => Some(QueryType::AAAA), + "SRV" => Some(QueryType::SRV), + "HTTPS" => Some(QueryType::HTTPS), + _ => None, } } } diff --git a/src/record.rs b/src/record.rs index 8d65879..f525cbb 100644 --- a/src/record.rs +++ b/src/record.rs @@ -43,6 +43,10 @@ pub enum DnsRecord { } impl DnsRecord { + pub fn is_unknown(&self) -> bool { + matches!(self, DnsRecord::UNKNOWN { .. }) + } + pub fn ttl(&self) -> u32 { match self { DnsRecord::A { ttl, .. } @@ -137,7 +141,7 @@ impl DnsRecord { ttl, }) } - QueryType::UNKNOWN(_) => { + _ => { buffer.step(data_len as usize)?; Ok(DnsRecord::UNKNOWN { diff --git a/src/service_store.rs b/src/service_store.rs new file mode 100644 index 0000000..26b2daf --- /dev/null +++ b/src/service_store.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use log::{info, warn}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct ServiceEntry { + pub name: String, + pub target_port: u16, +} + +pub struct ServiceStore { + entries: HashMap, + /// Services defined in numa.toml (not persisted to user file) + config_services: std::collections::HashSet, + persist_path: PathBuf, +} + +impl Default for ServiceStore { + fn default() -> Self { + Self::new() + } +} + +impl ServiceStore { + pub fn new() -> Self { + let persist_path = dirs_path(); + ServiceStore { + entries: HashMap::new(), + config_services: std::collections::HashSet::new(), + persist_path, + } + } + + /// Insert a service from numa.toml config (not persisted) + pub fn insert_from_config(&mut self, name: &str, target_port: u16) { + let key = name.to_lowercase(); + self.config_services.insert(key.clone()); + self.entries.insert( + key.clone(), + ServiceEntry { + name: key, + target_port, + }, + ); + } + + /// Insert a user-defined service (persisted to ~/.config/numa/services.json) + pub fn insert(&mut self, name: &str, target_port: u16) { + let key = name.to_lowercase(); + self.entries.insert( + key.clone(), + ServiceEntry { + name: key, + target_port, + }, + ); + self.save(); + } + + pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> { + self.entries.get(&name.to_lowercase()) + } + + pub fn remove(&mut self, name: &str) -> bool { + let key = name.to_lowercase(); + let removed = self.entries.remove(&key).is_some(); + if removed { + self.save(); + } + removed + } + + pub fn list(&self) -> Vec<&ServiceEntry> { + let mut entries: Vec<_> = self.entries.values().collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries + } + + /// Load user-defined services from ~/.config/numa/services.json + pub fn load_persisted(&mut self) { + if !self.persist_path.exists() { + return; + } + match std::fs::read_to_string(&self.persist_path) { + Ok(contents) => match serde_json::from_str::>(&contents) { + Ok(entries) => { + let count = entries.len(); + for entry in entries { + let key = entry.name.to_lowercase(); + // Don't overwrite config-defined services + if !self.config_services.contains(&key) { + self.entries.insert(key, entry); + } + } + if count > 0 { + info!( + "loaded {} persisted services from {:?}", + count, self.persist_path + ); + } + } + Err(e) => warn!("failed to parse {:?}: {}", self.persist_path, e), + }, + Err(e) => warn!("failed to read {:?}: {}", self.persist_path, e), + } + } + + /// Save user-defined services (excluding config and "numa") to disk + fn save(&self) { + let user_services: Vec<&ServiceEntry> = self + .entries + .values() + .filter(|e| !self.config_services.contains(&e.name)) + .collect(); + + if let Some(parent) = self.persist_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + match serde_json::to_string_pretty(&user_services) { + Ok(json) => { + if let Err(e) = std::fs::write(&self.persist_path, json) { + warn!("failed to save services to {:?}: {}", self.persist_path, e); + } + } + Err(e) => warn!("failed to serialize services: {}", e), + } + } +} + +fn dirs_path() -> PathBuf { + crate::config_dir().join("services.json") +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 3e9d4e6..6b63c48 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -10,38 +10,48 @@ pub struct ForwardingRule { pub upstream: SocketAddr, } -/// Discover system DNS forwarding rules from the OS. -/// On macOS, parses `scutil --dns`. Returns rules sorted longest-suffix-first -/// so more specific matches take priority. -pub fn discover_forwarding_rules() -> Vec { +/// Result of system DNS discovery — default upstream + conditional forwarding rules. +pub struct SystemDnsInfo { + pub default_upstream: Option, + pub forwarding_rules: Vec, +} + +/// Discover system DNS configuration in a single pass. +/// On macOS: parses `scutil --dns` once for both the default upstream and forwarding rules. +/// On Linux: reads `/etc/resolv.conf` for upstream, no forwarding rules yet. +pub fn discover_system_dns() -> SystemDnsInfo { #[cfg(target_os = "macos")] { discover_macos() } #[cfg(not(target_os = "macos"))] { - info!("system DNS auto-discovery not implemented for this OS"); - Vec::new() + SystemDnsInfo { + default_upstream: detect_upstream_linux_or_backup(), + forwarding_rules: Vec::new(), + } } } #[cfg(target_os = "macos")] -fn discover_macos() -> Vec { +fn discover_macos() -> SystemDnsInfo { use log::{debug, warn}; let output = match std::process::Command::new("scutil").arg("--dns").output() { Ok(o) => o, Err(e) => { warn!("failed to run scutil --dns: {}", e); - return Vec::new(); + return SystemDnsInfo { + default_upstream: None, + forwarding_rules: Vec::new(), + }; } }; let text = String::from_utf8_lossy(&output.stdout); let mut rules = Vec::new(); + let mut default_upstream: Option = None; - // Parse resolver blocks: look for blocks with both `domain` and `nameserver[0]` - // that have the `Supplemental` flag (conditional forwarding, not default) let mut current_domain: Option = None; let mut current_nameserver: Option = None; let mut is_supplemental = false; @@ -50,7 +60,7 @@ fn discover_macos() -> Vec { let line = line.trim(); if line.starts_with("resolver #") { - // Emit previous block if valid + // Emit previous supplemental block as forwarding rule if let (Some(domain), Some(ns), true) = ( current_domain.take(), current_nameserver.take(), @@ -64,7 +74,6 @@ fn discover_macos() -> Vec { current_nameserver = None; is_supplemental = false; } else if line.starts_with("domain") && line.contains(':') { - // "domain : tailcee7cc.ts.net." if let Some(val) = line.split(':').nth(1) { let domain = val.trim().trim_end_matches('.').to_lowercase(); if !domain.is_empty() @@ -78,15 +87,21 @@ fn discover_macos() -> Vec { } else if line.starts_with("nameserver[0]") && line.contains(':') { if let Some(val) = line.split(':').nth(1) { let ns = val.trim().to_string(); - // Only use IPv4 nameservers for now if ns.parse::().is_ok() { - current_nameserver = Some(ns); + current_nameserver = Some(ns.clone()); + // Capture first non-supplemental, non-loopback nameserver as default upstream + if !is_supplemental + && default_upstream.is_none() + && ns != "127.0.0.1" + && ns != "0.0.0.0" + { + default_upstream = Some(ns); + } } } } else if line.starts_with("flags") && line.contains("Supplemental") { is_supplemental = true; } else if line.starts_with("DNS configuration (for scoped") { - // Stop at scoped section — those are interface-specific, not conditional if let (Some(domain), Some(ns), true) = ( current_domain.take(), current_nameserver.take(), @@ -116,12 +131,17 @@ fn discover_macos() -> Vec { rule.suffix, rule.upstream ); } - if rules.is_empty() { - debug!("no conditional forwarding rules discovered from scutil --dns"); + debug!("no conditional forwarding rules discovered"); + } + if let Some(ref ns) = default_upstream { + info!("detected system upstream: {}", ns); } - rules + SystemDnsInfo { + default_upstream, + forwarding_rules: rules, + } } #[cfg(target_os = "macos")] @@ -134,6 +154,45 @@ fn make_rule(domain: &str, nameserver: &str) -> Option { }) } +/// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf +/// only has loopback (meaning numa install already ran). +#[cfg(not(target_os = "macos"))] +fn detect_upstream_linux_or_backup() -> Option { + // Try /etc/resolv.conf first + if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") { + info!("detected system upstream: {}", ns); + return Some(ns); + } + // If resolv.conf only has loopback, check the backup from `numa install` + let backup = { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/root")); + home.join(".numa").join("original-resolv.conf") + }; + if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) { + info!("detected original upstream from backup: {}", ns); + return Some(ns); + } + None +} + +#[cfg(not(target_os = "macos"))] +fn read_upstream_from_file(path: &str) -> Option { + let text = std::fs::read_to_string(path).ok()?; + for line in text.lines() { + let line = line.trim(); + if line.starts_with("nameserver") { + if let Some(ns) = line.split_whitespace().nth(1) { + if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" { + return Some(ns.to_string()); + } + } + } + } + None +} + /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. @@ -152,21 +211,25 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option Result<(), String> { #[cfg(target_os = "macos")] - { - install_macos() - } + let result = install_macos(); #[cfg(target_os = "linux")] - { - install_linux() - } + let result = install_linux(); #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Err("system DNS configuration not supported on this OS".to_string()) + let result = Err("system DNS configuration not supported on this OS".to_string()); + + if result.is_ok() { + if let Err(e) = trust_ca() { + eprintln!(" warning: could not trust CA: {}", e); + eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n"); + } } + result } /// Restore the original system DNS settings saved during install. pub fn uninstall_system_dns() -> Result<(), String> { + let _ = untrust_ca(); + #[cfg(target_os = "macos")] { uninstall_macos() @@ -186,8 +249,9 @@ pub fn uninstall_system_dns() -> Result<(), String> { #[cfg(target_os = "macos")] fn numa_data_dir() -> std::path::PathBuf { let home = std::env::var("HOME") + .or_else(|_| std::env::var("SUDO_USER").map(|u| format!("/Users/{}", u))) .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); + .unwrap_or_else(|_| std::path::PathBuf::from("/var/root")); home.join(".numa") } @@ -315,17 +379,489 @@ fn uninstall_macos() -> Result<(), String> { Ok(()) } -// --- Linux stubs --- +// --- Service management --- + +#[cfg(target_os = "macos")] +const PLIST_LABEL: &str = "com.numa.dns"; +#[cfg(target_os = "macos")] +const PLIST_DEST: &str = "/Library/LaunchDaemons/com.numa.dns.plist"; +#[cfg(target_os = "linux")] +const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service"; + +/// Install Numa as a system service that starts on boot and auto-restarts. +pub fn install_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + install_service_macos() + } + #[cfg(target_os = "linux")] + { + install_service_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service installation not supported on this OS".to_string()) + } +} + +/// Uninstall the Numa system service. +pub fn uninstall_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + uninstall_service_macos() + } + #[cfg(target_os = "linux")] + { + uninstall_service_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service uninstallation not supported on this OS".to_string()) + } +} + +/// Restart the service (kill process, launchd/systemd auto-restarts with new binary). +pub fn restart_service() -> Result<(), String> { + // Show version of the binary that will be running after restart + let version = match std::process::Command::new("/usr/local/bin/numa") + .arg("--version") + .output() + { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(_) => "unknown".to_string(), + }; + + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("launchctl") + .args(["list", PLIST_LABEL]) + .output(); + match output { + Ok(o) if o.status.success() => { + eprintln!(" Tip: use 'make deploy' instead — handles codesign + restart.\n"); + // Codesign, then kill service. Launchd KeepAlive respawns it. + // This will kill us too (we ARE /usr/local/bin/numa), so + // codesign and print output first. + let _ = std::process::Command::new("codesign") + .args(["-f", "-s", "-", "/usr/local/bin/numa"]) + .output(); // use output() to suppress codesign stderr + eprintln!(" Service restarting → {}\n", version); + let _ = std::process::Command::new("pkill") + .args(["-f", "/usr/local/bin/numa"]) + .status(); + Ok(()) + } + _ => Err("Service is not installed. Run 'sudo numa service start' first.".to_string()), + } + } + #[cfg(target_os = "linux")] + { + run_systemctl(&["restart", "numa"])?; + eprintln!(" Service restarted → {}\n", version); + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service restart not supported on this OS".to_string()) + } +} + +/// Show the service status. +pub fn service_status() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + service_status_macos() + } + #[cfg(target_os = "linux")] + { + service_status_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service status not supported on this OS".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn install_service_macos() -> Result<(), String> { + // Check binary exists + if !std::path::Path::new("/usr/local/bin/numa").exists() { + return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); + } + + // Create log directory + std::fs::create_dir_all("/usr/local/var/log") + .map_err(|e| format!("failed to create log dir: {}", e))?; + + // Write plist + let plist = include_str!("../com.numa.dns.plist"); + std::fs::write(PLIST_DEST, plist) + .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; + + // Load the service + let status = std::process::Command::new("launchctl") + .args(["load", "-w", PLIST_DEST]) + .status() + .map_err(|e| format!("failed to run launchctl: {}", e))?; + + if !status.success() { + return Err("launchctl load failed".to_string()); + } + + // Set system DNS to 127.0.0.1 now that the service is running + eprintln!(" Service installed and started."); + if let Err(e) = install_macos() { + eprintln!(" warning: failed to configure system DNS: {}", e); + } + eprintln!(" Numa will auto-start on boot and restart if killed."); + eprintln!(" Logs: /usr/local/var/log/numa.log"); + eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn uninstall_service_macos() -> Result<(), String> { + // Restore DNS first, while numa is still running to handle any final queries + if let Err(e) = uninstall_macos() { + eprintln!(" warning: failed to restore system DNS: {}", e); + } + + // Remove plist first so service won't restart on boot even if unload fails + if let Err(e) = std::fs::remove_file(PLIST_DEST) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("failed to remove {}: {}", PLIST_DEST, e)); + } + } + + // Unload the service + let status = std::process::Command::new("launchctl") + .args(["unload", "-w", PLIST_DEST]) + .status(); + if let Ok(s) = status { + if !s.success() { + eprintln!( + " warning: launchctl unload returned non-zero (service may still be running)" + ); + } + } + + eprintln!(" Service uninstalled. Numa will no longer auto-start.\n"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn service_status_macos() -> Result<(), String> { + let output = std::process::Command::new("launchctl") + .args(["list", PLIST_LABEL]) + .output() + .map_err(|e| format!("failed to run launchctl: {}", e))?; + + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + eprintln!(" Numa service is loaded.\n"); + for line in text.lines() { + eprintln!(" {}", line); + } + eprintln!(); + } else { + eprintln!(" Numa service is not installed.\n"); + } + Ok(()) +} + +// --- Linux implementation --- + +#[cfg(target_os = "linux")] +fn backup_path_linux() -> std::path::PathBuf { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/root")); + home.join(".numa").join("original-resolv.conf") +} + +#[cfg(target_os = "linux")] +fn is_systemd_resolved_active() -> bool { + std::process::Command::new("systemctl") + .args(["is-active", "--quiet", "systemd-resolved"]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} #[cfg(target_os = "linux")] fn install_linux() -> Result<(), String> { - Err( - "Linux auto-configuration not yet implemented. Manually set your DNS to 127.0.0.1" - .to_string(), - ) + // Detect systemd-resolved — direct resolv.conf manipulation won't persist + if is_systemd_resolved_active() { + let resolved_dir = std::path::Path::new("/etc/systemd/resolved.conf.d"); + std::fs::create_dir_all(resolved_dir) + .map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?; + + let drop_in = resolved_dir.join("numa.conf"); + std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n") + .map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?; + + let _ = run_systemctl(&["restart", "systemd-resolved"]); + eprintln!(" systemd-resolved detected."); + eprintln!(" Installed drop-in: {}", drop_in.display()); + eprintln!(" Run 'sudo numa uninstall' to remove.\n"); + return Ok(()); + } + + // Fallback: direct resolv.conf manipulation + let resolv = std::path::Path::new("/etc/resolv.conf"); + let backup = backup_path_linux(); + + // Ensure backup directory exists + if let Some(parent) = backup.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + + // Back up current resolv.conf (ignore NotFound) + match std::fs::copy(resolv, &backup) { + Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)), + } + + if resolv + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + eprintln!(" warning: /etc/resolv.conf is a symlink — changes may not persist."); + eprintln!(" Consider using systemd-resolved or NetworkManager instead.\n"); + } + + let content = + "# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n"; + std::fs::write(resolv, content) + .map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?; + + eprintln!(" Set /etc/resolv.conf -> nameserver 127.0.0.1"); + eprintln!(" Run 'sudo numa uninstall' to restore.\n"); + Ok(()) } #[cfg(target_os = "linux")] fn uninstall_linux() -> Result<(), String> { - Err("Linux auto-configuration not yet implemented.".to_string()) + // Check for systemd-resolved drop-in first + let drop_in = std::path::Path::new("/etc/systemd/resolved.conf.d/numa.conf"); + if drop_in.exists() { + std::fs::remove_file(drop_in) + .map_err(|e| format!("failed to remove {}: {}", drop_in.display(), e))?; + let _ = run_systemctl(&["restart", "systemd-resolved"]); + eprintln!(" Removed systemd-resolved drop-in. DNS restored.\n"); + return Ok(()); + } + + // Fallback: restore resolv.conf from backup + let backup = backup_path_linux(); + let resolv = std::path::Path::new("/etc/resolv.conf"); + + match std::fs::copy(&backup, resolv) { + Ok(_) => { + std::fs::remove_file(&backup).ok(); + eprintln!(" Restored /etc/resolv.conf from backup. Backup removed.\n"); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!(" No backup found at {}.", backup.display()); + eprintln!(" Manually edit /etc/resolv.conf to restore your DNS.\n"); + } + Err(e) => return Err(format!("failed to restore /etc/resolv.conf: {}", e)), + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_binary_installed() -> Result<(), String> { + if !std::path::Path::new("/usr/local/bin/numa").exists() { + return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn install_service_linux() -> Result<(), String> { + ensure_binary_installed()?; + + let unit = include_str!("../numa.service"); + std::fs::write(SYSTEMD_UNIT, unit) + .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; + + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", "numa"])?; + run_systemctl(&["start", "numa"])?; + + eprintln!(" Service installed and started."); + + // Set system DNS now that the service is running + if let Err(e) = install_linux() { + eprintln!(" warning: failed to configure system DNS: {}", e); + } + eprintln!(" Numa will auto-start on boot and restart if killed."); + eprintln!(" Logs: journalctl -u numa -f"); + eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n"); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn uninstall_service_linux() -> Result<(), String> { + // Restore DNS first, while numa is still running + if let Err(e) = uninstall_linux() { + eprintln!(" warning: failed to restore system DNS: {}", e); + } + + if let Err(e) = run_systemctl(&["stop", "numa"]) { + eprintln!(" warning: {}", e); + } + if let Err(e) = run_systemctl(&["disable", "numa"]) { + eprintln!(" warning: {}", e); + } + + if let Err(e) = std::fs::remove_file(SYSTEMD_UNIT) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("failed to remove {}: {}", SYSTEMD_UNIT, e)); + } + } + let _ = run_systemctl(&["daemon-reload"]); + + eprintln!(" Service uninstalled. Numa will no longer auto-start.\n"); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn service_status_linux() -> Result<(), String> { + let output = std::process::Command::new("systemctl") + .args(["status", "numa"]) + .output() + .map_err(|e| format!("failed to run systemctl: {}", e))?; + + let text = String::from_utf8_lossy(&output.stdout); + if text.is_empty() { + eprintln!(" Numa service is not installed.\n"); + } else { + for line in text.lines() { + eprintln!(" {}", line); + } + eprintln!(); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn run_systemctl(args: &[&str]) -> Result<(), String> { + let status = std::process::Command::new("systemctl") + .args(args) + .status() + .map_err(|e| format!("systemctl {} failed: {}", args.join(" "), e))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "systemctl {} exited with {}", + args.join(" "), + status + )) + } +} + +// --- CA trust management --- + +fn trust_ca() -> Result<(), String> { + let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + if !ca_path.exists() { + return Err("CA not generated yet — start numa first to create certificates".into()); + } + + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("security") + .args([ + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + "/Library/Keychains/System.keychain", + ]) + .arg(&ca_path) + .status() + .map_err(|e| format!("security: {}", e))?; + if !status.success() { + return Err("security add-trusted-cert failed".into()); + } + eprintln!(" Trusted Numa CA in system keychain"); + } + + #[cfg(target_os = "linux")] + { + let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt"); + std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?; + let status = std::process::Command::new("update-ca-certificates") + .status() + .map_err(|e| format!("update-ca-certificates: {}", e))?; + if !status.success() { + return Err("update-ca-certificates failed".into()); + } + eprintln!(" Trusted Numa CA system-wide"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + return Err("CA trust not supported on this OS".into()); + } + + Ok(()) +} + +fn untrust_ca() -> Result<(), String> { + let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + + #[cfg(target_os = "macos")] + { + // Find all Numa CA certs by hash and delete each one + if let Ok(out) = std::process::Command::new("security") + .args([ + "find-certificate", + "-c", + "Numa Local CA", + "-a", + "-Z", + "/Library/Keychains/System.keychain", + ]) + .output() + { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Some(hash) = line.strip_prefix("SHA-1 hash: ") { + let hash = hash.trim(); + let _ = std::process::Command::new("security") + .args([ + "delete-certificate", + "-Z", + hash, + "/Library/Keychains/System.keychain", + ]) + .output(); + } + } + } + eprintln!(" Removed Numa CA from system keychain"); + } + + #[cfg(target_os = "linux")] + { + let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt"); + if dest.exists() { + let _ = std::fs::remove_file(dest); + let _ = std::process::Command::new("update-ca-certificates") + .arg("--fresh") + .status(); + eprintln!(" Removed Numa CA from system trust store"); + } + } + + let _ = ca_path; // suppress unused warning on other platforms + Ok(()) } diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..5fdada5 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,129 @@ +use std::path::Path; +use std::sync::Arc; + +use log::{info, warn}; +use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::ServerConfig; +use time::{Duration, OffsetDateTime}; + +const CA_VALIDITY_DAYS: i64 = 3650; // 10 years +const CERT_VALIDITY_DAYS: i64 = 365; // 1 year + +/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree. +pub const TLS_DIR: &str = "/usr/local/var/numa"; + +/// Build a TLS config with a cert covering all provided service names. +/// Wildcards under single-label TLDs (*.numa) are rejected by browsers, +/// so we list each service explicitly as a SAN. +pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result> { + let dir = std::path::PathBuf::from(TLS_DIR); + let (ca_cert, ca_key) = ensure_ca(&dir)?; + let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; + + // Ensure a crypto provider is installed (rustls needs one) + let _ = rustls::crypto::ring::default_provider().install_default(); + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, key)?; + + info!( + "TLS configured for {} .{} domains", + service_names.len(), + tld + ); + Ok(Arc::new(config)) +} + +fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { + let ca_key_path = dir.join("ca.key"); + let ca_cert_path = dir.join("ca.pem"); + + if ca_key_path.exists() && ca_cert_path.exists() { + let key_pem = std::fs::read_to_string(&ca_key_path)?; + let cert_pem = std::fs::read_to_string(&ca_cert_path)?; + let key_pair = KeyPair::from_pem(&key_pem)?; + let params = CertificateParams::from_ca_cert_pem(&cert_pem)?; + let cert = params.self_signed(&key_pair)?; + info!("loaded CA from {:?}", ca_cert_path); + return Ok((cert, key_pair)); + } + + // Generate new CA + std::fs::create_dir_all(dir)?; + + let key_pair = KeyPair::generate()?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::CommonName, "Numa Local CA"); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + Duration::days(CA_VALIDITY_DAYS); + + let cert = params.self_signed(&key_pair)?; + + std::fs::write(&ca_key_path, key_pair.serialize_pem())?; + std::fs::write(&ca_cert_path, cert.pem())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600))?; + } + + info!("generated CA at {:?}", ca_cert_path); + Ok((cert, key_pair)) +} + +/// Generate a cert with explicit SANs for each service name. +/// Always regenerated at startup (~5ms) — no disk caching needed. +fn generate_service_cert( + ca_cert: &rcgen::Certificate, + ca_key: &KeyPair, + tld: &str, + service_names: &[String], +) -> crate::Result<(Vec>, PrivateKeyDer<'static>)> { + let key_pair = KeyPair::generate()?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::CommonName, format!("Numa .{} services", tld)); + + // Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc. + let mut sans = Vec::new(); + for name in service_names { + let fqdn = format!("{}.{}", name, tld); + match fqdn.clone().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid SAN {}: {}", fqdn, e), + } + } + + if sans.is_empty() { + return Err("no valid service names for TLS cert".into()); + } + + params.subject_alt_names = sans; + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS); + + let cert = params.signed_by(&key_pair, ca_cert, ca_key)?; + + info!( + "generated TLS cert for: {}", + service_names + .iter() + .map(|n| format!("{}.{}", n, tld)) + .collect::>() + .join(", ") + ); + + let cert_der = CertificateDer::from(cert.der().to_vec()); + let ca_der = CertificateDer::from(ca_cert.der().to_vec()); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); + + Ok((vec![cert_der, ca_der], key_der)) +}
No REST API + auto-expiry
Local service proxyNoNoNoNo.numa domains + WebSocket
Data stays local Yes