Web terminal: open a shell on a site from the dashboard
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:
1 parent
1c1f2b1d44
commit
66483d8385
14 files changed
+1012
-5
No files matched your search
+68
-3
@@ -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')))))))));
|
||||
|
||||
Reference in new issue
Block a user