Skip to content

Commit ff6fefb

Browse files
ralyodioclaude
andcommitted
feat(dns): authenticated /dns tool backed by /api/dns/verify
Move the DNS setup/verify tool from a local docs file to an authenticated app route with a real server-side verifier — the add-record-then-verify flow Resend/ForwardEmail/Railway use. - services/api/src/dns.ts: POST /api/dns/verify (signed-in only, via currentUser). Resolves SPF, DMARC, send SPF, CAA (issue+issuewild), TLS-RPT, MTA-STS, DKIM, and apex A/AAAA server-side with Node's Resolver against public DNS; returns per-record pass/warn/fail + observed value + summary. Domain is validated; DNS-only (no outbound HTTP), so no SSRF surface. - apps/web/public/dns.html + dns.js: copy-record rows + "Verify DNS" button calling the endpoint. External JS (site CSP is script-src 'self'); auth-gated client-side like settings (redirect to /login). - Caddyfile: pretty-URL rewrite /dns -> /dns.html + short cache header. - Remove docs/dns-setup.html (superseded by the hosted tool). Typecheck passes; verifier smoke-tested against live tronbrowser.dev DNS (SPF fail, DMARC p=none, send ~all, A ok, no IPv6/CAA, DKIM ok). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6f05785 commit ff6fefb

6 files changed

Lines changed: 375 additions & 290 deletions

File tree

Caddyfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
# (no content hashing yet — don't let stale JS strand logged-in users).
4141
@images path *.svg *.png *.ico
4242
header @images Cache-Control "public, max-age=86400"
43-
@code path *.js *.css *.html / /privacy /login /settings
43+
@code path *.js *.css *.html / /privacy /login /settings /dns
4444
header @code Cache-Control "public, max-age=60, must-revalidate"
4545

4646
encode gzip zstd
@@ -52,6 +52,8 @@
5252
rewrite @login /login.html
5353
@settings path /settings
5454
rewrite @settings /settings.html
55+
@dns path /dns
56+
rewrite @dns /dns.html
5557

5658
file_server
5759

apps/web/public/dns.html

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="robots" content="noindex, nofollow" />
7+
<title>DNS setup &amp; verify — TronBrowser</title>
8+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9+
<style>
10+
:root { --bg:#05070d; --panel:#0b1020; --line:#1b2540; --cyan:#34e7ff;
11+
--fg:#cfe8ff; --muted:#6f86b3; --warn:#ffcf6f; --crit:#ff6f8b; --ok:#5fd08b; }
12+
* { box-sizing:border-box; }
13+
body { margin:0; background:radial-gradient(1200px 600px at 50% -10%, #0a1430 0%, var(--bg) 60%);
14+
color:var(--fg); font:15px/1.6 ui-monospace,"SF Mono",Menlo,monospace; min-height:100vh; }
15+
.wrap { max-width:980px; margin:0 auto; padding:32px 20px 96px; }
16+
a { color:var(--cyan); }
17+
.top { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
18+
h1 { color:#fff; font-size:24px; margin:0; }
19+
.top a { font-size:13px; }
20+
.sub { color:var(--muted); margin:0 0 20px; }
21+
h2 { color:#fff; font-size:18px; margin:36px 0 6px; }
22+
.note { color:var(--muted); font-size:13px; margin:4px 0 12px; }
23+
.toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center;
24+
background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
25+
.toolbar label { color:var(--muted); font-size:13px; }
26+
.toolbar input { background:#04060c; border:1px solid var(--line); color:var(--fg);
27+
border-radius:8px; padding:9px 12px; font:inherit; font-size:14px; min-width:200px; }
28+
button { font:inherit; cursor:pointer; }
29+
.verify { background:var(--cyan); color:#04060c; font-weight:700; border:none;
30+
border-radius:8px; padding:11px 20px; font-size:14px; }
31+
.verify:hover { filter:brightness(1.1); }
32+
.verify:disabled { opacity:.5; cursor:default; }
33+
.summary { margin-left:auto; color:var(--muted); font-size:13px; }
34+
.rec { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px 18px; margin:12px 0; }
35+
.rec .title { color:var(--cyan); font-weight:700; margin-bottom:10px; }
36+
.row { display:grid; grid-template-columns:64px 1fr auto; gap:10px; align-items:center; margin:6px 0; }
37+
.row .k { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:1px; }
38+
.row .v { background:#04060c; border:1px solid var(--line); border-radius:8px;
39+
padding:9px 12px; color:var(--fg); overflow-x:auto; white-space:nowrap; }
40+
.row .v.val { color:var(--cyan); }
41+
button.copy { background:transparent; border:1px solid var(--line); color:var(--fg);
42+
border-radius:6px; padding:7px 12px; font-size:12px; }
43+
button.copy:hover { border-color:var(--cyan); color:var(--cyan); }
44+
button.copy.done { border-color:var(--ok); color:var(--ok); }
45+
.result { margin-top:10px; font-size:13px; border-top:1px dashed var(--line); padding-top:10px; display:none; }
46+
.result.show { display:block; }
47+
.result .st { font-weight:700; text-transform:uppercase; letter-spacing:1px; margin-right:8px; }
48+
.result.pass .st { color:var(--ok); }
49+
.result.warn .st { color:var(--warn); }
50+
.result.fail .st { color:var(--crit); }
51+
.result.checking .st { color:var(--muted); }
52+
.result .obs { color:var(--muted); display:block; margin-top:4px; word-break:break-all; white-space:pre-wrap; }
53+
.warn-inline { color:var(--warn); font-size:13px; margin:8px 0 0; }
54+
code { color:var(--fg); }
55+
ol { color:var(--muted); font-size:14px; }
56+
</style>
57+
</head>
58+
<body>
59+
<div class="wrap">
60+
<div class="top">
61+
<h1>DNS setup &amp; verify</h1>
62+
<a href="/">← Home</a>
63+
</div>
64+
<p class="sub">Copy each record into the Porkbun dashboard, add it manually, then click <strong>Verify DNS</strong>. Verification runs server-side (authenticated <code>/api/dns/verify</code>) against public resolvers.</p>
65+
66+
<div class="toolbar">
67+
<label>Domain <input id="domain" value="tronbrowser.dev" spellcheck="false" /></label>
68+
<label>DKIM selector <input id="selector" value="resend" spellcheck="false" style="min-width:130px" /></label>
69+
<button class="verify" id="verify">Verify DNS</button>
70+
<span class="summary" id="summary"></span>
71+
</div>
72+
<p class="note">Order: add SPF + DMARC first, monitor ~2 weeks, then raise DMARC to <code>p=reject</code>. Source: AEO audit DNS Analyzer (28/100).</p>
73+
74+
<div id="records"></div>
75+
76+
<h2>How to apply</h2>
77+
<ol>
78+
<li>Create the <code>dmarc-reports@</code> alias in ForwardEmail first (the DMARC <code>rua</code> mailbox must exist).</li>
79+
<li>Add the two <strong>critical</strong> records (root SPF, DMARC), then click Verify DNS.</li>
80+
<li>Wait for reports to look clean (~2 weeks), then change DMARC to <code>p=reject</code>.</li>
81+
<li>Add the harden records. Add CAA only after confirming your host's cert CA (Railway must issue via Let's Encrypt).</li>
82+
</ol>
83+
</div>
84+
<script src="/dns.js"></script>
85+
</body>
86+
</html>

apps/web/public/dns.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// DNS setup & verify — signed-in ops tool. Renders copy-able record rows and
2+
// calls the authenticated /api/dns/verify endpoint (server-side resolution).
3+
// Keep as a plain script (site CSP is script-src 'self' — no inline JS).
4+
5+
// Client display spec. `key` matches a result key from /api/dns/verify.
6+
// `copyable:false` marks verify-only rows (managed elsewhere, e.g. Railway).
7+
const RECORDS = [
8+
{ key: 'spf', title: 'Root SPF', host: '@', type: 'TXT',
9+
value: 'v=spf1 include:forwardemail.net -all',
10+
note: 'Stops spoofing of @DOMAIN. Add other senders (e.g. include:amazonses.com) before -all if you use them.' },
11+
{ key: 'dmarc', title: 'DMARC policy', host: '_dmarc', type: 'TXT',
12+
value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@tronbrowser.dev; aspf=r; adkim=r;',
13+
note: 'The rua mailbox must exist. p=none means no enforcement.' },
14+
{ key: 'sendspf', title: 'send subdomain SPF', host: 'send', type: 'TXT',
15+
value: 'v=spf1 include:amazonses.com -all',
16+
note: 'Currently uses ~all (softfail). Tighten to -all if it only sends via SES.' },
17+
{ key: 'caa_issue', title: 'CAA — issue', host: '@', type: 'CAA',
18+
value: '0 issue "letsencrypt.org"',
19+
note: '⚠ Verify your host’s CA first — Railway must issue via Let’s Encrypt or this breaks cert renewal.' },
20+
{ key: 'caa_wild', title: 'CAA — issuewild', host: '@', type: 'CAA',
21+
value: '0 issuewild ";"', note: 'Disallow wildcard certs.' },
22+
{ key: 'tlsrpt', title: 'TLS-RPT', host: '_smtp._tls', type: 'TXT',
23+
value: 'v=TLSRPTv1; rua=mailto:tls-reports@tronbrowser.dev',
24+
note: 'Reports inbound TLS failures. Mailbox must exist.' },
25+
{ key: 'mtasts', title: 'MTA-STS', host: '_mta-sts', type: 'TXT',
26+
value: 'v=STSv1; id=20260701000000',
27+
note: 'Also needs a policy file at https://mta-sts.DOMAIN/.well-known/mta-sts.txt.' },
28+
{ key: 'dkim', title: 'DKIM (email sender)', host: '<selector>._domainkey', type: 'TXT',
29+
value: 'v=DKIM1; k=rsa; p=<public key from your sender>', copyable: false,
30+
note: 'Set the selector above — "resend" for Resend, or your ForwardEmail selector from app.forwardemail.net.' },
31+
{ key: 'a', title: 'Web apex — A record (Railway)', host: '@', type: 'A',
32+
value: '(managed by Railway)', copyable: false,
33+
note: 'Verify-only; should already resolve since the site is live.' },
34+
{ key: 'aaaa', title: 'Web apex — AAAA / IPv6', host: '@', type: 'AAAA',
35+
value: '(optional — add if Railway exposes IPv6)', copyable: false,
36+
note: 'Audit flagged no IPv6. Optional; add the AAAA target if Railway provides one.' },
37+
];
38+
39+
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
40+
41+
function row(k, v, copyable, cls) {
42+
const btn = copyable
43+
? '<button class="copy" data-copy="' + esc(v).replace(/"/g, '&quot;') + '">copy</button>'
44+
: '<span></span>';
45+
return '<div class="row"><span class="k">' + k + '</span><span class="v ' + (cls || '') + '">' + esc(v) + '</span>' + btn + '</div>';
46+
}
47+
48+
function render() {
49+
const wrap = document.getElementById('records');
50+
RECORDS.forEach((r) => {
51+
const canCopy = r.copyable !== false;
52+
const div = document.createElement('div');
53+
div.className = 'rec';
54+
div.innerHTML =
55+
'<div class="title">' + esc(r.title) + '</div>' +
56+
row('Host', r.host, canCopy && r.host.indexOf('<') < 0) +
57+
row('Type', r.type, canCopy) +
58+
row('Value', r.value, canCopy && r.value.indexOf('<') < 0, 'val') +
59+
(r.note ? '<p class="' + (r.note[0] === '⚠' ? 'warn-inline' : 'note') + '">' + esc(r.note) + '</p>' : '') +
60+
'<div class="result" id="res_' + r.key + '"></div>';
61+
wrap.appendChild(div);
62+
});
63+
}
64+
65+
document.addEventListener('click', async (e) => {
66+
const b = e.target.closest('button.copy');
67+
if (!b) return;
68+
try {
69+
await navigator.clipboard.writeText(b.getAttribute('data-copy'));
70+
const t = b.textContent; b.textContent = 'copied'; b.classList.add('done');
71+
setTimeout(() => { b.textContent = t; b.classList.remove('done'); }, 1200);
72+
} catch (_) { /* clipboard unavailable */ }
73+
});
74+
75+
async function verify() {
76+
const btn = document.getElementById('verify');
77+
const summary = document.getElementById('summary');
78+
const domain = document.getElementById('domain').value.trim();
79+
const selector = document.getElementById('selector').value.trim();
80+
if (!domain) return;
81+
btn.disabled = true;
82+
RECORDS.forEach((r) => {
83+
const el = document.getElementById('res_' + r.key);
84+
el.className = 'result show checking';
85+
el.innerHTML = '<span class="st">checking…</span>';
86+
});
87+
summary.textContent = '';
88+
try {
89+
const res = await fetch('/api/dns/verify', {
90+
method: 'POST', credentials: 'include',
91+
headers: { 'content-type': 'application/json' },
92+
body: JSON.stringify({ domain, selector }),
93+
});
94+
if (res.status === 401) { location.href = '/login'; return; }
95+
const data = await res.json();
96+
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
97+
for (const out of data.results) {
98+
const el = document.getElementById('res_' + out.key);
99+
if (!el) continue;
100+
el.className = 'result show ' + out.status;
101+
el.innerHTML = '<span class="st">' + out.status + '</span>' + esc(out.msg) +
102+
(out.observed ? '<span class="obs">' + esc(out.observed) + '</span>' : '');
103+
}
104+
const s = data.summary;
105+
summary.textContent = s.pass + ' pass · ' + s.warn + ' warn · ' + s.fail + ' fail';
106+
} catch (err) {
107+
RECORDS.forEach((r) => {
108+
const el = document.getElementById('res_' + r.key);
109+
el.className = 'result show fail';
110+
el.innerHTML = '<span class="st">error</span>' + esc(err.message);
111+
});
112+
} finally {
113+
btn.disabled = false;
114+
}
115+
}
116+
117+
// Auth gate (mirrors settings.js): redirect to /login if not signed in.
118+
(async function init() {
119+
render();
120+
document.getElementById('verify').addEventListener('click', verify);
121+
try {
122+
const me = await fetch('/api/auth/me', { credentials: 'include' }).then((r) => r.json());
123+
if (!me.signedIn) { location.href = '/login'; }
124+
} catch (_) { /* offline: leave the page usable; verify will 401→login */ }
125+
})();

0 commit comments

Comments
 (0)