Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2403ecf9d |
No files matched your search
@@ -36,7 +36,7 @@ Settings and secrets can live in a `.env` file instead of the command line; see
|
||||
- **Tunnel:** one WireGuard peer per edge, through a userspace network stack (no root, no kernel interface, no driver; works on Windows, macOS and Linux). Its private key never leaves this machine. TCP connections and native UDP datagrams are accepted from the edge and handed to your services.
|
||||
- **Docker discovery:** with `-docker`, lists running containers (and their ports and networks) so you can pick one as a target in the dashboard.
|
||||
- **Service discovery:** off unless you ask for it. With `-discover`, lists the TCP services listening on this machine (read from `/proc/net/tcp`, so Linux only). With `-discover-subnets`, it also tries the chosen ports on every address of those subnets. Each hit is identified (SSH, SMTP, FTP, HTTP, HTTPS, or the usual service for that port) and reported to Hammerhead as a suggested target; nothing is exposed until you pick one. It is deliberately gentle: only private, loopback or link-local subnets of at most 1024 addresses are accepted, connection attempts are capped (50 per second by default), a sweep runs every 15 minutes, and a service that stays silent gets a single TLS handshake and then one `HEAD /` request, nothing more. At most 500 services are reported. Only scan networks you are allowed to scan.
|
||||
- **SSH server:** off unless you ask for it. With `-ssh`, Bamboo runs a small SSH server, so you can open a shell or run a command on this machine from anywhere through a tunnelled port, with no sshd to install. Put the public keys that may sign in in `authorized_keys` in the data folder (one per line, like OpenSSH; Bamboo will not start the server without at least one, and picks up edits without a restart). Then, in Hammerhead, add a TCP port whose target is this site at `127.0.0.1:2222`, and connect with `ssh -p <port> user@<edge address>`. The user name is ignored: sessions run as the user Bamboo runs as, so a Bamboo running as root gives a root shell, and the keys should be treated that way. Public keys only (no passwords), session channels only (no port forwarding, agent forwarding or SFTP), and it listens on the loopback address unless you change `-ssh-listen`. The host key is created in the data folder on first start and its fingerprint is written to the log, so you can check it the first time you connect. Not available on Windows.
|
||||
- **SSH server:** off unless you ask for it. With `-ssh`, Bamboo runs a small SSH server, so you can open a shell or run a command on this machine from anywhere through a tunnelled port, with no sshd to install. Put the public keys that may sign in in `authorized_keys` in the data folder (one per line, like OpenSSH; Bamboo will not start the server without at least one, and picks up edits without a restart). Then, in Hammerhead, add a TCP port whose target is this site at `127.0.0.1:2222`, and connect with `ssh -p <port> user@<edge address>`. The user name is ignored: sessions run as the user Bamboo runs as, so a Bamboo running as root gives a root shell, and the keys should be treated that way. Public keys only (no passwords), session channels only (no port forwarding, agent forwarding or SFTP), and it listens on the loopback address unless you change `-ssh-listen`. The host key is created in the data folder on first start and its fingerprint is written to the log, so you can check it the first time you connect. Hammerhead's dashboard terminal reaches this server through the agent itself: Bamboo reports where the server listens, and when someone opens a terminal it connects out to Hammerhead and joins that connection to the server, so no port has to be open anywhere. Add the public key Hammerhead shows (SSH button on the Sites page) to `authorized_keys` to allow it. Not available on Windows.
|
||||
- **Mail relay:** for a mail egress configured in the dashboard, opens a small SMTP relay on this machine (default `127.0.0.1:2525`). Point your mail server at it (Postfix: `relayhost = [127.0.0.1]:2525`) and each outgoing message leaves from the edge's public IP, so reverse DNS, SPF and your HELO name line up. Supports `AUTH PLAIN/LOGIN`, requires a password if it listens beyond `127.0.0.1`, relays one destination domain per transaction (extra domains get `452` so your server resends them separately), and passes your server's queue and retries through unchanged. 50 MB message limit.
|
||||
|
||||
## Deploying
|
||||
|
||||
@@ -37,6 +37,7 @@ type Config struct {
|
||||
DataDir string // holds this agent's WireGuard key
|
||||
Docker bool // report local Docker containers so they can be picked as targets
|
||||
Discover discover.Config // opt-in: report listening services found on this host and configured subnets as suggested targets
|
||||
SSH string // where this machine's SSH server listens ("" = not running); Hammerhead's terminal reaches it through the agent
|
||||
|
||||
// Version, AutoUpdate and UpdateNow work as in blacktip.Config.
|
||||
Version string
|
||||
@@ -57,6 +58,7 @@ type Bamboo struct {
|
||||
tun *wg.Device
|
||||
edges []model.EdgeRef
|
||||
lastRep string
|
||||
terms map[string]time.Time // terminal sessions already answered, by id
|
||||
|
||||
updating atomic.Bool
|
||||
updateMsg string // outcome of the update Hammerhead asked for, reported back
|
||||
@@ -185,6 +187,7 @@ func (b *Bamboo) apply(cfg *model.SiteConfig) {
|
||||
b.mu.Unlock()
|
||||
b.syncPeers()
|
||||
b.syncMail(cfg.MailEgress)
|
||||
b.openTerminals(cfg.Terminals)
|
||||
go b.report()
|
||||
}
|
||||
|
||||
@@ -284,6 +287,7 @@ func (b *Bamboo) report() {
|
||||
msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...)
|
||||
b.mu.Unlock()
|
||||
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, Discovered: found, MailEgress: b.mailStatus(),
|
||||
SSH: b.cfg.SSH, SSHRelay: true,
|
||||
Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate, UpdateMsg: msg, UpdateID: uid, UpdateStage: stage, UpdatePct: pct, UpdateLog: ulog})
|
||||
req, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
||||
|
||||
+3
-1
@@ -79,6 +79,7 @@ func main() {
|
||||
if dc.Ports, err = discover.ParsePorts(*discoverPorts); err != nil {
|
||||
log.Fatalf("-discover-ports: %v", err)
|
||||
}
|
||||
sshAddr := "" // reported to Hammerhead, so its terminal can reach the server through this agent
|
||||
if *sshOn {
|
||||
if !sshd.Supported {
|
||||
log.Fatal("-ssh: the SSH server is not available on this platform")
|
||||
@@ -91,6 +92,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("-ssh: %v", err)
|
||||
}
|
||||
sshAddr = *sshListen
|
||||
log.Printf("ssh: listening on %s, host key %s", *sshListen, srv.Fingerprint())
|
||||
go func() {
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
@@ -98,7 +100,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc,
|
||||
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc, SSH: sshAddr,
|
||||
Version: version, AutoUpdate: *autoUpdate,
|
||||
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
|
||||
c := upd
|
||||
|
||||
@@ -3,16 +3,16 @@ module git.veillax.com/Veillax/bamboo
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
git.veillax.com/Veillax/reef v0.1.11
|
||||
git.veillax.com/Veillax/reef v0.1.12
|
||||
github.com/creack/pty v1.1.24
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/time v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
||||
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
|
||||
git.veillax.com/Veillax/reef v0.1.12/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=
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package bamboo
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// Terminal relay. When someone opens a terminal for this site in Hammerhead, Hammerhead lists a
|
||||
// session in the site's config (model.SiteConfig.Terminals). For each one the agent opens a
|
||||
// connection out to Hammerhead and joins it to its own SSH server, so Hammerhead can sign in over it.
|
||||
// Nothing listens for this, no port has to be open on an edge, and the SSH server still decides who
|
||||
// gets in: Hammerhead needs its public key in authorized_keys like any other client.
|
||||
|
||||
const (
|
||||
termSeenFor = time.Minute // a session id is answered once, even if the config is fetched again
|
||||
termDial = 10 * time.Second
|
||||
)
|
||||
|
||||
// sshTarget is the address to dial for the local SSH server. An address that listens on every
|
||||
// interface is reached on the loopback one.
|
||||
func sshTarget(listen string) string {
|
||||
if listen == "" {
|
||||
return ""
|
||||
}
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return listen
|
||||
}
|
||||
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// openTerminals starts a relay for each session Hammerhead is waiting on that has not been answered yet.
|
||||
func (b *Bamboo) openTerminals(ids []string) {
|
||||
for _, id := range ids {
|
||||
b.mu.Lock()
|
||||
if b.terms == nil {
|
||||
b.terms = map[string]time.Time{}
|
||||
}
|
||||
for k, t := range b.terms {
|
||||
if time.Since(t) > termSeenFor {
|
||||
delete(b.terms, k)
|
||||
}
|
||||
}
|
||||
_, dup := b.terms[id]
|
||||
if !dup {
|
||||
b.terms[id] = time.Now()
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if !dup {
|
||||
go b.relayTerminal(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bamboo) relayTerminal(id string) {
|
||||
target := sshTarget(b.cfg.SSH)
|
||||
if target == "" {
|
||||
log.Printf("terminal: Hammerhead asked for a terminal but the SSH server is not running (start bamboo with -ssh)")
|
||||
return
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(b.cfg.Hammerhead, "http") + "/site/terminal/" + id
|
||||
cfg, err := websocket.NewConfig(wsURL, b.cfg.Hammerhead)
|
||||
if err != nil {
|
||||
log.Printf("terminal: %v", err)
|
||||
return
|
||||
}
|
||||
cfg.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
||||
cfg.Dialer = &net.Dialer{Timeout: termDial}
|
||||
ws, err := websocket.DialConfig(cfg)
|
||||
if err != nil {
|
||||
log.Printf("terminal: could not reach Hammerhead: %v", err)
|
||||
return
|
||||
}
|
||||
ws.PayloadType = websocket.BinaryFrame
|
||||
defer ws.Close()
|
||||
local, err := net.DialTimeout("tcp", target, termDial)
|
||||
if err != nil {
|
||||
log.Printf("terminal: the SSH server at %s is not answering: %v", target, err)
|
||||
return // closing the connection tells Hammerhead at once
|
||||
}
|
||||
defer local.Close()
|
||||
|
||||
var once sync.Once
|
||||
stop := func() { once.Do(func() { ws.Close(); local.Close() }) }
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { io.Copy(local, ws); stop(); done <- struct{}{} }()
|
||||
go func() { io.Copy(ws, local); stop(); done <- struct{}{} }()
|
||||
<-done
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package bamboo
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
func TestSSHTarget(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"": "",
|
||||
"127.0.0.1:2222": "127.0.0.1:2222",
|
||||
":2222": "127.0.0.1:2222",
|
||||
"0.0.0.0:2200": "127.0.0.1:2200",
|
||||
"[::]:2222": "127.0.0.1:2222",
|
||||
"10.0.0.5:2222": "10.0.0.5:2222",
|
||||
} {
|
||||
if got := sshTarget(in); got != want {
|
||||
t.Errorf("sshTarget(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hammerhead lists a terminal session; the agent calls back with its token and joins the connection
|
||||
// to its SSH server. Here the "SSH server" is a local echo, and Hammerhead is a test server.
|
||||
func TestTerminalRelayJoinsHammerheadToTheLocalSSHServer(t *testing.T) {
|
||||
echo, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer echo.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := echo.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||
}
|
||||
}()
|
||||
|
||||
got := make(chan *websocket.Conn, 4)
|
||||
auth := make(chan string, 4)
|
||||
paths := make(chan string, 4)
|
||||
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth <- r.Header.Get("Authorization")
|
||||
paths <- r.URL.Path
|
||||
release := make(chan struct{})
|
||||
websocket.Server{Handshake: func(*websocket.Config, *http.Request) error { return nil }, Handler: func(ws *websocket.Conn) {
|
||||
ws.PayloadType = websocket.BinaryFrame
|
||||
got <- ws
|
||||
<-release
|
||||
}}.ServeHTTP(w, r)
|
||||
close(release)
|
||||
}))
|
||||
defer hh.Close()
|
||||
|
||||
b, err := New(Config{Hammerhead: hh.URL, Token: "site-token", DataDir: t.TempDir(), SSH: echo.Addr().String()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.openTerminals([]string{"abc"})
|
||||
b.openTerminals([]string{"abc"}) // fetching the config again must not answer the same session twice
|
||||
|
||||
var ws *websocket.Conn
|
||||
select {
|
||||
case ws = <-got:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the agent never called back")
|
||||
}
|
||||
if a := <-auth; a != "Bearer site-token" {
|
||||
t.Errorf("call-back authorization: %q", a)
|
||||
}
|
||||
if p := <-paths; p != "/site/terminal/abc" {
|
||||
t.Errorf("call-back path: %q", p)
|
||||
}
|
||||
if _, err := ws.Write([]byte("hello through the relay")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := ws.Read(buf)
|
||||
if err != nil || string(buf[:n]) != "hello through the relay" {
|
||||
t.Fatalf("echo through the relay: %q, %v", buf[:n], err)
|
||||
}
|
||||
select {
|
||||
case <-got:
|
||||
t.Error("the same session was answered twice")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
ws.Close()
|
||||
}
|
||||
|
||||
// With no SSH server the agent does not call back at all, rather than connect and hang.
|
||||
func TestTerminalRelayDoesNothingWithoutAnSSHServer(t *testing.T) {
|
||||
called := make(chan struct{}, 1)
|
||||
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} }))
|
||||
defer hh.Close()
|
||||
b, err := New(Config{Hammerhead: hh.URL, Token: "t", DataDir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.openTerminals([]string{"x"})
|
||||
select {
|
||||
case <-called:
|
||||
t.Error("the agent called Hammerhead although it has no SSH server")
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user