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>
This commit is contained in:
1 parent
17c222c946
commit
710b101436
8 files changed
+717
-16
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user