// Package portscan performs concurrent TCP port scanning on all online hosts. package portscan import ( "context" "fmt" "log/slog" "net" "sort" "sync" "time" "github.com/cereda-systems/nexus-scanner/internal/config" "github.com/cereda-systems/nexus-scanner/internal/db" ) const modName = "port_scan" // Common ports to scan when none are configured. var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100} // Module scans TCP ports on all online hosts. type Module struct { site string cfg config.PortScanConfig store *db.Store } // New creates a new port scan module. func New(site string, cfg config.PortScanConfig, store *db.Store) *Module { return &Module{site: site, cfg: cfg, store: store} } func (m *Module) Name() string { return modName } func (m *Module) Interval() time.Duration { return m.cfg.Interval } // Run scans all online hosts and stores their open ports. func (m *Module) Run(ctx context.Context) error { hosts, err := m.store.ListHosts("") if err != nil { return fmt.Errorf("list hosts: %w", err) } ports := m.cfg.Ports if len(ports) == 0 { ports = defaultPorts } timeout := m.cfg.Timeout if timeout == 0 { timeout = 500 * time.Millisecond } scanID, err := m.store.BeginScan(modName) if err != nil { return fmt.Errorf("begin scan: %w", err) } found := 0 for _, h := range hosts { if h.Status != "online" { continue } open := scanHost(ctx, h.IP, ports, timeout) sort.Ints(open) if err := m.store.UpsertHostPorts(h.IP, open); err != nil { slog.Error("save host ports", "ip", h.IP, "err", err) } if len(open) > 0 { slog.Debug("port scan", "ip", h.IP, "open", open) found++ } } _ = m.store.EndScan(scanID, nil) slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts)) return nil } // scanHost tries all ports concurrently (max 30 goroutines) and returns open ones. func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int { var mu sync.Mutex var open []int var wg sync.WaitGroup sem := make(chan struct{}, 30) for _, port := range ports { wg.Add(1) go func(p int) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() addr := fmt.Sprintf("%s:%d", ip, p) d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", addr) if err == nil { conn.Close() mu.Lock() open = append(open, p) mu.Unlock() } }(port) } wg.Wait() return open }