diff --git a/e2e/ssh_test.go b/e2e/ssh_test.go new file mode 100644 index 0000000..90e8eeb --- /dev/null +++ b/e2e/ssh_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 6606510..04cc6a4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.veillax.com/Veillax/hammerhead go 1.27.3 require ( - git.veillax.com/Veillax/bamboo v0.1.7 + 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 @@ -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 diff --git a/go.sum b/go.sum index 38dec57..c8f1676 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,13 @@ -git.veillax.com/Veillax/bamboo v0.1.7 h1:men+7c+MyAbpQmNZKxwdO7yPdRgLtec2KuU4EQ4/EFA= -git.veillax.com/Veillax/bamboo v0.1.7/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780= +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= diff --git a/web/index.html b/web/index.html index 4873a54..78de45c 100644 --- a/web/index.html +++ b/web/index.html @@ -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')))))))));