|
|
|
@@ -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')))))))));
|
|
|
|
|