Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,65 @@ _No unreleased changes._

---

## 26.12 — 2026-05-27

Service / application discovery (P2-01). Turns "port 22 is open" into
"SSH-2.0-OpenSSH_9.6p1 Ubuntu", and similar for nine more protocols.
Banners now flow into `Port.Service` for every persisted port, in addition
to the existing `Host.OSFingerprint` for the liveness winner.

### Added

- **`internal/scanner/banner.go`** — three new banner-grab strategies:
- `lineBanner` for protocols where the server greets first (SMTP
25/465/587, FTP 21, POP3 110, IMAP 143, Telnet 23). Bounded
`capReader` defends against peers that flood without an EOL.
- `tlsHTTPSFingerprint` for HTTPS (443/8443). Completes a TLS
handshake (InsecureSkipVerify — we're scraping for ID, not
trusting), peeks at the peer cert CN/SAN, then reuses the same
connection for a HEAD to capture the Server header. Two IDs for
the cost of one dial.
- `mysqlGreeting` for MySQL/MariaDB/Percona (3306). Reads the v10
handshake packet and extracts the server-version string. Passive
— we never write to the socket.
- **HTTP port list expanded** to include 8000 and 8888 (common
developer-server defaults).
- **`Port.Service` populated by the scanner** for every TCP port
upserted by the liveness, deep-probe, and HTTPS-fingerprint paths.
The column has existed in the schema since the initial migration
but was never written until now.
- **`internal/scanner/banner_test.go`** — full coverage of every
banner: SMTP greeting parse, FTP greeting parse, silent-server
timeout (must return ""), valid v10 MySQL handshake parse, wrong
protover MySQL guard, HTTPS handshake with cert CN extraction +
Server header pickup via a `httptest.NewUnstartedServer`.

### Changed

- **`scanner.upsertPort` gained a `service string` parameter.** The
three existing call sites (liveness, deepScan, udpScan) pass the
result of `fingerprint()` for TCP and `""` for UDP. UDP banner
probes are protocol-specific and out of scope for this sprint.
- **`fingerprint()` dispatch table grew** from 4 entries to 12 — see
the function comment for the full list.

### Notes

- The liveness path no longer redials for its banner: `host.OSFingerprint`
was already populated by `fingerprint()` before the port upsert, so
the same string is reused as the liveness-port `Service`.
- The deepScan path *does* redial inside `fingerprint()` per open
port. The first dial in `deepScan` was a connect-and-close to
confirm liveness; protocols where the server speaks first need a
fresh socket so the read deadline starts cleanly. The cost is one
extra dial per deep-open port per cycle — well within the global
worker semaphore.
- UDP services (DNS 53, SNMP 161, NTP 123, …) are not banner-grabbed
yet. Each requires a protocol-specific request packet rather than a
passive listen; deferred to a future sprint if asked.

---

## 26.11 — 2026-05-27

Device-type classifier (P2-03 from the operator-feedback queue). Populates
Expand Down
176 changes: 176 additions & 0 deletions internal/scanner/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package scanner

import (
"bufio"
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
)

// maxBannerBytes caps how much we read from each service. The longest
// realistic line banner is an Apache 2.4 Server header at ~120 bytes;
// MySQL greetings cap at the protocol-defined 256-byte packet.
const maxBannerBytes = 512

// lineBanner reads up to the first CR/LF or maxBannerBytes from a service
// that greets the client without prompting (SMTP, FTP, POP3, IMAP, Telnet).
// The label prefix ("SMTP: ", "FTP: ", …) keeps the stored string
// self-describing — operators inspecting Port.Service shouldn't have to
// look up the port number to know which protocol the banner came from.
func lineBanner(ctx context.Context, ip string, port int, timeout time.Duration, label string) string {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
if err != nil {
return ""
}
defer func() { _ = conn.Close() }()

_ = conn.SetReadDeadline(time.Now().Add(timeout))
line, err := bufio.NewReader(&capReader{r: conn, n: maxBannerBytes}).ReadString('\n')
if err != nil && line == "" {
return ""
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
return ""
}
return label + ": " + line
}

// tlsHTTPSFingerprint completes a TLS handshake to harvest the peer
// certificate's CN/SAN (which often identifies the appliance vendor —
// e.g. "*.unifi.example.com" → Ubiquiti), then sends a HEAD over the
// already-established connection to capture the Server header. Two pieces
// of identification for the cost of one connection.
//
// InsecureSkipVerify is intentional: self-signed and expired certs are the
// rule, not the exception, on internal inventory targets. We're not
// trusting the cert for security — we're scraping it for identification.
func tlsHTTPSFingerprint(ctx context.Context, ip string, port int, timeout time.Duration) string {
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: timeout},
Config: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // intentional, see comment
MinVersion: tls.VersionTLS10,
},
}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
if err != nil {
return ""
}
defer func() { _ = conn.Close() }()

var certInfo string
if tlsConn, ok := conn.(*tls.Conn); ok {
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) > 0 {
cert := state.PeerCertificates[0]
if cert.Subject.CommonName != "" {
certInfo = "CN=" + cert.Subject.CommonName
} else if len(cert.DNSNames) > 0 {
certInfo = "SAN=" + cert.DNSNames[0]
}
}
}

// HEAD over the existing TLS conn so we don't burn a second dial.
_ = conn.SetDeadline(time.Now().Add(timeout))
req, err := http.NewRequestWithContext(ctx, http.MethodHead,
fmt.Sprintf("https://%s/", net.JoinHostPort(ip, strconv.Itoa(port))), nil)
if err != nil && certInfo == "" {
return ""
}
var serverInfo string
if err == nil {
if writeErr := req.Write(conn); writeErr == nil {
resp, readErr := http.ReadResponse(bufio.NewReader(conn), req)
if readErr == nil {
_ = resp.Body.Close()
if s := resp.Header.Get("Server"); s != "" {
serverInfo = "Server=" + s
}
}
}
}

switch {
case certInfo != "" && serverInfo != "":
return "HTTPS: " + certInfo + " " + serverInfo
case serverInfo != "":
return "HTTPS: " + serverInfo
case certInfo != "":
return "HTTPS: " + certInfo
default:
return ""
}
}

// mysqlGreeting reads MySQL's initial handshake packet. The protocol is
// docs.oracle.com/cd/E17952_01/mysql-8.0-en/connection-phase-packets-
// protocol-handshake-v10.html — for our purposes:
//
// bytes 0..2 : packet length (LE)
// byte 3 : packet number (always 0 for greeting)
// byte 4 : protocol version (almost always 10)
// bytes 5.. : null-terminated server version string
//
// We don't need to parse past the version string. The handshake is the
// server's first packet — we never send anything, so this is passive.
func mysqlGreeting(ctx context.Context, ip string, port int, timeout time.Duration) string {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
if err != nil {
return ""
}
defer func() { _ = conn.Close() }()

_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 256)
n, err := conn.Read(buf)
if err != nil || n < 6 {
return ""
}
// Header sanity: byte 4 is the protocol version. MySQL has used
// version 10 since the 3.21 era (~1998). MariaDB and Percona too.
if buf[4] != 10 {
return ""
}
// Version string starts at byte 5, null-terminated, capped at the
// remainder of the read so we don't run off the end if the server
// sends less than expected.
end := 5
for end < n && buf[end] != 0 {
end++
}
if end == 5 {
return ""
}
return "MySQL: " + string(buf[5:end])
}

// capReader wraps an io.Reader with a hard byte cap, defended against a
// peer that sends data without an end-of-line for longer than we'd want
// to wait. Used by lineBanner.
type capReader struct {
r interface {
Read([]byte) (int, error)
}
n int
}

func (c *capReader) Read(p []byte) (int, error) {
if c.n <= 0 {
return 0, fmt.Errorf("banner exceeded %d bytes", maxBannerBytes)
}
if len(p) > c.n {
p = p[:c.n]
}
n, err := c.r.Read(p)
c.n -= n
return n, err
}
Loading
Loading