Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bd4d8646 | ||
|
|
a2403ecf9d | ||
|
|
1fba65fd3e | ||
|
|
6142ae6262 | ||
|
|
5edb1ed2b9 | ||
|
|
fc3d98ac83 | ||
|
|
228d376dae | ||
|
|
2dea372f9a | ||
|
|
710b101436 | ||
|
|
17c222c946 | ||
|
|
2943af9bbf | ||
|
|
e3353f65da | ||
|
|
a30afb1026 | ||
|
|
3dc8d1af46 |
No files matched your search
@@ -7,5 +7,21 @@ BAMBOO_TOKEN=
|
||||
# Report local Docker containers so they can be picked as targets.
|
||||
#BAMBOO_DOCKER=1
|
||||
|
||||
# Report services listening on this machine (Linux) as suggested targets.
|
||||
#BAMBOO_DISCOVER=1
|
||||
# Also scan these private subnets (comma separated, each at most a /22), on these ports, at most this many connections per second.
|
||||
#BAMBOO_DISCOVER_SUBNETS=192.168.1.0/24
|
||||
#BAMBOO_DISCOVER_PORTS=22,80,443,8000-8010
|
||||
#BAMBOO_DISCOVER_RATE=50
|
||||
|
||||
# Run a built-in SSH server (public keys only). The keys that may sign in go in authorized_keys in the data folder.
|
||||
#BAMBOO_SSH=1
|
||||
#BAMBOO_SSH_LISTEN=127.0.0.1:2222
|
||||
#BAMBOO_SSH_KEYS=
|
||||
|
||||
# Folder for this agent's WireGuard key.
|
||||
#BAMBOO_DATA=./bamboo-data
|
||||
|
||||
# Check for new releases every few hours and update and restart by itself (Linux and macOS).
|
||||
# setup.sh turns this on; remove it to update by hand.
|
||||
#BAMBOO_AUTO_UPDATE=1
|
||||
@@ -20,6 +20,13 @@ Bamboo only makes outbound connections (HTTPS to Hammerhead, and WireGuard UDP t
|
||||
| `-token` | `BAMBOO_TOKEN` | the site's token |
|
||||
| `-data` | `BAMBOO_DATA` | folder for this agent's WireGuard key (default `./bamboo-data`) |
|
||||
| `-docker` | `BAMBOO_DOCKER=1` | report local Docker containers so they can be picked as targets |
|
||||
| `-discover` | `BAMBOO_DISCOVER=1` | report services listening on this machine (Linux) as suggested targets |
|
||||
| `-discover-subnets` | `BAMBOO_DISCOVER_SUBNETS` | also scan these private subnets, e.g. `192.168.1.0/24,10.0.0.0/24` (each at most a /22) |
|
||||
| `-discover-ports` | `BAMBOO_DISCOVER_PORTS` | ports to try when scanning, e.g. `22,80,443,8000-8010` (default: 19 common ones) |
|
||||
| `-discover-rate` | `BAMBOO_DISCOVER_RATE` | most connection attempts per second (default 50) |
|
||||
| `-ssh` | `BAMBOO_SSH=1` | run a small SSH server so this machine can be reached through a tunnelled port (Linux and macOS) |
|
||||
| `-ssh-listen` | `BAMBOO_SSH_LISTEN` | address it listens on (default `127.0.0.1:2222`) |
|
||||
| `-ssh-keys` | `BAMBOO_SSH_KEYS` | `authorized_keys` file listing who may sign in (default: `authorized_keys` in the data folder) |
|
||||
| `-env` | | `.env` file to read (default `./.env`) |
|
||||
|
||||
Settings and secrets can live in a `.env` file instead of the command line; see `.env.example`. Real environment variables always win.
|
||||
@@ -28,6 +35,8 @@ 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. 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,10 +46,10 @@ Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea
|
||||
**On a Linux server (systemd)**
|
||||
|
||||
```
|
||||
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/bamboo_v0.1.0_linux_amd64.tar.gz
|
||||
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.0/SHA256SUMS
|
||||
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.6/bamboo_v0.1.6_linux_amd64.tar.gz
|
||||
curl -LO https://git.veillax.com/Veillax/bamboo/releases/download/v0.1.6/SHA256SUMS
|
||||
sha256sum -c --ignore-missing SHA256SUMS
|
||||
tar xzf bamboo_v0.1.0_linux_amd64.tar.gz && cd bamboo_v0.1.0_linux_amd64
|
||||
tar xzf bamboo_v0.1.6_linux_amd64.tar.gz && cd bamboo_v0.1.6_linux_amd64
|
||||
sudo ./install.sh # installs the binary and a hardened systemd service
|
||||
sudoedit /etc/bamboo/bamboo.env # your settings and secrets
|
||||
sudo systemctl start bamboo
|
||||
@@ -48,6 +57,10 @@ sudo systemctl start bamboo
|
||||
|
||||
Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/bamboo` and `/var/lib/bamboo` alone. `bamboo -version` tells you which build is running.
|
||||
|
||||
**Updates**
|
||||
|
||||
Set `BAMBOO_AUTO_UPDATE=1` (or pass `-auto-update`) and Bamboo checks this server for a newer release every few hours, verifies it against `SHA256SUMS`, checks that the new binary runs, swaps it in and restarts itself. It only follows full releases (never pre-releases), never downgrades, and keeps the old binary if anything looks wrong. `setup.sh` turns it on. It is not available on Windows, and it is for the systemd and plain-binary installs: with Docker, pull a new image instead.
|
||||
|
||||
**With Docker**
|
||||
|
||||
```
|
||||
|
||||
@@ -21,8 +21,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/bamboo/discover"
|
||||
"git.veillax.com/Veillax/reef/docker"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/tunnel"
|
||||
@@ -32,8 +34,15 @@ import (
|
||||
type Config struct {
|
||||
Hammerhead string
|
||||
Token string
|
||||
DataDir string // holds this agent's WireGuard key
|
||||
Docker bool // report local Docker containers so they can be picked as targets
|
||||
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
|
||||
AutoUpdate bool
|
||||
UpdateNow func(ctx context.Context, progress func(stage string, pct int, line string)) (newVersion string, err error)
|
||||
}
|
||||
|
||||
type Bamboo struct {
|
||||
@@ -49,6 +58,17 @@ 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
|
||||
updateID string // the request that updateMsg answers
|
||||
updStage string // progress of the running update, reported to Hammerhead
|
||||
updPct int
|
||||
updLog []string
|
||||
updSent time.Time
|
||||
|
||||
disc *discover.Discoverer // nil unless discovery is switched on
|
||||
|
||||
mail map[string]*mailRun
|
||||
mailErr map[string]string // mail egress ID -> "" (listening) or an error
|
||||
@@ -66,8 +86,14 @@ func New(cfg Config) (*Bamboo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Bamboo{cfg: cfg, http: &http.Client{Timeout: 40 * time.Second}, ctx: context.Background(), wgKey: key, wgPub: pub,
|
||||
mail: map[string]*mailRun{}, mailErr: map[string]string{}}, nil
|
||||
b := &Bamboo{cfg: cfg, http: &http.Client{Timeout: 40 * time.Second}, ctx: context.Background(), wgKey: key, wgPub: pub,
|
||||
mail: map[string]*mailRun{}, mailErr: map[string]string{}}
|
||||
if cfg.Discover.Enabled() {
|
||||
if b.disc, err = discover.New(cfg.Discover); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// loadOrCreateKey keeps this agent's WireGuard identity across restarts.
|
||||
@@ -92,6 +118,9 @@ func (b *Bamboo) Run(ctx context.Context) error {
|
||||
log.Printf("bamboo started; WireGuard public key %s", b.wgPub)
|
||||
go b.reportLoop(ctx)
|
||||
go b.refreshLoop(ctx)
|
||||
if b.disc != nil {
|
||||
go b.disc.Run(ctx, log.Printf)
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
cfg, err := b.fetchConfig(ctx)
|
||||
if err != nil {
|
||||
@@ -143,6 +172,9 @@ func (b *Bamboo) apply(cfg *model.SiteConfig) {
|
||||
b.mu.Lock()
|
||||
b.version = cfg.Version
|
||||
b.edges = cfg.Edges
|
||||
if cfg.UpdateID != "" {
|
||||
b.requestUpdate(cfg.UpdateID)
|
||||
}
|
||||
if cfg.TunnelIP != b.tunIP && b.tun != nil {
|
||||
b.tun.Close()
|
||||
b.tun = nil
|
||||
@@ -155,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()
|
||||
}
|
||||
|
||||
@@ -246,7 +279,16 @@ func (b *Bamboo) report() {
|
||||
containers, _ = docker.List(ctx)
|
||||
cancel()
|
||||
}
|
||||
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, MailEgress: b.mailStatus()})
|
||||
var found []model.DiscoveredService
|
||||
if b.disc != nil {
|
||||
found = b.disc.Results()
|
||||
}
|
||||
b.mu.Lock()
|
||||
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)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -414,3 +456,73 @@ func serveUDP(pc net.PacketConn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requestUpdate acts on "update now" from Hammerhead (id identifies the request). It only ever installs
|
||||
// what this machine's own auto-update would (the newest release from its configured server), and only
|
||||
// when auto-update is on. Caller holds b.mu.
|
||||
func (b *Bamboo) requestUpdate(id string) {
|
||||
if id == b.updateID {
|
||||
return // already handled, or in progress
|
||||
}
|
||||
b.updateID, b.updateMsg = id, ""
|
||||
b.updStage, b.updPct, b.updLog = "starting", 0, nil
|
||||
b.updNote("Update requested from the dashboard")
|
||||
if !b.cfg.AutoUpdate || b.cfg.UpdateNow == nil {
|
||||
b.updateMsg = "auto-update is off on this machine (set BAMBOO_AUTO_UPDATE=1 and restart it)"
|
||||
b.updStage = "failed"
|
||||
b.updNote("Refused: " + b.updateMsg)
|
||||
go b.report()
|
||||
return
|
||||
}
|
||||
if !b.updating.CompareAndSwap(false, true) {
|
||||
b.updateMsg = "another update is already running"
|
||||
b.updStage = "failed"
|
||||
b.updNote("Refused: " + b.updateMsg)
|
||||
go b.report()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer b.updating.Store(false)
|
||||
tag, err := b.cfg.UpdateNow(b.ctx, b.updProgress) // on success this restarts the process and does not return
|
||||
b.mu.Lock()
|
||||
switch {
|
||||
case err != nil:
|
||||
b.updateMsg = "update failed: " + err.Error()
|
||||
case tag == "":
|
||||
b.updateMsg = "already on the newest release"
|
||||
default:
|
||||
b.updateMsg = "updated to " + tag + ", restarting"
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.report()
|
||||
}()
|
||||
}
|
||||
|
||||
// updNote adds a timestamped line to the update log. Caller holds b.mu.
|
||||
func (b *Bamboo) updNote(line string) {
|
||||
b.updLog = append(b.updLog, time.Now().Format("15:04:05")+" "+line)
|
||||
if len(b.updLog) > 200 {
|
||||
b.updLog = b.updLog[len(b.updLog)-200:]
|
||||
}
|
||||
}
|
||||
|
||||
// updProgress is called by the updater as it works; Hammerhead gets a fresh report at most once a
|
||||
// second (and straight away for every new log line), so its progress bar follows along.
|
||||
func (b *Bamboo) updProgress(stage string, pct int, line string) {
|
||||
b.mu.Lock()
|
||||
b.updStage = stage
|
||||
if pct >= 0 {
|
||||
b.updPct = pct
|
||||
}
|
||||
if line != "" {
|
||||
b.updNote(line)
|
||||
}
|
||||
send := line != "" || time.Since(b.updSent) > time.Second
|
||||
if send {
|
||||
b.updSent = time.Now()
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if send {
|
||||
go b.report()
|
||||
}
|
||||
}
|
||||
+60
-1
@@ -7,10 +7,15 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.veillax.com/Veillax/bamboo"
|
||||
"git.veillax.com/Veillax/bamboo/discover"
|
||||
"git.veillax.com/Veillax/bamboo/sshd"
|
||||
"git.veillax.com/Veillax/reef/dotenv"
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
)
|
||||
|
||||
// version is set at build time (deploy/build.sh).
|
||||
@@ -23,6 +28,13 @@ func env(k, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
func envFloat(k string, def float64) float64 {
|
||||
if f, err := strconv.ParseFloat(os.Getenv(k), 64); err == nil && f > 0 {
|
||||
return f
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
// secrets (tokens, DNS-provider credentials) can live in a .env file instead of the command line
|
||||
envFile := dotenv.ArgPath(os.Args[1:], ".env")
|
||||
@@ -33,7 +45,15 @@ func main() {
|
||||
token := flag.String("token", env("BAMBOO_TOKEN", ""), "site token from the hammerhead UI")
|
||||
data := flag.String("data", env("BAMBOO_DATA", "./bamboo-data"), "directory for this agent's WireGuard key")
|
||||
dockerOn := flag.Bool("docker", os.Getenv("BAMBOO_DOCKER") == "1", "report local Docker containers so they can be picked as targets in the UI")
|
||||
discoverOn := flag.Bool("discover", os.Getenv("BAMBOO_DISCOVER") == "1", "report services listening on this machine as suggested targets in the UI (Linux)")
|
||||
discoverSubnets := flag.String("discover-subnets", os.Getenv("BAMBOO_DISCOVER_SUBNETS"), "comma-separated private subnets to scan for services, e.g. 192.168.1.0/24 (at most /22 each; off by default)")
|
||||
discoverPorts := flag.String("discover-ports", os.Getenv("BAMBOO_DISCOVER_PORTS"), "TCP ports to try on scanned addresses, e.g. 22,80,443,8000-8010 (default: a short list of common ones)")
|
||||
discoverRate := flag.Float64("discover-rate", envFloat("BAMBOO_DISCOVER_RATE", discover.DefaultRate), "most connection attempts per second discovery may make")
|
||||
sshOn := flag.Bool("ssh", os.Getenv("BAMBOO_SSH") == "1", "run a built-in SSH server (public keys only) so this machine can be reached through a tunnelled port")
|
||||
sshListen := flag.String("ssh-listen", env("BAMBOO_SSH_LISTEN", sshd.DefaultListen), "address the SSH server listens on; point a Hammerhead port at it from this site")
|
||||
sshKeys := flag.String("ssh-keys", os.Getenv("BAMBOO_SSH_KEYS"), "authorized_keys file listing who may sign in (default: authorized_keys in the data folder)")
|
||||
flag.String("env", ".env", "file to read secrets and settings from")
|
||||
autoUpdate := flag.Bool("auto-update", os.Getenv("BAMBOO_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
@@ -47,7 +67,46 @@ func main() {
|
||||
log.Printf("bamboo %s starting", version)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn})
|
||||
upd := selfupdate.Config{Server: "https://git.veillax.com", Repo: "Veillax/bamboo", App: "bamboo", Current: version, Logf: log.Printf}
|
||||
if *autoUpdate {
|
||||
go selfupdate.Run(ctx, upd)
|
||||
}
|
||||
dc := discover.Config{Local: *discoverOn || *discoverSubnets != "", Rate: *discoverRate}
|
||||
var err error
|
||||
if dc.Subnets, err = discover.ParseSubnets(*discoverSubnets); err != nil {
|
||||
log.Fatalf("-discover-subnets: %v", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
keys := *sshKeys
|
||||
if keys == "" {
|
||||
keys = filepath.Join(*data, "authorized_keys")
|
||||
}
|
||||
srv, err := sshd.New(sshd.Config{Listen: *sshListen, AuthorizedKeys: keys, HostKey: filepath.Join(*data, "ssh_host_ed25519_key"), Logf: log.Printf})
|
||||
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 {
|
||||
log.Printf("ssh: stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
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
|
||||
c.Progress = func(e selfupdate.Event) { progress(e.Stage, e.Pct, e.Line) }
|
||||
return selfupdate.CheckOnce(ctx, c)
|
||||
}})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Wants=network-online.target
|
||||
User=bamboo
|
||||
Group=bamboo
|
||||
EnvironmentFile=-/etc/bamboo/bamboo.env
|
||||
ExecStart=/usr/local/bin/bamboo -data /var/lib/bamboo
|
||||
ExecStart=/usr/local/lib/bamboo/bamboo -data /var/lib/bamboo
|
||||
WorkingDirectory=/var/lib/bamboo
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -16,7 +16,7 @@ RestartSec=3
|
||||
# It makes outbound connections only, so it needs no privileges at all.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/bamboo
|
||||
ReadWritePaths=/var/lib/bamboo /usr/local/lib/bamboo
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
|
||||
+12
-6
@@ -5,7 +5,7 @@
|
||||
# sudo ./install.sh --start the same, then start (or restart) the service
|
||||
# sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data)
|
||||
#
|
||||
# Files: /usr/local/bin/bamboo, /etc/systemd/system/bamboo.service,
|
||||
# Files: /usr/local/lib/bamboo/bamboo (linked from /usr/local/bin/bamboo), /etc/systemd/system/bamboo.service,
|
||||
# /etc/bamboo/bamboo.env (your settings and secrets), /var/lib/bamboo (data).
|
||||
# Set DESTDIR=/some/dir to stage everything there without touching the system.
|
||||
set -eu
|
||||
@@ -17,6 +17,7 @@ UNIT=$ROOT/etc/systemd/system/$NAME.service
|
||||
CONF=$ROOT/etc/$NAME
|
||||
ENVFILE=$CONF/$NAME.env
|
||||
DATA=$ROOT/var/lib/$NAME
|
||||
LIB=$ROOT/usr/local/lib/$NAME # the program lives here, owned by the service, so auto-update can replace it
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system?
|
||||
@@ -30,7 +31,7 @@ if [ "${1:-}" = "--uninstall" ]; then
|
||||
if real; then
|
||||
systemctl disable --now "$NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$UNIT" "$BIN"
|
||||
rm -f "$UNIT" "$BIN" "$LIB/$NAME"
|
||||
if real; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
@@ -46,15 +47,20 @@ if real && ! id "$NAME" > /dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA"
|
||||
install -m 0755 "$HERE/$NAME" "$BIN.new"
|
||||
mv -f "$BIN.new" "$BIN" # atomic, so upgrading a running service is safe
|
||||
mkdir -p "$LIB"
|
||||
install -m 0755 "$HERE/$NAME" "$LIB/$NAME.new"
|
||||
mv -f "$LIB/$NAME.new" "$LIB/$NAME" # atomic, so upgrading a running service is safe
|
||||
rm -f "$BIN"
|
||||
LINK=/usr/local/lib/$NAME/$NAME
|
||||
[ -z "${DESTDIR:-}" ] || LINK=$LIB/$NAME # staging: point inside the staging folder
|
||||
ln -s "$LINK" "$BIN"
|
||||
install -m 0644 "$HERE/$NAME.service" "$UNIT"
|
||||
if [ ! -f "$ENVFILE" ]; then
|
||||
install -m 0600 "$HERE/.env.example" "$ENVFILE"
|
||||
say "Created $ENVFILE. Edit it before starting."
|
||||
fi
|
||||
if real; then
|
||||
chown -R "$NAME:$NAME" "$DATA"
|
||||
chown -R "$NAME:$NAME" "$DATA" "$LIB"
|
||||
fi
|
||||
|
||||
if real; then
|
||||
@@ -66,7 +72,7 @@ if real; then
|
||||
fi
|
||||
fi
|
||||
|
||||
say "Installed $("$BIN" -version 2> /dev/null || echo "$NAME")."
|
||||
say "Installed $("$LIB/$NAME" -version 2> /dev/null || echo "$NAME")."
|
||||
say "Next:"
|
||||
say " 1. edit $ENVFILE"
|
||||
say " 2. systemctl start $NAME (or run this again with --start)"
|
||||
|
||||
@@ -42,19 +42,30 @@ else
|
||||
echo "release $TAG already exists (id $id): replacing its files"
|
||||
fi
|
||||
|
||||
existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json
|
||||
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
|
||||
asset_ids() {
|
||||
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
|
||||
for a in json.load(sys.stdin):
|
||||
print(a["id"], a["name"])')
|
||||
if a["name"] == sys.argv[1]:
|
||||
print(a["id"])' "$1"
|
||||
}
|
||||
|
||||
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
echo "$existing" | while read -r aid aname; do
|
||||
if [ "$aname" = "$name" ]; then
|
||||
n=1
|
||||
while :; do
|
||||
for aid in $(asset_ids "$name"); do
|
||||
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
|
||||
done
|
||||
echo "uploading $name (attempt $n)"
|
||||
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
|
||||
break
|
||||
fi
|
||||
# large uploads can hit a proxy timeout (HTTP 502); try again
|
||||
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
|
||||
n=$((n + 1))
|
||||
sleep $((n * 10))
|
||||
done
|
||||
echo "uploading $name"
|
||||
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
|
||||
done
|
||||
echo "published $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -0,0 +1,455 @@
|
||||
// Package discover finds services listening on this machine and, only when asked to, on configured
|
||||
// private subnets, so Hammerhead can offer them as suggested targets. It is opt-in and deliberately
|
||||
// gentle: connections are plain TCP connects, probes are rate limited, only private address ranges
|
||||
// of a bounded size are ever scanned, and nothing is sent beyond one HTTP HEAD (or a TLS handshake)
|
||||
// to a port that stayed silent.
|
||||
package discover
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSubnetAddrs caps how many addresses one subnet may cover (a /22).
|
||||
MaxSubnetAddrs = 1024
|
||||
// MaxResults caps what is reported, so a noisy network cannot bloat every report.
|
||||
MaxResults = 500
|
||||
|
||||
DefaultRate = 50 // new connections per second
|
||||
DefaultInterval = 15 * time.Minute
|
||||
)
|
||||
|
||||
// DefaultPorts are the TCP ports a subnet scan looks at when none are configured.
|
||||
var DefaultPorts = []int{22, 80, 443, 445, 1883, 3000, 3306, 5000, 5432, 6379, 8000, 8080, 8096, 8123, 8443, 8888, 9000, 9090, 32400}
|
||||
|
||||
// wellKnown names a likely service from the port alone, used when a service says nothing.
|
||||
var wellKnown = map[int]string{
|
||||
21: "ftp", 22: "ssh", 25: "smtp", 80: "http", 110: "pop3", 143: "imap", 443: "https", 445: "smb",
|
||||
587: "smtp", 1883: "mqtt", 3306: "mysql", 3389: "rdp", 5432: "postgres", 5900: "vnc", 6379: "redis",
|
||||
8080: "http", 8443: "https", 27017: "mongodb",
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Local bool // list this machine's listening sockets
|
||||
Subnets []netip.Prefix // private subnets to scan; empty = no scanning
|
||||
Ports []int // ports to try on each scanned address (default DefaultPorts)
|
||||
Rate float64 // probes per second across everything (default DefaultRate)
|
||||
Interval time.Duration // between sweeps (default DefaultInterval)
|
||||
Exclude []int // local listening ports to leave out (Bamboo's own relay, for instance)
|
||||
|
||||
// the next fields exist for tests
|
||||
dial func(ctx context.Context, addr string) (net.Conn, error)
|
||||
listening func() ([]netip.AddrPort, error)
|
||||
timeout time.Duration // per probe (default 1.5s)
|
||||
}
|
||||
|
||||
// ParseSubnets turns "192.168.1.0/24, 10.0.0.0/24" into prefixes and refuses anything that is not
|
||||
// a private, link-local or loopback range of at most MaxSubnetAddrs addresses.
|
||||
func ParseSubnets(s string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == ';' }) {
|
||||
p, err := netip.ParsePrefix(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad subnet %q: %v", f, err)
|
||||
}
|
||||
p = p.Masked()
|
||||
if err := checkSubnet(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func checkSubnet(p netip.Prefix) error {
|
||||
a := p.Addr()
|
||||
if !(a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast()) {
|
||||
return fmt.Errorf("subnet %s is not a private range; only private networks are scanned", p)
|
||||
}
|
||||
bits := a.BitLen() - p.Bits()
|
||||
if bits > 10 {
|
||||
return fmt.Errorf("subnet %s is too large (at most %d addresses, a /%d for IPv4)", p, MaxSubnetAddrs, 32-10)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParsePorts turns "80,443,8000-8010" into a sorted, de-duplicated list.
|
||||
func ParsePorts(s string) ([]int, error) {
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||||
lo, hi := f, f
|
||||
if i := strings.IndexByte(f, '-'); i > 0 {
|
||||
lo, hi = f[:i], f[i+1:]
|
||||
}
|
||||
a, err1 := strconv.Atoi(lo)
|
||||
b, err2 := strconv.Atoi(hi)
|
||||
if err1 != nil || err2 != nil || a < 1 || b > 65535 || a > b || b-a > 1000 {
|
||||
return nil, fmt.Errorf("bad port or range %q", f)
|
||||
}
|
||||
for p := a; p <= b; p++ {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether this configuration will look at anything.
|
||||
func (c Config) Enabled() bool { return c.Local || len(c.Subnets) > 0 }
|
||||
|
||||
// Discoverer runs sweeps and keeps the latest results.
|
||||
type Discoverer struct {
|
||||
cfg Config
|
||||
lim *rate.Limiter
|
||||
|
||||
mu sync.Mutex
|
||||
results []model.DiscoveredService
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Discoverer, error) {
|
||||
for _, p := range cfg.Subnets {
|
||||
if err := checkSubnet(p); err != nil { // a Config built in code gets the same checks as flags
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(cfg.Ports) == 0 {
|
||||
cfg.Ports = DefaultPorts
|
||||
}
|
||||
if cfg.Rate <= 0 {
|
||||
cfg.Rate = DefaultRate
|
||||
}
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = DefaultInterval
|
||||
}
|
||||
if cfg.timeout <= 0 {
|
||||
cfg.timeout = 1500 * time.Millisecond
|
||||
}
|
||||
if cfg.dial == nil {
|
||||
cfg.dial = func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
if cfg.listening == nil {
|
||||
cfg.listening = listeningSockets
|
||||
}
|
||||
return &Discoverer{cfg: cfg, lim: rate.NewLimiter(rate.Limit(cfg.Rate), 1)}, nil
|
||||
}
|
||||
|
||||
// Results returns the latest sweep's findings (a copy).
|
||||
func (d *Discoverer) Results() []model.DiscoveredService {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]model.DiscoveredService(nil), d.results...)
|
||||
}
|
||||
|
||||
// Run sweeps now and then every Interval until ctx ends. logf may be nil.
|
||||
func (d *Discoverer) Run(ctx context.Context, logf func(string, ...any)) {
|
||||
for {
|
||||
res := d.Sweep(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.results = res
|
||||
d.mu.Unlock()
|
||||
if logf != nil {
|
||||
logf("discovery: %d service(s) found", len(res))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(d.cfg.Interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep does one pass: listening sockets first, then each configured subnet.
|
||||
func (d *Discoverer) Sweep(ctx context.Context) []model.DiscoveredService {
|
||||
var out []model.DiscoveredService
|
||||
seen := map[netip.AddrPort]bool{}
|
||||
add := func(s model.DiscoveredService) {
|
||||
ap, err := netip.ParseAddrPort(net.JoinHostPort(s.Host, strconv.Itoa(s.Port)))
|
||||
if err == nil && !seen[ap] && len(out) < MaxResults {
|
||||
seen[ap] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if d.cfg.Local {
|
||||
eps, err := d.cfg.listening()
|
||||
if err != nil {
|
||||
eps = nil
|
||||
}
|
||||
for _, ap := range eps {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if d.excluded(int(ap.Port())) {
|
||||
continue
|
||||
}
|
||||
host := ap.Addr()
|
||||
if host.IsUnspecified() {
|
||||
host = netip.AddrFrom4([4]byte{127, 0, 0, 1})
|
||||
}
|
||||
if s, ok := d.probe(ctx, netip.AddrPortFrom(host, ap.Port()), "local"); ok {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sub := range d.cfg.Subnets {
|
||||
for a := sub.Addr(); sub.Contains(a) && ctx.Err() == nil; a = a.Next() {
|
||||
if !a.Is4() || !a.IsValid() {
|
||||
continue
|
||||
}
|
||||
if sub.Bits() < 31 && (a == sub.Addr() || a == lastAddr(sub)) {
|
||||
continue // network and broadcast addresses
|
||||
}
|
||||
for _, port := range d.cfg.Ports {
|
||||
if s, ok := d.probe(ctx, netip.AddrPortFrom(a, uint16(port)), "scan"); ok {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Host != out[j].Host {
|
||||
return out[i].Host < out[j].Host
|
||||
}
|
||||
return out[i].Port < out[j].Port
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func lastAddr(p netip.Prefix) netip.Addr {
|
||||
b := p.Addr().As4()
|
||||
host := 32 - p.Bits()
|
||||
v := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
v |= (1 << host) - 1
|
||||
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
||||
}
|
||||
|
||||
func (d *Discoverer) excluded(port int) bool {
|
||||
for _, p := range d.cfg.Exclude {
|
||||
if p == port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// probe connects to one address (waiting its turn on the rate limiter) and identifies what answers.
|
||||
func (d *Discoverer) probe(ctx context.Context, ap netip.AddrPort, source string) (model.DiscoveredService, bool) {
|
||||
if d.lim.Wait(ctx) != nil {
|
||||
return model.DiscoveredService{}, false
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, d.cfg.timeout)
|
||||
defer cancel()
|
||||
c, err := d.cfg.dial(cctx, ap.String())
|
||||
if err != nil {
|
||||
return model.DiscoveredService{}, false
|
||||
}
|
||||
c.Close()
|
||||
svc, detail := d.identify(ctx, ap)
|
||||
return model.DiscoveredService{Host: ap.Addr().String(), Port: int(ap.Port()), Proto: "tcp", Service: svc, Detail: detail, Source: source}, true
|
||||
}
|
||||
|
||||
// identify guesses the protocol: first by listening for a banner (SSH, SMTP, FTP and others speak
|
||||
// first), then by a TLS handshake, then by one HTTP HEAD, and finally by the port number.
|
||||
func (d *Discoverer) identify(ctx context.Context, ap netip.AddrPort) (service, detail string) {
|
||||
hint := wellKnown[int(ap.Port())]
|
||||
if c := d.connect(ctx, ap); c != nil {
|
||||
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
|
||||
if line, ok := readLine(c); ok {
|
||||
c.Close()
|
||||
if s := bannerService(line); s != "" {
|
||||
return s, line
|
||||
}
|
||||
return hint, line
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
if c := d.connect(ctx, ap); c != nil {
|
||||
c.SetDeadline(time.Now().Add(d.cfg.timeout))
|
||||
t := tls.Client(c, &tls.Config{InsecureSkipVerify: true, ServerName: ap.Addr().String()}) // identification only; no data is sent
|
||||
if t.HandshakeContext(ctx) == nil {
|
||||
t.Close()
|
||||
return "https", ""
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
if c := d.connect(ctx, ap); c != nil {
|
||||
c.SetDeadline(time.Now().Add(d.cfg.timeout))
|
||||
fmt.Fprintf(c, "HEAD / HTTP/1.0\r\nHost: %s\r\nUser-Agent: bamboo-discovery\r\n\r\n", ap.Addr())
|
||||
br := bufio.NewReaderSize(c, 2048)
|
||||
line, _ := br.ReadString('\n')
|
||||
if line = trim(line); strings.HasPrefix(line, "HTTP/") {
|
||||
server := headerValue(br, "Server")
|
||||
c.Close()
|
||||
return "http", join(line, server)
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
return hint, ""
|
||||
}
|
||||
|
||||
func (d *Discoverer) connect(ctx context.Context, ap netip.AddrPort) net.Conn {
|
||||
if d.lim.Wait(ctx) != nil {
|
||||
return nil
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, d.cfg.timeout)
|
||||
defer cancel()
|
||||
c, err := d.cfg.dial(cctx, ap.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func readLine(c net.Conn) (string, bool) {
|
||||
line, _ := bufio.NewReaderSize(c, 512).ReadString('\n')
|
||||
line = trim(line)
|
||||
return line, line != ""
|
||||
}
|
||||
|
||||
func trim(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 120 {
|
||||
s = s[:120]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// headerValue scans the remaining response headers for one header.
|
||||
func headerValue(br *bufio.Reader, name string) string {
|
||||
for i := 0; i < 40; i++ {
|
||||
l, err := br.ReadString('\n')
|
||||
l = strings.TrimSpace(l)
|
||||
if k, v, ok := strings.Cut(l, ":"); ok && strings.EqualFold(k, name) {
|
||||
return trim(v)
|
||||
}
|
||||
if err != nil || l == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func join(a, b string) string {
|
||||
if b == "" {
|
||||
return a
|
||||
}
|
||||
return a + "; Server: " + b
|
||||
}
|
||||
|
||||
func bannerService(line string) string {
|
||||
l := strings.ToUpper(line)
|
||||
switch {
|
||||
case strings.HasPrefix(l, "SSH-"):
|
||||
return "ssh"
|
||||
case strings.HasPrefix(l, "HTTP/"):
|
||||
return "http"
|
||||
case strings.HasPrefix(l, "220") && strings.Contains(l, "FTP"):
|
||||
return "ftp"
|
||||
case strings.HasPrefix(l, "220") && (strings.Contains(l, "SMTP") || strings.Contains(l, "ESMTP")):
|
||||
return "smtp"
|
||||
case strings.HasPrefix(l, "+OK"):
|
||||
return "pop3"
|
||||
case strings.HasPrefix(l, "* OK"):
|
||||
return "imap"
|
||||
case strings.HasPrefix(l, "RFB "):
|
||||
return "vnc"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseProcNetTCP reads the text of /proc/net/tcp or /proc/net/tcp6 and returns listening endpoints.
|
||||
func ParseProcNetTCP(text string, v6 bool) []netip.AddrPort {
|
||||
var out []netip.AddrPort
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Scan() // header
|
||||
for sc.Scan() {
|
||||
f := strings.Fields(sc.Text())
|
||||
if len(f) < 4 || f[3] != "0A" { // 0A = LISTEN
|
||||
continue
|
||||
}
|
||||
ip, port, ok := strings.Cut(f[1], ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
p, err := strconv.ParseUint(port, 16, 16)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if a, ok := parseProcAddr(ip, v6); ok {
|
||||
out = append(out, netip.AddrPortFrom(a, uint16(p)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseProcAddr decodes the kernel's hex address: each 32-bit word is written in host (little-endian) order.
|
||||
func parseProcAddr(h string, v6 bool) (netip.Addr, bool) {
|
||||
n := 8
|
||||
if v6 {
|
||||
n = 32
|
||||
}
|
||||
if len(h) != n {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
b := make([]byte, n/2)
|
||||
for i := range b {
|
||||
v, err := strconv.ParseUint(h[2*i:2*i+2], 16, 8)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
b[i] = byte(v)
|
||||
}
|
||||
for w := 0; w+4 <= len(b); w += 4 {
|
||||
b[w], b[w+1], b[w+2], b[w+3] = b[w+3], b[w+2], b[w+1], b[w]
|
||||
}
|
||||
if v6 {
|
||||
a := netip.AddrFrom16([16]byte(b))
|
||||
return a.Unmap(), true
|
||||
}
|
||||
return netip.AddrFrom4([4]byte(b)), true
|
||||
}
|
||||
|
||||
// listeningSockets lists this machine's TCP listeners. Only Linux exposes them cheaply; elsewhere
|
||||
// it returns nothing and the caller relies on scanning.
|
||||
func listeningSockets() ([]netip.AddrPort, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []netip.AddrPort
|
||||
for _, f := range []struct {
|
||||
path string
|
||||
v6 bool
|
||||
}{{"/proc/net/tcp", false}, {"/proc/net/tcp6", true}} {
|
||||
b, err := os.ReadFile(f.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ParseProcNetTCP(string(b), f.v6)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package discover
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseSubnetsRefusesPublicAndHuge(t *testing.T) {
|
||||
got, err := ParseSubnets("192.168.1.7/24, 10.0.0.0/22")
|
||||
if err != nil || len(got) != 2 || got[0].String() != "192.168.1.0/24" {
|
||||
t.Fatalf("got %v, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"8.8.8.0/24", "10.0.0.0/8", "192.168.0.0/16", "nonsense", "1.2.3.4/32"} {
|
||||
if _, err := ParseSubnets(bad); err == nil {
|
||||
t.Errorf("%q should be refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePorts(t *testing.T) {
|
||||
got, err := ParsePorts("443, 80,8000-8002,80")
|
||||
if err != nil || len(got) != 5 || got[0] != 80 || got[4] != 8002 {
|
||||
t.Fatalf("got %v, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"0", "70000", "9-3", "x", "1-5000"} {
|
||||
if _, err := ParsePorts(bad); err == nil {
|
||||
t.Errorf("%q should be refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcNetTCP(t *testing.T) {
|
||||
v4 := ` sl local_address rem_address st tx_queue rx_queue
|
||||
0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 1 1
|
||||
1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1
|
||||
2: 0100007F:D431 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 1000 0 3 1
|
||||
`
|
||||
got := ParseProcNetTCP(v4, false)
|
||||
if len(got) != 2 || got[0].String() != "127.0.0.1:8080" || got[1].String() != "0.0.0.0:22" {
|
||||
t.Fatalf("v4: %v", got)
|
||||
}
|
||||
v6 := ` sl local_address rem_address st
|
||||
0: 00000000000000000000000001000000:0050 00000000000000000000000000000000:0000 0A 0:0 0:0 0 0 0 1
|
||||
`
|
||||
got = ParseProcNetTCP(v6, true)
|
||||
if len(got) != 1 || got[0].String() != "[::1]:80" {
|
||||
t.Fatalf("v6: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// banner starts a listener that says line first (or stays silent when line is "").
|
||||
func banner(t *testing.T, line string) netip.AddrPort {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if line != "" {
|
||||
c.Write([]byte(line + "\r\n"))
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
return netip.MustParseAddrPort(ln.Addr().String())
|
||||
}
|
||||
|
||||
func newTest(t *testing.T, cfg Config) *Discoverer {
|
||||
t.Helper()
|
||||
cfg.Rate = 10000
|
||||
cfg.timeout = time.Second
|
||||
d, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestIdentify(t *testing.T) {
|
||||
ssh := banner(t, "SSH-2.0-OpenSSH_9.6")
|
||||
smtp := banner(t, "220 mail.example ESMTP Postfix")
|
||||
web := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "testd/1"); w.WriteHeader(200) }))
|
||||
defer web.Close()
|
||||
tlsWeb := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer tlsWeb.Close()
|
||||
silent := banner(t, "")
|
||||
|
||||
d := newTest(t, Config{})
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
ap netip.AddrPort
|
||||
want string
|
||||
det string
|
||||
}{
|
||||
{"ssh", ssh, "ssh", "SSH-2.0"},
|
||||
{"smtp", smtp, "smtp", "ESMTP"},
|
||||
{"http", netip.MustParseAddrPort(strings.TrimPrefix(web.URL, "http://")), "http", "testd/1"},
|
||||
{"https", netip.MustParseAddrPort(strings.TrimPrefix(tlsWeb.URL, "https://")), "https", ""},
|
||||
{"silent", silent, "", ""},
|
||||
} {
|
||||
svc, detail := d.identify(ctx, c.ap)
|
||||
if svc != c.want || !strings.Contains(detail, c.det) {
|
||||
t.Errorf("%s: got %q %q, want %q containing %q", c.name, svc, detail, c.want, c.det)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepLocalAndScan(t *testing.T) {
|
||||
ssh := banner(t, "SSH-2.0-test")
|
||||
d := newTest(t, Config{
|
||||
Local: true,
|
||||
Subnets: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/30")},
|
||||
Ports: []int{int(ssh.Port()), 1}, // port 1 is closed
|
||||
Exclude: []int{1},
|
||||
listening: func() ([]netip.AddrPort, error) {
|
||||
return []netip.AddrPort{netip.AddrPortFrom(netip.IPv4Unspecified(), ssh.Port())}, nil
|
||||
},
|
||||
})
|
||||
res := d.Sweep(context.Background())
|
||||
if len(res) != 1 { // the local hit and the scan hit are the same endpoint, reported once
|
||||
t.Fatalf("want one service, got %+v", res)
|
||||
}
|
||||
r := res[0]
|
||||
if r.Host != "127.0.0.1" || r.Port != int(ssh.Port()) || r.Service != "ssh" || r.Source != "local" || r.Proto != "tcp" {
|
||||
t.Errorf("unexpected: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludeLocalPort(t *testing.T) {
|
||||
ssh := banner(t, "SSH-2.0-test")
|
||||
d := newTest(t, Config{Local: true, Exclude: []int{int(ssh.Port())},
|
||||
listening: func() ([]netip.AddrPort, error) { return []netip.AddrPort{ssh}, nil }})
|
||||
if res := d.Sweep(context.Background()); len(res) != 0 {
|
||||
t.Fatalf("excluded port was reported: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSkipsNetworkAndBroadcast(t *testing.T) {
|
||||
var hosts = map[string]bool{}
|
||||
d := newTest(t, Config{Subnets: []netip.Prefix{netip.MustParsePrefix("192.168.9.0/30")}, Ports: []int{80}})
|
||||
d.cfg.dial = func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
h, _, _ := net.SplitHostPort(addr)
|
||||
hosts[h] = true
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
d.Sweep(context.Background())
|
||||
if len(hosts) != 2 || hosts["192.168.9.0"] || hosts["192.168.9.3"] {
|
||||
t.Fatalf("probed %v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
var n atomic.Int32
|
||||
d, err := New(Config{Subnets: []netip.Prefix{netip.MustParsePrefix("192.168.9.0/29")}, Ports: []int{80, 81}, Rate: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.cfg.dial = func(ctx context.Context, addr string) (net.Conn, error) { n.Add(1); return nil, net.ErrClosed }
|
||||
start := time.Now()
|
||||
d.Sweep(context.Background()) // 6 hosts x 2 ports = 12 probes at 50/s: at least ~220ms
|
||||
if n.Load() != 12 {
|
||||
t.Fatalf("probes: %d", n.Load())
|
||||
}
|
||||
if el := time.Since(start); el < 200*time.Millisecond {
|
||||
t.Fatalf("not rate limited: %v", el)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepStopsOnCancel(t *testing.T) {
|
||||
d, _ := New(Config{Subnets: []netip.Prefix{netip.MustParsePrefix("10.9.0.0/22")}, Ports: []int{80}, Rate: 1})
|
||||
d.cfg.dial = func(ctx context.Context, addr string) (net.Conn, error) { return nil, net.ErrClosed }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { d.Sweep(ctx); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("sweep ignored cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesPublicSubnet(t *testing.T) {
|
||||
if _, err := New(Config{Subnets: []netip.Prefix{netip.MustParsePrefix("8.8.8.0/24")}}); err == nil {
|
||||
t.Fatal("public subnet accepted")
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,18 @@ module git.veillax.com/Veillax/bamboo
|
||||
|
||||
go 1.27.3
|
||||
|
||||
require git.veillax.com/Veillax/reef v0.1.0
|
||||
require (
|
||||
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/crypto v0.58.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
||||
git.veillax.com/Veillax/reef v0.1.0/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=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
@@ -10,6 +12,8 @@ golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
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/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build !windows
|
||||
|
||||
package sshd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
ptySupported = true
|
||||
runSupported = true
|
||||
)
|
||||
|
||||
// run starts the user's shell (or, for exec, runs cmd through it) and returns its exit code.
|
||||
// With a pty request it gets a terminal, otherwise plain pipes.
|
||||
func run(ch ssh.Channel, cmd string, p *ptyReq, win <-chan [2]uint32) int {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
// Without this the client sees only a session that ends at once. The container image Bamboo is
|
||||
// published in contains no shell at all, so say so, and what to do about it.
|
||||
if _, err := exec.LookPath(shell); err != nil {
|
||||
fmt.Fprintf(ch.Stderr(), "bamboo: there is no shell to run (%v).\r\n"+
|
||||
"The Bamboo container image has none, and a shell in a container is not a shell on the machine. "+
|
||||
"Run Bamboo directly on the machine (for example with its systemd unit) to get one, or set SHELL to a shell that exists.\r\n", err)
|
||||
return 127
|
||||
}
|
||||
var c *exec.Cmd
|
||||
if cmd == "" {
|
||||
c = exec.Command(shell, "-l")
|
||||
} else {
|
||||
c = exec.Command(shell, "-c", cmd)
|
||||
}
|
||||
c.Env = os.Environ()
|
||||
if p != nil {
|
||||
term := p.term
|
||||
if term == "" {
|
||||
term = "xterm"
|
||||
}
|
||||
c.Env = append(c.Env, "TERM="+term)
|
||||
return runPty(ch, c, p, win)
|
||||
}
|
||||
in, err := c.StdinPipe()
|
||||
if err != nil {
|
||||
fmt.Fprintf(ch.Stderr(), "bamboo: %v\r\n", err)
|
||||
return 255
|
||||
}
|
||||
go func() { io.Copy(in, ch); in.Close() }()
|
||||
c.Stdout = ch
|
||||
c.Stderr = ch.Stderr()
|
||||
return exitCode(c.Run(), c)
|
||||
}
|
||||
|
||||
func runPty(ch ssh.Channel, c *exec.Cmd, p *ptyReq, win <-chan [2]uint32) int {
|
||||
f, err := pty.StartWithSize(c, &pty.Winsize{Cols: uint16(p.cols), Rows: uint16(p.rows)})
|
||||
if err != nil {
|
||||
fmt.Fprintf(ch.Stderr(), "bamboo: %v\r\n", err)
|
||||
return 255
|
||||
}
|
||||
defer f.Close()
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case w := <-win:
|
||||
pty.Setsize(f, &pty.Winsize{Cols: uint16(w[0]), Rows: uint16(w[1])})
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() { io.Copy(f, ch) }()
|
||||
io.Copy(ch, f) // ends with an error when the shell exits and the terminal closes
|
||||
return exitCode(c.Wait(), c)
|
||||
}
|
||||
|
||||
func exitCode(err error, c *exec.Cmd) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && ee.ExitCode() >= 0 {
|
||||
return ee.ExitCode()
|
||||
}
|
||||
return 255
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build !windows
|
||||
|
||||
package sshd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func session(t *testing.T) (*ssh.Session, func()) {
|
||||
t.Helper()
|
||||
good := newSigner(t)
|
||||
addr, _, _ := start(t, good)
|
||||
c, err := dial(addr, ssh.PublicKeys(good))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := c.NewSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, func() { s.Close(); c.Close() }
|
||||
}
|
||||
|
||||
func TestExec(t *testing.T) {
|
||||
s, done := session(t)
|
||||
defer done()
|
||||
out, err := s.Output("echo hello; echo oops >&2")
|
||||
if err != nil || string(out) != "hello\n" {
|
||||
t.Fatalf("got %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecExitStatus(t *testing.T) {
|
||||
s, done := session(t)
|
||||
defer done()
|
||||
err := s.Run("exit 3")
|
||||
var ee *ssh.ExitError
|
||||
if !errors.As(err, &ee) || ee.ExitStatus() != 3 {
|
||||
t.Fatalf("want exit status 3, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecReadsStdin(t *testing.T) {
|
||||
s, done := session(t)
|
||||
defer done()
|
||||
s.Stdin = strings.NewReader("from the client\n")
|
||||
out, err := s.Output("cat")
|
||||
if err != nil || string(out) != "from the client\n" {
|
||||
t.Fatalf("got %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPtyExec(t *testing.T) {
|
||||
s, done := session(t)
|
||||
defer done()
|
||||
if err := s.RequestPty("xterm", 24, 80, ssh.TerminalModes{ssh.ECHO: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.CombinedOutput(`echo term=$TERM; test -t 0 && echo tty`)
|
||||
if err != nil || !strings.Contains(string(out), "term=xterm") || !strings.Contains(string(out), "tty") {
|
||||
t.Fatalf("got %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShell(t *testing.T) {
|
||||
s, done := session(t)
|
||||
defer done()
|
||||
s.Stdin = strings.NewReader("echo from-shell\nexit 4\n")
|
||||
var out strings.Builder
|
||||
s.Stdout = &out
|
||||
err := s.Shell()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.Wait()
|
||||
var ee *ssh.ExitError
|
||||
if !errors.As(err, &ee) || ee.ExitStatus() != 4 || !strings.Contains(out.String(), "from-shell") {
|
||||
t.Fatalf("got %q, %v", out.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with no shell (the published container image is one) must say so, not just hang up.
|
||||
func TestNoShellIsExplained(t *testing.T) {
|
||||
t.Setenv("SHELL", "/nonexistent/shell")
|
||||
for name, usePty := range map[string]bool{"exec": false, "pty shell": true} {
|
||||
s, done := session(t)
|
||||
var stderr, stdout strings.Builder
|
||||
s.Stderr, s.Stdout = &stderr, &stdout
|
||||
var err error
|
||||
if usePty {
|
||||
if err = s.RequestPty("xterm", 24, 80, ssh.TerminalModes{}); err == nil {
|
||||
err = s.Shell()
|
||||
if err == nil {
|
||||
err = s.Wait()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = s.Run("true")
|
||||
}
|
||||
done()
|
||||
var ee *ssh.ExitError
|
||||
if !errors.As(err, &ee) || ee.ExitStatus() != 127 {
|
||||
t.Errorf("%s: want exit status 127, got %v", name, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String()+stdout.String(), "no shell to run") {
|
||||
t.Errorf("%s: no explanation (stderr %q, stdout %q)", name, stderr.String(), stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package sshd
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
|
||||
// Shells need a pseudo-terminal, which Windows has no simple equivalent of here, so the server
|
||||
// signs people in but refuses to start a session. main reports this before it starts the server.
|
||||
const (
|
||||
ptySupported = false
|
||||
runSupported = false
|
||||
)
|
||||
|
||||
func run(ssh.Channel, string, *ptyReq, <-chan [2]uint32) int { return 1 }
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
// Package sshd is a small SSH server for Bamboo, so a machine behind NAT can be reached with an
|
||||
// ordinary ssh client through a tunnelled port, without installing or configuring sshd. It is
|
||||
// opt-in and deliberately narrow: public keys only (listed in an authorized_keys file), session
|
||||
// channels only (a shell or one command, no port forwarding, no agent forwarding, no SFTP), and
|
||||
// it listens on the loopback address unless told otherwise. Sessions run as the user Bamboo runs as.
|
||||
package sshd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultListen is loopback only: a Hammerhead port that targets this site reaches it
|
||||
// through Bamboo, and nothing else on the network can.
|
||||
DefaultListen = "127.0.0.1:2222"
|
||||
|
||||
maxConns = 16
|
||||
handshakeTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// Supported says whether this platform can run shells (it cannot on Windows).
|
||||
const Supported = runSupported
|
||||
|
||||
// Config says where the server listens and which keys may sign in.
|
||||
type Config struct {
|
||||
Listen string // host:port, default DefaultListen
|
||||
AuthorizedKeys string // path of an authorized_keys file; read on every sign-in, so edits apply at once
|
||||
HostKey string // path of the server's ed25519 key; created on first start
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Server accepts SSH connections.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
conf *ssh.ServerConfig
|
||||
host ssh.Signer
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// New loads (or creates) the host key and checks that at least one authorized key exists, so
|
||||
// the server never starts in a state where nobody, or everybody, could sign in.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = DefaultListen
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = func(string, ...any) {}
|
||||
}
|
||||
if cfg.AuthorizedKeys == "" {
|
||||
return nil, errors.New("no authorized_keys file configured")
|
||||
}
|
||||
keys, err := loadAuthorizedKeys(cfg.AuthorizedKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, fmt.Errorf("%s has no public keys: add the keys that may sign in, one per line", cfg.AuthorizedKeys)
|
||||
}
|
||||
signer, err := loadOrCreateHostKey(cfg.HostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, host: signer, sem: make(chan struct{}, maxConns)}
|
||||
s.conf = &ssh.ServerConfig{
|
||||
MaxAuthTries: 3,
|
||||
// Only public keys. There is no password or keyboard-interactive callback at all.
|
||||
PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
keys, err := loadAuthorizedKeys(s.cfg.AuthorizedKeys)
|
||||
if err != nil {
|
||||
s.cfg.Logf("ssh: %v", err)
|
||||
return nil, errors.New("no access")
|
||||
}
|
||||
for _, k := range keys {
|
||||
if bytes.Equal(k.Marshal(), key.Marshal()) {
|
||||
return &ssh.Permissions{Extensions: map[string]string{"fp": ssh.FingerprintSHA256(key)}}, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("key not authorized")
|
||||
},
|
||||
}
|
||||
s.conf.AddHostKey(signer)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Fingerprint is the server's host key fingerprint, for the log so it can be checked on first use.
|
||||
func (s *Server) Fingerprint() string { return ssh.FingerprintSHA256(s.host.PublicKey()) }
|
||||
|
||||
// Run listens until ctx is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
ln, err := net.Listen("tcp", s.cfg.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Serve(ctx, ln)
|
||||
}
|
||||
|
||||
// Serve accepts connections on ln until ctx is cancelled.
|
||||
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
|
||||
go func() { <-ctx.Done(); ln.Close() }()
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case s.sem <- struct{}{}:
|
||||
default:
|
||||
c.Close() // too many sessions already
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-s.sem }()
|
||||
s.handle(ctx, c)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handle(ctx context.Context, nc net.Conn) {
|
||||
defer nc.Close()
|
||||
nc.SetDeadline(time.Now().Add(handshakeTimeout))
|
||||
rc := &readCounter{Conn: nc}
|
||||
conn, chans, reqs, err := ssh.NewServerConn(rc, s.conf)
|
||||
if err != nil {
|
||||
// A client that connects and never says anything is a port probe or a health check (Hammerhead
|
||||
// checks every TCP resource this way), not a failed sign-in, so it is not worth a log line each time.
|
||||
if rc.n.Load() > 0 {
|
||||
s.cfg.Logf("ssh: sign-in from %s failed: %v", nc.RemoteAddr(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
nc.SetDeadline(time.Time{})
|
||||
defer conn.Close()
|
||||
s.cfg.Logf("ssh: %s signed in as %s (%s)", conn.User(), nc.RemoteAddr(), conn.Permissions.Extensions["fp"])
|
||||
go func() { <-ctx.Done(); conn.Close() }()
|
||||
go ssh.DiscardRequests(reqs)
|
||||
var wg sync.WaitGroup
|
||||
for ch := range chans {
|
||||
if ch.ChannelType() != "session" {
|
||||
ch.Reject(ssh.UnknownChannelType, "only sessions are allowed")
|
||||
continue
|
||||
}
|
||||
c, creqs, err := ch.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.session(c, creqs)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// readCounter counts the bytes a client has sent.
|
||||
type readCounter struct {
|
||||
net.Conn
|
||||
n atomic.Int64
|
||||
}
|
||||
|
||||
func (r *readCounter) Read(p []byte) (int, error) {
|
||||
n, err := r.Conn.Read(p)
|
||||
r.n.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
type ptyReq struct {
|
||||
term string
|
||||
cols, rows uint32
|
||||
}
|
||||
|
||||
// session answers a session channel's requests: pty-req, window-change, env (ignored), shell and exec.
|
||||
func (s *Server) session(ch ssh.Channel, reqs <-chan *ssh.Request) {
|
||||
defer ch.Close()
|
||||
var pty *ptyReq
|
||||
var win chan [2]uint32
|
||||
for req := range reqs {
|
||||
switch req.Type {
|
||||
case "pty-req":
|
||||
var p struct {
|
||||
Term string
|
||||
Cols, Rows, Wpx, Hpx uint32
|
||||
Modes string
|
||||
}
|
||||
if ssh.Unmarshal(req.Payload, &p) != nil || !ptySupported {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
pty = &ptyReq{term: p.Term, cols: p.Cols, rows: p.Rows}
|
||||
win = make(chan [2]uint32, 4)
|
||||
req.Reply(true, nil)
|
||||
case "window-change":
|
||||
var w struct{ Cols, Rows, Wpx, Hpx uint32 }
|
||||
if ssh.Unmarshal(req.Payload, &w) == nil && win != nil {
|
||||
select {
|
||||
case win <- [2]uint32{w.Cols, w.Rows}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
case "env":
|
||||
req.Reply(true, nil) // accepted and ignored: the client cannot change the environment
|
||||
case "shell", "exec":
|
||||
var cmd string
|
||||
if req.Type == "exec" {
|
||||
var e struct{ Command string }
|
||||
if ssh.Unmarshal(req.Payload, &e) != nil {
|
||||
req.Reply(false, nil)
|
||||
return
|
||||
}
|
||||
cmd = e.Command
|
||||
}
|
||||
if !runSupported {
|
||||
req.Reply(false, nil)
|
||||
return
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
code := run(ch, cmd, pty, win)
|
||||
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
||||
return
|
||||
default:
|
||||
req.Reply(false, nil) // subsystem (sftp), agent and X11 forwarding, signals: not offered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadAuthorizedKeys reads the public keys from an authorized_keys file. Options before a key
|
||||
// (command=, from=, ...) are not supported, so a line that has them is skipped rather than being
|
||||
// honoured too loosely.
|
||||
func loadAuthorizedKeys(path string) ([]ssh.PublicKey, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorized keys: %w", err)
|
||||
}
|
||||
var keys []ssh.PublicKey
|
||||
for len(b) > 0 {
|
||||
key, _, options, rest, err := ssh.ParseAuthorizedKey(b)
|
||||
if err != nil {
|
||||
break // comments and blank lines are skipped by the parser; anything else ends the list
|
||||
}
|
||||
b = rest
|
||||
if len(options) > 0 {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// loadOrCreateHostKey keeps the server's identity across restarts, so clients are not warned
|
||||
// that it changed.
|
||||
func loadOrCreateHostKey(path string) (ssh.Signer, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("no host key path configured")
|
||||
}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
return ssh.ParsePrivateKey(b)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.ParsePrivateKey(pemBytes)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package sshd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func newSigner(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
|
||||
}
|
||||
|
||||
func authLine(s ssh.Signer) string { return string(ssh.MarshalAuthorizedKey(s.PublicKey())) }
|
||||
|
||||
// start runs a server that accepts the given signers and returns its address.
|
||||
func start(t *testing.T, allowed ...ssh.Signer) (addr, keysFile string, srv *Server) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keysFile = filepath.Join(dir, "authorized_keys")
|
||||
var lines string
|
||||
for _, s := range allowed {
|
||||
lines += authLine(s)
|
||||
}
|
||||
if err := os.WriteFile(keysFile, []byte("# who may sign in\n"+lines), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := New(Config{AuthorizedKeys: keysFile, HostKey: filepath.Join(dir, "host_key"), Logf: t.Logf})
|
||||
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())
|
||||
done := make(chan struct{})
|
||||
go func() { srv.Serve(ctx, ln); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
return ln.Addr().String(), keysFile, srv
|
||||
}
|
||||
|
||||
func dial(addr string, auth ...ssh.AuthMethod) (*ssh.Client, error) {
|
||||
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "tester", Auth: auth, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second})
|
||||
}
|
||||
|
||||
func TestNewNeedsKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{AuthorizedKeys: filepath.Join(dir, "authorized_keys"), HostKey: filepath.Join(dir, "host_key")}
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("a missing authorized_keys file should be an error")
|
||||
}
|
||||
os.WriteFile(cfg.AuthorizedKeys, []byte("# nothing yet\n\n"), 0o600)
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("an authorized_keys file without keys should be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsLinesAreSkipped(t *testing.T) {
|
||||
a, b := newSigner(t), newSigner(t)
|
||||
path := filepath.Join(t.TempDir(), "authorized_keys")
|
||||
os.WriteFile(path, []byte(`command="/bin/false" `+authLine(a)+authLine(b)), 0o600)
|
||||
keys, err := loadAuthorizedKeys(path)
|
||||
if err != nil || len(keys) != 1 || ssh.FingerprintSHA256(keys[0]) != ssh.FingerprintSHA256(b.PublicKey()) {
|
||||
t.Fatalf("only the key without options should load, got %d keys, err %v", len(keys), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyIsStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keys := filepath.Join(dir, "authorized_keys")
|
||||
os.WriteFile(keys, []byte(authLine(newSigner(t))), 0o600)
|
||||
cfg := Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "sub", "host_key")}
|
||||
a, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Fingerprint() != b.Fingerprint() {
|
||||
t.Fatal("the host key changed between starts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAuth(t *testing.T) {
|
||||
good, bad := newSigner(t), newSigner(t)
|
||||
addr, _, _ := start(t, good)
|
||||
|
||||
c, err := dial(addr, ssh.PublicKeys(good))
|
||||
if err != nil {
|
||||
t.Fatalf("authorized key refused: %v", err)
|
||||
}
|
||||
c.Close()
|
||||
if _, err := dial(addr, ssh.PublicKeys(bad)); err == nil {
|
||||
t.Fatal("unknown key was accepted")
|
||||
}
|
||||
if _, err := dial(addr, ssh.Password("hunter2")); err == nil {
|
||||
t.Fatal("a password was accepted")
|
||||
}
|
||||
if _, err := dial(addr, ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { return nil, nil })); err == nil {
|
||||
t.Fatal("keyboard-interactive was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysCanChangeWhileRunning(t *testing.T) {
|
||||
first, second := newSigner(t), newSigner(t)
|
||||
addr, keysFile, _ := start(t, first)
|
||||
if _, err := dial(addr, ssh.PublicKeys(second)); err == nil {
|
||||
t.Fatal("second key accepted before it was added")
|
||||
}
|
||||
f, _ := os.OpenFile(keysFile, os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
f.WriteString(authLine(second))
|
||||
f.Close()
|
||||
c, err := dial(addr, ssh.PublicKeys(second))
|
||||
if err != nil {
|
||||
t.Fatalf("key added to the file was not picked up: %v", err)
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
|
||||
func TestNoForwarding(t *testing.T) {
|
||||
good := newSigner(t)
|
||||
addr, _, _ := start(t, good)
|
||||
c, err := dial(addr, ssh.PublicKeys(good))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if conn, err := c.Dial("tcp", "127.0.0.1:1"); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("port forwarding was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// Hammerhead health-checks a TCP resource by connecting and hanging up; that must not look like
|
||||
// a failed sign-in in the log, while a real failure still does.
|
||||
func TestProbesAreNotLogged(t *testing.T) {
|
||||
good := newSigner(t)
|
||||
dir := t.TempDir()
|
||||
keys := filepath.Join(dir, "authorized_keys")
|
||||
if err := os.WriteFile(keys, []byte(authLine(good)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var logs []string
|
||||
srv, err := New(Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "host_key"), Logf: func(f string, a ...any) {
|
||||
mu.Lock()
|
||||
logs = append(logs, fmt.Sprintf(f, a...))
|
||||
mu.Unlock()
|
||||
}})
|
||||
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())
|
||||
done := make(chan struct{})
|
||||
go func() { srv.Serve(ctx, ln); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
c, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if _, err := dial(ln.Addr().String(), ssh.PublicKeys(newSigner(t))); err == nil {
|
||||
t.Fatal("an unknown key signed in")
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], "failed") {
|
||||
t.Fatalf("want exactly the one real failure logged, got %q", logs)
|
||||
}
|
||||
}
|
||||
+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