Compare commits
2
Commits
17c222c946
...
2dea372f9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dea372f9a | ||
|
|
710b101436 |
No files matched your search
@@ -7,6 +7,13 @@ BAMBOO_TOKEN=
|
|||||||
# Report local Docker containers so they can be picked as targets.
|
# Report local Docker containers so they can be picked as targets.
|
||||||
#BAMBOO_DOCKER=1
|
#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
|
||||||
|
|
||||||
# Folder for this agent's WireGuard key.
|
# Folder for this agent's WireGuard key.
|
||||||
#BAMBOO_DATA=./bamboo-data
|
#BAMBOO_DATA=./bamboo-data
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ Bamboo only makes outbound connections (HTTPS to Hammerhead, and WireGuard UDP t
|
|||||||
| `-token` | `BAMBOO_TOKEN` | the site's token |
|
| `-token` | `BAMBOO_TOKEN` | the site's token |
|
||||||
| `-data` | `BAMBOO_DATA` | folder for this agent's WireGuard key (default `./bamboo-data`) |
|
| `-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 |
|
| `-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) |
|
||||||
| `-env` | | `.env` file to read (default `./.env`) |
|
| `-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.
|
Settings and secrets can live in a `.env` file instead of the command line; see `.env.example`. Real environment variables always win.
|
||||||
@@ -28,6 +32,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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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
|
## Deploying
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo/discover"
|
||||||
"git.veillax.com/Veillax/reef/docker"
|
"git.veillax.com/Veillax/reef/docker"
|
||||||
"git.veillax.com/Veillax/reef/model"
|
"git.veillax.com/Veillax/reef/model"
|
||||||
"git.veillax.com/Veillax/reef/tunnel"
|
"git.veillax.com/Veillax/reef/tunnel"
|
||||||
@@ -35,6 +36,7 @@ type Config struct {
|
|||||||
Token string
|
Token string
|
||||||
DataDir string // holds this agent's WireGuard key
|
DataDir string // holds this agent's WireGuard key
|
||||||
Docker bool // report local Docker containers so they can be picked as targets
|
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
|
||||||
|
|
||||||
// Version, AutoUpdate and UpdateNow work as in blacktip.Config.
|
// Version, AutoUpdate and UpdateNow work as in blacktip.Config.
|
||||||
Version string
|
Version string
|
||||||
@@ -64,6 +66,8 @@ type Bamboo struct {
|
|||||||
updLog []string
|
updLog []string
|
||||||
updSent time.Time
|
updSent time.Time
|
||||||
|
|
||||||
|
disc *discover.Discoverer // nil unless discovery is switched on
|
||||||
|
|
||||||
mail map[string]*mailRun
|
mail map[string]*mailRun
|
||||||
mailErr map[string]string // mail egress ID -> "" (listening) or an error
|
mailErr map[string]string // mail egress ID -> "" (listening) or an error
|
||||||
}
|
}
|
||||||
@@ -80,8 +84,14 @@ func New(cfg Config) (*Bamboo, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Bamboo{cfg: cfg, http: &http.Client{Timeout: 40 * time.Second}, ctx: context.Background(), wgKey: key, wgPub: pub,
|
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{}}, nil
|
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.
|
// loadOrCreateKey keeps this agent's WireGuard identity across restarts.
|
||||||
@@ -106,6 +116,9 @@ func (b *Bamboo) Run(ctx context.Context) error {
|
|||||||
log.Printf("bamboo started; WireGuard public key %s", b.wgPub)
|
log.Printf("bamboo started; WireGuard public key %s", b.wgPub)
|
||||||
go b.reportLoop(ctx)
|
go b.reportLoop(ctx)
|
||||||
go b.refreshLoop(ctx)
|
go b.refreshLoop(ctx)
|
||||||
|
if b.disc != nil {
|
||||||
|
go b.disc.Run(ctx, log.Printf)
|
||||||
|
}
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
cfg, err := b.fetchConfig(ctx)
|
cfg, err := b.fetchConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -263,10 +276,14 @@ func (b *Bamboo) report() {
|
|||||||
containers, _ = docker.List(ctx)
|
containers, _ = docker.List(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
var found []model.DiscoveredService
|
||||||
|
if b.disc != nil {
|
||||||
|
found = b.disc.Results()
|
||||||
|
}
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...)
|
msg, uid, stage, pct, ulog := b.updateMsg, b.updateID, b.updStage, b.updPct, append([]string(nil), b.updLog...)
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, MailEgress: b.mailStatus(),
|
body, _ := json.Marshal(model.SiteReport{PublicKey: b.wgPub, Connected: ids, Containers: containers, Discovered: found, MailEgress: b.mailStatus(),
|
||||||
Version: b.cfg.Version, AutoUpdate: b.cfg.AutoUpdate, UpdateMsg: msg, UpdateID: uid, UpdateStage: stage, UpdatePct: pct, UpdateLog: ulog})
|
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, _ := http.NewRequest("POST", b.cfg.Hammerhead+"/site/report", bytes.NewReader(body))
|
||||||
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
req.Header.Set("Authorization", "Bearer "+b.cfg.Token)
|
||||||
|
|||||||
+22
-1
@@ -7,9 +7,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/bamboo"
|
"git.veillax.com/Veillax/bamboo"
|
||||||
|
"git.veillax.com/Veillax/bamboo/discover"
|
||||||
"git.veillax.com/Veillax/reef/dotenv"
|
"git.veillax.com/Veillax/reef/dotenv"
|
||||||
"git.veillax.com/Veillax/reef/selfupdate"
|
"git.veillax.com/Veillax/reef/selfupdate"
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,13 @@ func env(k, def string) string {
|
|||||||
return def
|
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() {
|
func main() {
|
||||||
// secrets (tokens, DNS-provider credentials) can live in a .env file instead of the command line
|
// secrets (tokens, DNS-provider credentials) can live in a .env file instead of the command line
|
||||||
envFile := dotenv.ArgPath(os.Args[1:], ".env")
|
envFile := dotenv.ArgPath(os.Args[1:], ".env")
|
||||||
@@ -34,6 +43,10 @@ func main() {
|
|||||||
token := flag.String("token", env("BAMBOO_TOKEN", ""), "site token from the hammerhead UI")
|
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")
|
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")
|
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")
|
||||||
flag.String("env", ".env", "file to read secrets and settings from")
|
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)")
|
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")
|
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||||
@@ -53,7 +66,15 @@ func main() {
|
|||||||
if *autoUpdate {
|
if *autoUpdate {
|
||||||
go selfupdate.Run(ctx, upd)
|
go selfupdate.Run(ctx, upd)
|
||||||
}
|
}
|
||||||
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn,
|
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)
|
||||||
|
}
|
||||||
|
b, err := bamboo.New(bamboo.Config{Hammerhead: strings.TrimRight(*hh, "/"), Token: *token, DataDir: *data, Docker: *dockerOn, Discover: dc,
|
||||||
Version: version, AutoUpdate: *autoUpdate,
|
Version: version, AutoUpdate: *autoUpdate,
|
||||||
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
|
UpdateNow: func(ctx context.Context, progress func(string, int, string)) (string, error) {
|
||||||
c := upd
|
c := upd
|
||||||
|
|||||||
@@ -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,7 +2,10 @@ module git.veillax.com/Veillax/bamboo
|
|||||||
|
|
||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require git.veillax.com/Veillax/reef v0.1.7
|
require (
|
||||||
|
git.veillax.com/Veillax/reef v0.1.11
|
||||||
|
golang.org/x/time v0.7.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.4 // indirect
|
github.com/Microsoft/go-winio v0.6.4 // indirect
|
||||||
@@ -10,7 +13,6 @@ require (
|
|||||||
golang.org/x/crypto v0.58.0 // indirect
|
golang.org/x/crypto v0.58.0 // indirect
|
||||||
golang.org/x/net v0.61.0 // indirect
|
golang.org/x/net v0.61.0 // indirect
|
||||||
golang.org/x/sys v0.49.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/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
||||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||||
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.5 h1:BydjLJsfGgU4zEIqUsePJRjPi9NgbFdmvq0M8dvJCgM=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.5/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU=
|
|
||||||
git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
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/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||||
|
|||||||
Reference in new issue
Block a user