Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
+894
@@ -0,0 +1,894 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hammerhead</title>
|
||||
<style>
|
||||
:root { --bg:#0a0a0a; --side:#0f0f10; --panel:#141415; --line:#262628; --text:#fafafa; --dim:#a1a1aa; --muted:#1c1c1e; --accent:#06b6d4; --accent-fg:#fff; --ok:#22c55e; --bad:#ef4444; --warn:#eab308; --shade:rgba(0,0,0,.65); --radius:10px; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#fafafa; --side:#fff; --panel:#fff; --line:#e4e4e7; --text:#18181b; --dim:#71717a; --muted:#f4f4f5; --accent:#0891b2; --shade:rgba(24,24,27,.5); } }
|
||||
* { box-sizing:border-box; }
|
||||
html, body { height:100%; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 Inter,system-ui,-apple-system,Segoe UI,sans-serif; -webkit-font-smoothing:antialiased; }
|
||||
a { color:var(--accent); }
|
||||
.app { display:flex; min-height:100vh; }
|
||||
.side { width:248px; flex:none; background:var(--side); border-right:1px solid var(--line); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:14px 12px; gap:4px; overflow-y:auto; }
|
||||
.brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; }
|
||||
.brand svg { color:var(--accent); }
|
||||
.orgsel { margin:0 0 10px; }
|
||||
.orgsel select { background:var(--muted); font-weight:600; }
|
||||
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; }
|
||||
.navitem { display:flex; align-items:center; gap:10px; width:100%; background:none; border:0; color:var(--dim); padding:8px 10px; border-radius:8px; cursor:pointer; font:inherit; font-weight:500; text-align:left; }
|
||||
.navitem:hover { background:var(--muted); color:var(--text); }
|
||||
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
|
||||
.navitem svg { flex:none; }
|
||||
.grow { flex:1; }
|
||||
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; }
|
||||
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.content { flex:1; min-width:0; }
|
||||
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; }
|
||||
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:22px; flex-wrap:wrap; }
|
||||
.page-h h1 { font-size:24px; font-weight:700; margin:0; letter-spacing:-.01em; }
|
||||
.page-h p { margin:4px 0 0; color:var(--dim); }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:18px; box-shadow:0 1px 2px rgba(0,0,0,.18); }
|
||||
.card.flush { padding:0; overflow:hidden; }
|
||||
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; }
|
||||
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
|
||||
.row-h h2 { display:none; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; align-items:end; }
|
||||
.stack { display:flex; flex-direction:column; gap:12px; }
|
||||
label { display:flex; flex-direction:column; gap:5px; font-size:12.5px; font-weight:500; color:var(--dim); }
|
||||
label.chk { flex-direction:row; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:400; }
|
||||
input, select, textarea { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; min-width:0; width:100%; }
|
||||
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); }
|
||||
input:focus, select:focus, textarea:focus { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:0; border-color:var(--accent); }
|
||||
input:disabled { opacity:.6; }
|
||||
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; }
|
||||
button.btn:hover { filter:brightness(1.08); }
|
||||
button.ghost { background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:5px 11px; cursor:pointer; font:inherit; font-weight:500; }
|
||||
button.ghost:hover { background:var(--muted); }
|
||||
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { text-align:left; font-size:12px; color:var(--dim); font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; background:color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); }
|
||||
tr:last-child td { border-bottom:0; }
|
||||
.card > table, .card > .scroll > table { margin:-4px 0; }
|
||||
.tag { display:inline-flex; align-items:center; padding:1px 8px; border-radius:6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; background:var(--muted); border:1px solid var(--line); color:var(--dim); }
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--dim); margin-right:7px; flex:none; }
|
||||
.dot.ok { background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } .dot.bad { background:var(--bad); } .dot.warn { background:var(--warn); }
|
||||
code, .mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12.5px; }
|
||||
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:12px; overflow:auto; margin:8px 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
||||
.dim { color:var(--dim); } .err { color:var(--bad); } .small { font-size:12px; }
|
||||
.empty { color:var(--dim); padding:28px 12px; text-align:center; }
|
||||
.scroll { overflow-x:auto; }
|
||||
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
|
||||
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; }
|
||||
.banner { background:color-mix(in srgb, var(--warn) 12%, transparent); border:1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius:var(--radius); padding:12px 16px; margin-bottom:18px; }
|
||||
.shade { position:fixed; inset:0; background:var(--shade); backdrop-filter:blur(2px); display:flex; align-items:flex-start; justify-content:center; padding:4vh 12px; overflow:auto; z-index:10; }
|
||||
.modal { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px; width:100%; max-width:780px; box-shadow:0 20px 60px rgba(0,0,0,.45); }
|
||||
.modal h2 { margin:0 0 16px; font-size:18px; }
|
||||
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; }
|
||||
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; }
|
||||
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; }
|
||||
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; }
|
||||
.sub { color:var(--dim); font-size:12px; font-weight:400; }
|
||||
.tabs2 { display:flex; gap:4px; margin-bottom:16px; background:var(--muted); padding:4px; border-radius:10px; width:fit-content; max-width:100%; flex-wrap:wrap; }
|
||||
.tabs2 button { background:none; border:0; color:var(--dim); border-radius:7px; padding:5px 14px; cursor:pointer; font:inherit; font-weight:500; }
|
||||
.tabs2 button.on { background:var(--panel); color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,.25); }
|
||||
#toast { position:fixed; bottom:20px; right:20px; background:var(--panel); border:1px solid var(--line); padding:11px 16px; border-radius:10px; z-index:30; display:none; max-width:min(420px,90vw); box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
||||
#toast.bad { border-color:var(--bad); color:var(--bad); }
|
||||
@media (max-width:860px) {
|
||||
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; }
|
||||
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
|
||||
main { padding:18px 14px 60px; } .tgt { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
// ---------- tiny toolkit ----------
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (v === true) el.setAttribute(k, '');
|
||||
else if (v !== false && v != null) el.setAttribute(k, v);
|
||||
}
|
||||
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
return el;
|
||||
}
|
||||
const $app = document.getElementById('app');
|
||||
let toastTimer;
|
||||
function toast(msg, bad) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.className = bad ? 'bad' : ''; t.style.display = 'block';
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.display = 'none', bad ? 6000 : 2500);
|
||||
}
|
||||
async function api(method, url, body) {
|
||||
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401 && url !== '/api/login' && !url.startsWith('/api/login/') && url !== '/api/me' && url !== '/api/public') {
|
||||
// the session ended while we were signed in: go back to the login page once.
|
||||
// (Not for /api/me or /api/public: boot() calls those, and reacting to them would loop forever.)
|
||||
if (S.me) { S.me = null; boot(); }
|
||||
throw new Error('Please sign in');
|
||||
}
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
}
|
||||
// run an action; show errors as a toast instead of throwing into the void
|
||||
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } };
|
||||
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
|
||||
const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Date.now() - new Date(d)) / 1000; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
|
||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
|
||||
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
|
||||
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
|
||||
const cmdBox = t => h('pre', {class:'mono'}, t);
|
||||
function modal(title, body, opts = {}) {
|
||||
const shade = h('div', {class:'shade'});
|
||||
const close = () => shade.remove();
|
||||
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
|
||||
shade.append(h('div', {class:'modal'}, h('h2', {}, title), body));
|
||||
document.body.append(shade);
|
||||
return close;
|
||||
}
|
||||
const modalOpen = () => !!document.querySelector('.shade');
|
||||
|
||||
// ---------- icons (lucide-style, static strings) ----------
|
||||
const ICONS = {
|
||||
fin:'<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
||||
mine:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
ports:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
sites:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
|
||||
domains:'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
access:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
logs:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8"/>',
|
||||
edges:'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
|
||||
orgs:'<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M12 6h.01M16 6h.01M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01"/>',
|
||||
users:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
idps:'<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
settings:'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
audit:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
account:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
egress:'<path d="M7 17 17 7M8 7h9v9"/>',
|
||||
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
||||
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
||||
};
|
||||
function icon(name, size = 18) {
|
||||
const s = document.createElement('span');
|
||||
s.style.display = 'inline-flex';
|
||||
s.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
||||
return s;
|
||||
}
|
||||
const brand = () => h('div', {class:'brand'}, icon('fin', 24), 'Hammerhead');
|
||||
|
||||
// ---------- state ----------
|
||||
const S = { me: null, org: localStorage.getItem('org'), tab: 'ports', st: null, pub: null };
|
||||
const isAdmin = () => S.me && (S.me.serverAdmin || (S.me.orgs.find(o => o.id === S.org) || {}).admin);
|
||||
|
||||
async function boot() {
|
||||
try { S.pub = await api('GET', '/api/public'); } catch (e) { $app.replaceChildren(card('Cannot reach the server', e.message)); return; }
|
||||
const inv = (location.hash.match(/^#invite=(.+)$/) || [])[1];
|
||||
if (S.pub.needsSetup) return $app.replaceChildren(setupView());
|
||||
try { S.me = await api('GET', '/api/me'); } catch { S.me = null; }
|
||||
if (inv) return $app.replaceChildren(await inviteView(inv));
|
||||
if (!S.me) return $app.replaceChildren(loginView());
|
||||
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
|
||||
if (S.org) localStorage.setItem('org', S.org);
|
||||
await render();
|
||||
}
|
||||
|
||||
// ---------- first run, login, invites ----------
|
||||
function setupView() {
|
||||
const f = { org: h('input', {value:'Default'}), name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
|
||||
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
|
||||
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password (8+ characters)', f.pw),
|
||||
h('button', {class:'btn', onclick: act(async () => {
|
||||
await api('POST', '/api/setup', {orgName:f.org.value, name:f.name.value, email:f.email.value, password:f.pw.value});
|
||||
await api('POST', '/api/login', {email:f.email.value, password:f.pw.value}); boot();
|
||||
})}, 'Create account'))));
|
||||
}
|
||||
|
||||
function loginView() {
|
||||
const email = h('input', {type:'email', autocomplete:'username'}), pw = h('input', {type:'password', autocomplete:'current-password'});
|
||||
const code = h('input', {placeholder:'6-digit or backup code', autocomplete:'one-time-code'}); let ticket = null;
|
||||
const codeBox = h('div', {class:'stack', style:'display:none'}, field('Authenticator code', code));
|
||||
const go = act(async () => {
|
||||
const j = ticket ? await api('POST', '/api/login/totp', {ticket, code:code.value}) : await api('POST', '/api/login', {email:email.value, password:pw.value});
|
||||
if (j.needTotp) { ticket = j.ticket; codeBox.style.display = ''; pw.parentElement.style.display = email.parentElement.style.display = 'none'; code.focus(); return; }
|
||||
boot();
|
||||
});
|
||||
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
||||
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Sign in'), h('div', {class:'stack'},
|
||||
field('Email', email), field('Password', pw), codeBox, h('button', {class:'btn', onclick:go}, 'Sign in'),
|
||||
(S.pub.providers || []).map(p => h('a', {href:'/auth/oidc/' + p.id + '/start?return=/'}, h('button', {class:'ghost', style:'width:100%;padding:8px'}, 'Continue with ' + p.name))))));
|
||||
}
|
||||
|
||||
async function inviteView(tok) {
|
||||
let info;
|
||||
try { info = await api('GET', '/api/invites/' + tok); } catch (e) { return h('div', {class:'center'}, card('Invitation', e.message)); }
|
||||
const name = h('input'), pw = h('input', {type:'password'});
|
||||
const accept = act(async () => { await api('POST', '/api/invites/accept', {token:tok, name:name.value, password:pw.value}); location.hash = ''; boot(); });
|
||||
if (info.existing) return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, `${info.email} already has an account.`),
|
||||
S.me && S.me.email === info.email ? h('button', {class:'btn', onclick:accept}, 'Join ' + info.org) : h('p', {}, 'Sign in as that account first, then open this link again.'),
|
||||
S.me ? null : h('button', {class:'ghost', onclick:() => { location.hash = ''; boot(); }}, 'Go to sign in')));
|
||||
return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, 'Create your account for ' + info.email + '.'),
|
||||
h('div', {class:'stack'}, field('Your name', name), field('Password (8+ characters)', pw), h('button', {class:'btn', onclick:accept}, 'Create account'))));
|
||||
}
|
||||
|
||||
// ---------- shell ----------
|
||||
async function loadOrg() { S.st = S.org ? await api('GET', `/api/orgs/${S.org}/state`) : null; }
|
||||
const O = p => `/api/orgs/${S.org}${p}`;
|
||||
|
||||
const PAGES = {
|
||||
mine: ['My apps', 'Apps you have been given access to.'],
|
||||
ports: ['Resources', 'Open ports on your edge IPs and send them to your services, with an optional login in front.'],
|
||||
sites: ['Sites', 'Private networks (or the edge machine itself) that hold your services.'],
|
||||
domains: ['Domains', 'Domains you own. Web hostnames must live under one of them.'],
|
||||
access: ['Access control', 'Members, roles, invitations and API keys for this organization.'],
|
||||
egress: ['Mail egress', 'Send your mail server\'s outgoing mail from an edge\'s public IP, so reverse DNS, SPF and your HELO name all line up.'],
|
||||
alerts: ['Alerts', 'Get told by email or webhook when something stops working, and when it recovers.'],
|
||||
srvalerts:['Server alerts', 'Where to send alerts about edges, which belong to the whole server.'],
|
||||
logs: ['Logs', 'Who reached what, and what changed.'],
|
||||
account: ['Account', 'Your profile, password and two-factor authentication.'],
|
||||
edges: ['Edges', 'Public servers running Blacktip that own your IP addresses.'],
|
||||
srvorgs: ['Organizations', 'Separate workspaces on this server.'],
|
||||
users: ['Users', 'Everyone with an account on this server.'],
|
||||
idps: ['Single sign-on', 'Sign in with an external OpenID Connect provider.'],
|
||||
settings:['Settings', 'Dashboard URL, certificates and email.'],
|
||||
srvaudit:['Audit log', 'Server-level activity.'],
|
||||
};
|
||||
// server pages are top-level sidebar entries that map onto serverViews
|
||||
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
|
||||
|
||||
async function render() {
|
||||
const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label);
|
||||
const side = [h('div', {}, brand())];
|
||||
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
|
||||
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
|
||||
const all = [];
|
||||
if (S.org) {
|
||||
side.push(h('div', {class:'navsec'}, 'Organization'), nav('mine', 'My apps', 'mine'));
|
||||
all.push('mine');
|
||||
if (isAdmin()) {
|
||||
side.push(nav('ports', 'Resources', 'ports'), nav('sites', 'Sites', 'sites'), nav('domains', 'Domains', 'domains'), nav('egress', 'Mail egress', 'egress'), nav('alerts', 'Alerts', 'alerts'), nav('access', 'Access control', 'access'), nav('logs', 'Logs', 'logs'));
|
||||
all.push('ports', 'sites', 'domains', 'egress', 'alerts', 'access', 'logs');
|
||||
}
|
||||
}
|
||||
if (S.me.serverAdmin) {
|
||||
side.push(h('div', {class:'navsec'}, 'Server'), nav('edges', 'Edges', 'edges'), nav('srvorgs', 'Organizations', 'orgs'), nav('users', 'Users', 'users'), nav('idps', 'Single sign-on', 'idps'), nav('settings', 'Settings', 'settings'), nav('srvalerts', 'Alerts', 'alerts'), nav('srvaudit', 'Audit log', 'audit'));
|
||||
all.push(...Object.keys(SRV));
|
||||
}
|
||||
all.push('account');
|
||||
if (!all.includes(S.tab)) S.tab = all[0];
|
||||
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
||||
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
||||
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function pageHeader(tab) {
|
||||
const [t, d] = PAGES[tab] || ['', ''];
|
||||
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null));
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function refresh() {
|
||||
const my = ++seq;
|
||||
try {
|
||||
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
|
||||
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
|
||||
if (my !== seq) return;
|
||||
const v = document.getElementById('view');
|
||||
if (v) v.replaceChildren(pageHeader(S.tab), view);
|
||||
} catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
|
||||
}
|
||||
setInterval(() => {
|
||||
if (!S.me || document.hidden || modalOpen()) return;
|
||||
const a = document.activeElement;
|
||||
if (a && ['INPUT', 'SELECT', 'TEXTAREA'].includes(a.tagName)) return;
|
||||
if (['ports', 'sites', 'logs', 'mine', 'alerts', 'egress'].includes(S.tab) && !(S.tab === 'logs' && logPaged)) refresh();
|
||||
}, 4000);
|
||||
|
||||
const dashURL = () => (S.st && S.st.dashboardUrl) || location.origin;
|
||||
|
||||
// ---------- views ----------
|
||||
const views = {};
|
||||
|
||||
views.mine = async () => {
|
||||
const list = await api('GET', '/api/me/resources');
|
||||
return card('Apps you can open', list.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Organization', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, list.map(r => h('tr', {}, h('td', {}, r.name), h('td', {class:'dim'}, r.org), h('td', {}, h('a', {href:r.url, target:'_blank', rel:'noopener'}, h('button', {class:'ghost'}, 'Open'))))))) :
|
||||
h('div', {class:'empty'}, 'Nothing here yet. Ask an administrator to give you access to an app.'));
|
||||
};
|
||||
|
||||
// ----- ports -----
|
||||
const PROTOS = [['tcp','TCP'],['udp','UDP'],['both','TCP + UDP'],['http','HTTP (by hostname)'],['https','HTTPS (automatic certificate)'],['tls','TLS passthrough (by SNI)']];
|
||||
|
||||
views.ports = async () => {
|
||||
const st = S.st;
|
||||
if (!st.edges.length) return card('No edges yet', h('p', {class:'dim'}, S.me.serverAdmin ? 'Add an edge (Blacktip) on the Server tab first: it is the public server that owns your IP addresses.' : 'Ask a server administrator to add an edge for your organization.'));
|
||||
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
|
||||
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
|
||||
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
|
||||
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
||||
h('div', {class:'card flush'}, table));
|
||||
};
|
||||
|
||||
function spark(series) {
|
||||
if (!series || !series.some(v => v > 0)) return null;
|
||||
const w = 84, hgt = 22, max = Math.max(...series, 1), ns = 'http://www.w3.org/2000/svg';
|
||||
const pts = series.map((v, i) => `${(i / (series.length - 1) * w).toFixed(1)},${(hgt - 2 - v / max * (hgt - 4)).toFixed(1)}`).join(' ');
|
||||
const svg = document.createElementNS(ns, 'svg'), pl = document.createElementNS(ns, 'polyline');
|
||||
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`);
|
||||
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'var(--accent)'); pl.setAttribute('stroke-width', '1.5');
|
||||
svg.append(pl);
|
||||
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
|
||||
}
|
||||
|
||||
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
|
||||
|
||||
function mappingRow(m, st, series) {
|
||||
const edge = byId(st.edges, m.edgeId), s = m.status || {};
|
||||
let dot = 'dot', why = 'paused';
|
||||
if (m.enabled) {
|
||||
const th = s.targets || [];
|
||||
if (!m.statusKnown) { dot += ' warn'; why = 'waiting for the edge'; }
|
||||
else if (s.error) { dot += ' bad'; why = s.error; }
|
||||
else if (th.length && th.every(t => !t.healthy)) { dot += ' bad'; why = 'no healthy target'; }
|
||||
else if (th.some(t => !t.healthy)) { dot += ' warn'; why = 'some targets are down'; }
|
||||
else { dot += ' ok'; why = 'listening'; }
|
||||
}
|
||||
const web = m.proto === 'http' || m.proto === 'https';
|
||||
const prot = m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email);
|
||||
const pubAddr = (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, h('span', {class:dot})),
|
||||
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {class:'tag', style:'margin-left:4px', title:'login required'}, '🔒') : null),
|
||||
s.error ? h('div', {class:'err small mono'}, s.error) : null,
|
||||
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, '🔐 ' + s.cert) : null),
|
||||
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, edge ? edge.name : '?')),
|
||||
h('td', {class:'mono small'}, m.targets.map((t, i) => {
|
||||
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
||||
return h('div', {title:th && !th.healthy ? th.detail : ''}, h('span', {class:'dot ' + (th ? (th.healthy ? 'ok' : 'bad') : '')}), (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
|
||||
})),
|
||||
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `↑${bytes(m.total.bytesIn)} ↓${bytes(m.total.bytesOut)}`] : '—', spark(series)),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
|
||||
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
|
||||
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: act(async () => {
|
||||
if (confirm(`Sign everyone out of ${m.name}?`)) { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); }
|
||||
})}, 'Sign out visitors') : null,
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Close ${m.name}?`)) { await api('DELETE', O('/mappings/' + m.id)); refresh(); } })}, 'Close'))));
|
||||
}
|
||||
|
||||
function portForm(existing) {
|
||||
const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] };
|
||||
const a = m.auth || {};
|
||||
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)'))));
|
||||
const ipIn = h('input', {list:'ips', value:m.bindIp}), ipList = h('datalist', {id:'ips'});
|
||||
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
|
||||
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||
const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first');
|
||||
const proxySel = h('select', {}, [['', 'Off'], ['v2', 'PROXY protocol v2 (recommended)'], ['v1', 'PROXY protocol v1']].map(([v, t]) => h('option', {value:v, selected:v === (m.proxyProtocol || '')}, t)));
|
||||
const proxyBox = field('Tell the target who the real client is', proxySel, 'Without this the target sees every visitor as coming from the tunnel. Turn it on only if your server expects it: Velocity/BungeeCord haproxy-protocol, Postfix postscreen_upstream_proxy_protocol = haproxy, Dovecot haproxy_trusted_networks, nginx proxy_protocol.');
|
||||
const tgtBox = h('div', {class:'stack'});
|
||||
let targets = m.targets.map(t => ({...t}));
|
||||
const roleChecks = st.roles.map(r => ({r, c:chk(r.name + (r.admin ? ' (admin)' : ''), (a.allowedRoles || []).includes(r.id))}));
|
||||
const pwIn = h('input', {type:'password', placeholder:existing && a.password ? 'set: leave blank to keep' : 'shared password', autocomplete:'new-password'});
|
||||
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4-12 digits', inputmode:'numeric', autocomplete:'off'});
|
||||
const ssoC = chk('Sign in with a Hammerhead account', a.sso), pwC = chk('Shared password', a.password), pinC = chk('PIN', a.pin), mailC = chk('Emailed one-time code', a.email);
|
||||
const mails = h('input', {value:(a.allowedMails || []).join(', '), placeholder:'a@x.com, *@company.com'});
|
||||
const hours = h('input', {type:'number', min:1, max:2160, value:a.sessionHours || 24});
|
||||
const ruleBox = h('div', {class:'stack'}); let rules = (m.rules || []).map(r => ({...r}));
|
||||
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz (blank = TCP connect check)'});
|
||||
const healthSec = h('input', {type:'number', value:m.healthSeconds > 0 ? m.healthSeconds : 30, min:5});
|
||||
const healthOff = chk('Turn health checks off', m.healthSeconds === -1);
|
||||
const redir = chk('Also answer on port 80 with a redirect to HTTPS', m.redirectHttp), bhttps = chk('Talk HTTPS to the targets (certificate not verified)', m.backendHttps);
|
||||
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'blank = keep the visitor\'s Host'});
|
||||
const webBox = h('div', {class:'stack'});
|
||||
const hostLbl = hostBox;
|
||||
|
||||
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipList.replaceChildren(...['0.0.0.0', ...((e && e.ips) || [])].map(ip => h('option', {value:ip}))); };
|
||||
const siteOpts = () => st.sites.filter(s => !s.local || s.edgeId === edgeSel.value);
|
||||
const drawTargets = () => {
|
||||
const many = protoSel.value !== 'udp' && protoSel.value !== 'both';
|
||||
tgtBox.replaceChildren(...targets.map((t, i) => {
|
||||
const site = h('select', {onchange:e => { t.siteId = e.target.value; const s = byId(st.sites, t.siteId); if (s && s.local && !t.host) t.host = '127.0.0.1'; }},
|
||||
h('option', {value:'', disabled:true, selected:!t.siteId}, 'Choose a site…'), siteOpts().map(s => h('option', {value:s.id, selected:s.id === t.siteId}, s.name)));
|
||||
const th = h('input', {value:t.host, oninput:e => t.host = e.target.value, placeholder:'127.0.0.1 or 192.168.1.20'});
|
||||
const tp = h('input', {type:'number', value:t.port, placeholder:'port', oninput:e => t.port = +e.target.value});
|
||||
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
|
||||
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, '🐳'),
|
||||
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', onclick:() => { targets.splice(i, 1); drawTargets(); }}, '✕') : h('span'));
|
||||
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, '+ Add another target (load balance / failover)') : null);
|
||||
};
|
||||
const discover = act(async (t, th, tp) => {
|
||||
if (!t.siteId) throw new Error('Choose a site first');
|
||||
const list = await api('GET', O(`/sites/${t.siteId}/containers`));
|
||||
if (!list.length) throw new Error('No containers reported. Run the agent with --docker (and make sure Docker is running).');
|
||||
const opts = [];
|
||||
for (const c of list) for (const p of c.ports) {
|
||||
if (p.public) opts.push({label:`${c.name} 127.0.0.1:${p.public} (published)`, host:'127.0.0.1', port:p.public});
|
||||
for (const [n, ip] of Object.entries(c.networks)) opts.push({label:`${c.name} ${ip}:${p.private} (${n} network)`, host:ip, port:p.private});
|
||||
}
|
||||
const sel = h('select', {size:Math.min(12, opts.length + 1)}, opts.map((o, i) => h('option', {value:i}, o.label)));
|
||||
const close = modal('Pick a container', h('div', {class:'stack'}, sel, h('button', {class:'btn', onclick:() => { const o = opts[sel.value]; if (!o) return; t.host = th.value = o.host; t.port = o.port; tp.value = o.port; close(); }}, 'Use this')));
|
||||
});
|
||||
const drawRules = () => ruleBox.replaceChildren(...rules.map((r, i) => h('div', {class:'grid', style:'grid-template-columns:110px 110px 1fr auto'},
|
||||
h('select', {onchange:e => r.action = e.target.value}, ['allow', 'deny'].map(v => h('option', {value:v, selected:r.action === v}, v === 'allow' ? 'Skip login' : 'Block'))),
|
||||
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
|
||||
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
|
||||
h('button', {class:'ghost danger', type:'button', onclick:() => { rules.splice(i, 1); drawRules(); }}, '✕'))),
|
||||
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, '+ Add a rule'));
|
||||
const drawWeb = () => {
|
||||
const p = protoSel.value, web = p === 'http' || p === 'https';
|
||||
hostLbl.style.display = ['http', 'https', 'tls'].includes(p) ? '' : 'none';
|
||||
webBox.style.display = web ? '' : 'none';
|
||||
redir.style.display = p === 'https' ? '' : 'none';
|
||||
proxyBox.style.display = ['tcp', 'both', 'tls'].includes(p) ? '' : 'none';
|
||||
drawTargets();
|
||||
};
|
||||
webBox.append(
|
||||
h('fieldset', {}, h('legend', {}, 'Who can open this?'), h('div', {class:'stack'},
|
||||
h('div', {class:'sub'}, 'Leave everything off for a public site. Turn on one or more methods to put a login in front.'),
|
||||
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles (none ticked = every member of the organization):'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
|
||||
pwC, field('Password', pwIn), pinC, field('PIN', pinIn), mailC, field('Allowed email addresses', mails, 'Needs email set up under Server → Settings'),
|
||||
field('Stay signed in for (hours)', hours))),
|
||||
h('fieldset', {}, h('legend', {}, 'Rules (checked before login, first match wins)'), ruleBox),
|
||||
h('fieldset', {}, h('legend', {}, 'Advanced'), h('div', {class:'stack'}, redir, bhttps, field('Host header sent to targets', rewrite))));
|
||||
const adv = h('fieldset', {}, h('legend', {}, 'Health checks'), h('div', {class:'grid'}, field('Check path (web ports)', healthPath), field('Every (seconds)', healthSec), healthOff));
|
||||
|
||||
edgeSel.onchange = () => { refreshIPs(); drawTargets(); }; protoSel.onchange = drawWeb; refreshIPs(); drawRules(); drawWeb();
|
||||
const save = act(async () => {
|
||||
const pr = (port.value || '').trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
|
||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
||||
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||
const base = {
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value.trim() || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
||||
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
|
||||
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||
redirectHttp:redir.box.checked, backendHttps:bhttps.box.checked, rewriteHost:rewrite.value, healthPath:healthPath.value,
|
||||
healthSeconds:healthOff.box.checked ? -1 : (+healthSec.value || 0),
|
||||
};
|
||||
if (pwIn.value) base.newPassword = pwIn.value; if (pinIn.value) base.newPin = pinIn.value;
|
||||
if (!pwC.box.checked) base.newPassword = ''; if (!pinC.box.checked) base.newPin = '';
|
||||
const protos = protoSel.value === 'both' ? ['tcp', 'udp'] : [protoSel.value];
|
||||
if (existing) await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, proxyProtocol:['tcp', 'tls'].includes(m.proto) ? proxySel.value : ''});
|
||||
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
|
||||
close(); toast('Saved'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit port' : 'Open a port', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
|
||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
async function shareDialog(m) {
|
||||
const body = h('div', {class:'stack'});
|
||||
const draw = async () => {
|
||||
const links = await api('GET', O(`/mappings/${m.id}/shares`));
|
||||
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'hours (blank = no expiry)'}), uses = h('input', {type:'number', placeholder:'max uses (blank = unlimited)'});
|
||||
const out = h('div');
|
||||
body.replaceChildren(
|
||||
h('p', {class:'dim'}, 'A share link lets someone in without an account or password. Anyone with the link gets access, so keep it private.'),
|
||||
h('div', {class:'grid'}, field('Label', name), field('Expires after', hrs), field('Max uses', uses), h('button', {class:'btn', onclick: act(async () => {
|
||||
const r = await api('POST', O(`/mappings/${m.id}/shares`), {name:name.value, hours:+hrs.value || 0, maxUses:+uses.value || 0});
|
||||
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
|
||||
)}, 'Create link')), out,
|
||||
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || '—'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
|
||||
};
|
||||
await draw(); modal('Share links for ' + m.name, body);
|
||||
}
|
||||
|
||||
// ----- sites -----
|
||||
views.sites = async () => {
|
||||
const st = S.st, name = h('input', {placeholder:'e.g. Home lab'});
|
||||
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
|
||||
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
|
||||
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
|
||||
return h('div', {},
|
||||
card('Add a site', h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'),
|
||||
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
|
||||
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
||||
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
|
||||
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
|
||||
h('td', {}, s.local ? '—' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
||||
const l = await api('GET', O(`/sites/${s.id}/containers`));
|
||||
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
|
||||
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
|
||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${s.name} and every port that uses only it?`)) { await api('DELETE', O('/sites/' + s.id)); refresh(); } })}, 'Delete')))))))));
|
||||
};
|
||||
|
||||
// ----- domains -----
|
||||
views.domains = async () => {
|
||||
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
|
||||
return h('div', {},
|
||||
card('Add a domain', h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
|
||||
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
|
||||
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
|
||||
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `✓ Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/domains/' + d.id)); refresh(); })}, 'Remove'))))))) : h('div', {class:'empty'}, 'None yet.')));
|
||||
};
|
||||
|
||||
|
||||
// ----- mail egress -----
|
||||
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
||||
|
||||
function checklist(e) {
|
||||
const c = e.check, ip = e.sourceIp, helo = e.heloName;
|
||||
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||
h('span', {class:'dot ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), style:'margin-top:6px'}),
|
||||
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
|
||||
const body = h('div', {},
|
||||
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
|
||||
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
|
||||
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
|
||||
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
|
||||
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
|
||||
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
|
||||
c.port25 === 'open' ? null : c.port25) : null,
|
||||
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
|
||||
cmdBox(spfFor(ip)),
|
||||
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
|
||||
return body;
|
||||
}
|
||||
|
||||
views.egress = async () => {
|
||||
const st = S.st;
|
||||
const remote = st.sites.filter(s => !s.local);
|
||||
const rows = st.mailEgress || [];
|
||||
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['', 'Name', 'Relay (on the site\'s machine)', 'Mail leaves from', 'Deliverability', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, rows.map(e => {
|
||||
const site = byId(st.sites, e.siteId), edge = byId(st.edges, e.edgeId), c = e.check;
|
||||
let dot = 'dot', why = 'paused';
|
||||
if (e.enabled) {
|
||||
if (!e.statusKnown) { dot += ' warn'; why = 'waiting for the site'; }
|
||||
else if (e.status) { dot += ' bad'; why = e.status; }
|
||||
else { dot += ' ok'; why = 'listening'; }
|
||||
}
|
||||
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, h('span', {class:dot})),
|
||||
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
||||
e.username ? h('span', {class:'tag', style:'margin-left:6px'}, '🔒') : null, e.requireTls ? h('span', {class:'tag', style:'margin-left:4px'}, 'tls required') : null),
|
||||
h('td', {class:'mono'}, e.listen, h('div', {class:'dim small'}, site ? site.name : '?')),
|
||||
h('td', {class:'mono'}, e.sourceIp, h('div', {class:'dim small'}, e.heloName + (e.smarthost ? ' → ' + e.smarthost : ' → recipient MX'))),
|
||||
h('td', {}, h('button', {class:'ghost', onclick:() => modal('Deliverability for ' + e.sourceIp, checklist(e))},
|
||||
!c ? '…' : good ? '✓ looks good' : '⚠ needs attention')),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => mailForm(e)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
|
||||
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
|
||||
return h('div', {},
|
||||
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')),
|
||||
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
||||
h('div', {class:'card flush'}, table),
|
||||
await dkimCard(),
|
||||
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
|
||||
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
|
||||
};
|
||||
|
||||
|
||||
async function dkimCard() {
|
||||
const keys = await api('GET', O('/dkim'));
|
||||
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'});
|
||||
const chip = k => ({published:['ok', '✓ published'], missing:['warn', 'not in DNS yet'], mismatch:['bad', 'DNS has a different key'], unknown:['', 'waiting for a check']}[k.status] || ['', k.status]);
|
||||
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
|
||||
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
|
||||
return card('DKIM signing', h('p', {class:'dim'}, 'The edge signs your outgoing mail so receiving servers can prove it came from you. Make a key for each domain you send from, then publish its record. If your mail server already signs, it is left alone.'),
|
||||
keys.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Selector', 'DNS', ''].map(x => h('th', {}, x)))), h('tbody', {}, keys.map(k => h('tr', {},
|
||||
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel),
|
||||
h('button', {class:'btn', onclick: act(async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, 'Create key')));
|
||||
}
|
||||
|
||||
function mailForm(existing) {
|
||||
const st = S.st, e = existing || {enabled:true, listen:'', ratePerMinute:60, requireTls:false};
|
||||
const sites = st.sites.filter(s => !s.local);
|
||||
const siteSel = h('select', {}, sites.map(s => h('option', {value:s.id, selected:s.id === e.siteId}, s.name)));
|
||||
const edgeSel = h('select', {}, st.edges.map(x => h('option', {value:x.id, selected:x.id === e.edgeId}, x.name + (x.online ? '' : ' (offline)'))));
|
||||
const name = h('input', {value:e.name || '', placeholder:'e.g. Mail out'});
|
||||
const src = h('input', {list:'srcips', value:e.sourceIp || '', placeholder:'an IP address of the edge'});
|
||||
const ipList = h('datalist', {id:'srcips'});
|
||||
const helo = h('input', {value:e.heloName || '', placeholder:'mail.example.com'});
|
||||
const listen = h('input', {value:e.listen || '127.0.0.1:2525'});
|
||||
const smart = h('input', {value:e.smarthost || '', placeholder:'blank = deliver to each recipient\'s own server'});
|
||||
const user = h('input', {value:e.username || '', autocomplete:'off', placeholder:'needed if you listen beyond 127.0.0.1'});
|
||||
const pass = h('input', {type:'password', autocomplete:'new-password', placeholder:e.passwordSet ? 'unchanged' : ''});
|
||||
const rate = h('input', {type:'number', min:1, max:6000, value:e.ratePerMinute || 60});
|
||||
const tls = chk('Require verified TLS (refuse to send to servers without it)', e.requireTls);
|
||||
const refreshIPs = () => { const x = byId(st.edges, edgeSel.value); ipList.replaceChildren(...((x && x.ips) || []).map(ip => h('option', {value:ip}))); };
|
||||
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', O('/mail-egress' + (existing ? '/' + existing.id : '')), {name:name.value, siteId:siteSel.value, edgeId:edgeSel.value, sourceIp:src.value,
|
||||
heloName:helo.value, listen:listen.value, smarthost:smart.value, requireTls:tls.box.checked, ratePerMinute:+rate.value || 0, username:user.value, password:pass.value, enabled:e.enabled !== false});
|
||||
close(); toast('Saved. Check the deliverability column in a moment.'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit mail egress' : 'Add mail egress', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Site (your mail server\'s network)', siteSel), field('Edge (where mail leaves)', edgeSel)),
|
||||
h('div', {class:'grid'}, field('Send from this edge IP', src, 'Its reverse DNS should be your HELO name'), field('HELO hostname', helo, 'What your server announces, e.g. mail.example.com')),
|
||||
ipList,
|
||||
h('div', {class:'grid'}, field('Relay listens on (on the site)', listen, '127.0.0.1 keeps it private to that machine'), field('Messages per minute (limit)', rate, 'Caps what a compromised server could send from your IP')),
|
||||
h('div', {class:'grid'}, field('Username', user), field('Password', pass)),
|
||||
field('Smarthost (optional)', smart, 'Use this if you relay through a provider that allows your edge IP. Leave blank for direct delivery.'), tls,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
|
||||
// ----- alerts -----
|
||||
const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], ['ntfy', 'ntfy']];
|
||||
|
||||
function channelForm(base, existing, trusted) {
|
||||
const c = existing || {kind:'email', format:'json', enabled:true};
|
||||
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
|
||||
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
|
||||
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
|
||||
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
|
||||
const en = chk('Enabled', c.enabled !== false);
|
||||
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
|
||||
const targetBox = field('Where', target);
|
||||
const redraw = () => {
|
||||
const w = kind.value === 'webhook';
|
||||
fmtBox.style.display = w ? '' : 'none';
|
||||
priv.style.display = w && trusted ? '' : 'none';
|
||||
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
};
|
||||
kind.onchange = redraw; redraw();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
close(); toast('Saved'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
|
||||
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, 'Save'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
|
||||
}
|
||||
|
||||
async function alertsView(base, scoped, trusted) {
|
||||
const d = await api('GET', base);
|
||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
||||
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
|
||||
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
|
||||
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
||||
return h('div', {},
|
||||
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
||||
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
||||
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
|
||||
card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')),
|
||||
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
|
||||
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
||||
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Saved'); })}, 'Save'))) : null,
|
||||
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, new Date(e.time).toLocaleString()),
|
||||
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : h('div', {class:'empty'}, 'Nothing has fired yet.')));
|
||||
}
|
||||
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
|
||||
|
||||
// ----- access -----
|
||||
views.access = async () => {
|
||||
const [members, invites, keys] = await Promise.all([api('GET', O('/members')), api('GET', O('/invites')), api('GET', O('/apikeys'))]);
|
||||
const roles = S.st.roles;
|
||||
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
|
||||
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
|
||||
const kname = h('input', {placeholder:'e.g. CI deploy'});
|
||||
return h('div', {},
|
||||
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
|
||||
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
|
||||
h('td', {}, mm.totp ? '✓' : '—'),
|
||||
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
|
||||
card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(async () => {
|
||||
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
|
||||
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })}, 'Create invitation')),
|
||||
invites.length ? h('table', {style:'margin-top:12px'}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null),
|
||||
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
|
||||
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
|
||||
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
|
||||
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
|
||||
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
|
||||
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'btn', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
|
||||
};
|
||||
|
||||
// ----- logs -----
|
||||
let logKind = 'access', logMap = '', logQ = '', logStatus = '', logResult = '', logPaged = false;
|
||||
views.logs = async () => {
|
||||
const st = S.st;
|
||||
const params = extra => new URLSearchParams({kind:logKind, limit:200, mapping:logMap, q:logQ, status:logStatus, result:logResult, ...extra});
|
||||
const rows = await api('GET', O('/logs?' + params()));
|
||||
const set = fn => e => { fn(e.target.value); logPaged = false; refresh(); };
|
||||
const named = [...st.mappings, ...(st.mailEgress || [])];
|
||||
const mapSel = h('select', {style:'width:auto', onchange:set(v => logMap = v)}, h('option', {value:''}, 'All ports'), named.map(m => h('option', {value:m.id, selected:m.id === logMap}, m.name)));
|
||||
const statusSel = h('select', {style:'width:auto', onchange:set(v => logStatus = v)}, [['', 'Any status'], ['2', '2xx'], ['3', '3xx'], ['4', '4xx'], ['5', '5xx']].map(([v, t]) => h('option', {value:v, selected:v === logStatus}, t)));
|
||||
const resultSel = h('select', {style:'width:auto', onchange:set(v => logResult = v)}, [['', 'Any result'], ['denied', 'Denied'], ['auth-required', 'Login required'], ['login', 'Logins'], ['mail', 'Mail sent'], ['mail-failed', 'Mail failed'], ['mail-refused', 'Mail refused'], ['no-target', 'No target']].map(([v, t]) => h('option', {value:v, selected:v === logResult}, t)));
|
||||
const search = h('input', {placeholder:'Search host, path, address, user…', value:logQ, style:'width:260px'});
|
||||
search.addEventListener('keydown', e => { if (e.key === 'Enter') { logQ = search.value.trim(); logPaged = false; refresh(); } });
|
||||
const t = d => new Date(d).toLocaleString();
|
||||
const tbody = h('tbody');
|
||||
const addRows = list => list.forEach(r => {
|
||||
if (logKind === 'audit') { tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))); return; }
|
||||
const m = byId(st.mappings, r.mapping) || byId(st.mailEgress, r.mapping);
|
||||
tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, m ? m.name : r.mapping), h('td', {class:'mono small'}, r.remote), h('td', {class:'mono small'}, r.method ? `${r.method} ${r.host}${r.path}` : (r.host || '(connection)')),
|
||||
h('td', {}, r.status || ''), h('td', {}, r.user || ''), h('td', {class:r.result && !['allowed', 'connection', 'bypass', 'login', 'logout', 'mail'].includes(r.result) ? 'err' : 'dim'}, r.result), h('td', {class:'small dim'}, `↑${bytes(r.bytesIn)} ↓${bytes(r.bytesOut)}`), h('td', {class:'dim'}, r.ms)));
|
||||
});
|
||||
addRows(rows);
|
||||
let last = rows.length ? rows[rows.length - 1].id : 0;
|
||||
const more = h('button', {class:'ghost', style:'margin:12px auto;display:block', onclick: act(async () => {
|
||||
const next = await api('GET', O('/logs?' + params({before:last})));
|
||||
if (!next.length) { more.remove(); toast('That is everything'); return; }
|
||||
logPaged = true; addRows(next); last = next[next.length - 1].id;
|
||||
})}, 'Load older entries');
|
||||
const head = logKind === 'audit' ? ['Time', 'Who', 'What', 'Detail'] : ['Time', 'Port', 'From', 'Request', 'Status', 'User', 'Result', 'Bytes', 'ms'];
|
||||
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
|
||||
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
|
||||
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
|
||||
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : h('div', {class:'empty'}, 'Nothing matches.')));
|
||||
};
|
||||
|
||||
// ----- server administration -----
|
||||
let serverTab = 'edges';
|
||||
views.server = async () => {
|
||||
return await serverViews[serverTab]();
|
||||
};
|
||||
const serverViews = {};
|
||||
serverViews.alerts = () => alertsView('/api/admin/alerts', false, true);
|
||||
|
||||
serverViews.edges = async () => {
|
||||
const [edges, me] = [await api('GET', '/api/edges'), S.me];
|
||||
const orgs = me.orgs, name = h('input', {placeholder:'e.g. VPS Frankfurt'}), addr = h('input', {placeholder:'203.0.113.10:51820'});
|
||||
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
|
||||
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
|
||||
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
|
||||
return h('div', {},
|
||||
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
|
||||
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), h('button', {class:'btn', onclick: act(async () => {
|
||||
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); showCmd(e); refresh(); })}, 'Add edge')),
|
||||
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
|
||||
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
||||
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete this edge and all its ports?')) { await api('DELETE', '/api/edges/' + e.id); refresh(); } })}, 'Delete'))))))) : h('div', {class:'empty'}, 'None yet.')));
|
||||
};
|
||||
|
||||
serverViews.orgs = async () => {
|
||||
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
|
||||
await boot0();
|
||||
return h('div', {},
|
||||
card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))),
|
||||
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
|
||||
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
|
||||
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
||||
const st = await api('GET', `/api/orgs/${o.id}/state`);
|
||||
await api('PUT', `/api/orgs/${o.id}`, {allowLocal:!st.org.allowLocal}); toast(`Local access ${st.org.allowLocal ? 'off' : 'on'} for ${o.name}`); })}, 'Toggle local access')),
|
||||
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Delete ${o.name} and everything in it?`)) { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); } })}, 'Delete'))))))));
|
||||
};
|
||||
async function boot0() { S.me = await api('GET', '/api/me'); }
|
||||
|
||||
serverViews.users = async () => {
|
||||
const users = await api('GET', '/api/admin/users');
|
||||
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
|
||||
return h('div', {},
|
||||
card('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'),
|
||||
h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))),
|
||||
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
|
||||
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
|
||||
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
|
||||
u.totp ? h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('2FA reset'); refresh(); })}, 'Reset 2FA') : null,
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast('Signed out everywhere'); })}, 'Sign out'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { const p = prompt('New password (8+ characters)'); if (p) { await api('PUT', '/api/admin/users/' + u.id, {password:p}); toast('Password changed'); } })}, 'Set password'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Delete ' + u.email + '?')) { await api('DELETE', '/api/admin/users/' + u.id); refresh(); } })}, 'Delete')))))))));
|
||||
};
|
||||
|
||||
serverViews.idps = async () => {
|
||||
const idps = await api('GET', '/api/admin/idps'), orgs = S.me.orgs;
|
||||
const form = (e = {}) => {
|
||||
const f = {name:h('input', {value:e.name || ''}), issuer:h('input', {value:e.issuer || '', placeholder:'https://accounts.google.com'}), cid:h('input', {value:e.clientId || ''}),
|
||||
sec:h('input', {type:'password', placeholder:e.id ? 'unchanged' : ''}), scopes:h('input', {value:e.scopes || 'openid email profile'}), dom:h('input', {value:(e.allowedDomains || []).join(', '), placeholder:'company.com (blank = any)'})};
|
||||
const auto = chk('Create accounts for new users automatically', e.autoProvision);
|
||||
const orgSel = h('select', {}, orgs.map(o => h('option', {value:o.id, selected:o.id === e.orgId}, o.name)));
|
||||
const close = modal(e.id ? 'Edit provider' : 'Add an OpenID Connect provider', h('div', {class:'stack'},
|
||||
h('p', {class:'dim small'}, 'Redirect URI to register at the provider: ' + (S.me.dashboardUrl || location.origin) + '/auth/oidc/callback'),
|
||||
field('Name shown on the button', f.name), field('Issuer URL', f.issuer), field('Client ID', f.cid), field('Client secret', f.sec), field('Scopes', f.scopes), field('Allowed email domains', f.dom),
|
||||
auto, field('New users join', orgSel, 'as a Member'),
|
||||
h('button', {class:'btn', onclick: act(async () => {
|
||||
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
|
||||
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
|
||||
};
|
||||
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
|
||||
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'),
|
||||
h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider'));
|
||||
};
|
||||
|
||||
serverViews.settings = async () => {
|
||||
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
|
||||
const f = {url:h('input', {value:s.dashboardUrl, placeholder:'https://hammerhead.example.com'}), acme:h('input', {type:'email', value:s.acmeEmail, placeholder:'you@example.com'}),
|
||||
host:h('input', {value:s.smtp.host}), port:h('input', {type:'number', value:s.smtp.port || ''}), user:h('input', {value:s.smtp.user}), pass:h('input', {type:'password', placeholder:smtpPassSet ? 'unchanged' : ''}), from:h('input', {value:s.smtp.from, placeholder:'Hammerhead <no-reply@example.com>'}),
|
||||
mode:h('select', {}, ['starttls', 'tls', 'none'].map(m => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, m)))};
|
||||
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
|
||||
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
|
||||
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
|
||||
return h('div', {},
|
||||
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
|
||||
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
|
||||
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
|
||||
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
|
||||
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
|
||||
h('button', {class:'btn', onclick:save}, 'Save settings'));
|
||||
};
|
||||
|
||||
serverViews.audit = async () => {
|
||||
const rows = await api('GET', '/api/admin/audit');
|
||||
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : h('div', {class:'empty'}, 'Nothing yet.'));
|
||||
};
|
||||
|
||||
// ----- account -----
|
||||
views.account = async () => {
|
||||
S.me = await api('GET', '/api/me');
|
||||
const name = h('input', {value:S.me.name}), cur = h('input', {type:'password', autocomplete:'current-password'}), nw = h('input', {type:'password', autocomplete:'new-password'});
|
||||
const twofa = h('div', {class:'stack'});
|
||||
const draw2fa = () => {
|
||||
if (S.me.totpEnabled) {
|
||||
const pw = h('input', {type:'password', placeholder:'Password'}), code = h('input', {placeholder:'Authenticator code'});
|
||||
twofa.replaceChildren(h('p', {}, '✓ Two-factor authentication is on. ' + S.me.backupCodes + ' backup codes left.'), h('div', {class:'grid'}, field('Password', pw), field('Code', code),
|
||||
h('button', {class:'ghost danger', onclick: act(async () => { await api('POST', '/api/me/2fa/disable', {password:pw.value, code:code.value}); S.me = await api('GET', '/api/me'); draw2fa(); })}, 'Turn off')));
|
||||
} else twofa.replaceChildren(h('p', {class:'dim'}, 'Add an authenticator app (Google Authenticator, 1Password, Aegis…) for a second step at sign-in.'),
|
||||
h('button', {class:'btn', style:'align-self:flex-start', onclick: act(async () => {
|
||||
const r = await api('POST', '/api/me/2fa/setup'), code = h('input', {placeholder:'6-digit code', inputmode:'numeric'});
|
||||
const close = modal('Set up two-factor authentication', h('div', {class:'stack'}, h('p', {class:'dim'}, 'In your authenticator app choose "enter a setup key" and paste this key (or open the link on your phone):'), cmdBox(r.secret), h('a', {href:r.uri}, 'Open in authenticator app'),
|
||||
field('Then enter the code it shows', code), h('button', {class:'btn', onclick: act(async () => {
|
||||
const e = await api('POST', '/api/me/2fa/enable', {code:code.value}); close(); S.me = await api('GET', '/api/me'); draw2fa();
|
||||
modal('Save your backup codes', h('div', {}, h('p', {class:'dim'}, 'Each code works once if you lose your device. They are not shown again.'), cmdBox(e.backupCodes.join('\n')))); })}, 'Turn on')), {sticky:true});
|
||||
})}, 'Set up two-factor'));
|
||||
};
|
||||
draw2fa();
|
||||
const sessions = await api('GET', '/api/me/sessions');
|
||||
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
|
||||
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? h('span', {class:'tag'}, 'this device') : null),
|
||||
h('td', {class:'small'}, (x.ua || 'unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
|
||||
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
|
||||
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'btn', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Saved'); })}, 'Save'))),
|
||||
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password (8+ characters)', nw),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed; other sessions were signed out'); })}, 'Change password'))),
|
||||
card('Two-factor authentication', twofa), devices);
|
||||
};
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in</title>
|
||||
<style>
|
||||
:root { --bg:#0e1621; --panel:#16212f; --line:#25344a; --text:#e6edf5; --dim:#8da2bb; --accent:#06b6d4; --bad:#ef5350; }
|
||||
@media (prefers-color-scheme: light) { :root { --bg:#f3f6fa; --panel:#fff; --line:#d5dde8; --text:#18222f; --dim:#5b6c80; --accent:#0891b2; } }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.45 system-ui,sans-serif; display:grid; place-items:center; min-height:100vh; padding:16px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; width:100%; max-width:380px; }
|
||||
h1 { font-size:18px; margin:0 0 4px; }
|
||||
.sub { color:var(--dim); margin:0 0 16px; font-size:13px; }
|
||||
.sec { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; display:flex; flex-direction:column; gap:8px; }
|
||||
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
||||
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--dim); margin:0; }
|
||||
input { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:9px 10px; font:inherit; width:100%; }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:-1px; }
|
||||
button, a.btn { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:center; text-decoration:none; display:block; }
|
||||
button.ghost, a.ghost { background:none; border:1px solid var(--line); color:var(--text); }
|
||||
#err { color:var(--bad); min-height:18px; font-size:13px; margin-top:10px; }
|
||||
.ok { color:var(--dim); font-size:13px; }
|
||||
[hidden] { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 id="title">Sign in</h1>
|
||||
<p class="sub" id="sub"></p>
|
||||
<div id="body"></div>
|
||||
<div id="err"></div>
|
||||
</div>
|
||||
<script>
|
||||
const id = location.pathname.split('/')[3];
|
||||
const params = new URLSearchParams(location.search);
|
||||
const ret = params.get('return') || '';
|
||||
const $ = s => document.querySelector(s);
|
||||
const body = $('#body');
|
||||
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (v === true) el.setAttribute(k, '');
|
||||
else if (v != null && v !== false) el.setAttribute(k, v);
|
||||
}
|
||||
for (const k of kids.flat()) if (k != null) el.append(k.nodeType ? k : document.createTextNode(k));
|
||||
return el;
|
||||
}
|
||||
const err = m => $('#err').textContent = m || '';
|
||||
|
||||
async function post(path, data) {
|
||||
err('');
|
||||
const r = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
}
|
||||
async function finish(path, data) {
|
||||
try {
|
||||
const j = await post(`/auth/r/${id}/${path}`, { ...data, return: ret });
|
||||
if (j.redirect) location.href = j.redirect;
|
||||
} catch (e) { err(e.message); }
|
||||
}
|
||||
|
||||
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
|
||||
|
||||
function passwordForm(kind, label, type) {
|
||||
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
|
||||
const go = () => finish(kind, { [kind]: i.value });
|
||||
i.addEventListener('keydown', e => e.key === 'Enter' && go());
|
||||
return section(label, i, h('button', {onclick:go}, 'Continue'));
|
||||
}
|
||||
|
||||
function emailForm() {
|
||||
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
||||
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
|
||||
const msg = h('div', {class:'ok'});
|
||||
const go = h('button', {}, 'Email me a code');
|
||||
go.onclick = async () => {
|
||||
try {
|
||||
if (code.hidden) {
|
||||
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
||||
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
|
||||
} else await finish('email/verify', { email: email.value, code: code.value });
|
||||
} catch (e) { err(e.message); }
|
||||
};
|
||||
return section('Email code', email, code, msg, go);
|
||||
}
|
||||
|
||||
function ssoForm(info) {
|
||||
const kids = [];
|
||||
if (info.user) {
|
||||
if (info.user.allowed) {
|
||||
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
||||
} else {
|
||||
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
|
||||
}
|
||||
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
|
||||
return section('Account', ...kids);
|
||||
}
|
||||
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
|
||||
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
|
||||
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
|
||||
let ticket = null;
|
||||
const go = async () => {
|
||||
try {
|
||||
let j;
|
||||
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
|
||||
else j = await post('/api/login', { email: email.value, password: pw.value });
|
||||
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
|
||||
location.reload();
|
||||
} catch (e) { err(e.message); }
|
||||
};
|
||||
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
||||
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
|
||||
for (const p of info.providers || []) {
|
||||
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
|
||||
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
|
||||
}
|
||||
return section('Account', ...kids);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// share links skip the form
|
||||
const share = params.get('share');
|
||||
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
|
||||
try {
|
||||
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
|
||||
const info = await r.json();
|
||||
if (!r.ok) throw new Error(info.error);
|
||||
$('#title').textContent = info.name;
|
||||
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
|
||||
if (info.sso) body.append(ssoForm(info));
|
||||
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
|
||||
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
|
||||
if (info.email) body.append(emailForm());
|
||||
if (!body.children.length) err('No sign-in method is available.');
|
||||
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
|
||||
}
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user