From 3bfcd827ac052bcac8233fe0a0109c1d1d34494c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 01:15:07 +0200 Subject: [PATCH] add TLS, service persistence, blocking panel, query types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Local TLS: auto-generated CA + per-service certs (explicit SANs, not wildcards — browsers reject *.numa under single-label TLDs). HTTPS proxy on :443 via rustls/tokio-rustls. `numa install` trusts CA in macOS Keychain / Linux ca-certificates. - Service persistence: user-added services saved to ~/.config/numa/services.json, survive restarts. - Blocking panel: renamed "Check Domain" to "Blocking" with sources display, allowlist management UI, unpause button. - Query types: recognize SOA, PTR, TXT, SRV, HTTPS (type 65) instead of logging as UNKNOWN. - Blocklist gzip: reqwest now decompresses gzip responses from CDNs. - Unified config_dir() in lib.rs for consistent path resolution under sudo and launchd. TLS certs use /usr/local/var/numa/ (writable as root daemon). - Dashboard UX: panel subtitles differentiating overrides vs services, better placeholders, proxy route display, 600px query log height. - Deploy: make deploy handles build+copy+codesign+restart cycle. - Demo: scripts/record-demo.sh for recording hero GIF with CDP. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 18 +- Cargo.lock | 422 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 9 +- numa.toml | 1 + scripts/record-demo.sh | 334 ++++++++++++++++++++++++++++++++ site/dashboard.html | 141 ++++++++++++-- src/api.rs | 6 + src/blocklist.rs | 5 + src/config.rs | 6 + src/lib.rs | 27 +++ src/main.rs | 42 +++- src/proxy.rs | 76 +++++++- src/question.rs | 44 +++-- src/record.rs | 2 +- src/service_store.rs | 88 ++++++++- src/system_dns.rs | 99 +++++++++- src/tls.rs | 125 ++++++++++++ 17 files changed, 1377 insertions(+), 68 deletions(-) create mode 100755 scripts/record-demo.sh create mode 100644 src/tls.rs diff --git a/CLAUDE.md b/CLAUDE.md index e1739e8..9d0fd47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,8 @@ UDP :53 ──▶ handle_query() └─ 6. Upstream Forward (auto-detected from OS, conditional forwarding) HTTP :80 ──▶ Reverse proxy for .numa domains (WebSocket support) -HTTP :5380 ──▶ Axum REST API (22 endpoints) + Dashboard +HTTPS :443 ──▶ TLS reverse proxy (auto-generated local CA + wildcard *.numa cert) +HTTP :5380 ──▶ Axum REST API (22+ endpoints) + Dashboard ``` ### Source Files @@ -59,8 +60,9 @@ src/ 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 reverse proxy for .numa domains (port 80, WebSocket upgrade support) - service_store.rs # ServiceStore — name-to-port mappings for local service proxy + 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 @@ -70,7 +72,7 @@ src/ 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, MX, AAAA) + 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/ @@ -87,14 +89,18 @@ site/ 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`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}` +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 (zero DNS libraries) +- 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` diff --git a/Cargo.lock b/Cargo.lock index e09573e..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,12 @@ 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" @@ -618,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" @@ -628,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" @@ -670,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" @@ -681,6 +886,50 @@ 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" @@ -692,13 +941,27 @@ dependencies = [ "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" @@ -711,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" @@ -753,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" @@ -785,7 +1064,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -806,7 +1085,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -870,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" @@ -957,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", @@ -971,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" @@ -987,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", @@ -1085,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" @@ -1150,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]] @@ -1170,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" @@ -1231,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" @@ -1294,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", @@ -1675,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 2f824f1..e88dfdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +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 } -hyper = { version = "1", features = ["client", "http1"] } +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/numa.toml b/numa.toml index 18bb59d..faa455d 100644 --- a/numa.toml +++ b/numa.toml @@ -16,6 +16,7 @@ max_ttl = 86400 [proxy] enabled = true port = 80 +tls_port = 443 tld = "numa" # Pre-configured services (numa.numa is always added automatically) 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 fde4c35..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; @@ -477,7 +477,7 @@ body {
DNS that governs itself
- +
@@ -568,10 +568,11 @@ body {