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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user