add TLS, service persistence, blocking panel, query types
- 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 <noreply@anthropic.com>
This commit is contained in:
18
CLAUDE.md
18
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<String>, 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<dyn std::error::Error + Send + Sync>` / `type Result<T>` aliased in `lib.rs`
|
||||
|
||||
422
Cargo.lock
generated
422
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
334
scripts/record-demo.sh
Executable file
334
scripts/record-demo.sh
Executable file
@@ -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 = '<span style=\"color:#8baa6e\">\$</span> <span style=\"color:#d48a5a\">dig</span> <span style=\"color:#8b9fbb\">@127.0.0.1</span> peekm.numa +short\n<span style=\"color:#8baa6e\">${DIG_RESULT}</span>';
|
||||
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 ' '
|
||||
@@ -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 {
|
||||
<div class="tagline">DNS that governs itself</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:1.2rem;">
|
||||
<button class="btn" id="pauseBtn" onclick="pauseBlocking()" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
|
||||
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
|
||||
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
|
||||
<div class="status-badge">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
@@ -568,10 +568,11 @@ body {
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<!-- Blocklist check -->
|
||||
<div class="panel">
|
||||
<!-- Blocking -->
|
||||
<div class="panel" id="blockingPanel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Check Domain</span>
|
||||
<span class="panel-title">Blocking</span>
|
||||
<span class="panel-title" id="blockingRefresh" style="color:var(--text-dim);font-weight:400;"></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="override-form" onsubmit="return checkDomain(event)" style="margin-bottom:0;border-bottom:none;padding-bottom:0;">
|
||||
@@ -581,21 +582,26 @@ body {
|
||||
</div>
|
||||
</form>
|
||||
<div id="checkResult" style="display:none;margin-top:0.6rem;padding:0.5rem 0.6rem;border-radius:5px;font-family:var(--font-mono);font-size:0.72rem;"></div>
|
||||
<div id="blockingSources" style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--border);"></div>
|
||||
<div id="blockingAllowlist" style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--border);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active overrides -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Active Overrides</span>
|
||||
<div>
|
||||
<span class="panel-title">Active Overrides</span>
|
||||
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Redirect any domain to any IP. Temporary, DNS-only.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="override-form" id="overrideForm" onsubmit="return addOverride(event)">
|
||||
<input type="text" id="ovDomain" placeholder="domain (e.g. api.dev)" required>
|
||||
<input type="text" id="ovTarget" placeholder="target IP (e.g. 127.0.0.1)" required>
|
||||
<input type="text" id="ovDomain" placeholder="domain (e.g. api.stripe.com)" required>
|
||||
<input type="text" id="ovTarget" placeholder="target (e.g. 10.0.0.5 or 127.0.0.1)" required>
|
||||
<div class="override-form-row">
|
||||
<input type="number" id="ovTTL" placeholder="TTL" value="60" min="1">
|
||||
<input type="number" id="ovDuration" placeholder="Duration (s)" value="300" min="1">
|
||||
<input type="number" id="ovTTL" placeholder="DNS TTL" value="60" min="1" title="How long clients may cache this DNS response">
|
||||
<input type="number" id="ovDuration" placeholder="Expires in (s)" value="300" min="1">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-add">Add Override</button>
|
||||
<div class="override-error" id="overrideError"></div>
|
||||
@@ -609,13 +615,16 @@ body {
|
||||
<!-- Local services -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Local Services</span>
|
||||
<div>
|
||||
<span class="panel-title">Local Services</span>
|
||||
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="override-form" id="serviceForm" onsubmit="return addService(event)">
|
||||
<div class="override-form-row">
|
||||
<input type="text" id="svcName" placeholder="name (e.g. myapp)" required style="flex:2">
|
||||
<input type="number" id="svcPort" placeholder="port" required min="1" max="65535" style="flex:1">
|
||||
<input type="text" id="svcName" placeholder="name (becomes name.numa)" required style="flex:2">
|
||||
<input type="number" id="svcPort" placeholder="port (e.g. 3000)" required min="1" max="65535" style="flex:1">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-add">Add Service</button>
|
||||
<div class="override-error" id="serviceError"></div>
|
||||
@@ -836,12 +845,14 @@ function renderCache(entries) {
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [stats, logs, overrides, cache, services] = await Promise.all([
|
||||
const [stats, logs, overrides, cache, services, blockingInfo, allowlist] = await Promise.all([
|
||||
fetchJSON('/stats'),
|
||||
fetchJSON('/query-log?limit=100'),
|
||||
fetchJSON('/query-log?limit=200'),
|
||||
fetchJSON('/overrides'),
|
||||
fetchJSON('/cache'),
|
||||
fetchJSON('/services'),
|
||||
fetchJSON('/blocking/stats'),
|
||||
fetchJSON('/blocking/allowlist'),
|
||||
]);
|
||||
|
||||
// Connection status
|
||||
@@ -857,16 +868,22 @@ async function refresh() {
|
||||
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
||||
const bl = stats.blocking;
|
||||
document.getElementById('blockedSub').textContent =
|
||||
bl.paused ? 'paused' :
|
||||
!bl.enabled ? 'disabled' :
|
||||
bl.domains_loaded > 0 ? `${formatNumber(bl.domains_loaded)} in blocklist` : 'loading...';
|
||||
|
||||
// Blocking controls
|
||||
// Blocking controls — single primary button + secondary toggle
|
||||
const toggleBtn = document.getElementById('toggleBtn');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
toggleBtn.style.display = 'inline-block';
|
||||
pauseBtn.style.display = bl.enabled && !bl.paused ? 'inline-block' : 'none';
|
||||
if (bl.paused) {
|
||||
// Primary action: unpause. Hide toggle to prevent accidental disable.
|
||||
pauseBtn.style.display = 'inline-block';
|
||||
pauseBtn.textContent = 'Unpause';
|
||||
pauseBtn.onclick = unpauseBlocking;
|
||||
toggleBtn.textContent = 'Paused';
|
||||
toggleBtn.style.background = 'var(--amber)';
|
||||
toggleBtn.onclick = unpauseBlocking; // clicking "Paused" unpauses, not disables
|
||||
} else if (bl.enabled) {
|
||||
toggleBtn.textContent = 'Blocking On';
|
||||
toggleBtn.style.background = 'var(--emerald)';
|
||||
@@ -900,6 +917,8 @@ async function refresh() {
|
||||
renderOverrides(overrides);
|
||||
renderCache(cache);
|
||||
renderServices(services);
|
||||
renderBlockingInfo(blockingInfo);
|
||||
renderAllowlist(allowlist);
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('statusDot').className = 'status-dot error';
|
||||
@@ -931,6 +950,13 @@ async function pauseBlocking() {
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async function unpauseBlocking() {
|
||||
try {
|
||||
await fetch(API + '/blocking/unpause', { method: 'POST' });
|
||||
refresh();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async function allowDomain(domain) {
|
||||
try {
|
||||
await fetch(API + '/blocking/allowlist', {
|
||||
@@ -971,6 +997,85 @@ async function checkDomain(event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function shortenUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
// For GitHub CDN URLs, show "owner/repo/.../filename"
|
||||
if (u.hostname.includes('jsdelivr') || u.hostname.includes('github')) {
|
||||
const owner = parts[1] || '';
|
||||
const file = parts[parts.length - 1] || '';
|
||||
return owner ? `${owner} / ${file}` : file;
|
||||
}
|
||||
// For other URLs, show hostname + filename
|
||||
const file = parts[parts.length - 1] || '';
|
||||
return `${u.hostname} / ${file}`;
|
||||
} catch { return url; }
|
||||
}
|
||||
|
||||
function renderBlockingInfo(info) {
|
||||
const el = document.getElementById('blockingSources');
|
||||
const refreshEl = document.getElementById('blockingRefresh');
|
||||
if (info.last_refresh_secs_ago != null) {
|
||||
refreshEl.textContent = `refreshed ${formatUptime(info.last_refresh_secs_ago)} ago`;
|
||||
}
|
||||
const sources = info.list_sources || [];
|
||||
if (!sources.length) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div style="font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.4rem;">Sources · ${formatNumber(info.domains_loaded)} domains</div>
|
||||
${sources.map(s => `
|
||||
<div style="padding:0.3rem 0;font-family:var(--font-mono);font-size:0.72rem;">
|
||||
<a href="${s}" target="_blank" rel="noopener" style="color:var(--text-secondary);text-decoration:none;" title="${s}">${shortenUrl(s)}</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAllowlist(entries) {
|
||||
const el = document.getElementById('blockingAllowlist');
|
||||
const count = entries.length;
|
||||
el.innerHTML = `
|
||||
<div style="font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.4rem;">Allowlist${count ? ` (${count})` : ''}</div>
|
||||
${count ? entries.map(d => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;border-bottom:1px solid var(--border);">
|
||||
<span style="font-family:var(--font-mono);font-size:0.75rem;color:var(--emerald);">${d}</span>
|
||||
<button class="btn-delete" onclick="removeAllowlistDomain('${d}')">×</button>
|
||||
</div>
|
||||
`).join('') : '<div class="empty-state">No exceptions</div>'}
|
||||
<form onsubmit="return addAllowlistDomain(event)" style="display:flex;gap:0.4rem;margin-top:0.4rem;">
|
||||
<input type="text" id="allowDomainInput" placeholder="domain to allow" required style="flex:1;font-family:var(--font-mono);font-size:0.75rem;padding:0.35rem 0.5rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-primary);outline:none;">
|
||||
<button type="submit" class="btn" style="background:var(--emerald);color:white;flex-shrink:0;">Allow</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
async function addAllowlistDomain(event) {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById('allowDomainInput');
|
||||
const domain = input.value.trim();
|
||||
if (!domain) return false;
|
||||
try {
|
||||
await fetch(API + '/blocking/allowlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain }),
|
||||
});
|
||||
input.value = '';
|
||||
refresh();
|
||||
} catch (err) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function removeAllowlistDomain(domain) {
|
||||
try {
|
||||
await fetch(API + '/blocking/allowlist/' + encodeURIComponent(domain), { method: 'DELETE' });
|
||||
refresh();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
function renderServices(entries) {
|
||||
const el = document.getElementById('servicesList');
|
||||
if (!entries.length) {
|
||||
@@ -982,7 +1087,7 @@ function renderServices(entries) {
|
||||
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
|
||||
<div class="service-info">
|
||||
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a></div>
|
||||
<div class="service-port">:${e.target_port}</div>
|
||||
<div class="service-port">localhost:${e.target_port} → proxied</div>
|
||||
</div>
|
||||
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> 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))
|
||||
@@ -536,6 +537,11 @@ async fn blocking_pause(
|
||||
Json(serde_json::json!({ "paused_minutes": req.minutes }))
|
||||
}
|
||||
|
||||
async fn blocking_unpause(State(ctx): State<Arc<ServerCtx>>) -> Json<serde_json::Value> {
|
||||
ctx.blocklist.lock().unwrap().unpause();
|
||||
Json(serde_json::json!({ "paused": false }))
|
||||
}
|
||||
|
||||
async fn blocking_check(
|
||||
State(ctx): State<Arc<ServerCtx>>,
|
||||
Path(domain): Path<String>,
|
||||
|
||||
@@ -161,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)
|
||||
@@ -233,6 +237,7 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -166,6 +166,8 @@ pub struct ProxyConfig {
|
||||
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,
|
||||
}
|
||||
@@ -175,6 +177,7 @@ impl Default for ProxyConfig {
|
||||
ProxyConfig {
|
||||
enabled: default_proxy_enabled(),
|
||||
port: default_proxy_port(),
|
||||
tls_port: default_proxy_tls_port(),
|
||||
tld: default_proxy_tld(),
|
||||
}
|
||||
}
|
||||
@@ -186,6 +189,9 @@ fn default_proxy_enabled() -> bool {
|
||||
fn default_proxy_port() -> u16 {
|
||||
80
|
||||
}
|
||||
fn default_proxy_tls_port() -> u16 {
|
||||
443
|
||||
}
|
||||
fn default_proxy_tld() -> String {
|
||||
"numa".to_string()
|
||||
}
|
||||
|
||||
27
src/lib.rs
27
src/lib.rs
@@ -14,7 +14,34 @@ pub mod question;
|
||||
pub mod record;
|
||||
pub mod service_store;
|
||||
pub mod stats;
|
||||
pub mod tls;
|
||||
pub mod system_dns;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
42
src/main.rs
42
src/main.rs
@@ -104,12 +104,13 @@ async fn main() -> numa::Result<()> {
|
||||
blocklist.set_enabled(false);
|
||||
}
|
||||
|
||||
// Build service store from config, always include numa dashboard
|
||||
// Build service store: config services + persisted user services
|
||||
let mut service_store = ServiceStore::new();
|
||||
service_store.insert("numa", config.server.api_port);
|
||||
service_store.insert_from_config("numa", config.server.api_port);
|
||||
for svc in &config.services {
|
||||
service_store.insert(&svc.name, svc.target_port);
|
||||
service_store.insert_from_config(&svc.name, svc.target_port);
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
let forwarding_rules = system_dns.forwarding_rules;
|
||||
|
||||
@@ -150,8 +151,12 @@ async fn main() -> numa::Result<()> {
|
||||
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 {
|
||||
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",
|
||||
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port));
|
||||
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",
|
||||
@@ -198,12 +203,35 @@ async fn main() -> numa::Result<()> {
|
||||
if config.proxy.enabled {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let proxy_port = config.proxy.port;
|
||||
let proxy_tld = config.proxy.tld.clone();
|
||||
tokio::spawn(async move {
|
||||
numa::proxy::start_proxy(proxy_ctx, proxy_port, &proxy_tld).await;
|
||||
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<String> = ctx
|
||||
.services
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list()
|
||||
.iter()
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
match numa::tls::build_tls_config(&config.proxy.tld, &service_names) {
|
||||
Ok(tls_config) => {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let 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 {
|
||||
|
||||
76
src/proxy.rs
76
src/proxy.rs
@@ -11,7 +11,9 @@ 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;
|
||||
|
||||
@@ -21,10 +23,9 @@ type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Bod
|
||||
struct ProxyState {
|
||||
ctx: Arc<ServerCtx>,
|
||||
client: HttpClient,
|
||||
tld_suffix: String, // pre-computed ".{tld}"
|
||||
}
|
||||
|
||||
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16) {
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
@@ -45,7 +46,6 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
let state = ProxyState {
|
||||
ctx,
|
||||
client,
|
||||
tld_suffix: format!(".{}", tld),
|
||||
};
|
||||
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
@@ -53,6 +53,68 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<ServerConfig>) {
|
||||
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<String> {
|
||||
req.headers()
|
||||
.get(hyper::header::HOST)
|
||||
@@ -68,12 +130,12 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
}
|
||||
};
|
||||
|
||||
let service_name = match hostname.strip_suffix(state.tld_suffix.as_str()) {
|
||||
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.tld_suffix, hostname),
|
||||
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -86,7 +148,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("unknown service: {}{}", service_name, state.tld_suffix),
|
||||
format!("unknown service: {}{}", service_name, state.ctx.proxy_tld_suffix),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -98,7 +160,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or("/");
|
||||
let target_uri: hyper::Uri = format!("http://127.0.0.1:{}{}", target_port, path_and_query)
|
||||
let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -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<QueryType> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ impl DnsRecord {
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::UNKNOWN(_) => {
|
||||
_ => {
|
||||
buffer.step(data_len as usize)?;
|
||||
|
||||
Ok(DnsRecord::UNKNOWN {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceEntry {
|
||||
pub name: String,
|
||||
pub target_port: u16,
|
||||
@@ -10,6 +12,9 @@ pub struct ServiceEntry {
|
||||
|
||||
pub struct ServiceStore {
|
||||
entries: HashMap<String, ServiceEntry>,
|
||||
/// Services defined in numa.toml (not persisted to user file)
|
||||
config_services: std::collections::HashSet<String>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ServiceStore {
|
||||
@@ -20,13 +25,18 @@ impl Default for ServiceStore {
|
||||
|
||||
impl ServiceStore {
|
||||
pub fn new() -> Self {
|
||||
let persist_path = dirs_path();
|
||||
ServiceStore {
|
||||
entries: HashMap::new(),
|
||||
config_services: std::collections::HashSet::new(),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, name: &str, target_port: u16) {
|
||||
/// 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 {
|
||||
@@ -36,12 +46,30 @@ impl ServiceStore {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.entries.remove(&name.to_lowercase()).is_some()
|
||||
let key = name.to_lowercase();
|
||||
let removed = self.entries.remove(&key).is_some();
|
||||
if removed {
|
||||
self.save();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<&ServiceEntry> {
|
||||
@@ -49,4 +77,56 @@ impl ServiceStore {
|
||||
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::<Vec<ServiceEntry>>(&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")
|
||||
}
|
||||
|
||||
@@ -211,21 +211,25 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
|
||||
/// Saves the original DNS settings for later restoration.
|
||||
pub fn install_system_dns() -> 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()
|
||||
@@ -761,3 +765,82 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// --- CA trust management ---
|
||||
|
||||
fn trust_ca() -> Result<(), String> {
|
||||
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
|
||||
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")]
|
||||
{
|
||||
if ca_path.exists() {
|
||||
let _ = std::process::Command::new("security")
|
||||
.args(["remove-trusted-cert", "-d"])
|
||||
.arg(&ca_path)
|
||||
.status();
|
||||
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(())
|
||||
}
|
||||
|
||||
125
src/tls.rs
Normal file
125
src/tls.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
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<Arc<ServerConfig>> {
|
||||
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<CertificateDer<'static>>, 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::<Vec<_>>()
|
||||
.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))
|
||||
}
|
||||
Reference in New Issue
Block a user