2 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 1c1f2b1d44 SSH access from the Sites page; containers and discovery dialog; dialogs fit small screens
CI / test (push) Successful in 6m5s
Release / release (push) Successful in 6m26s
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>
2026-10-31 00:34:05 -06:00
Veillax 92262441db Build tests against Blacktip v0.1.10 and Bamboo v0.1.7
CI / test (push) Failing after 4m13s
Release / release (push) Successful in 5m57s
2026-10-30 22:08:12 -06:00
4 changed files with 196 additions and 12 deletions

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
package e2e
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/bamboo/sshd"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/ssh"
)
func newSSHSigner(t *testing.T) ssh.Signer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
s, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
return s
}
// A TCP port on the edge whose target is Bamboo's SSH server at the site reaches that server
// with an ordinary ssh client: the path is client, edge, WireGuard tunnel, Bamboo, sshd.
func TestSSHThroughTheEdgeToBamboosServer(t *testing.T) {
w := setup(t, blacktip.TraefikConfig{})
dir := t.TempDir()
signer := newSSHSigner(t)
keys := filepath.Join(dir, "authorized_keys")
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(signer.PublicKey()), 0o600); err != nil {
t.Fatal(err)
}
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go srv.Serve(ctx, ln)
edgePort := freePort(t)
w.open(model.Mapping{Proto: model.ProtoTCP, Port: edgePort, Name: "ssh", Targets: tgt(w.site, ln.Addr().(*net.TCPAddr).Port)})
addr := "127.0.0.1:" + strconv.Itoa(edgePort)
connect := func(auth ssh.AuthMethod) (*ssh.Client, error) {
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "alex", Auth: []ssh.AuthMethod{auth}, Timeout: 5 * time.Second,
// the key the client sees through the tunnel must be Bamboo's, not something in between
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
if got := ssh.FingerprintSHA256(k); got != srv.Fingerprint() {
return fmt.Errorf("host key %s is not the Bamboo server's %s", got, srv.Fingerprint())
}
return nil
}})
}
var client *ssh.Client
var last error
for i := 0; i < 60 && client == nil; i++ {
if client, last = connect(ssh.PublicKeys(signer)); client == nil {
time.Sleep(250 * time.Millisecond)
}
}
if client == nil {
t.Fatalf("could not sign in through the edge: %v", last)
}
defer client.Close()
if sshd.Supported {
sess, err := client.NewSession()
if err != nil {
t.Fatal(err)
}
out, err := sess.Output("echo through-the-tunnel")
sess.Close()
if err != nil || strings.TrimSpace(string(out)) != "through-the-tunnel" {
t.Fatalf("running a command: %q, %v", out, err)
}
}
// the same narrow rules hold through the tunnel
if c, err := connect(ssh.PublicKeys(newSSHSigner(t))); err == nil {
c.Close()
t.Error("a key that is not in authorized_keys signed in")
}
if c, err := connect(ssh.Password("hunter2")); err == nil {
c.Close()
t.Error("a password signed in")
}
if c, err := client.Dial("tcp", "127.0.0.1:1"); err == nil {
c.Close()
t.Error("port forwarding through the SSH server is allowed")
}
}
+3 -2
View File
@@ -3,8 +3,8 @@ module git.veillax.com/Veillax/hammerhead
go 1.27.3
require (
git.veillax.com/Veillax/bamboo v0.1.5
git.veillax.com/Veillax/blacktip v0.1.9
git.veillax.com/Veillax/bamboo v0.1.8
git.veillax.com/Veillax/blacktip v0.1.10
git.veillax.com/Veillax/reef v0.1.11
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
@@ -13,6 +13,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/uuid v1.6.0 // indirect
+8 -4
View File
@@ -1,11 +1,13 @@
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
git.veillax.com/Veillax/bamboo v0.1.8 h1:mOdkGBzM8CjFVjR3784/WAYGt3U4bSdOaAJqFWMVh3I=
git.veillax.com/Veillax/bamboo v0.1.8/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780=
git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
@@ -34,6 +36,8 @@ golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+77 -6
View File
@@ -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')))))))));