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:
@@ -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">×</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);
|
||||
|
||||
@@ -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 — 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>
|
||||
<div class="hero-actions">
|
||||
<a href="#technical" class="btn btn-primary">Get Started</a>
|
||||
@@ -1066,8 +1072,9 @@ footer .closing {
|
||||
<ul>
|
||||
<li>Ad & tracker blocking — 385K+ domains, zero config</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>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>Single binary, portable — your ad blocker travels with you</li>
|
||||
</ul>
|
||||
@@ -1116,6 +1123,10 @@ footer .closing {
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box highlight">Overrides</div></div>
|
||||
<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>
|
||||
<span class="pipeline-arrow">→</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 — 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<ServerCtx> + std::sync::Mutex (sub-µ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 & tracker blocking — 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 — 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 — .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>
|
||||
</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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user