SSH access from the Sites page; containers and discovery dialog; dialogs fit small screens
Each site has an SSH button: it opens a TCP port on an edge whose target is Bamboo's built-in SSH server, and shows the ssh command to use. An end-to-end test signs in through the edge, the tunnel and Bamboo's server. The Found by dialog now lists Docker containers and discovered services, wraps long names and groups TCP and UDP ports. The dialog overlay no longer lets a wide table push a dialog off a phone screen. Builds against Bamboo v0.1.8. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
92262441db
commit
1c1f2b1d44
4 files changed
+193
-9
No files matched your search
+77
-6
@@ -102,6 +102,8 @@
|
||||
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
|
||||
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
|
||||
.scroll { overflow-x:auto; }
|
||||
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:260px; }
|
||||
.ports { display:flex; flex-wrap:wrap; gap:var(--s1); }
|
||||
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
||||
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
|
||||
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
||||
@@ -179,7 +181,7 @@
|
||||
@keyframes shimmer { to { background-position:-200% 0; } }
|
||||
|
||||
/* dialogs, tabs, toasts */
|
||||
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
|
||||
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
|
||||
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
|
||||
.modal.wide { width:min(780px,100%); }
|
||||
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
|
||||
@@ -1028,6 +1030,77 @@ async function shareDialog(m) {
|
||||
}
|
||||
|
||||
// ----- sites -----
|
||||
// What an agent reports about its network: Docker containers (-docker) and listening services (-discover).
|
||||
async function siteFound(s) {
|
||||
const [list, found] = await Promise.all([api('GET', O(`/sites/${s.id}/containers`)), api('GET', O(`/sites/${s.id}/discovered`))]);
|
||||
// a published port is "8777→8777"; TCP and UDP on the same port share one entry
|
||||
const portChips = ports => {
|
||||
const by = new Map();
|
||||
for (const p of ports) { const k = (p.public ? p.public + '→' : '') + p.private; by.set(k, [...(by.get(k) || []), p.type]); }
|
||||
return h('div', {class:'ports'}, [...by].map(([k, types]) => h('span', {class:'tag mono'}, k + '/' + [...new Set(types)].join(','))));
|
||||
};
|
||||
const section = (title, count, body, empty) => h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, title + (count ? ` (${count})` : '')), count ? h('div', {class:'scroll'}, body) : h('p', {class:'dim', style:'margin:0'}, empty));
|
||||
const containers = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, list.map(c => h('tr', {}, h('td', {class:'wrap'}, c.name), h('td', {class:'dim wrap'}, c.image), h('td', {}, portChips(c.ports))))));
|
||||
const services = h('table', {}, h('thead', {}, h('tr', {}, ['Service', 'Address', 'Found', 'Detail'].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, found.map(d => h('tr', {}, h('td', {}, d.service || 'tcp'), h('td', {class:'mono small'}, d.host + ':' + d.port),
|
||||
h('td', {class:'dim'}, d.source === 'local' ? 'Listening here' : 'Network scan'), h('td', {class:'dim small wrap'}, d.detail || '')))));
|
||||
const close = modal('Found by ' + s.name, h('div', {class:'stack'},
|
||||
h('p', {class:'dim', style:'margin:0'}, 'These show up as suggestions when you pick a target for a resource. Nothing is shared until you choose it.'),
|
||||
section('Docker containers', list.length, containers, 'None reported. Start the agent with -docker (BAMBOO_DOCKER=1, or BLACKTIP_DOCKER=1 for an edge) on a machine where Docker is running.'),
|
||||
section('Services on the network', found.length, services, 'None found. Start the agent with -discover (BAMBOO_DISCOVER=1) for services listening on its machine, and add -discover-subnets (BAMBOO_DISCOVER_SUBNETS) to scan other addresses.'),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
|
||||
}
|
||||
|
||||
// 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;
|
||||
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);
|
||||
// what to type after the @: a domain pointed at the address when there is one, else the address itself
|
||||
const edgeHost = (e, m) => {
|
||||
if (m.bindIp && m.bindIp !== '0.0.0.0') { const d = st.domains.find(x => x.ip === m.bindIp); return d ? d.name : m.bindIp; }
|
||||
const d = st.domains.find(x => x.ip && (!x.edgeId || x.edgeId === m.edgeId));
|
||||
return d ? d.name : ((e && e.ips) || [])[0] || 'your-edge-address';
|
||||
};
|
||||
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 draw = () => {
|
||||
connectBox.style.display = ports.length ? '' : 'none';
|
||||
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),
|
||||
h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(cmd); toast('Copied'); })}, 'Copy'));
|
||||
}));
|
||||
};
|
||||
draw();
|
||||
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name + (e.online ? '' : ' (offline)'))));
|
||||
const ipSel = h('select', {});
|
||||
const refreshIPs = () => { const e = byId(st.edges, edgeSel.value); ipSel.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...((e && e.ips) || []).map(ip => h('option', {value:ip}, ip))); };
|
||||
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||
const port = h('input', {type:'number', min:1, max:65535, value:2222, placeholder:'2222'});
|
||||
const target = h('input', {value:SSH_TARGET});
|
||||
const create = act(async () => {
|
||||
const [h2, p2] = target.value.trim().split(':');
|
||||
if (!st.edges.length) throw new Error('Add an edge first');
|
||||
const m = await api('POST', O('/mappings'), {name:'SSH to ' + s.name, proto:'tcp', edgeId:edgeSel.value, bindIp:ipSel.value || '0.0.0.0', port:+port.value, tags:['ssh'], enabled:true,
|
||||
targets:[{siteId:s.id, host:h2 || tHost, port:+p2 || tPort}]});
|
||||
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
|
||||
});
|
||||
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', {}, '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'),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'button', onclick:create}, 'Open SSH port')))),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close'))), {wide:true});
|
||||
}
|
||||
|
||||
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(); });
|
||||
@@ -1047,17 +1120,15 @@ views.sites = async () => {
|
||||
};
|
||||
return h('div', {},
|
||||
tagBar('sites', tags),
|
||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Discovered', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
||||
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
||||
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||
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(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', {}, 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:() => 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