Web terminal: open a shell on a site from the dashboard
CI / test (push) Successful in 3m51s
Release / release (push) Successful in 6m28s

Hammerhead signs in to the site's Bamboo SSH server through the SSH port an edge
exposes, with a key of its own, and relays the shell to the browser over a
WebSocket (xterm.js, served by Hammerhead). The SSH dialog shows Hammerhead's
public key to add to authorized_keys. Host keys are saved on first use and a
changed one is refused; the terminal opens only from the dashboard's own
origin, is audited, and closes after 30 minutes idle. Errors reach the page as
messages over the socket. An end-to-end test runs a real shell through the
edge, tunnel and Bamboo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 01:02:28 -06:00
1 parent 1c1f2b1d44
commit 66483d8385
14 files changed
+1012 -5

No files matched your search

+68 -3
View File
@@ -9,6 +9,7 @@
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--terminal-bg:#0c0c0c; /* a terminal stays dark in both themes */
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
@@ -161,6 +162,8 @@
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.term { background:var(--terminal-bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2); height:min(60vh,460px); overflow:hidden; }
.term .xterm-viewport { background-color:var(--terminal-bg) !important; }
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
@@ -374,6 +377,12 @@ function modal(title, body, opts = {}) {
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return;
// Inside a terminal every key belongs to the shell (Escape for vim, Tab for completion), except
// Ctrl+Alt+Escape, which moves focus out so the dialog can still be left from the keyboard.
if (opts.keys && e.target.closest && e.target.closest(opts.keys)) {
if (e.key === 'Escape' && e.ctrlKey && e.altKey) { e.preventDefault(); e.stopPropagation(); box.focus(); }
return;
}
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
@@ -1052,12 +1061,61 @@ async function siteFound(s) {
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
}
// ----- terminal -----
// A shell on a site, in the dashboard. xterm.js is served by Hammerhead itself and loaded the first time it is needed.
let termLoad = null;
function loadTerm() {
if (!termLoad) {
const script = src => new Promise((ok, bad) => { const el = h('script', {src}); el.onload = ok; el.onerror = () => bad(new Error('Could not load the terminal.')); document.head.append(el); });
document.head.append(h('link', {rel:'stylesheet', href:'/static/term/xterm.css'}));
termLoad = script('/static/term/xterm.js').then(() => script('/static/term/addon-fit.js')).catch(e => { termLoad = null; throw e; });
}
return termLoad;
}
async function openTerminal(s) {
await loadTerm();
const box = h('div', {class:'term', role:'group', 'aria-label':'Terminal on ' + s.name});
const status = h('div', {class:'small dim', role:'status'}, 'Connecting…');
const forget = h('button', {class:'ghost', type:'button', style:'display:none', onclick: act(async () => { await api('DELETE', O(`/sites/${s.id}/terminal/hostkey`)); toast('Saved host key forgotten. Open the terminal again.'); })}, 'Forget saved host key');
const term = new Terminal({cursorBlink:true, fontFamily:'ui-monospace, Menlo, Consolas, monospace', fontSize:14, scrollback:5000,
theme:{background:getComputedStyle(document.documentElement).getPropertyValue('--terminal-bg').trim()}});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
let ws = null, ended = false, ro = null;
const close = modal('Terminal: ' + s.name, h('div', {class:'stack'}, box, status,
h('div', {class:'actions form-actions'}, forget, h('button', {class:'btn', onclick:() => close()}, 'Close'))),
{sticky:true, wide:true, keys:'.term', onClose:() => { ended = true; if (ro) ro.disconnect(); if (ws) ws.close(); term.dispose(); }});
term.open(box); fit.fit(); term.focus();
const say = (text, bad) => { status.textContent = text; status.className = 'small ' + (bad ? 'err' : 'dim'); };
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${O(`/sites/${s.id}/terminal`)}?cols=${term.cols}&rows=${term.rows}`);
ws.binaryType = 'arraybuffer';
ws.onmessage = ev => {
if (typeof ev.data !== 'string') { term.write(new Uint8Array(ev.data)); return; } // the shell's output
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.type === 'ready') say('Connected. Esc and Tab go to the shell. Press Ctrl+Alt+Esc to move focus out of the terminal.');
else if (m.type === 'error') { say(m.message, true); if (m.hostKeyChanged) forget.style.display = ''; }
else if (m.type === 'exit') say('The shell ended' + (m.code ? ` (exit code ${m.code})` : '') + '.');
else if (m.type === 'idle') say('Closed after 30 minutes without typing.', true);
};
ws.onclose = () => {
if (ended) return;
if (status.textContent === 'Connecting…') say('Could not open the terminal. Sign in again and retry.', true);
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
const enc = new TextEncoder();
term.onData(d => { if (ws.readyState === 1) ws.send(enc.encode(d)); });
term.onBinary(d => { if (ws.readyState === 1) ws.send(Uint8Array.from(d, c => c.charCodeAt(0))); });
ro = new ResizeObserver(() => { fit.fit(); if (ws.readyState === 1) ws.send(JSON.stringify({type:'resize', cols:term.cols, rows:term.rows})); });
ro.observe(box);
}
// Bamboo's built-in SSH server (started with -ssh) listens here on the site by default.
const SSH_TARGET = '127.0.0.1:2222';
// Reach a site's Bamboo SSH server with an ordinary ssh client: a TCP port on an edge whose target is that server.
function sshAccess(s) {
const st = S.st;
async function sshAccess(s) {
const st = S.st, key = await api('GET', O('/terminal'));
const [tHost, tPort] = [SSH_TARGET.split(':')[0], +SSH_TARGET.split(':')[1]];
const isSSH = m => m.proto === 'tcp' && !m.redirectTo && m.targets.some(t => t.siteId === s.id && (t.port === tPort || (m.tags || []).includes('ssh')));
let ports = st.mappings.filter(isSSH);
@@ -1069,8 +1127,11 @@ function sshAccess(s) {
};
const cmds = h('div', {class:'stack'});
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
const draw = () => {
connectBox.style.display = ports.length ? '' : 'none';
termBtn.disabled = !ports.length;
termBtn.title = ports.length ? '' : 'Open an SSH port first';
cmds.replaceChildren(...ports.map(m => {
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
@@ -1094,6 +1155,10 @@ function sshAccess(s) {
const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine from anywhere without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'),
connectBox,
h('fieldset', {}, h('legend', {}, 'Terminal in Hammerhead'), h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Open a shell here in the dashboard. Hammerhead signs in with a key of its own, so add this line to authorized_keys on the site first (Bamboo picks it up without a restart).'),
cmdBox(key.publicKey),
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
h('fieldset', {}, h('legend', {}, 'Open an SSH port'), h('div', {class:'stack'},
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
@@ -1128,7 +1193,7 @@ views.sites = async () => {
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(() => siteFound(s))}, 'View')),
h('td', {}, s.local ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => sshAccess(s)}, 'SSH'),
h('button', {class:'ghost', onclick: act(() => sshAccess(s))}, 'SSH'),
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));