|
| 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, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 40 | + |
| 41 | +function row(k, v, copyable, cls) { |
| 42 | + const btn = copyable |
| 43 | + ? '<button class="copy" data-copy="' + esc(v).replace(/"/g, '"') + '">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