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:
Razvan Dimescu
2026-03-20 15:07:15 +02:00
parent 14a9e9e7e3
commit 8f959ce0a5
15 changed files with 762 additions and 53 deletions

View File

@@ -348,6 +348,41 @@ body {
flex-shrink: 0;
}
/* Service items */
.service-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.service-item:last-child { border-bottom: none; }
.service-info { flex: 1; min-width: 0; }
.service-name {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
color: var(--cyan);
}
.service-name a {
color: inherit;
text-decoration: none;
}
.service-name a:hover { text-decoration: underline; }
.service-port {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
}
.health-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.health-dot.up { background: var(--emerald); }
.health-dot.down { background: var(--rose); }
/* Override form */
.override-form {
display: flex;
@@ -571,6 +606,26 @@ body {
</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 -->
<div class="panel">
<div class="panel-header">
@@ -781,11 +836,12 @@ function renderCache(entries) {
async function refresh() {
try {
const [stats, logs, overrides, cache] = await Promise.all([
const [stats, logs, overrides, cache, services] = await Promise.all([
fetchJSON('/stats'),
fetchJSON('/query-log?limit=100'),
fetchJSON('/overrides'),
fetchJSON('/cache'),
fetchJSON('/services'),
]);
// Connection status
@@ -843,6 +899,7 @@ async function refresh() {
renderQueryLog(logs);
renderOverrides(overrides);
renderCache(cache);
renderServices(services);
} catch (err) {
document.getElementById('statusDot').className = 'status-dot error';
@@ -914,6 +971,59 @@ async function checkDomain(event) {
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">&times;</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
refresh();
setInterval(refresh, 2000);

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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.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">
@@ -612,6 +612,12 @@ p.lead {
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 {
font-family: var(--font-mono);
@@ -1004,7 +1010,7 @@ footer .closing {
<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="description">
Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust &mdash; 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 &mdash; no Raspberry Pi, no cloud, no account.
</p>
<div class="hero-actions">
<a href="#technical" class="btn btn-primary">Get Started</a>
@@ -1066,8 +1072,9 @@ footer .closing {
<ul>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>Ephemeral DNS overrides with auto-revert</li>
<li>Local service proxy &mdash; <code>frontend.numa</code> instead of <code>localhost:5173</code></li>
<li>Live dashboard with real-time stats and controls</li>
<li>REST API &mdash; 18 endpoints for programmatic control</li>
<li>REST API &mdash; 22 endpoints for programmatic control</li>
<li>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; your ad blocker travels with you</li>
</ul>
@@ -1116,6 +1123,10 @@ footer .closing {
<span class="pipeline-arrow">&rarr;</span>
<div class="pipeline-node"><div class="pipeline-box highlight">Overrides</div></div>
<span class="pipeline-arrow">&rarr;</span>
<div class="pipeline-node"><div class="pipeline-box hl-cyan">.numa TLD</div></div>
<span class="pipeline-arrow">&rarr;</span>
<div class="pipeline-node"><div class="pipeline-box">Blocklist</div></div>
<span class="pipeline-arrow">&rarr;</span>
<div class="pipeline-node"><div class="pipeline-box">Local Zones</div></div>
<span class="pipeline-arrow">&rarr;</span>
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
@@ -1230,6 +1241,14 @@ footer .closing {
<td class="cross">No</td>
<td class="check">REST API + auto-expiry</td>
</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>
<td>Data stays local</td>
<td class="check">Yes</td>
@@ -1286,10 +1305,10 @@ footer .closing {
<dd>Zero &mdash; wire protocol parsed from scratch</dd>
<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>
<dd>RFC 1035 compliant, 512-byte UDP</dd>
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
<dt>Concurrency</dt>
<dd>Arc&lt;ServerCtx&gt; + std::sync::Mutex (sub-&micro;s holds, never across .await)</dd>
@@ -1299,13 +1318,12 @@ footer .closing {
</dl>
<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">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">curl</span> localhost:5380/overrides <span class="comment"># REST API</span>
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/overrides \
<span class="flag">-d</span> <span class="str">'{"domain":"api.stripe.com",
"target":"127.0.0.1",
"duration_secs":1800}'</span> <span class="comment"># 30-min override</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/services \
<span class="flag">-d</span> <span class="str">'{"name":"frontend",
"target_port":5173}'</span> <span class="comment"># http://frontend.numa</span>
</div>
</div>
</div>
@@ -1333,28 +1351,32 @@ footer .closing {
<span class="phase">Phase 2</span>
<span class="phase-desc">Ad &amp; tracker blocking &mdash; 385K+ domains, live dashboard, one-click allowlist</span>
</div>
<div class="roadmap-item phase-teal">
<div class="roadmap-item done">
<span class="phase">Phase 3</span>
<span class="phase-desc">System integration &mdash; auto-discovery of OS DNS routing, one-command install</span>
</div>
<div class="roadmap-item phase-teal">
<div class="roadmap-item done">
<span class="phase">Phase 4</span>
<span class="phase-desc">Local service proxy &mdash; .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 &mdash; DHT resolution and publish endpoint</span>
</div>
<div class="roadmap-item phase-teal">
<span class="phase">Phase 5</span>
<span class="phase">Phase 6</span>
<span class="phase-desc">pkarr product &mdash; human-readable aliases, re-publish daemon, key management</span>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
</div>