Files
Bamboo/discover/discover.go
T
VeillaxandClaude Sonnet 5.5 710b101436 Add opt-in service discovery: local listeners and rate-limited private subnet scans
Reports identified services (HTTP, HTTPS, SSH, SMTP, FTP, ...) to Hammerhead as
suggested targets. Needs a Reef release containing model.DiscoveredService.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:56:46 -06:00

456 lines
13 KiB
Go

// 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
}