Merge pull request #3 from razvandimescu/feat/async-tokio
Async architecture, local service proxy, TLS, blocking panel
This commit is contained in:
78
.github/workflows/release.yml
vendored
Normal file
78
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
name: numa-macos-x86_64
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
name: numa-macos-aarch64
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
name: numa-linux-x86_64
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
name: numa-linux-aarch64
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --target ${{ matrix.target }}
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: |
|
||||||
|
cd target/${{ matrix.target }}/release
|
||||||
|
tar czf ../../../${{ matrix.name }}.tar.gz numa
|
||||||
|
cd ../../..
|
||||||
|
sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
path: |
|
||||||
|
${{ matrix.name }}.tar.gz
|
||||||
|
${{ matrix.name }}.tar.gz.sha256
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
*.tar.gz
|
||||||
|
*.sha256
|
||||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Numa** — a portable DNS resolver with ad blocking, developer overrides, and a live dashboard. Built from scratch in Rust. Named after Numa Pompilius, the Roman king who established lasting institutions.
|
||||||
|
|
||||||
|
Today: DNS forwarding/caching proxy with ad blocking, ephemeral overrides, live dashboard, and system DNS integration.
|
||||||
|
Next: Self-sovereign DNS via pkarr/Mainline DHT.
|
||||||
|
Vision: Incentivized resolver network with staking, challenge-based auditing, and token economics.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # compile
|
||||||
|
sudo cargo run # run with default config (numa.toml)
|
||||||
|
sudo cargo run -- path/to/config # run with custom config path
|
||||||
|
RUST_LOG=debug sudo cargo run # verbose logging
|
||||||
|
make lint # clippy + rustfmt check
|
||||||
|
```
|
||||||
|
|
||||||
|
Test with: `dig @127.0.0.1 google.com`
|
||||||
|
|
||||||
|
CLI commands:
|
||||||
|
```bash
|
||||||
|
numa help # show all commands
|
||||||
|
numa install # set system DNS to 127.0.0.1
|
||||||
|
numa uninstall # restore original DNS
|
||||||
|
numa service start # install as persistent service (launchd/systemd)
|
||||||
|
numa service stop # uninstall service + restore DNS
|
||||||
|
numa service status # check service status
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: http://numa.numa (or http://localhost:5380)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
UDP :53 ──▶ handle_query()
|
||||||
|
│
|
||||||
|
├─ 1. Override Store (ephemeral, auto-expiry)
|
||||||
|
├─ 2. .numa TLD (local service domains → 127.0.0.1)
|
||||||
|
├─ 3. Blocklist (385K+ domains, subdomain matching)
|
||||||
|
├─ 4. Local Zones (TOML config)
|
||||||
|
├─ 5. Cache (TTL-aware, lazy eviction)
|
||||||
|
└─ 6. Upstream Forward (auto-detected from OS, conditional forwarding)
|
||||||
|
|
||||||
|
HTTP :80 ──▶ Reverse proxy for .numa domains (WebSocket support)
|
||||||
|
HTTPS :443 ──▶ TLS reverse proxy (auto-generated local CA + wildcard *.numa cert)
|
||||||
|
HTTP :5380 ──▶ Axum REST API (22+ endpoints) + Dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs # startup: load config, bind UDP, spawn API + proxy, blocklist download, per-query task loop
|
||||||
|
lib.rs # module declarations, Error/Result type aliases
|
||||||
|
ctx.rs # ServerCtx shared state + handle_query() pipeline
|
||||||
|
api.rs # Axum REST server (22 endpoints, port 5380) + embedded dashboard
|
||||||
|
config.rs # TOML config loading with defaults (server, upstream, cache, blocking, proxy, zones)
|
||||||
|
proxy.rs # HTTP/HTTPS reverse proxy for .numa domains (port 80 + 443, WebSocket upgrade)
|
||||||
|
tls.rs # Local CA + wildcard cert generation (rcgen), rustls ServerConfig builder
|
||||||
|
service_store.rs # ServiceStore — name-to-port mappings, persisted to ~/.config/numa/services.json
|
||||||
|
blocklist.rs # BlocklistStore — HashSet<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
|
||||||
|
cache.rs # DnsCache — TTL-aware, lazy eviction every 1000 lookups
|
||||||
|
forward.rs # async UDP forwarding to upstream resolver
|
||||||
|
stats.rs # ServerStats counters + QueryPath enum (6 categories)
|
||||||
|
system_dns.rs # OS DNS discovery (scutil/resolv.conf), install/uninstall, service management
|
||||||
|
buffer.rs # BytePacketBuffer — 4096-byte DNS wire format I/O
|
||||||
|
header.rs # DnsHeader — 12-byte bitfield parsing/serialization
|
||||||
|
question.rs # DnsQuestion + QueryType enum (A, NS, CNAME, SOA, PTR, MX, TXT, AAAA, SRV, HTTPS)
|
||||||
|
record.rs # DnsRecord enum — wire format read/write per record type (filters UNKNOWN on write)
|
||||||
|
packet.rs # DnsPacket — header + questions + answers + authorities + resources
|
||||||
|
site/
|
||||||
|
dashboard.html # live dashboard (embedded at compile time via include_str!)
|
||||||
|
index.html # landing page (Roman Stone theme)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`numa.toml` at project root. Sections: `[server]`, `[upstream]`, `[cache]`, `[blocking]`, `[proxy]`, `[[services]]`, `[[zones]]`. Falls back to sensible defaults if file is missing. Upstream auto-detected from system resolver if not set.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
Dashboard: GET `/` (embedded HTML)
|
||||||
|
Override management: POST/GET/DELETE `/overrides`, POST `/overrides/environment`
|
||||||
|
Services: GET/POST `/services`, DELETE `/services/{name}`
|
||||||
|
Blocking: GET `/blocking/stats`, PUT `/blocking/toggle`, POST `/blocking/pause`, POST `/blocking/unpause`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}`
|
||||||
|
Diagnostics: GET `/diagnose/{domain}`, `/query-log`, `/stats`, `/cache`, `/health`
|
||||||
|
Cache: DELETE `/cache`, `/cache/{domain}`
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
|
||||||
|
- Rust 2021 edition, async via `tokio` (rt-multi-thread)
|
||||||
|
- Deps: tokio, axum, hyper, hyper-util, serde, serde_json, toml, log, env_logger, reqwest, futures, rcgen, rustls, tokio-rustls, time (zero DNS libraries)
|
||||||
|
- Shared config dir: `~/.config/numa/` via `config_dir()` in `lib.rs` (handles sudo correctly)
|
||||||
|
- TLS: auto-generated local CA + wildcard `*.numa` cert at `~/.config/numa/`. `numa install` trusts CA in OS keychain.
|
||||||
|
- Service persistence: user-added services saved to `~/.config/numa/services.json`, survives restarts
|
||||||
|
- Deploy workflow: `make deploy` (build release → copy → codesign → kill → launchd respawns)
|
||||||
|
- DNS buffer size: 4096 bytes (EDNS-compatible). UNKNOWN record types (e.g. OPT) filtered on serialization.
|
||||||
|
- `BytePacketBuffer::read_qname` handles label compression (pointer jumps)
|
||||||
|
- `type Error = Box<dyn std::error::Error + Send + Sync>` / `type Result<T>` aliased in `lib.rs`
|
||||||
|
- Shared state via `Arc<ServerCtx>` with `std::sync::Mutex` (sub-microsecond holds, never across `.await`)
|
||||||
|
- Cache: TTL clamped between `min_ttl` and `max_ttl`, lazy eviction every 1000 queries
|
||||||
|
- Blocklist: parsed outside lock, swapped atomically. `is_blocked()` takes `&self` (read-only).
|
||||||
|
- Upstream: auto-detected from `scutil --dns` (macOS) or `/etc/resolv.conf` (Linux). Falls back to Quad9.
|
||||||
|
- Conditional forwarding: Tailscale/VPN domains auto-routed to correct upstream.
|
||||||
|
- macOS service: launchd plist with KeepAlive + RunAtLoad. Use `launchctl bootstrap/bootout` (not load/unload).
|
||||||
|
- Logging controlled via `RUST_LOG` env var. Default: `info`
|
||||||
481
Cargo.lock
generated
481
Cargo.lock
generated
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -61,12 +67,91 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
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]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
@@ -150,6 +235,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -165,12 +252,76 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -182,6 +333,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -217,6 +374,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -226,6 +393,27 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -233,6 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -241,6 +430,40 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -253,8 +476,13 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
@@ -563,6 +791,16 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -573,6 +811,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.183"
|
||||||
@@ -615,6 +859,22 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -626,20 +886,82 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"toml",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -652,6 +974,16 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -694,6 +1026,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -726,7 +1064,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -747,7 +1085,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -811,6 +1149,20 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -898,12 +1250,23 @@ version = "2.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.37"
|
version = "0.23.37"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -912,6 +1275,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -928,6 +1300,7 @@ version = "0.103.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -1026,6 +1399,12 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1091,13 +1470,33 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1111,6 +1510,37 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1172,6 +1602,19 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
@@ -1235,13 +1678,18 @@ version = "0.6.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1616,6 +2064,33 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|||||||
11
Cargo.toml
11
Cargo.toml
@@ -17,4 +17,13 @@ serde_json = "1"
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
|
reqwest = { version = "0.12", features = ["rustls-tls", "gzip"], default-features = false }
|
||||||
|
hyper = { version = "1", features = ["client", "http1", "server"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
|
||||||
|
time = "0.3"
|
||||||
|
rustls = "0.23"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt check test clean
|
.PHONY: all build lint fmt check test clean deploy
|
||||||
|
|
||||||
all: lint build
|
all: lint build
|
||||||
|
|
||||||
@@ -18,3 +18,11 @@ test:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
cargo build --release
|
||||||
|
sudo cp target/release/numa /usr/local/bin/numa
|
||||||
|
sudo codesign -f -s - /usr/local/bin/numa
|
||||||
|
sudo kill $$(pgrep -f /usr/local/bin/numa) 2>/dev/null || true
|
||||||
|
@sleep 1
|
||||||
|
@dig @127.0.0.1 google.com +short +time=3 > /dev/null && echo "Service restarted successfully" || echo "Warning: DNS not responding yet"
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
**DNS you own. Everywhere you go.**
|
**DNS you own. Everywhere you go.**
|
||||||
|
|
||||||
Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account.
|
Block ads and trackers. Override DNS for development. Name your local services. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
- **Ad blocking that travels with you** — 385K+ domains blocked out of the box. Works on any network: coffee shops, hotels, airports.
|
- **Ad blocking that travels with you** — 385K+ domains blocked out of the box. Works on any network: coffee shops, hotels, airports.
|
||||||
- **Developer overrides** — point any hostname to any IP with auto-revert. No more editing `/etc/hosts`.
|
- **Developer overrides** — point any hostname to any IP with auto-revert. No more editing `/etc/hosts`.
|
||||||
|
- **Local service proxy** — access `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR.
|
||||||
- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver.
|
- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver.
|
||||||
- **Live dashboard** — real-time query stats, blocking controls, override management at `http://localhost:5380`.
|
- **Live dashboard** — real-time query stats, blocking controls, override management, local services at `http://numa.numa` (or `localhost:5380`).
|
||||||
- **Single binary, zero config** — just run it.
|
- **Single binary, zero config** — just run it.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -32,7 +33,7 @@ docker run -p 53:53/udp -p 5380:5380 numa
|
|||||||
|
|
||||||
### Try it
|
### Try it
|
||||||
|
|
||||||
Open the dashboard: **http://localhost:5380**
|
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dig @127.0.0.1 google.com # ✓ resolves normally
|
dig @127.0.0.1 google.com # ✓ resolves normally
|
||||||
@@ -59,18 +60,51 @@ curl -X POST http://localhost:5380/overrides \
|
|||||||
dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min)
|
dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local Service Proxy
|
||||||
|
|
||||||
|
Name your local dev services with `.numa` domains instead of remembering port numbers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a service via API
|
||||||
|
curl -X POST http://localhost:5380/services \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"frontend","target_port":5173}'
|
||||||
|
|
||||||
|
# Now access it by name
|
||||||
|
open http://frontend.numa # → proxied to localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Or configure in `numa.toml`:
|
||||||
|
```toml
|
||||||
|
[[services]]
|
||||||
|
name = "frontend"
|
||||||
|
target_port = 5173
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
name = "api"
|
||||||
|
target_port = 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
- `numa.numa` is pre-configured — the dashboard itself, accessible without remembering the port
|
||||||
|
- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs. `sudo numa install` trusts the CA in your system keychain.
|
||||||
|
- WebSocket support — Vite/webpack HMR works through the proxy
|
||||||
|
- Health checks — dashboard shows green/red status for each service
|
||||||
|
- Services persist across restarts (`~/.config/numa/services.json`)
|
||||||
|
- Manage via dashboard UI or REST API
|
||||||
|
|
||||||
## Resolution Pipeline
|
## Resolution Pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
Query → Overrides → Blocklist → Local Zones → Cache → Upstream → Respond
|
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream → Respond
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Overrides** — ephemeral, time-scoped redirects (highest priority)
|
1. **Overrides** — ephemeral, time-scoped redirects (highest priority)
|
||||||
2. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::`
|
2. **`.numa` TLD** — synthetic domains for local services → returns `127.0.0.1`
|
||||||
3. **Local zones** — records defined in `[[zones]]` config
|
3. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::`
|
||||||
4. **Cache** — TTL-adjusted cached upstream responses (sub-ms)
|
4. **Local zones** — records defined in `[[zones]]` config
|
||||||
5. **Forward** — query upstream resolver, cache the result
|
5. **Cache** — TTL-adjusted cached upstream responses (sub-ms)
|
||||||
6. **SERVFAIL** — returned on upstream failure
|
6. **Forward** — query upstream resolver, cache the result
|
||||||
|
7. **SERVFAIL** — returned on upstream failure
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
@@ -80,6 +114,7 @@ Live at `http://localhost:5380` when Numa is running:
|
|||||||
- Resolution path breakdown (forward / cached / local / override / blocked)
|
- Resolution path breakdown (forward / cached / local / override / blocked)
|
||||||
- Scrolling query log with colored path tags
|
- Scrolling query log with colored path tags
|
||||||
- Active overrides with create/edit/delete
|
- Active overrides with create/edit/delete
|
||||||
|
- Local services with health status and add/remove
|
||||||
- Blocking controls: toggle on/off, pause 5 minutes, one-click allowlist
|
- Blocking controls: toggle on/off, pause 5 minutes, one-click allowlist
|
||||||
- Cached domains list
|
- Cached domains list
|
||||||
|
|
||||||
@@ -110,6 +145,15 @@ lists = [
|
|||||||
refresh_hours = 24
|
refresh_hours = 24
|
||||||
allowlist = []
|
allowlist = []
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
enabled = true
|
||||||
|
port = 80
|
||||||
|
tld = "numa"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
name = "frontend"
|
||||||
|
target_port = 5173
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
domain = "mysite.local"
|
domain = "mysite.local"
|
||||||
record_type = "A"
|
record_type = "A"
|
||||||
@@ -119,7 +163,7 @@ ttl = 60
|
|||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
REST API on port 5380 (18 endpoints):
|
REST API on port 5380 (22 endpoints):
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
@@ -130,12 +174,16 @@ REST API on port 5380 (18 endpoints):
|
|||||||
| `/overrides/environment` | POST | Batch load overrides |
|
| `/overrides/environment` | POST | Batch load overrides |
|
||||||
| `/overrides/{domain}` | GET | Get specific override |
|
| `/overrides/{domain}` | GET | Get specific override |
|
||||||
| `/overrides/{domain}` | DELETE | Remove specific override |
|
| `/overrides/{domain}` | DELETE | Remove specific override |
|
||||||
|
| `/services` | GET | List local services (with health status) |
|
||||||
|
| `/services` | POST | Register a local service |
|
||||||
|
| `/services/{name}` | DELETE | Remove a local service |
|
||||||
| `/blocking/stats` | GET | Blocklist stats (domains loaded, sources, enabled) |
|
| `/blocking/stats` | GET | Blocklist stats (domains loaded, sources, enabled) |
|
||||||
| `/blocking/toggle` | PUT | Enable/disable blocking |
|
| `/blocking/toggle` | PUT | Enable/disable blocking |
|
||||||
| `/blocking/pause` | POST | Pause blocking for N minutes |
|
| `/blocking/pause` | POST | Pause blocking for N minutes |
|
||||||
| `/blocking/allowlist` | GET | List allowlisted domains |
|
| `/blocking/allowlist` | GET | List allowlisted domains |
|
||||||
| `/blocking/allowlist` | POST | Add domain to allowlist |
|
| `/blocking/allowlist` | POST | Add domain to allowlist |
|
||||||
| `/blocking/allowlist/{domain}` | DELETE | Remove from allowlist |
|
| `/blocking/allowlist/{domain}` | DELETE | Remove from allowlist |
|
||||||
|
| `/blocking/check/{domain}` | GET | Check if domain is blocked |
|
||||||
| `/diagnose/{domain}` | GET | Trace resolution path |
|
| `/diagnose/{domain}` | GET | Trace resolution path |
|
||||||
| `/query-log` | GET | Recent queries (filterable) |
|
| `/query-log` | GET | Recent queries (filterable) |
|
||||||
| `/stats` | GET | Server statistics |
|
| `/stats` | GET | Server statistics |
|
||||||
@@ -151,6 +199,7 @@ REST API on port 5380 (18 endpoints):
|
|||||||
| Ad blocking | Yes | Yes | Limited | 385K+ domains |
|
| Ad blocking | Yes | Yes | Limited | 385K+ domains |
|
||||||
| Portable | No (Raspberry Pi) | Cloud only | Cloud only | Single binary |
|
| Portable | No (Raspberry Pi) | Cloud only | Cloud only | Single binary |
|
||||||
| Developer overrides | No | No | No | REST API + auto-expiry |
|
| Developer overrides | No | No | No | REST API + auto-expiry |
|
||||||
|
| Local service proxy | No | No | No | `.numa` domains + HTTPS + WebSocket |
|
||||||
| Data stays local | Yes | Cloud | Cloud | 100% local |
|
| Data stays local | Yes | Cloud | Cloud | 100% local |
|
||||||
| Zero config | Complex setup | Yes | Yes | Works out of the box |
|
| Zero config | Complex setup | Yes | Yes | Works out of the box |
|
||||||
| Self-sovereign DNS | No | No | No | pkarr/DHT roadmap |
|
| Self-sovereign DNS | No | No | No | pkarr/DHT roadmap |
|
||||||
@@ -159,6 +208,8 @@ REST API on port 5380 (18 endpoints):
|
|||||||
|
|
||||||
**Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network.
|
**Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network.
|
||||||
|
|
||||||
|
**Name your local services** — `frontend.numa` instead of `localhost:5173`. CORS-friendly, HMR-compatible.
|
||||||
|
|
||||||
**Mock external services** — `Point api.stripe.com to localhost:8080 for 30 minutes`
|
**Mock external services** — `Point api.stripe.com to localhost:8080 for 30 minutes`
|
||||||
|
|
||||||
**Provision dev environments** — Create overrides for `db.dev`, `api.dev`, `cache.dev`
|
**Provision dev environments** — Create overrides for `db.dev`, `api.dev`, `cache.dev`
|
||||||
@@ -176,6 +227,7 @@ Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies
|
|||||||
- [x] Ad blocking — 385K+ domains, dashboard, allowlist
|
- [x] Ad blocking — 385K+ domains, dashboard, allowlist
|
||||||
- [x] System DNS auto-discovery — Tailscale, VPN split-DNS
|
- [x] System DNS auto-discovery — Tailscale, VPN split-DNS
|
||||||
- [x] System DNS auto-configuration — `numa install` / `numa uninstall`
|
- [x] System DNS auto-configuration — `numa install` / `numa uninstall`
|
||||||
|
- [x] Local service proxy — `.numa` domains with HTTP/HTTPS reverse proxy, auto TLS, WebSocket
|
||||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||||
- [ ] Decentralized resolver network — staking, auditing, token economics
|
- [ ] Decentralized resolver network — staking, auditing, token economics
|
||||||
|
|
||||||
|
|||||||
25
com.numa.dns.plist
Normal file
25
com.numa.dns.plist
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.numa.dns</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/local/bin/numa</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/usr/local/var/log/numa.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/usr/local/var/log/numa.log</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>RUST_LOG</key>
|
||||||
|
<string>info</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
71
install.sh
Executable file
71
install.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Numa installer — detects OS/arch and downloads the latest release
|
||||||
|
# Usage: curl -sSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO="razvandimescu/numa"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
OS="$(uname -s)"
|
||||||
|
case "$OS" in
|
||||||
|
Darwin) OS_NAME="macos" ;;
|
||||||
|
Linux) OS_NAME="linux" ;;
|
||||||
|
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) ARCH_NAME="x86_64" ;;
|
||||||
|
arm64|aarch64) ARCH_NAME="aarch64" ;;
|
||||||
|
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ASSET="numa-${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " \033[1;38;2;192;98;58mNuma\033[0m installer"
|
||||||
|
echo ""
|
||||||
|
echo " OS: $OS_NAME"
|
||||||
|
echo " Arch: $ARCH_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get latest release tag
|
||||||
|
echo " Fetching latest release..."
|
||||||
|
TAG=$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
|
||||||
|
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo " Error: could not find latest release."
|
||||||
|
echo " Check https://github.com/${REPO}/releases"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}"
|
||||||
|
echo " Downloading ${TAG}..."
|
||||||
|
|
||||||
|
# Download and extract
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
curl -sSL "$URL" -o "$TMP/$ASSET"
|
||||||
|
tar xzf "$TMP/$ASSET" -C "$TMP"
|
||||||
|
|
||||||
|
# Install
|
||||||
|
if [ -w "$INSTALL_DIR" ]; then
|
||||||
|
mv "$TMP/numa" "$INSTALL_DIR/numa"
|
||||||
|
else
|
||||||
|
echo " Installing to $INSTALL_DIR (requires sudo)..."
|
||||||
|
sudo mv "$TMP/numa" "$INSTALL_DIR/numa"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$INSTALL_DIR/numa"
|
||||||
|
rm -rf "$TMP"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
|
||||||
|
echo ""
|
||||||
|
echo " Get started:"
|
||||||
|
echo " sudo numa # start the DNS server"
|
||||||
|
echo " sudo numa install # set as system DNS"
|
||||||
|
echo " sudo numa service start # run as persistent service"
|
||||||
|
echo " open http://localhost:5380 # dashboard"
|
||||||
|
echo ""
|
||||||
16
numa.service
Normal file
16
numa.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Numa DNS — DNS you own, everywhere you go
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/numa
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=numa
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
24
numa.toml
24
numa.toml
@@ -2,16 +2,32 @@
|
|||||||
bind_addr = "0.0.0.0:53"
|
bind_addr = "0.0.0.0:53"
|
||||||
api_port = 5380
|
api_port = 5380
|
||||||
|
|
||||||
[upstream]
|
# [upstream]
|
||||||
address = "8.8.8.8"
|
# address = "" # auto-detect from system resolver (default)
|
||||||
port = 53
|
# address = "9.9.9.9" # or set explicitly
|
||||||
timeout_ms = 3000
|
# port = 53
|
||||||
|
# timeout_ms = 3000
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
max_entries = 10000
|
max_entries = 10000
|
||||||
min_ttl = 60
|
min_ttl = 60
|
||||||
max_ttl = 86400
|
max_ttl = 86400
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
enabled = true
|
||||||
|
port = 80
|
||||||
|
tls_port = 443
|
||||||
|
tld = "numa"
|
||||||
|
|
||||||
|
# Pre-configured services (numa.numa is always added automatically)
|
||||||
|
# [[services]]
|
||||||
|
# name = "frontend"
|
||||||
|
# target_port = 5173
|
||||||
|
#
|
||||||
|
# [[services]]
|
||||||
|
# name = "api"
|
||||||
|
# target_port = 8000
|
||||||
|
|
||||||
# Example zone records:
|
# Example zone records:
|
||||||
# [[zones]]
|
# [[zones]]
|
||||||
# domain = "dimescu.ro"
|
# domain = "dimescu.ro"
|
||||||
|
|||||||
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 table */
|
||||||
.query-log {
|
.query-log {
|
||||||
max-height: 380px;
|
max-height: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--bg-elevated) transparent;
|
scrollbar-color: var(--bg-elevated) transparent;
|
||||||
@@ -348,6 +348,41 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Service items */
|
||||||
|
.service-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.service-item:last-child { border-bottom: none; }
|
||||||
|
.service-info { flex: 1; min-width: 0; }
|
||||||
|
.service-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.service-name a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.service-name a:hover { text-decoration: underline; }
|
||||||
|
.service-port {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.health-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.health-dot.up { background: var(--emerald); }
|
||||||
|
.health-dot.down { background: var(--rose); }
|
||||||
|
|
||||||
/* Override form */
|
/* Override form */
|
||||||
.override-form {
|
.override-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -442,7 +477,7 @@ body {
|
|||||||
<div class="tagline">DNS that governs itself</div>
|
<div class="tagline">DNS that governs itself</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:1.2rem;">
|
<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>
|
<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">
|
<div class="status-badge">
|
||||||
<span class="status-dot" id="statusDot"></span>
|
<span class="status-dot" id="statusDot"></span>
|
||||||
@@ -497,7 +532,21 @@ body {
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Recent Queries</span>
|
<span class="panel-title">Recent Queries</span>
|
||||||
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="text" id="logFilterDomain" placeholder="filter domain..." oninput="applyLogFilter()"
|
||||||
|
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.5rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-primary);outline:none;width:150px;">
|
||||||
|
<select id="logFilterPath" onchange="applyLogFilter()"
|
||||||
|
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
|
||||||
|
<option value="">all paths</option>
|
||||||
|
<option value="FORWARD">forward</option>
|
||||||
|
<option value="CACHED">cached</option>
|
||||||
|
<option value="BLOCKED">blocked</option>
|
||||||
|
<option value="OVERRIDE">override</option>
|
||||||
|
<option value="LOCAL">local</option>
|
||||||
|
<option value="SERVFAIL">error</option>
|
||||||
|
</select>
|
||||||
|
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="query-log" id="queryLog">
|
<div class="query-log" id="queryLog">
|
||||||
<table>
|
<table>
|
||||||
@@ -519,18 +568,40 @@ body {
|
|||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
|
<!-- Blocking -->
|
||||||
|
<div class="panel" id="blockingPanel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<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;">
|
||||||
|
<div class="override-form-row">
|
||||||
|
<input type="text" id="checkDomainInput" placeholder="Is this domain blocked?" required style="flex:3">
|
||||||
|
<button type="submit" class="btn" style="background:var(--violet);color:white;flex-shrink:0;">Check</button>
|
||||||
|
</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 -->
|
<!-- Active overrides -->
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<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>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<form class="override-form" id="overrideForm" onsubmit="return addOverride(event)">
|
<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="ovDomain" placeholder="domain (e.g. api.stripe.com)" required>
|
||||||
<input type="text" id="ovTarget" placeholder="target IP (e.g. 127.0.0.1)" 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">
|
<div class="override-form-row">
|
||||||
<input type="number" id="ovTTL" placeholder="TTL" value="60" 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="Duration (s)" value="300" min="1">
|
<input type="number" id="ovDuration" placeholder="Expires in (s)" value="300" min="1">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-add">Add Override</button>
|
<button type="submit" class="btn btn-add">Add Override</button>
|
||||||
<div class="override-error" id="overrideError"></div>
|
<div class="override-error" id="overrideError"></div>
|
||||||
@@ -541,6 +612,29 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local services -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<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 (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>
|
||||||
|
</form>
|
||||||
|
<div id="servicesList">
|
||||||
|
<div class="empty-state">No services configured</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cache entries -->
|
<!-- Cache entries -->
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -558,6 +652,7 @@ body {
|
|||||||
<script>
|
<script>
|
||||||
const API = '';
|
const API = '';
|
||||||
let prevTotal = null;
|
let prevTotal = null;
|
||||||
|
let lastLogEntries = [];
|
||||||
let prevTime = null;
|
let prevTime = null;
|
||||||
|
|
||||||
async function fetchJSON(path) {
|
async function fetchJSON(path) {
|
||||||
@@ -630,9 +725,29 @@ function renderPaths(queries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderQueryLog(entries) {
|
function renderQueryLog(entries) {
|
||||||
|
lastLogEntries = entries;
|
||||||
|
applyLogFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLogFilter() {
|
||||||
|
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
|
||||||
|
const pathFilter = document.getElementById('logFilterPath').value;
|
||||||
|
|
||||||
|
let filtered = lastLogEntries;
|
||||||
|
if (domainFilter) {
|
||||||
|
filtered = filtered.filter(e => e.domain.toLowerCase().includes(domainFilter));
|
||||||
|
}
|
||||||
|
if (pathFilter) {
|
||||||
|
filtered = filtered.filter(e => e.path === pathFilter);
|
||||||
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('queryLogBody');
|
const tbody = document.getElementById('queryLogBody');
|
||||||
document.getElementById('queryCount').textContent = `last ${entries.length}`;
|
document.getElementById('queryCount').textContent =
|
||||||
tbody.innerHTML = entries.map(e => {
|
filtered.length < lastLogEntries.length
|
||||||
|
? `${filtered.length} / ${lastLogEntries.length}`
|
||||||
|
: `last ${filtered.length}`;
|
||||||
|
|
||||||
|
tbody.innerHTML = filtered.map(e => {
|
||||||
const allowBtn = e.path === 'BLOCKED'
|
const allowBtn = e.path === 'BLOCKED'
|
||||||
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
||||||
: '';
|
: '';
|
||||||
@@ -730,11 +845,14 @@ function renderCache(entries) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [stats, logs, overrides, cache] = await Promise.all([
|
const [stats, logs, overrides, cache, services, blockingInfo, allowlist] = await Promise.all([
|
||||||
fetchJSON('/stats'),
|
fetchJSON('/stats'),
|
||||||
fetchJSON('/query-log?limit=100'),
|
fetchJSON('/query-log?limit=200'),
|
||||||
fetchJSON('/overrides'),
|
fetchJSON('/overrides'),
|
||||||
fetchJSON('/cache'),
|
fetchJSON('/cache'),
|
||||||
|
fetchJSON('/services'),
|
||||||
|
fetchJSON('/blocking/stats'),
|
||||||
|
fetchJSON('/blocking/allowlist'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Connection status
|
// Connection status
|
||||||
@@ -750,16 +868,22 @@ async function refresh() {
|
|||||||
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
||||||
const bl = stats.blocking;
|
const bl = stats.blocking;
|
||||||
document.getElementById('blockedSub').textContent =
|
document.getElementById('blockedSub').textContent =
|
||||||
|
bl.paused ? 'paused' :
|
||||||
|
!bl.enabled ? 'disabled' :
|
||||||
bl.domains_loaded > 0 ? `${formatNumber(bl.domains_loaded)} in blocklist` : 'loading...';
|
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 toggleBtn = document.getElementById('toggleBtn');
|
||||||
const pauseBtn = document.getElementById('pauseBtn');
|
const pauseBtn = document.getElementById('pauseBtn');
|
||||||
toggleBtn.style.display = 'inline-block';
|
toggleBtn.style.display = 'inline-block';
|
||||||
pauseBtn.style.display = bl.enabled && !bl.paused ? 'inline-block' : 'none';
|
|
||||||
if (bl.paused) {
|
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.textContent = 'Paused';
|
||||||
toggleBtn.style.background = 'var(--amber)';
|
toggleBtn.style.background = 'var(--amber)';
|
||||||
|
toggleBtn.onclick = unpauseBlocking; // clicking "Paused" unpauses, not disables
|
||||||
} else if (bl.enabled) {
|
} else if (bl.enabled) {
|
||||||
toggleBtn.textContent = 'Blocking On';
|
toggleBtn.textContent = 'Blocking On';
|
||||||
toggleBtn.style.background = 'var(--emerald)';
|
toggleBtn.style.background = 'var(--emerald)';
|
||||||
@@ -792,6 +916,9 @@ async function refresh() {
|
|||||||
renderQueryLog(logs);
|
renderQueryLog(logs);
|
||||||
renderOverrides(overrides);
|
renderOverrides(overrides);
|
||||||
renderCache(cache);
|
renderCache(cache);
|
||||||
|
renderServices(services);
|
||||||
|
renderBlockingInfo(blockingInfo);
|
||||||
|
renderAllowlist(allowlist);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('statusDot').className = 'status-dot error';
|
document.getElementById('statusDot').className = 'status-dot error';
|
||||||
@@ -823,6 +950,13 @@ async function pauseBlocking() {
|
|||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function unpauseBlocking() {
|
||||||
|
try {
|
||||||
|
await fetch(API + '/blocking/unpause', { method: 'POST' });
|
||||||
|
refresh();
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function allowDomain(domain) {
|
async function allowDomain(domain) {
|
||||||
try {
|
try {
|
||||||
await fetch(API + '/blocking/allowlist', {
|
await fetch(API + '/blocking/allowlist', {
|
||||||
@@ -834,9 +968,176 @@ async function allowDomain(domain) {
|
|||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkDomain(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const domain = document.getElementById('checkDomainInput').value.trim();
|
||||||
|
const el = document.getElementById('checkResult');
|
||||||
|
if (!domain) return false;
|
||||||
|
try {
|
||||||
|
const result = await fetchJSON('/blocking/check/' + encodeURIComponent(domain));
|
||||||
|
el.style.display = 'block';
|
||||||
|
if (result.blocked) {
|
||||||
|
el.style.background = 'rgba(181, 68, 58, 0.1)';
|
||||||
|
el.style.color = 'var(--rose)';
|
||||||
|
el.innerHTML = `<strong>Blocked</strong> — ${result.reason}` +
|
||||||
|
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : '') +
|
||||||
|
` <button class="btn-delete" onclick="allowDomain('${domain}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
|
||||||
|
} else {
|
||||||
|
el.style.background = 'rgba(82, 122, 82, 0.1)';
|
||||||
|
el.style.color = 'var(--emerald)';
|
||||||
|
el.innerHTML = `<strong>Allowed</strong> — ${result.reason}` +
|
||||||
|
(result.matched_rule ? `<br>Rule: <code>${result.matched_rule}</code>` : '');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.background = 'rgba(181, 68, 58, 0.1)';
|
||||||
|
el.style.color = 'var(--rose)';
|
||||||
|
el.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
el.innerHTML = '<div class="empty-state">No services configured</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = entries.map(e => `
|
||||||
|
<div class="service-item">
|
||||||
|
<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">localhost:${e.target_port} → proxied</div>
|
||||||
|
</div>
|
||||||
|
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">×</button>`}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addService(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const errEl = document.getElementById('serviceError');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: document.getElementById('svcName').value.trim(),
|
||||||
|
target_port: parseInt(document.getElementById('svcPort').value) || 0,
|
||||||
|
};
|
||||||
|
const res = await fetch(API + '/services', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
document.getElementById('svcName').value = '';
|
||||||
|
document.getElementById('svcPort').value = '';
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteService(name) {
|
||||||
|
try {
|
||||||
|
await fetch(API + '/services/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||||
|
refresh();
|
||||||
|
} catch (err) { /* next refresh will update */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load + polling
|
// Initial load + polling
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 2000);
|
setInterval(refresh, 2000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
|
||||||
|
Logs: <span id="logPath" style="user-select:all;">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
||||||
|
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Numa — DNS that governs itself</title>
|
<title>Numa — DNS that governs itself</title>
|
||||||
<meta name="description" content="DNS you own. Block ads, override DNS for development, cache for speed. A single portable binary built from scratch in Rust. No Raspberry Pi, no cloud, no account.">
|
<meta name="description" content="DNS you own. Block ads, override DNS for development, name your local services with .numa domains, cache for speed. A single portable binary built from scratch in Rust.">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
@@ -612,6 +612,12 @@ p.lead {
|
|||||||
color: var(--emerald);
|
color: var(--emerald);
|
||||||
}
|
}
|
||||||
.pipeline-box.hl-emerald:hover { border-color: var(--emerald); color: var(--emerald); }
|
.pipeline-box.hl-emerald:hover { border-color: var(--emerald); color: var(--emerald); }
|
||||||
|
.pipeline-box.hl-cyan {
|
||||||
|
border-color: rgba(74, 124, 138, 0.35);
|
||||||
|
background: rgba(74, 124, 138, 0.06);
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.pipeline-box.hl-cyan:hover { border-color: var(--cyan); color: var(--cyan); }
|
||||||
|
|
||||||
.pipeline-arrow {
|
.pipeline-arrow {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -1004,7 +1010,7 @@ footer .closing {
|
|||||||
<div class="tagline">DNS you own. Everywhere you go.</div>
|
<div class="tagline">DNS you own. Everywhere you go.</div>
|
||||||
<p class="epigraph">After Numa Pompilius, who built institutions that outlasted kings.</p>
|
<p class="epigraph">After Numa Pompilius, who built institutions that outlasted kings.</p>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. Your DNS travels with you.
|
Block ads and trackers. Override DNS for development. Name your local services with <code style="font-size:0.9em;color:var(--cyan)">.numa</code> domains. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="#technical" class="btn btn-primary">Get Started</a>
|
<a href="#technical" class="btn btn-primary">Get Started</a>
|
||||||
@@ -1066,8 +1072,9 @@ footer .closing {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
<li>Ephemeral DNS overrides with auto-revert</li>
|
<li>Ephemeral DNS overrides with auto-revert</li>
|
||||||
|
<li>Local service proxy — <code>frontend.numa</code> instead of <code>localhost:5173</code></li>
|
||||||
<li>Live dashboard with real-time stats and controls</li>
|
<li>Live dashboard with real-time stats and controls</li>
|
||||||
<li>REST API — 18 endpoints for programmatic control</li>
|
<li>REST API — 22 endpoints for programmatic control</li>
|
||||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||||
<li>Single binary, portable — your ad blocker travels with you</li>
|
<li>Single binary, portable — your ad blocker travels with you</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1116,6 +1123,10 @@ footer .closing {
|
|||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box highlight">Overrides</div></div>
|
<div class="pipeline-node"><div class="pipeline-box highlight">Overrides</div></div>
|
||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
|
<div class="pipeline-node"><div class="pipeline-box hl-cyan">.numa TLD</div></div>
|
||||||
|
<span class="pipeline-arrow">→</span>
|
||||||
|
<div class="pipeline-node"><div class="pipeline-box">Blocklist</div></div>
|
||||||
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box">Local Zones</div></div>
|
<div class="pipeline-node"><div class="pipeline-box">Local Zones</div></div>
|
||||||
<span class="pipeline-arrow">→</span>
|
<span class="pipeline-arrow">→</span>
|
||||||
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
||||||
@@ -1230,6 +1241,14 @@ footer .closing {
|
|||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
<td class="check">REST API + auto-expiry</td>
|
<td class="check">REST API + auto-expiry</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Local service proxy</td>
|
||||||
|
<td class="cross">No</td>
|
||||||
|
<td class="cross">No</td>
|
||||||
|
<td class="cross">No</td>
|
||||||
|
<td class="cross">No</td>
|
||||||
|
<td class="check">.numa domains + WebSocket</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Data stays local</td>
|
<td>Data stays local</td>
|
||||||
<td class="check">Yes</td>
|
<td class="check">Yes</td>
|
||||||
@@ -1286,10 +1305,10 @@ footer .closing {
|
|||||||
<dd>Zero — wire protocol parsed from scratch</dd>
|
<dd>Zero — wire protocol parsed from scratch</dd>
|
||||||
|
|
||||||
<dt>Dependencies</dt>
|
<dt>Dependencies</dt>
|
||||||
<dd>6 runtime crates (tokio, axum, serde, serde_json, toml, log)</dd>
|
<dd>8 runtime crates (tokio, axum, hyper, serde, serde_json, toml, log, futures)</dd>
|
||||||
|
|
||||||
<dt>Packet Format</dt>
|
<dt>Packet Format</dt>
|
||||||
<dd>RFC 1035 compliant, 512-byte UDP</dd>
|
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
|
||||||
|
|
||||||
<dt>Concurrency</dt>
|
<dt>Concurrency</dt>
|
||||||
<dd>Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)</dd>
|
<dd>Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)</dd>
|
||||||
@@ -1299,13 +1318,12 @@ footer .closing {
|
|||||||
</dl>
|
</dl>
|
||||||
<div class="code-block reveal reveal-delay-2">
|
<div class="code-block reveal reveal-delay-2">
|
||||||
<span class="prompt">$</span> <span class="cmd">cargo install</span> numa
|
<span class="prompt">$</span> <span class="cmd">cargo install</span> numa
|
||||||
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53</span>
|
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53, :80, :5380</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">curl</span> localhost:5380/overrides <span class="comment"># REST API</span>
|
<span class="prompt">$</span> <span class="cmd">open</span> http://numa.numa <span class="comment"># dashboard</span>
|
||||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/overrides \
|
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
|
||||||
<span class="flag">-d</span> <span class="str">'{"domain":"api.stripe.com",
|
<span class="flag">-d</span> <span class="str">'{"name":"frontend",
|
||||||
"target":"127.0.0.1",
|
"target_port":5173}'</span> <span class="comment"># http://frontend.numa</span>
|
||||||
"duration_secs":1800}'</span> <span class="comment"># 30-min override</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1333,28 +1351,32 @@ footer .closing {
|
|||||||
<span class="phase">Phase 2</span>
|
<span class="phase">Phase 2</span>
|
||||||
<span class="phase-desc">Ad & tracker blocking — 385K+ domains, live dashboard, one-click allowlist</span>
|
<span class="phase-desc">Ad & tracker blocking — 385K+ domains, live dashboard, one-click allowlist</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 3</span>
|
<span class="phase">Phase 3</span>
|
||||||
<span class="phase-desc">System integration — auto-discovery of OS DNS routing, one-command install</span>
|
<span class="phase-desc">System integration — auto-discovery of OS DNS routing, one-command install</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 4</span>
|
<span class="phase">Phase 4</span>
|
||||||
|
<span class="phase-desc">Local service proxy — .numa domains, HTTP reverse proxy, WebSocket support</span>
|
||||||
|
</div>
|
||||||
|
<div class="roadmap-item phase-teal">
|
||||||
|
<span class="phase">Phase 5</span>
|
||||||
<span class="phase-desc">pkarr spike — DHT resolution and publish endpoint</span>
|
<span class="phase-desc">pkarr spike — DHT resolution and publish endpoint</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item phase-teal">
|
||||||
<span class="phase">Phase 5</span>
|
<span class="phase">Phase 6</span>
|
||||||
<span class="phase-desc">pkarr product — human-readable aliases, re-publish daemon, key management</span>
|
<span class="phase-desc">pkarr product — human-readable aliases, re-publish daemon, key management</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-amber">
|
<div class="roadmap-item phase-amber">
|
||||||
<span class="phase">Phase 4</span>
|
<span class="phase">Phase 7</span>
|
||||||
<span class="phase-desc">Challenge and audit protocol for verifiable resolver behavior</span>
|
<span class="phase-desc">Challenge and audit protocol for verifiable resolver behavior</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-violet">
|
<div class="roadmap-item phase-violet">
|
||||||
<span class="phase">Phase 5</span>
|
<span class="phase">Phase 8</span>
|
||||||
<span class="phase-desc">Token economics, staking, and slashing mechanism</span>
|
<span class="phase-desc">Token economics, staking, and slashing mechanism</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-violet">
|
<div class="roadmap-item phase-violet">
|
||||||
<span class="phase">Phase 6</span>
|
<span class="phase">Phase 9</span>
|
||||||
<span class="phase-desc">Decentralized resolver marketplace</span>
|
<span class="phase-desc">Decentralized resolver marketplace</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
125
src/api.rs
125
src/api.rs
@@ -35,12 +35,17 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
|||||||
.route("/blocking/stats", get(blocking_stats))
|
.route("/blocking/stats", get(blocking_stats))
|
||||||
.route("/blocking/toggle", put(blocking_toggle))
|
.route("/blocking/toggle", put(blocking_toggle))
|
||||||
.route("/blocking/pause", post(blocking_pause))
|
.route("/blocking/pause", post(blocking_pause))
|
||||||
|
.route("/blocking/unpause", post(blocking_unpause))
|
||||||
.route("/blocking/allowlist", get(blocking_allowlist))
|
.route("/blocking/allowlist", get(blocking_allowlist))
|
||||||
.route("/blocking/allowlist", post(blocking_allowlist_add))
|
.route("/blocking/allowlist", post(blocking_allowlist_add))
|
||||||
|
.route("/blocking/check/{domain}", get(blocking_check))
|
||||||
.route(
|
.route(
|
||||||
"/blocking/allowlist/{domain}",
|
"/blocking/allowlist/{domain}",
|
||||||
delete(blocking_allowlist_remove),
|
delete(blocking_allowlist_remove),
|
||||||
)
|
)
|
||||||
|
.route("/services", get(list_services))
|
||||||
|
.route("/services", post(create_service))
|
||||||
|
.route("/services/{name}", delete(remove_service))
|
||||||
.with_state(ctx)
|
.with_state(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +537,19 @@ async fn blocking_pause(
|
|||||||
Json(serde_json::json!({ "paused_minutes": req.minutes }))
|
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>,
|
||||||
|
) -> Json<crate::blocklist::BlockCheckResult> {
|
||||||
|
let result = ctx.blocklist.lock().unwrap().check(&domain);
|
||||||
|
Json(result)
|
||||||
|
}
|
||||||
|
|
||||||
async fn blocking_allowlist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> {
|
async fn blocking_allowlist(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<String>> {
|
||||||
let list = ctx.blocklist.lock().unwrap().allowlist();
|
let list = ctx.blocklist.lock().unwrap().allowlist();
|
||||||
Json(list)
|
Json(list)
|
||||||
@@ -563,3 +581,110 @@ async fn blocking_allowlist_remove(
|
|||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Service proxy handlers ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ServiceResponse {
|
||||||
|
name: String,
|
||||||
|
target_port: u16,
|
||||||
|
url: String,
|
||||||
|
healthy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateServiceRequest {
|
||||||
|
name: String,
|
||||||
|
target_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceResponse>> {
|
||||||
|
let entries: Vec<_> = {
|
||||||
|
let store = ctx.services.lock().unwrap();
|
||||||
|
store
|
||||||
|
.list()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.name.clone(), e.target_port))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let tld = &ctx.proxy_tld;
|
||||||
|
|
||||||
|
// Run all health checks concurrently
|
||||||
|
let health_futures: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(_, port)| check_health(*port))
|
||||||
|
.collect();
|
||||||
|
let health_results = futures::future::join_all(health_futures).await;
|
||||||
|
|
||||||
|
let results: Vec<_> = entries
|
||||||
|
.into_iter()
|
||||||
|
.zip(health_results)
|
||||||
|
.map(|((name, port), healthy)| ServiceResponse {
|
||||||
|
url: format!("http://{}.{}", name, tld),
|
||||||
|
name,
|
||||||
|
target_port: port,
|
||||||
|
healthy,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Json(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_service(
|
||||||
|
State(ctx): State<Arc<ServerCtx>>,
|
||||||
|
Json(req): Json<CreateServiceRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<ServiceResponse>), (StatusCode, String)> {
|
||||||
|
let name = req.name.to_lowercase();
|
||||||
|
|
||||||
|
// Validate name: alphanumeric + hyphens only, 1-63 chars
|
||||||
|
if name.is_empty() || name.len() > 63 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"name must be 1-63 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"name must contain only alphanumeric characters and hyphens".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req.target_port == 0 {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "target_port must be > 0".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tld = &ctx.proxy_tld;
|
||||||
|
ctx.services.lock().unwrap().insert(&name, req.target_port);
|
||||||
|
|
||||||
|
let healthy = check_health(req.target_port).await;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(ServiceResponse {
|
||||||
|
url: format!("http://{}.{}", name, tld),
|
||||||
|
name,
|
||||||
|
target_port: req.target_port,
|
||||||
|
healthy,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_service(State(ctx): State<Arc<ServerCtx>>, Path(name): Path<String>) -> StatusCode {
|
||||||
|
if name.eq_ignore_ascii_case("numa") {
|
||||||
|
return StatusCode::FORBIDDEN;
|
||||||
|
}
|
||||||
|
let mut store = ctx.services.lock().unwrap();
|
||||||
|
if store.remove(&name) {
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
} else {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_health(port: u16) -> bool {
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|r| r.is_ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,44 @@ pub struct BlocklistStore {
|
|||||||
last_refresh: Option<Instant>,
|
last_refresh: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct BlockCheckResult {
|
||||||
|
pub blocked: bool,
|
||||||
|
pub reason: String,
|
||||||
|
pub matched_rule: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockCheckResult {
|
||||||
|
fn blocked(rule: &str, reason: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
blocked: true,
|
||||||
|
reason: reason.to_string(),
|
||||||
|
matched_rule: Some(rule.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn allowed(rule: &str, reason: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
blocked: false,
|
||||||
|
reason: reason.to_string(),
|
||||||
|
matched_rule: Some(rule.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn not_blocked() -> Self {
|
||||||
|
Self {
|
||||||
|
blocked: false,
|
||||||
|
reason: "not in blocklist".to_string(),
|
||||||
|
matched_rule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
blocked: false,
|
||||||
|
reason: "blocking is disabled".to_string(),
|
||||||
|
matched_rule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct BlocklistStats {
|
pub struct BlocklistStats {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
@@ -73,6 +111,36 @@ impl BlocklistStore {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a domain is blocked and return the reason.
|
||||||
|
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
||||||
|
let domain = domain.to_lowercase();
|
||||||
|
|
||||||
|
if !self.enabled {
|
||||||
|
return BlockCheckResult::disabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.allowlist.contains(&domain) {
|
||||||
|
return BlockCheckResult::allowed(&domain, "exact match in allowlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.domains.contains(&domain) {
|
||||||
|
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut d = domain.as_str();
|
||||||
|
while let Some(dot) = d.find('.') {
|
||||||
|
d = &d[dot + 1..];
|
||||||
|
if self.allowlist.contains(d) {
|
||||||
|
return BlockCheckResult::allowed(d, "parent domain in allowlist");
|
||||||
|
}
|
||||||
|
if self.domains.contains(d) {
|
||||||
|
return BlockCheckResult::blocked(d, "parent domain in blocklist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockCheckResult::not_blocked()
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomically swap in a new domain set. Build the set outside the lock,
|
/// Atomically swap in a new domain set. Build the set outside the lock,
|
||||||
/// then call this to swap — keeps lock hold time sub-microsecond.
|
/// then call this to swap — keeps lock hold time sub-microsecond.
|
||||||
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
||||||
@@ -93,6 +161,10 @@ impl BlocklistStore {
|
|||||||
self.paused_until = Some(Instant::now() + std::time::Duration::from_secs(seconds));
|
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 {
|
pub fn is_paused(&self) -> bool {
|
||||||
self.paused_until
|
self.paused_until
|
||||||
.map(|until| Instant::now() < until)
|
.map(|until| Instant::now() < until)
|
||||||
@@ -165,6 +237,7 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
|||||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.gzip(true)
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
|
const BUF_SIZE: usize = 4096;
|
||||||
|
|
||||||
pub struct BytePacketBuffer {
|
pub struct BytePacketBuffer {
|
||||||
pub buf: [u8; 512],
|
pub buf: [u8; BUF_SIZE],
|
||||||
pub pos: usize,
|
pub pos: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ impl Default for BytePacketBuffer {
|
|||||||
impl BytePacketBuffer {
|
impl BytePacketBuffer {
|
||||||
pub fn new() -> BytePacketBuffer {
|
pub fn new() -> BytePacketBuffer {
|
||||||
BytePacketBuffer {
|
BytePacketBuffer {
|
||||||
buf: [0; 512],
|
buf: [0; BUF_SIZE],
|
||||||
pos: 0,
|
pos: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,7 @@ impl BytePacketBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read(&mut self) -> Result<u8> {
|
pub fn read(&mut self) -> Result<u8> {
|
||||||
if self.pos >= 512 {
|
if self.pos >= BUF_SIZE {
|
||||||
return Err("End of buffer".into());
|
return Err("End of buffer".into());
|
||||||
}
|
}
|
||||||
let res = self.buf[self.pos];
|
let res = self.buf[self.pos];
|
||||||
@@ -47,14 +49,14 @@ impl BytePacketBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, pos: usize) -> Result<u8> {
|
pub fn get(&self, pos: usize) -> Result<u8> {
|
||||||
if pos >= 512 {
|
if pos >= BUF_SIZE {
|
||||||
return Err("End of buffer".into());
|
return Err("End of buffer".into());
|
||||||
}
|
}
|
||||||
Ok(self.buf[pos])
|
Ok(self.buf[pos])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_range(&self, start: usize, len: usize) -> Result<&[u8]> {
|
pub fn get_range(&self, start: usize, len: usize) -> Result<&[u8]> {
|
||||||
if start + len > 512 {
|
if start + len > BUF_SIZE {
|
||||||
return Err("End of buffer".into());
|
return Err("End of buffer".into());
|
||||||
}
|
}
|
||||||
Ok(&self.buf[start..start + len])
|
Ok(&self.buf[start..start + len])
|
||||||
@@ -128,7 +130,7 @@ impl BytePacketBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&mut self, val: u8) -> Result<()> {
|
pub fn write(&mut self, val: u8) -> Result<()> {
|
||||||
if self.pos >= 512 {
|
if self.pos >= BUF_SIZE {
|
||||||
return Err("End of buffer".into());
|
return Err("End of buffer".into());
|
||||||
}
|
}
|
||||||
self.buf[self.pos] = val;
|
self.buf[self.pos] = val;
|
||||||
@@ -172,7 +174,7 @@ impl BytePacketBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&mut self, pos: usize, val: u8) -> Result<()> {
|
pub fn set(&mut self, pos: usize, val: u8) -> Result<()> {
|
||||||
if pos >= 512 {
|
if pos >= BUF_SIZE {
|
||||||
return Err("End of buffer".into());
|
return Err("End of buffer".into());
|
||||||
}
|
}
|
||||||
self.buf[pos] = val;
|
self.buf[pos] = val;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::question::QueryType;
|
|||||||
use crate::record::DnsRecord;
|
use crate::record::DnsRecord;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -21,6 +21,10 @@ pub struct Config {
|
|||||||
pub blocking: BlockingConfig,
|
pub blocking: BlockingConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub zones: Vec<ZoneRecord>,
|
pub zones: Vec<ZoneRecord>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy: ProxyConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub services: Vec<ServiceConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -69,7 +73,7 @@ impl Default for UpstreamConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_upstream_addr() -> String {
|
fn default_upstream_addr() -> String {
|
||||||
"8.8.8.8".to_string()
|
String::new() // empty = auto-detect from system resolver
|
||||||
}
|
}
|
||||||
fn default_upstream_port() -> u16 {
|
fn default_upstream_port() -> u16 {
|
||||||
53
|
53
|
||||||
@@ -156,15 +160,51 @@ fn default_zone_ttl() -> u32 {
|
|||||||
300
|
300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ProxyConfig {
|
||||||
|
#[serde(default = "default_proxy_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_proxy_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default = "default_proxy_tls_port")]
|
||||||
|
pub tls_port: u16,
|
||||||
|
#[serde(default = "default_proxy_tld")]
|
||||||
|
pub tld: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
ProxyConfig {
|
||||||
|
enabled: default_proxy_enabled(),
|
||||||
|
port: default_proxy_port(),
|
||||||
|
tls_port: default_proxy_tls_port(),
|
||||||
|
tld: default_proxy_tld(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proxy_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_proxy_port() -> u16 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
fn default_proxy_tls_port() -> u16 {
|
||||||
|
443
|
||||||
|
}
|
||||||
|
fn default_proxy_tld() -> String {
|
||||||
|
"numa".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ServiceConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub target_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_config(path: &str) -> Result<Config> {
|
pub fn load_config(path: &str) -> Result<Config> {
|
||||||
if !Path::new(path).exists() {
|
if !Path::new(path).exists() {
|
||||||
return Ok(Config {
|
return Ok(Config::default());
|
||||||
server: ServerConfig::default(),
|
|
||||||
upstream: UpstreamConfig::default(),
|
|
||||||
cache: CacheConfig::default(),
|
|
||||||
blocking: BlockingConfig::default(),
|
|
||||||
zones: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let contents = std::fs::read_to_string(path)?;
|
let contents = std::fs::read_to_string(path)?;
|
||||||
let config: Config = toml::from_str(&contents)?;
|
let config: Config = toml::from_str(&contents)?;
|
||||||
|
|||||||
25
src/ctx.rs
25
src/ctx.rs
@@ -14,7 +14,9 @@ use crate::header::ResultCode;
|
|||||||
use crate::override_store::OverrideStore;
|
use crate::override_store::OverrideStore;
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
use crate::query_log::{QueryLog, QueryLogEntry};
|
use crate::query_log::{QueryLog, QueryLogEntry};
|
||||||
|
use crate::question::QueryType;
|
||||||
use crate::record::DnsRecord;
|
use crate::record::DnsRecord;
|
||||||
|
use crate::service_store::ServiceStore;
|
||||||
use crate::stats::{QueryPath, ServerStats};
|
use crate::stats::{QueryPath, ServerStats};
|
||||||
use crate::system_dns::ForwardingRule;
|
use crate::system_dns::ForwardingRule;
|
||||||
|
|
||||||
@@ -26,9 +28,12 @@ pub struct ServerCtx {
|
|||||||
pub overrides: Mutex<OverrideStore>,
|
pub overrides: Mutex<OverrideStore>,
|
||||||
pub blocklist: Mutex<BlocklistStore>,
|
pub blocklist: Mutex<BlocklistStore>,
|
||||||
pub query_log: Mutex<QueryLog>,
|
pub query_log: Mutex<QueryLog>,
|
||||||
|
pub services: Mutex<ServiceStore>,
|
||||||
pub forwarding_rules: Vec<ForwardingRule>,
|
pub forwarding_rules: Vec<ForwardingRule>,
|
||||||
pub upstream: SocketAddr,
|
pub upstream: SocketAddr,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
|
pub proxy_tld: String,
|
||||||
|
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_query(
|
pub async fn handle_query(
|
||||||
@@ -51,7 +56,7 @@ pub async fn handle_query(
|
|||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pipeline: overrides -> blocklist -> local zones -> cache -> upstream
|
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||||
// Each lock is scoped to avoid holding MutexGuard across await points.
|
// Each lock is scoped to avoid holding MutexGuard across await points.
|
||||||
let (response, path) = {
|
let (response, path) = {
|
||||||
let override_record = ctx.overrides.lock().unwrap().lookup(&qname);
|
let override_record = ctx.overrides.lock().unwrap().lookup(&qname);
|
||||||
@@ -59,8 +64,24 @@ pub async fn handle_query(
|
|||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
resp.answers.push(record);
|
resp.answers.push(record);
|
||||||
(resp, QueryPath::Overridden)
|
(resp, QueryPath::Overridden)
|
||||||
|
} else if !ctx.proxy_tld_suffix.is_empty()
|
||||||
|
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
|
||||||
|
{
|
||||||
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
|
match qtype {
|
||||||
|
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: std::net::Ipv6Addr::LOCALHOST,
|
||||||
|
ttl: 300,
|
||||||
|
}),
|
||||||
|
_ => resp.answers.push(DnsRecord::A {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: std::net::Ipv4Addr::LOCALHOST,
|
||||||
|
ttl: 300,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
(resp, QueryPath::Local)
|
||||||
} else if ctx.blocklist.lock().unwrap().is_blocked(&qname) {
|
} else if ctx.blocklist.lock().unwrap().is_blocked(&qname) {
|
||||||
use crate::question::QueryType;
|
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
match qtype {
|
match qtype {
|
||||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||||
|
|||||||
29
src/lib.rs
29
src/lib.rs
@@ -8,11 +8,40 @@ pub mod forward;
|
|||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod override_store;
|
pub mod override_store;
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
|
pub mod proxy;
|
||||||
pub mod query_log;
|
pub mod query_log;
|
||||||
pub mod question;
|
pub mod question;
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
pub mod service_store;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod system_dns;
|
pub mod system_dns;
|
||||||
|
pub mod tls;
|
||||||
|
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
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")
|
||||||
|
}
|
||||||
|
|||||||
114
src/main.rs
114
src/main.rs
@@ -12,8 +12,12 @@ use numa::config::{build_zone_map, load_config};
|
|||||||
use numa::ctx::{handle_query, ServerCtx};
|
use numa::ctx::{handle_query, ServerCtx};
|
||||||
use numa::override_store::OverrideStore;
|
use numa::override_store::OverrideStore;
|
||||||
use numa::query_log::QueryLog;
|
use numa::query_log::QueryLog;
|
||||||
|
use numa::service_store::ServiceStore;
|
||||||
use numa::stats::ServerStats;
|
use numa::stats::ServerStats;
|
||||||
use numa::system_dns::{discover_forwarding_rules, install_system_dns, uninstall_system_dns};
|
use numa::system_dns::{
|
||||||
|
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
|
||||||
|
uninstall_service, uninstall_system_dns,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> numa::Result<()> {
|
async fn main() -> numa::Result<()> {
|
||||||
@@ -32,14 +36,36 @@ async fn main() -> numa::Result<()> {
|
|||||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
|
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
|
||||||
return uninstall_system_dns().map_err(|e| e.into());
|
return uninstall_system_dns().map_err(|e| e.into());
|
||||||
}
|
}
|
||||||
|
"service" => {
|
||||||
|
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||||
|
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n");
|
||||||
|
return match sub.as_str() {
|
||||||
|
"start" => install_service().map_err(|e| e.into()),
|
||||||
|
"stop" => uninstall_service().map_err(|e| e.into()),
|
||||||
|
"restart" => restart_service().map_err(|e| e.into()),
|
||||||
|
"status" => service_status().map_err(|e| e.into()),
|
||||||
|
_ => {
|
||||||
|
eprintln!("Usage: numa service <start|stop|restart|status>");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"version" | "--version" | "-V" => {
|
||||||
|
eprintln!("numa {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
"help" | "--help" | "-h" => {
|
"help" | "--help" | "-h" => {
|
||||||
eprintln!("Usage: numa [command] [config-path]");
|
eprintln!("Usage: numa [command] [config-path]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Commands:");
|
eprintln!("Commands:");
|
||||||
eprintln!(" (none) Start the DNS server (default)");
|
eprintln!(" (none) Start the DNS server (default)");
|
||||||
eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)");
|
eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)");
|
||||||
eprintln!(" uninstall Restore original system DNS settings");
|
eprintln!(" uninstall Restore original system DNS settings");
|
||||||
eprintln!(" help Show this help");
|
eprintln!(" service start Install as system service (auto-start on boot)");
|
||||||
|
eprintln!(" service stop Uninstall the system service");
|
||||||
|
eprintln!(" service restart Restart the service with updated binary");
|
||||||
|
eprintln!(" service status Check if the service is running");
|
||||||
|
eprintln!(" help Show this help");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Config path defaults to numa.toml");
|
eprintln!("Config path defaults to numa.toml");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -56,8 +82,18 @@ async fn main() -> numa::Result<()> {
|
|||||||
};
|
};
|
||||||
let config = load_config(&config_path)?;
|
let config = load_config(&config_path)?;
|
||||||
|
|
||||||
let upstream: SocketAddr =
|
// Discover system DNS in a single pass (upstream + forwarding rules)
|
||||||
format!("{}:{}", config.upstream.address, config.upstream.port).parse()?;
|
let system_dns = discover_system_dns();
|
||||||
|
|
||||||
|
let upstream_addr = if config.upstream.address.is_empty() {
|
||||||
|
system_dns.default_upstream.unwrap_or_else(|| {
|
||||||
|
info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)");
|
||||||
|
"9.9.9.9".to_string()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
config.upstream.address.clone()
|
||||||
|
};
|
||||||
|
let upstream: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||||
let api_port = config.server.api_port;
|
let api_port = config.server.api_port;
|
||||||
|
|
||||||
let mut blocklist = BlocklistStore::new();
|
let mut blocklist = BlocklistStore::new();
|
||||||
@@ -68,8 +104,15 @@ async fn main() -> numa::Result<()> {
|
|||||||
blocklist.set_enabled(false);
|
blocklist.set_enabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-discover conditional forwarding rules from OS (Tailscale, VPN, etc.)
|
// Build service store: config services + persisted user services
|
||||||
let forwarding_rules = discover_forwarding_rules();
|
let mut service_store = ServiceStore::new();
|
||||||
|
service_store.insert_from_config("numa", config.server.api_port);
|
||||||
|
for svc in &config.services {
|
||||||
|
service_store.insert_from_config(&svc.name, svc.target_port);
|
||||||
|
}
|
||||||
|
service_store.load_persisted();
|
||||||
|
|
||||||
|
let forwarding_rules = system_dns.forwarding_rules;
|
||||||
|
|
||||||
let ctx = Arc::new(ServerCtx {
|
let ctx = Arc::new(ServerCtx {
|
||||||
socket: UdpSocket::bind(&config.server.bind_addr).await?,
|
socket: UdpSocket::bind(&config.server.bind_addr).await?,
|
||||||
@@ -83,14 +126,21 @@ async fn main() -> numa::Result<()> {
|
|||||||
overrides: Mutex::new(OverrideStore::new()),
|
overrides: Mutex::new(OverrideStore::new()),
|
||||||
blocklist: Mutex::new(blocklist),
|
blocklist: Mutex::new(blocklist),
|
||||||
query_log: Mutex::new(QueryLog::new(1000)),
|
query_log: Mutex::new(QueryLog::new(1000)),
|
||||||
|
services: Mutex::new(service_store),
|
||||||
forwarding_rules,
|
forwarding_rules,
|
||||||
upstream,
|
upstream,
|
||||||
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||||
|
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(".{}", config.proxy.tld)
|
||||||
|
},
|
||||||
|
proxy_tld: config.proxy.tld.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||||
eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m");
|
eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m");
|
||||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m");
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;163;152;136mv{}\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m", env!("CARGO_PKG_VERSION"));
|
||||||
eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m");
|
eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m");
|
||||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr);
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr);
|
||||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port);
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port);
|
||||||
@@ -100,6 +150,17 @@ async fn main() -> numa::Result<()> {
|
|||||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries));
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries));
|
||||||
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
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.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() });
|
||||||
|
if config.proxy.enabled {
|
||||||
|
let schemes = if config.proxy.tls_port > 0 {
|
||||||
|
format!(
|
||||||
|
"http://:{} https://:{}",
|
||||||
|
config.proxy.port, config.proxy.tls_port
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port)
|
||||||
|
};
|
||||||
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes);
|
||||||
|
}
|
||||||
if !ctx.forwarding_rules.is_empty() {
|
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",
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
||||||
format!("{} conditional rules", ctx.forwarding_rules.len()));
|
format!("{} conditional rules", ctx.forwarding_rules.len()));
|
||||||
@@ -141,6 +202,39 @@ async fn main() -> numa::Result<()> {
|
|||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn HTTP reverse proxy for .numa domains
|
||||||
|
if config.proxy.enabled {
|
||||||
|
let proxy_ctx = Arc::clone(&ctx);
|
||||||
|
let proxy_port = config.proxy.port;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
numa::proxy::start_proxy(proxy_ctx, proxy_port).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn HTTPS reverse proxy with TLS termination
|
||||||
|
if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||||
|
let service_names: Vec<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
|
// UDP DNS listener
|
||||||
#[allow(clippy::infinite_loop)]
|
#[allow(clippy::infinite_loop)]
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -68,24 +68,33 @@ impl DnsPacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
|
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
|
||||||
|
// Filter out UNKNOWN records (e.g. EDNS OPT) that we can't re-serialize
|
||||||
|
let answers: Vec<_> = self.answers.iter().filter(|r| !r.is_unknown()).collect();
|
||||||
|
let authorities: Vec<_> = self
|
||||||
|
.authorities
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.is_unknown())
|
||||||
|
.collect();
|
||||||
|
let resources: Vec<_> = self.resources.iter().filter(|r| !r.is_unknown()).collect();
|
||||||
|
|
||||||
let mut header = self.header.clone();
|
let mut header = self.header.clone();
|
||||||
header.questions = self.questions.len() as u16;
|
header.questions = self.questions.len() as u16;
|
||||||
header.answers = self.answers.len() as u16;
|
header.answers = answers.len() as u16;
|
||||||
header.authoritative_entries = self.authorities.len() as u16;
|
header.authoritative_entries = authorities.len() as u16;
|
||||||
header.resource_entries = self.resources.len() as u16;
|
header.resource_entries = resources.len() as u16;
|
||||||
|
|
||||||
header.write(buffer)?;
|
header.write(buffer)?;
|
||||||
|
|
||||||
for question in &self.questions {
|
for question in &self.questions {
|
||||||
question.write(buffer)?;
|
question.write(buffer)?;
|
||||||
}
|
}
|
||||||
for rec in &self.answers {
|
for rec in answers {
|
||||||
rec.write(buffer)?;
|
rec.write(buffer)?;
|
||||||
}
|
}
|
||||||
for rec in &self.authorities {
|
for rec in authorities {
|
||||||
rec.write(buffer)?;
|
rec.write(buffer)?;
|
||||||
}
|
}
|
||||||
for rec in &self.resources {
|
for rec in resources {
|
||||||
rec.write(buffer)?;
|
rec.write(buffer)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
241
src/proxy.rs
Normal file
241
src/proxy.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::any;
|
||||||
|
use axum::Router;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::StatusCode;
|
||||||
|
use hyper_util::client::legacy::Client;
|
||||||
|
use hyper_util::rt::TokioExecutor;
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use rustls::ServerConfig;
|
||||||
|
use tokio::io::copy_bidirectional;
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
use crate::ctx::ServerCtx;
|
||||||
|
|
||||||
|
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ProxyState {
|
||||||
|
ctx: Arc<ServerCtx>,
|
||||||
|
client: HttpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"proxy: could not bind port {} ({}) — proxy disabled",
|
||||||
|
port, e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("HTTP proxy listening on {}", addr);
|
||||||
|
|
||||||
|
let client: HttpClient = Client::builder(TokioExecutor::new())
|
||||||
|
.http1_preserve_header_case(true)
|
||||||
|
.build_http();
|
||||||
|
|
||||||
|
let state = ProxyState { ctx, client };
|
||||||
|
|
||||||
|
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_proxy_tls(ctx: Arc<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)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|h| h.split(':').next().unwrap_or(h).to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::response::Response {
|
||||||
|
let hostname = match extract_host(&req) {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
return (StatusCode::BAD_REQUEST, "missing Host header").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_port = {
|
||||||
|
let store = state.ctx.services.lock().unwrap();
|
||||||
|
match store.lookup(&service_name) {
|
||||||
|
Some(entry) => entry.target_port,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!(
|
||||||
|
"unknown service: {}{}",
|
||||||
|
service_name, state.ctx.proxy_tld_suffix
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let path_and_query = req
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|pq| pq.as_str())
|
||||||
|
.unwrap_or("/");
|
||||||
|
let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query)
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Check for upgrade request (WebSocket, etc.)
|
||||||
|
let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some();
|
||||||
|
|
||||||
|
if is_upgrade {
|
||||||
|
return handle_upgrade(req, target_uri, state.client.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular HTTP proxy
|
||||||
|
let (mut parts, body) = req.into_parts();
|
||||||
|
parts.uri = target_uri;
|
||||||
|
let proxied_req = Request::from_parts(parts, body);
|
||||||
|
|
||||||
|
match state.client.request(proxied_req).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let (parts, body) = resp.into_parts();
|
||||||
|
let body = Body::new(body.map_err(axum::Error::new));
|
||||||
|
axum::response::Response::from_parts(parts, body)
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::BAD_GATEWAY, format!("proxy error: {}", e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_upgrade(
|
||||||
|
mut req: Request,
|
||||||
|
target_uri: hyper::Uri,
|
||||||
|
client: HttpClient,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
// Save the client-side upgrade future before forwarding
|
||||||
|
let client_upgrade = hyper::upgrade::on(&mut req);
|
||||||
|
|
||||||
|
// Forward the request to backend
|
||||||
|
let (mut parts, body) = req.into_parts();
|
||||||
|
parts.uri = target_uri;
|
||||||
|
let backend_req = Request::from_parts(parts, body);
|
||||||
|
|
||||||
|
let mut backend_resp = match client.request(backend_req).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return (StatusCode::BAD_GATEWAY, format!("upgrade error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if backend_resp.status() != StatusCode::SWITCHING_PROTOCOLS {
|
||||||
|
let (parts, body) = backend_resp.into_parts();
|
||||||
|
let body = Body::new(body.map_err(axum::Error::new));
|
||||||
|
return axum::response::Response::from_parts(parts, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save response headers before consuming for upgrade
|
||||||
|
let resp_headers = backend_resp.headers().clone();
|
||||||
|
let backend_upgrade = hyper::upgrade::on(&mut backend_resp);
|
||||||
|
|
||||||
|
// Spawn bidirectional pipe once both sides are upgraded
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (client_io, backend_io) = match tokio::try_join!(client_upgrade, backend_upgrade) {
|
||||||
|
Ok((c, b)) => (c, b),
|
||||||
|
Err(e) => {
|
||||||
|
error!("proxy upgrade failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut client_rw = hyper_util::rt::TokioIo::new(client_io);
|
||||||
|
let mut backend_rw = hyper_util::rt::TokioIo::new(backend_io);
|
||||||
|
|
||||||
|
match copy_bidirectional(&mut client_rw, &mut backend_rw).await {
|
||||||
|
Ok((up, down)) => debug!("ws proxy closed: {} up, {} down bytes", up, down),
|
||||||
|
Err(e) => debug!("ws proxy error: {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return 101 to client with the backend's upgrade headers
|
||||||
|
let mut resp = axum::response::Response::builder().status(StatusCode::SWITCHING_PROTOCOLS);
|
||||||
|
for (key, value) in &resp_headers {
|
||||||
|
resp = resp.header(key, value);
|
||||||
|
}
|
||||||
|
resp.body(Body::empty()).unwrap()
|
||||||
|
}
|
||||||
@@ -7,8 +7,13 @@ pub enum QueryType {
|
|||||||
A, // 1
|
A, // 1
|
||||||
NS, // 2
|
NS, // 2
|
||||||
CNAME, // 5
|
CNAME, // 5
|
||||||
|
SOA, // 6
|
||||||
|
PTR, // 12
|
||||||
MX, // 15
|
MX, // 15
|
||||||
|
TXT, // 16
|
||||||
AAAA, // 28
|
AAAA, // 28
|
||||||
|
SRV, // 33
|
||||||
|
HTTPS, // 65
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryType {
|
impl QueryType {
|
||||||
@@ -18,8 +23,13 @@ impl QueryType {
|
|||||||
QueryType::A => 1,
|
QueryType::A => 1,
|
||||||
QueryType::NS => 2,
|
QueryType::NS => 2,
|
||||||
QueryType::CNAME => 5,
|
QueryType::CNAME => 5,
|
||||||
|
QueryType::SOA => 6,
|
||||||
|
QueryType::PTR => 12,
|
||||||
QueryType::MX => 15,
|
QueryType::MX => 15,
|
||||||
|
QueryType::TXT => 16,
|
||||||
QueryType::AAAA => 28,
|
QueryType::AAAA => 28,
|
||||||
|
QueryType::SRV => 33,
|
||||||
|
QueryType::HTTPS => 65,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +38,13 @@ impl QueryType {
|
|||||||
1 => QueryType::A,
|
1 => QueryType::A,
|
||||||
2 => QueryType::NS,
|
2 => QueryType::NS,
|
||||||
5 => QueryType::CNAME,
|
5 => QueryType::CNAME,
|
||||||
|
6 => QueryType::SOA,
|
||||||
|
12 => QueryType::PTR,
|
||||||
15 => QueryType::MX,
|
15 => QueryType::MX,
|
||||||
|
16 => QueryType::TXT,
|
||||||
28 => QueryType::AAAA,
|
28 => QueryType::AAAA,
|
||||||
|
33 => QueryType::SRV,
|
||||||
|
65 => QueryType::HTTPS,
|
||||||
_ => QueryType::UNKNOWN(num),
|
_ => QueryType::UNKNOWN(num),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,25 +54,30 @@ impl QueryType {
|
|||||||
QueryType::A => "A",
|
QueryType::A => "A",
|
||||||
QueryType::NS => "NS",
|
QueryType::NS => "NS",
|
||||||
QueryType::CNAME => "CNAME",
|
QueryType::CNAME => "CNAME",
|
||||||
|
QueryType::SOA => "SOA",
|
||||||
|
QueryType::PTR => "PTR",
|
||||||
QueryType::MX => "MX",
|
QueryType::MX => "MX",
|
||||||
|
QueryType::TXT => "TXT",
|
||||||
QueryType::AAAA => "AAAA",
|
QueryType::AAAA => "AAAA",
|
||||||
|
QueryType::SRV => "SRV",
|
||||||
|
QueryType::HTTPS => "HTTPS",
|
||||||
QueryType::UNKNOWN(_) => "UNKNOWN",
|
QueryType::UNKNOWN(_) => "UNKNOWN",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_str(s: &str) -> Option<QueryType> {
|
pub fn parse_str(s: &str) -> Option<QueryType> {
|
||||||
if s.eq_ignore_ascii_case("A") {
|
match s.to_ascii_uppercase().as_str() {
|
||||||
Some(QueryType::A)
|
"A" => Some(QueryType::A),
|
||||||
} else if s.eq_ignore_ascii_case("NS") {
|
"NS" => Some(QueryType::NS),
|
||||||
Some(QueryType::NS)
|
"CNAME" => Some(QueryType::CNAME),
|
||||||
} else if s.eq_ignore_ascii_case("CNAME") {
|
"SOA" => Some(QueryType::SOA),
|
||||||
Some(QueryType::CNAME)
|
"PTR" => Some(QueryType::PTR),
|
||||||
} else if s.eq_ignore_ascii_case("MX") {
|
"MX" => Some(QueryType::MX),
|
||||||
Some(QueryType::MX)
|
"TXT" => Some(QueryType::TXT),
|
||||||
} else if s.eq_ignore_ascii_case("AAAA") {
|
"AAAA" => Some(QueryType::AAAA),
|
||||||
Some(QueryType::AAAA)
|
"SRV" => Some(QueryType::SRV),
|
||||||
} else {
|
"HTTPS" => Some(QueryType::HTTPS),
|
||||||
None
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ pub enum DnsRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DnsRecord {
|
impl DnsRecord {
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
matches!(self, DnsRecord::UNKNOWN { .. })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ttl(&self) -> u32 {
|
pub fn ttl(&self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
DnsRecord::A { ttl, .. }
|
DnsRecord::A { ttl, .. }
|
||||||
@@ -137,7 +141,7 @@ impl DnsRecord {
|
|||||||
ttl,
|
ttl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
QueryType::UNKNOWN(_) => {
|
_ => {
|
||||||
buffer.step(data_len as usize)?;
|
buffer.step(data_len as usize)?;
|
||||||
|
|
||||||
Ok(DnsRecord::UNKNOWN {
|
Ok(DnsRecord::UNKNOWN {
|
||||||
|
|||||||
135
src/service_store.rs
Normal file
135
src/service_store.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub target_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServiceStore {
|
||||||
|
entries: HashMap<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 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let persist_path = dirs_path();
|
||||||
|
ServiceStore {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
config_services: std::collections::HashSet::new(),
|
||||||
|
persist_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a service from numa.toml config (not persisted)
|
||||||
|
pub fn insert_from_config(&mut self, name: &str, target_port: u16) {
|
||||||
|
let key = name.to_lowercase();
|
||||||
|
self.config_services.insert(key.clone());
|
||||||
|
self.entries.insert(
|
||||||
|
key.clone(),
|
||||||
|
ServiceEntry {
|
||||||
|
name: key,
|
||||||
|
target_port,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a user-defined service (persisted to ~/.config/numa/services.json)
|
||||||
|
pub fn insert(&mut self, name: &str, target_port: u16) {
|
||||||
|
let key = name.to_lowercase();
|
||||||
|
self.entries.insert(
|
||||||
|
key.clone(),
|
||||||
|
ServiceEntry {
|
||||||
|
name: key,
|
||||||
|
target_port,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> {
|
||||||
|
self.entries.get(&name.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
let key = name.to_lowercase();
|
||||||
|
let removed = self.entries.remove(&key).is_some();
|
||||||
|
if removed {
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Vec<&ServiceEntry> {
|
||||||
|
let mut entries: Vec<_> = self.entries.values().collect();
|
||||||
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load user-defined services from ~/.config/numa/services.json
|
||||||
|
pub fn load_persisted(&mut self) {
|
||||||
|
if !self.persist_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match std::fs::read_to_string(&self.persist_path) {
|
||||||
|
Ok(contents) => match serde_json::from_str::<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")
|
||||||
|
}
|
||||||
@@ -10,38 +10,48 @@ pub struct ForwardingRule {
|
|||||||
pub upstream: SocketAddr,
|
pub upstream: SocketAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover system DNS forwarding rules from the OS.
|
/// Result of system DNS discovery — default upstream + conditional forwarding rules.
|
||||||
/// On macOS, parses `scutil --dns`. Returns rules sorted longest-suffix-first
|
pub struct SystemDnsInfo {
|
||||||
/// so more specific matches take priority.
|
pub default_upstream: Option<String>,
|
||||||
pub fn discover_forwarding_rules() -> Vec<ForwardingRule> {
|
pub forwarding_rules: Vec<ForwardingRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover system DNS configuration in a single pass.
|
||||||
|
/// On macOS: parses `scutil --dns` once for both the default upstream and forwarding rules.
|
||||||
|
/// On Linux: reads `/etc/resolv.conf` for upstream, no forwarding rules yet.
|
||||||
|
pub fn discover_system_dns() -> SystemDnsInfo {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
discover_macos()
|
discover_macos()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
info!("system DNS auto-discovery not implemented for this OS");
|
SystemDnsInfo {
|
||||||
Vec::new()
|
default_upstream: detect_upstream_linux_or_backup(),
|
||||||
|
forwarding_rules: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn discover_macos() -> Vec<ForwardingRule> {
|
fn discover_macos() -> SystemDnsInfo {
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
|
|
||||||
let output = match std::process::Command::new("scutil").arg("--dns").output() {
|
let output = match std::process::Command::new("scutil").arg("--dns").output() {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to run scutil --dns: {}", e);
|
warn!("failed to run scutil --dns: {}", e);
|
||||||
return Vec::new();
|
return SystemDnsInfo {
|
||||||
|
default_upstream: None,
|
||||||
|
forwarding_rules: Vec::new(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
let mut rules = Vec::new();
|
let mut rules = Vec::new();
|
||||||
|
let mut default_upstream: Option<String> = None;
|
||||||
|
|
||||||
// Parse resolver blocks: look for blocks with both `domain` and `nameserver[0]`
|
|
||||||
// that have the `Supplemental` flag (conditional forwarding, not default)
|
|
||||||
let mut current_domain: Option<String> = None;
|
let mut current_domain: Option<String> = None;
|
||||||
let mut current_nameserver: Option<String> = None;
|
let mut current_nameserver: Option<String> = None;
|
||||||
let mut is_supplemental = false;
|
let mut is_supplemental = false;
|
||||||
@@ -50,7 +60,7 @@ fn discover_macos() -> Vec<ForwardingRule> {
|
|||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
|
|
||||||
if line.starts_with("resolver #") {
|
if line.starts_with("resolver #") {
|
||||||
// Emit previous block if valid
|
// Emit previous supplemental block as forwarding rule
|
||||||
if let (Some(domain), Some(ns), true) = (
|
if let (Some(domain), Some(ns), true) = (
|
||||||
current_domain.take(),
|
current_domain.take(),
|
||||||
current_nameserver.take(),
|
current_nameserver.take(),
|
||||||
@@ -64,7 +74,6 @@ fn discover_macos() -> Vec<ForwardingRule> {
|
|||||||
current_nameserver = None;
|
current_nameserver = None;
|
||||||
is_supplemental = false;
|
is_supplemental = false;
|
||||||
} else if line.starts_with("domain") && line.contains(':') {
|
} else if line.starts_with("domain") && line.contains(':') {
|
||||||
// "domain : tailcee7cc.ts.net."
|
|
||||||
if let Some(val) = line.split(':').nth(1) {
|
if let Some(val) = line.split(':').nth(1) {
|
||||||
let domain = val.trim().trim_end_matches('.').to_lowercase();
|
let domain = val.trim().trim_end_matches('.').to_lowercase();
|
||||||
if !domain.is_empty()
|
if !domain.is_empty()
|
||||||
@@ -78,15 +87,21 @@ fn discover_macos() -> Vec<ForwardingRule> {
|
|||||||
} else if line.starts_with("nameserver[0]") && line.contains(':') {
|
} else if line.starts_with("nameserver[0]") && line.contains(':') {
|
||||||
if let Some(val) = line.split(':').nth(1) {
|
if let Some(val) = line.split(':').nth(1) {
|
||||||
let ns = val.trim().to_string();
|
let ns = val.trim().to_string();
|
||||||
// Only use IPv4 nameservers for now
|
|
||||||
if ns.parse::<std::net::Ipv4Addr>().is_ok() {
|
if ns.parse::<std::net::Ipv4Addr>().is_ok() {
|
||||||
current_nameserver = Some(ns);
|
current_nameserver = Some(ns.clone());
|
||||||
|
// Capture first non-supplemental, non-loopback nameserver as default upstream
|
||||||
|
if !is_supplemental
|
||||||
|
&& default_upstream.is_none()
|
||||||
|
&& ns != "127.0.0.1"
|
||||||
|
&& ns != "0.0.0.0"
|
||||||
|
{
|
||||||
|
default_upstream = Some(ns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if line.starts_with("flags") && line.contains("Supplemental") {
|
} else if line.starts_with("flags") && line.contains("Supplemental") {
|
||||||
is_supplemental = true;
|
is_supplemental = true;
|
||||||
} else if line.starts_with("DNS configuration (for scoped") {
|
} else if line.starts_with("DNS configuration (for scoped") {
|
||||||
// Stop at scoped section — those are interface-specific, not conditional
|
|
||||||
if let (Some(domain), Some(ns), true) = (
|
if let (Some(domain), Some(ns), true) = (
|
||||||
current_domain.take(),
|
current_domain.take(),
|
||||||
current_nameserver.take(),
|
current_nameserver.take(),
|
||||||
@@ -116,12 +131,17 @@ fn discover_macos() -> Vec<ForwardingRule> {
|
|||||||
rule.suffix, rule.upstream
|
rule.suffix, rule.upstream
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if rules.is_empty() {
|
if rules.is_empty() {
|
||||||
debug!("no conditional forwarding rules discovered from scutil --dns");
|
debug!("no conditional forwarding rules discovered");
|
||||||
|
}
|
||||||
|
if let Some(ref ns) = default_upstream {
|
||||||
|
info!("detected system upstream: {}", ns);
|
||||||
}
|
}
|
||||||
|
|
||||||
rules
|
SystemDnsInfo {
|
||||||
|
default_upstream,
|
||||||
|
forwarding_rules: rules,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -134,6 +154,45 @@ fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf
|
||||||
|
/// only has loopback (meaning numa install already ran).
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn detect_upstream_linux_or_backup() -> Option<String> {
|
||||||
|
// Try /etc/resolv.conf first
|
||||||
|
if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") {
|
||||||
|
info!("detected system upstream: {}", ns);
|
||||||
|
return Some(ns);
|
||||||
|
}
|
||||||
|
// If resolv.conf only has loopback, check the backup from `numa install`
|
||||||
|
let backup = {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
||||||
|
home.join(".numa").join("original-resolv.conf")
|
||||||
|
};
|
||||||
|
if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) {
|
||||||
|
info!("detected original upstream from backup: {}", ns);
|
||||||
|
return Some(ns);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn read_upstream_from_file(path: &str) -> Option<String> {
|
||||||
|
let text = std::fs::read_to_string(path).ok()?;
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with("nameserver") {
|
||||||
|
if let Some(ns) = line.split_whitespace().nth(1) {
|
||||||
|
if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" {
|
||||||
|
return Some(ns.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// Find the upstream for a domain by checking forwarding rules.
|
||||||
/// Returns None if no rule matches (use default upstream).
|
/// Returns None if no rule matches (use default upstream).
|
||||||
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
||||||
@@ -152,21 +211,25 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
|
|||||||
/// Saves the original DNS settings for later restoration.
|
/// Saves the original DNS settings for later restoration.
|
||||||
pub fn install_system_dns() -> Result<(), String> {
|
pub fn install_system_dns() -> Result<(), String> {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let result = install_macos();
|
||||||
install_macos()
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
let result = install_linux();
|
||||||
install_linux()
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
{
|
let result = Err("system DNS configuration not supported on this OS".to_string());
|
||||||
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.
|
/// Restore the original system DNS settings saved during install.
|
||||||
pub fn uninstall_system_dns() -> Result<(), String> {
|
pub fn uninstall_system_dns() -> Result<(), String> {
|
||||||
|
let _ = untrust_ca();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
uninstall_macos()
|
uninstall_macos()
|
||||||
@@ -186,8 +249,9 @@ pub fn uninstall_system_dns() -> Result<(), String> {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn numa_data_dir() -> std::path::PathBuf {
|
fn numa_data_dir() -> std::path::PathBuf {
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("SUDO_USER").map(|u| format!("/Users/{}", u)))
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
|
.unwrap_or_else(|_| std::path::PathBuf::from("/var/root"));
|
||||||
home.join(".numa")
|
home.join(".numa")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,17 +379,489 @@ fn uninstall_macos() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Linux stubs ---
|
// --- Service management ---
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const PLIST_LABEL: &str = "com.numa.dns";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const PLIST_DEST: &str = "/Library/LaunchDaemons/com.numa.dns.plist";
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service";
|
||||||
|
|
||||||
|
/// Install Numa as a system service that starts on boot and auto-restarts.
|
||||||
|
pub fn install_service() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
install_service_macos()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
install_service_linux()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
Err("service installation not supported on this OS".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstall the Numa system service.
|
||||||
|
pub fn uninstall_service() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
uninstall_service_macos()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
uninstall_service_linux()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
Err("service uninstallation not supported on this OS".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
||||||
|
pub fn restart_service() -> Result<(), String> {
|
||||||
|
// Show version of the binary that will be running after restart
|
||||||
|
let version = match std::process::Command::new("/usr/local/bin/numa")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
||||||
|
Err(_) => "unknown".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let output = std::process::Command::new("launchctl")
|
||||||
|
.args(["list", PLIST_LABEL])
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
eprintln!(" Tip: use 'make deploy' instead — handles codesign + restart.\n");
|
||||||
|
// Codesign, then kill service. Launchd KeepAlive respawns it.
|
||||||
|
// This will kill us too (we ARE /usr/local/bin/numa), so
|
||||||
|
// codesign and print output first.
|
||||||
|
let _ = std::process::Command::new("codesign")
|
||||||
|
.args(["-f", "-s", "-", "/usr/local/bin/numa"])
|
||||||
|
.output(); // use output() to suppress codesign stderr
|
||||||
|
eprintln!(" Service restarting → {}\n", version);
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args(["-f", "/usr/local/bin/numa"])
|
||||||
|
.status();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err("Service is not installed. Run 'sudo numa service start' first.".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
run_systemctl(&["restart", "numa"])?;
|
||||||
|
eprintln!(" Service restarted → {}\n", version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
Err("service restart not supported on this OS".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the service status.
|
||||||
|
pub fn service_status() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
service_status_macos()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
service_status_linux()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
Err("service status not supported on this OS".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn install_service_macos() -> Result<(), String> {
|
||||||
|
// Check binary exists
|
||||||
|
if !std::path::Path::new("/usr/local/bin/numa").exists() {
|
||||||
|
return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create log directory
|
||||||
|
std::fs::create_dir_all("/usr/local/var/log")
|
||||||
|
.map_err(|e| format!("failed to create log dir: {}", e))?;
|
||||||
|
|
||||||
|
// Write plist
|
||||||
|
let plist = include_str!("../com.numa.dns.plist");
|
||||||
|
std::fs::write(PLIST_DEST, plist)
|
||||||
|
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
||||||
|
|
||||||
|
// Load the service
|
||||||
|
let status = std::process::Command::new("launchctl")
|
||||||
|
.args(["load", "-w", PLIST_DEST])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err("launchctl load failed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set system DNS to 127.0.0.1 now that the service is running
|
||||||
|
eprintln!(" Service installed and started.");
|
||||||
|
if let Err(e) = install_macos() {
|
||||||
|
eprintln!(" warning: failed to configure system DNS: {}", e);
|
||||||
|
}
|
||||||
|
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||||
|
eprintln!(" Logs: /usr/local/var/log/numa.log");
|
||||||
|
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn uninstall_service_macos() -> Result<(), String> {
|
||||||
|
// Restore DNS first, while numa is still running to handle any final queries
|
||||||
|
if let Err(e) = uninstall_macos() {
|
||||||
|
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove plist first so service won't restart on boot even if unload fails
|
||||||
|
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
||||||
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unload the service
|
||||||
|
let status = std::process::Command::new("launchctl")
|
||||||
|
.args(["unload", "-w", PLIST_DEST])
|
||||||
|
.status();
|
||||||
|
if let Ok(s) = status {
|
||||||
|
if !s.success() {
|
||||||
|
eprintln!(
|
||||||
|
" warning: launchctl unload returned non-zero (service may still be running)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" Service uninstalled. Numa will no longer auto-start.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn service_status_macos() -> Result<(), String> {
|
||||||
|
let output = std::process::Command::new("launchctl")
|
||||||
|
.args(["list", PLIST_LABEL])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
eprintln!(" Numa service is loaded.\n");
|
||||||
|
for line in text.lines() {
|
||||||
|
eprintln!(" {}", line);
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
} else {
|
||||||
|
eprintln!(" Numa service is not installed.\n");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Linux implementation ---
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn backup_path_linux() -> std::path::PathBuf {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
||||||
|
home.join(".numa").join("original-resolv.conf")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn is_systemd_resolved_active() -> bool {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["is-active", "--quiet", "systemd-resolved"])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn install_linux() -> Result<(), String> {
|
fn install_linux() -> Result<(), String> {
|
||||||
Err(
|
// Detect systemd-resolved — direct resolv.conf manipulation won't persist
|
||||||
"Linux auto-configuration not yet implemented. Manually set your DNS to 127.0.0.1"
|
if is_systemd_resolved_active() {
|
||||||
.to_string(),
|
let resolved_dir = std::path::Path::new("/etc/systemd/resolved.conf.d");
|
||||||
)
|
std::fs::create_dir_all(resolved_dir)
|
||||||
|
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
|
||||||
|
|
||||||
|
let drop_in = resolved_dir.join("numa.conf");
|
||||||
|
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n")
|
||||||
|
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||||
|
|
||||||
|
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
||||||
|
eprintln!(" systemd-resolved detected.");
|
||||||
|
eprintln!(" Installed drop-in: {}", drop_in.display());
|
||||||
|
eprintln!(" Run 'sudo numa uninstall' to remove.\n");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct resolv.conf manipulation
|
||||||
|
let resolv = std::path::Path::new("/etc/resolv.conf");
|
||||||
|
let backup = backup_path_linux();
|
||||||
|
|
||||||
|
// Ensure backup directory exists
|
||||||
|
if let Some(parent) = backup.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back up current resolv.conf (ignore NotFound)
|
||||||
|
match std::fs::copy(resolv, &backup) {
|
||||||
|
Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolv
|
||||||
|
.symlink_metadata()
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
eprintln!(" warning: /etc/resolv.conf is a symlink — changes may not persist.");
|
||||||
|
eprintln!(" Consider using systemd-resolved or NetworkManager instead.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n";
|
||||||
|
std::fs::write(resolv, content)
|
||||||
|
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
|
||||||
|
|
||||||
|
eprintln!(" Set /etc/resolv.conf -> nameserver 127.0.0.1");
|
||||||
|
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn uninstall_linux() -> Result<(), String> {
|
fn uninstall_linux() -> Result<(), String> {
|
||||||
Err("Linux auto-configuration not yet implemented.".to_string())
|
// Check for systemd-resolved drop-in first
|
||||||
|
let drop_in = std::path::Path::new("/etc/systemd/resolved.conf.d/numa.conf");
|
||||||
|
if drop_in.exists() {
|
||||||
|
std::fs::remove_file(drop_in)
|
||||||
|
.map_err(|e| format!("failed to remove {}: {}", drop_in.display(), e))?;
|
||||||
|
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
||||||
|
eprintln!(" Removed systemd-resolved drop-in. DNS restored.\n");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: restore resolv.conf from backup
|
||||||
|
let backup = backup_path_linux();
|
||||||
|
let resolv = std::path::Path::new("/etc/resolv.conf");
|
||||||
|
|
||||||
|
match std::fs::copy(&backup, resolv) {
|
||||||
|
Ok(_) => {
|
||||||
|
std::fs::remove_file(&backup).ok();
|
||||||
|
eprintln!(" Restored /etc/resolv.conf from backup. Backup removed.\n");
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
eprintln!(" No backup found at {}.", backup.display());
|
||||||
|
eprintln!(" Manually edit /etc/resolv.conf to restore your DNS.\n");
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("failed to restore /etc/resolv.conf: {}", e)),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ensure_binary_installed() -> Result<(), String> {
|
||||||
|
if !std::path::Path::new("/usr/local/bin/numa").exists() {
|
||||||
|
return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn install_service_linux() -> Result<(), String> {
|
||||||
|
ensure_binary_installed()?;
|
||||||
|
|
||||||
|
let unit = include_str!("../numa.service");
|
||||||
|
std::fs::write(SYSTEMD_UNIT, unit)
|
||||||
|
.map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?;
|
||||||
|
|
||||||
|
run_systemctl(&["daemon-reload"])?;
|
||||||
|
run_systemctl(&["enable", "numa"])?;
|
||||||
|
run_systemctl(&["start", "numa"])?;
|
||||||
|
|
||||||
|
eprintln!(" Service installed and started.");
|
||||||
|
|
||||||
|
// Set system DNS now that the service is running
|
||||||
|
if let Err(e) = install_linux() {
|
||||||
|
eprintln!(" warning: failed to configure system DNS: {}", e);
|
||||||
|
}
|
||||||
|
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||||
|
eprintln!(" Logs: journalctl -u numa -f");
|
||||||
|
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn uninstall_service_linux() -> Result<(), String> {
|
||||||
|
// Restore DNS first, while numa is still running
|
||||||
|
if let Err(e) = uninstall_linux() {
|
||||||
|
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = run_systemctl(&["stop", "numa"]) {
|
||||||
|
eprintln!(" warning: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = run_systemctl(&["disable", "numa"]) {
|
||||||
|
eprintln!(" warning: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::remove_file(SYSTEMD_UNIT) {
|
||||||
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
return Err(format!("failed to remove {}: {}", SYSTEMD_UNIT, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = run_systemctl(&["daemon-reload"]);
|
||||||
|
|
||||||
|
eprintln!(" Service uninstalled. Numa will no longer auto-start.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn service_status_linux() -> Result<(), String> {
|
||||||
|
let output = std::process::Command::new("systemctl")
|
||||||
|
.args(["status", "numa"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run systemctl: {}", e))?;
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
if text.is_empty() {
|
||||||
|
eprintln!(" Numa service is not installed.\n");
|
||||||
|
} else {
|
||||||
|
for line in text.lines() {
|
||||||
|
eprintln!(" {}", line);
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
||||||
|
let status = std::process::Command::new("systemctl")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("systemctl {} failed: {}", args.join(" "), e))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"systemctl {} exited with {}",
|
||||||
|
args.join(" "),
|
||||||
|
status
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CA trust management ---
|
||||||
|
|
||||||
|
fn trust_ca() -> Result<(), String> {
|
||||||
|
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
|
||||||
|
if !ca_path.exists() {
|
||||||
|
return Err("CA not generated yet — start numa first to create certificates".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let status = std::process::Command::new("security")
|
||||||
|
.args([
|
||||||
|
"add-trusted-cert",
|
||||||
|
"-d",
|
||||||
|
"-r",
|
||||||
|
"trustRoot",
|
||||||
|
"-k",
|
||||||
|
"/Library/Keychains/System.keychain",
|
||||||
|
])
|
||||||
|
.arg(&ca_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("security: {}", e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("security add-trusted-cert failed".into());
|
||||||
|
}
|
||||||
|
eprintln!(" Trusted Numa CA in system keychain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
||||||
|
std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?;
|
||||||
|
let status = std::process::Command::new("update-ca-certificates")
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("update-ca-certificates: {}", e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("update-ca-certificates failed".into());
|
||||||
|
}
|
||||||
|
eprintln!(" Trusted Numa CA system-wide");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
return Err("CA trust not supported on this OS".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn untrust_ca() -> Result<(), String> {
|
||||||
|
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// Find all Numa CA certs by hash and delete each one
|
||||||
|
if let Ok(out) = std::process::Command::new("security")
|
||||||
|
.args([
|
||||||
|
"find-certificate",
|
||||||
|
"-c",
|
||||||
|
"Numa Local CA",
|
||||||
|
"-a",
|
||||||
|
"-Z",
|
||||||
|
"/Library/Keychains/System.keychain",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if let Some(hash) = line.strip_prefix("SHA-1 hash: ") {
|
||||||
|
let hash = hash.trim();
|
||||||
|
let _ = std::process::Command::new("security")
|
||||||
|
.args([
|
||||||
|
"delete-certificate",
|
||||||
|
"-Z",
|
||||||
|
hash,
|
||||||
|
"/Library/Keychains/System.keychain",
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!(" Removed Numa CA from system keychain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
||||||
|
if dest.exists() {
|
||||||
|
let _ = std::fs::remove_file(dest);
|
||||||
|
let _ = std::process::Command::new("update-ca-certificates")
|
||||||
|
.arg("--fresh")
|
||||||
|
.status();
|
||||||
|
eprintln!(" Removed Numa CA from system trust store");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = ca_path; // suppress unused warning on other platforms
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
129
src/tls.rs
Normal file
129
src/tls.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
||||||
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||||
|
use rustls::ServerConfig;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
||||||
|
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
||||||
|
|
||||||
|
/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree.
|
||||||
|
pub const TLS_DIR: &str = "/usr/local/var/numa";
|
||||||
|
|
||||||
|
/// Build a TLS config with a cert covering all provided service names.
|
||||||
|
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
|
||||||
|
/// so we list each service explicitly as a SAN.
|
||||||
|
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<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