add local service proxy with .numa domains
HTTP reverse proxy on port 80 lets developers use clean domain names (frontend.numa, api.numa) instead of localhost:PORT. Includes WebSocket upgrade support for HMR, TCP health checks, dashboard UI panel, and REST API for service management. numa.numa is preconfigured for the dashboard itself. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
CLAUDE.md
27
CLAUDE.md
@@ -32,7 +32,7 @@ numa service stop # uninstall service + restore DNS
|
|||||||
numa service status # check service status
|
numa service status # check service status
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard: http://localhost:5380
|
Dashboard: http://numa.numa (or http://localhost:5380)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -40,23 +40,27 @@ Dashboard: http://localhost:5380
|
|||||||
UDP :53 ──▶ handle_query()
|
UDP :53 ──▶ handle_query()
|
||||||
│
|
│
|
||||||
├─ 1. Override Store (ephemeral, auto-expiry)
|
├─ 1. Override Store (ephemeral, auto-expiry)
|
||||||
├─ 2. Blocklist (385K+ domains, subdomain matching)
|
├─ 2. .numa TLD (local service domains → 127.0.0.1)
|
||||||
├─ 3. Local Zones (TOML config)
|
├─ 3. Blocklist (385K+ domains, subdomain matching)
|
||||||
├─ 4. Cache (TTL-aware, lazy eviction)
|
├─ 4. Local Zones (TOML config)
|
||||||
└─ 5. Upstream Forward (auto-detected from OS, conditional forwarding)
|
├─ 5. Cache (TTL-aware, lazy eviction)
|
||||||
|
└─ 6. Upstream Forward (auto-detected from OS, conditional forwarding)
|
||||||
|
|
||||||
HTTP :5380 ──▶ Axum REST API (19 endpoints) + Dashboard
|
HTTP :80 ──▶ Reverse proxy for .numa domains (WebSocket support)
|
||||||
|
HTTP :5380 ──▶ Axum REST API (22 endpoints) + Dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Source Files
|
### Source Files
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.rs # startup: load config, bind UDP, spawn API, blocklist download, per-query task loop
|
main.rs # startup: load config, bind UDP, spawn API + proxy, blocklist download, per-query task loop
|
||||||
lib.rs # module declarations, Error/Result type aliases
|
lib.rs # module declarations, Error/Result type aliases
|
||||||
ctx.rs # ServerCtx shared state + handle_query() pipeline
|
ctx.rs # ServerCtx shared state + handle_query() pipeline
|
||||||
api.rs # Axum REST server (19 endpoints, port 5380) + embedded dashboard
|
api.rs # Axum REST server (22 endpoints, port 5380) + embedded dashboard
|
||||||
config.rs # TOML config loading with defaults (server, upstream, cache, blocking, zones)
|
config.rs # TOML config loading with defaults (server, upstream, cache, blocking, proxy, zones)
|
||||||
|
proxy.rs # HTTP reverse proxy for .numa domains (port 80, WebSocket upgrade support)
|
||||||
|
service_store.rs # ServiceStore — name-to-port mappings for local service proxy
|
||||||
blocklist.rs # BlocklistStore — HashSet<String>, download, parse, subdomain matching, check
|
blocklist.rs # BlocklistStore — HashSet<String>, download, parse, subdomain matching, check
|
||||||
override_store.rs # OverrideStore — ephemeral domain overrides with auto-expiry
|
override_store.rs # OverrideStore — ephemeral domain overrides with auto-expiry
|
||||||
query_log.rs # ring buffer (VecDeque, 1000 entries) for recent queries
|
query_log.rs # ring buffer (VecDeque, 1000 entries) for recent queries
|
||||||
@@ -76,12 +80,13 @@ site/
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
`numa.toml` at project root. Sections: `[server]`, `[upstream]`, `[cache]`, `[blocking]`, `[[zones]]`. Falls back to sensible defaults if file is missing. Upstream auto-detected from system resolver if not set.
|
`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
|
## REST API
|
||||||
|
|
||||||
Dashboard: GET `/` (embedded HTML)
|
Dashboard: GET `/` (embedded HTML)
|
||||||
Override management: POST/GET/DELETE `/overrides`, POST `/overrides/environment`
|
Override management: POST/GET/DELETE `/overrides`, POST `/overrides/environment`
|
||||||
|
Services: GET/POST `/services`, DELETE `/services/{name}`
|
||||||
Blocking: GET `/blocking/stats`, PUT `/blocking/toggle`, POST `/blocking/pause`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}`
|
Blocking: GET `/blocking/stats`, PUT `/blocking/toggle`, POST `/blocking/pause`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}`
|
||||||
Diagnostics: GET `/diagnose/{domain}`, `/query-log`, `/stats`, `/cache`, `/health`
|
Diagnostics: GET `/diagnose/{domain}`, `/query-log`, `/stats`, `/cache`, `/health`
|
||||||
Cache: DELETE `/cache`, `/cache/{domain}`
|
Cache: DELETE `/cache`, `/cache/{domain}`
|
||||||
@@ -89,7 +94,7 @@ Cache: DELETE `/cache`, `/cache/{domain}`
|
|||||||
## Key Details
|
## Key Details
|
||||||
|
|
||||||
- Rust 2021 edition, async via `tokio` (rt-multi-thread)
|
- Rust 2021 edition, async via `tokio` (rt-multi-thread)
|
||||||
- Deps: tokio, axum, serde, serde_json, toml, log, env_logger, reqwest (zero DNS libraries)
|
- Deps: tokio, axum, hyper, hyper-util, serde, serde_json, toml, log, env_logger, reqwest, futures (zero DNS libraries)
|
||||||
- DNS buffer size: 4096 bytes (EDNS-compatible). UNKNOWN record types (e.g. OPT) filtered on serialization.
|
- DNS buffer size: 4096 bytes (EDNS-compatible). UNKNOWN record types (e.g. OPT) filtered on serialization.
|
||||||
- `BytePacketBuffer::read_qname` handles label compression (pointer jumps)
|
- `BytePacketBuffer::read_qname` handles label compression (pointer jumps)
|
||||||
- `type Error = Box<dyn std::error::Error + Send + Sync>` / `type Result<T>` aliased in `lib.rs`
|
- `type Error = Box<dyn std::error::Error + Send + Sync>` / `type Result<T>` aliased in `lib.rs`
|
||||||
|
|||||||
59
Cargo.lock
generated
59
Cargo.lock
generated
@@ -226,6 +226,21 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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 +248,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 +257,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 +303,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",
|
||||||
]
|
]
|
||||||
@@ -632,6 +687,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ 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"], default-features = false }
|
||||||
|
hyper = { version = "1", features = ["client", "http1"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
|||||||
70
README.md
70
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 `frontend.numa`, `api.numa` instead of `localhost:5173`. Clean URLs with 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,49 @@ 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
|
||||||
|
- WebSocket support — Vite/webpack HMR works through the proxy
|
||||||
|
- Health checks — dashboard shows green/red status for each service
|
||||||
|
- 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 +112,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 +143,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 +161,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 +172,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 +197,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 + 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 +206,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 +225,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 reverse proxy + WebSocket support
|
||||||
- [ ] 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
|
||||||
|
|
||||||
|
|||||||
14
numa.toml
14
numa.toml
@@ -13,6 +13,20 @@ max_entries = 10000
|
|||||||
min_ttl = 60
|
min_ttl = 60
|
||||||
max_ttl = 86400
|
max_ttl = 86400
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
enabled = true
|
||||||
|
port = 80
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -571,6 +606,26 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local services -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Local Services</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="override-form" id="serviceForm" onsubmit="return addService(event)">
|
||||||
|
<div class="override-form-row">
|
||||||
|
<input type="text" id="svcName" placeholder="name (e.g. myapp)" required style="flex:2">
|
||||||
|
<input type="number" id="svcPort" placeholder="port" required min="1" max="65535" style="flex:1">
|
||||||
|
</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">
|
||||||
@@ -781,11 +836,12 @@ 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] = await Promise.all([
|
||||||
fetchJSON('/stats'),
|
fetchJSON('/stats'),
|
||||||
fetchJSON('/query-log?limit=100'),
|
fetchJSON('/query-log?limit=100'),
|
||||||
fetchJSON('/overrides'),
|
fetchJSON('/overrides'),
|
||||||
fetchJSON('/cache'),
|
fetchJSON('/cache'),
|
||||||
|
fetchJSON('/services'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Connection status
|
// Connection status
|
||||||
@@ -843,6 +899,7 @@ async function refresh() {
|
|||||||
renderQueryLog(logs);
|
renderQueryLog(logs);
|
||||||
renderOverrides(overrides);
|
renderOverrides(overrides);
|
||||||
renderCache(cache);
|
renderCache(cache);
|
||||||
|
renderServices(services);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('statusDot').className = 'status-dot error';
|
document.getElementById('statusDot').className = 'status-dot error';
|
||||||
@@ -914,6 +971,59 @@ async function checkDomain(event) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">:${e.target_port}</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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
107
src/api.rs
107
src/api.rs
@@ -42,6 +42,9 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
|||||||
"/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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,3 +575,107 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -156,15 +160,45 @@ 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_tld")]
|
||||||
|
pub tld: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
ProxyConfig {
|
||||||
|
enabled: default_proxy_enabled(),
|
||||||
|
port: default_proxy_port(),
|
||||||
|
tld: default_proxy_tld(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proxy_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_proxy_port() -> u16 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ 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;
|
||||||
|
|
||||||
|
|||||||
35
src/main.rs
35
src/main.rs
@@ -12,6 +12,7 @@ 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::{
|
use numa::system_dns::{
|
||||||
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
|
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
|
||||||
@@ -49,6 +50,10 @@ async fn main() -> numa::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
"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!();
|
||||||
@@ -99,6 +104,13 @@ async fn main() -> numa::Result<()> {
|
|||||||
blocklist.set_enabled(false);
|
blocklist.set_enabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build service store from config, always include numa dashboard
|
||||||
|
let mut service_store = ServiceStore::new();
|
||||||
|
service_store.insert("numa", config.server.api_port);
|
||||||
|
for svc in &config.services {
|
||||||
|
service_store.insert(&svc.name, svc.target_port);
|
||||||
|
}
|
||||||
|
|
||||||
let forwarding_rules = system_dns.forwarding_rules;
|
let forwarding_rules = system_dns.forwarding_rules;
|
||||||
|
|
||||||
let ctx = Arc::new(ServerCtx {
|
let ctx = Arc::new(ServerCtx {
|
||||||
@@ -113,14 +125,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);
|
||||||
@@ -130,6 +149,10 @@ 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 {
|
||||||
|
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
|
||||||
|
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port));
|
||||||
|
}
|
||||||
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()));
|
||||||
@@ -171,6 +194,16 @@ 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;
|
||||||
|
let proxy_tld = config.proxy.tld.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
numa::proxy::start_proxy(proxy_ctx, proxy_port, &proxy_tld).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// UDP DNS listener
|
// UDP DNS listener
|
||||||
#[allow(clippy::infinite_loop)]
|
#[allow(clippy::infinite_loop)]
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
188
src/proxy.rs
Normal file
188
src/proxy.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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 tokio::io::copy_bidirectional;
|
||||||
|
|
||||||
|
use crate::ctx::ServerCtx;
|
||||||
|
|
||||||
|
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ProxyState {
|
||||||
|
ctx: Arc<ServerCtx>,
|
||||||
|
client: HttpClient,
|
||||||
|
tld_suffix: String, // pre-computed ".{tld}"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
|
||||||
|
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,
|
||||||
|
tld_suffix: format!(".{}", tld),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.fallback(any(proxy_handler))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.tld_suffix.as_str()) {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("not a {} domain: {}", state.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.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://127.0.0.1:{}{}", 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()
|
||||||
|
}
|
||||||
52
src/service_store.rs
Normal file
52
src/service_store.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct ServiceEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub target_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServiceStore {
|
||||||
|
entries: HashMap<String, ServiceEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServiceStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ServiceStore {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> {
|
||||||
|
self.entries.get(&name.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
self.entries.remove(&name.to_lowercase()).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,6 +418,15 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
|
|
||||||
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
||||||
pub fn restart_service() -> Result<(), String> {
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let output = std::process::Command::new("launchctl")
|
let output = std::process::Command::new("launchctl")
|
||||||
@@ -425,11 +434,10 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
.output();
|
.output();
|
||||||
match output {
|
match output {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => {
|
||||||
// Service is loaded — kill the process, launchd restarts it
|
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.args(["-f", "/usr/local/bin/numa"])
|
.args(["-f", "/usr/local/bin/numa"])
|
||||||
.status();
|
.status();
|
||||||
eprintln!(" Service restarting (launchd will respawn).\n");
|
eprintln!(" Service restarting → {}\n", version);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => Err("Service is not installed. Run 'sudo numa service start' first.".to_string()),
|
_ => Err("Service is not installed. Run 'sudo numa service start' first.".to_string()),
|
||||||
@@ -438,7 +446,7 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
run_systemctl(&["restart", "numa"])?;
|
run_systemctl(&["restart", "numa"])?;
|
||||||
eprintln!(" Service restarted.\n");
|
eprintln!(" Service restarted → {}\n", version);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
|||||||
Reference in New Issue
Block a user