Files
netbird/util/capture/text.go
2026-04-15 19:19:09 +02:00

639 lines
15 KiB
Go

package capture
import (
"encoding/binary"
"fmt"
"io"
"net/netip"
"strings"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// TextWriter writes human-readable one-line-per-packet summaries.
// It is not safe for concurrent use; callers must serialize access.
type TextWriter struct {
w io.Writer
verbose bool
ascii bool
flows map[dirKey]uint32
}
type dirKey struct {
src netip.AddrPort
dst netip.AddrPort
}
// NewTextWriter creates a text formatter that writes to w.
func NewTextWriter(w io.Writer, verbose, ascii bool) *TextWriter {
return &TextWriter{
w: w,
verbose: verbose,
ascii: ascii,
flows: make(map[dirKey]uint32),
}
}
// tag formats the fixed-width "[DIR PROTO]" prefix with right-aligned protocol.
func tag(dir Direction, proto string) string {
return fmt.Sprintf("[%-3s %4s]", dir, proto)
}
// WritePacket formats and writes a single packet line.
func (tw *TextWriter) WritePacket(ts time.Time, data []byte, dir Direction) error {
ts = ts.Local()
info, ok := parsePacketInfo(data)
if !ok {
_, err := fmt.Fprintf(tw.w, "%s [%-3s ?] ??? len=%d\n",
ts.Format("15:04:05.000000"), dir, len(data))
return err
}
timeStr := ts.Format("15:04:05.000000")
var err error
switch info.proto {
case protoTCP:
err = tw.writeTCP(timeStr, dir, &info, data)
case protoUDP:
err = tw.writeUDP(timeStr, dir, &info, data)
case protoICMP:
err = tw.writeICMPv4(timeStr, dir, &info, data)
case protoICMPv6:
err = tw.writeICMPv6(timeStr, dir, &info, data)
default:
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err = fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n",
timeStr, tag(dir, fmt.Sprintf("P%d", info.proto)),
info.srcIP, info.dstIP, len(data)-info.hdrLen, verbose)
}
return err
}
func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
tcp := &layers.TCP{}
if err := tcp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "TCP", info, data)
}
flags := tcpFlagsStr(tcp)
plen := len(tcp.Payload)
// Protocol annotation
var annotation string
if plen > 0 {
annotation = annotatePayload(tcp.Payload)
}
if !tw.verbose {
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n",
timeStr, tag(dir, "TCP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
flags, plen, annotation)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(tcp.Payload)
}
return nil
}
relSeq, relAck := tw.relativeSeqAck(info, tcp.Seq, tcp.Ack)
var seqStr string
if plen > 0 {
seqStr = fmt.Sprintf(", seq %d:%d", relSeq, relSeq+uint32(plen))
} else {
seqStr = fmt.Sprintf(", seq %d", relSeq)
}
var ackStr string
if tcp.ACK {
ackStr = fmt.Sprintf(", ack %d", relAck)
}
var opts string
if s := formatTCPOptions(tcp.Options); s != "" {
opts = ", options [" + s + "]"
}
verbose := tw.verboseIP(data, info.family)
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n",
timeStr, tag(dir, "TCP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(tcp.Payload)
}
return nil
}
func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
udp := &layers.UDP{}
if err := udp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "UDP", info, data)
}
plen := len(udp.Payload)
// DNS replaces the entire line format
if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
if s := formatDNSPayload(udp.Payload); s != "" {
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n",
timeStr, tag(dir, "UDP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
s, verbose)
return err
}
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n",
timeStr, tag(dir, "UDP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
plen, verbose)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(udp.Payload)
}
return nil
}
func (tw *TextWriter) writeICMPv4(timeStr string, dir Direction, info *packetInfo, data []byte) error {
icmp := &layers.ICMPv4{}
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
}
var detail string
if icmp.TypeCode.Type() == layers.ICMPv4TypeEchoRequest || icmp.TypeCode.Type() == layers.ICMPv4TypeEchoReply {
detail = fmt.Sprintf("%s, id %d, seq %d", icmp.TypeCode.String(), icmp.Id, icmp.Seq)
} else {
detail = icmp.TypeCode.String()
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, detail, len(data)-info.hdrLen, verbose)
return err
}
func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInfo, data []byte) error {
icmp := &layers.ICMPv6{}
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, icmp.TypeCode.String(), len(data)-info.hdrLen, verbose)
return err
}
func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error {
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n",
timeStr, tag(dir, proto),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
len(data)-info.hdrLen)
return err
}
func (tw *TextWriter) verboseIP(data []byte, family uint8) string {
return fmt.Sprintf(", ttl %d, id %d, iplen %d",
ipTTL(data, family), ipID(data, family), len(data))
}
// relativeSeqAck returns seq/ack relative to the first seen value per direction.
func (tw *TextWriter) relativeSeqAck(info *packetInfo, seq, ack uint32) (relSeq, relAck uint32) {
fwd := dirKey{
src: netip.AddrPortFrom(info.srcIP, info.srcPort),
dst: netip.AddrPortFrom(info.dstIP, info.dstPort),
}
rev := dirKey{
src: netip.AddrPortFrom(info.dstIP, info.dstPort),
dst: netip.AddrPortFrom(info.srcIP, info.srcPort),
}
if isn, ok := tw.flows[fwd]; ok {
relSeq = seq - isn
} else {
tw.flows[fwd] = seq
}
if isn, ok := tw.flows[rev]; ok {
relAck = ack - isn
} else {
relAck = ack
}
return relSeq, relAck
}
// writeASCII prints payload bytes as printable ASCII.
func (tw *TextWriter) writeASCII(payload []byte) error {
if len(payload) == 0 {
return nil
}
buf := make([]byte, len(payload))
for i, b := range payload {
switch {
case b >= 0x20 && b < 0x7f:
buf[i] = b
case b == '\n' || b == '\r' || b == '\t':
buf[i] = b
default:
buf[i] = '.'
}
}
_, err := fmt.Fprintf(tw.w, "%s\n", buf)
return err
}
// --- TCP helpers ---
func ipTTL(data []byte, family uint8) uint8 {
if family == 4 && len(data) > 8 {
return data[8]
}
if family == 6 && len(data) > 7 {
return data[7]
}
return 0
}
func ipID(data []byte, family uint8) uint16 {
if family == 4 && len(data) >= 6 {
return binary.BigEndian.Uint16(data[4:6])
}
return 0
}
func tcpFlagsStr(tcp *layers.TCP) string {
var buf [6]byte
n := 0
if tcp.SYN {
buf[n] = 'S'
n++
}
if tcp.FIN {
buf[n] = 'F'
n++
}
if tcp.RST {
buf[n] = 'R'
n++
}
if tcp.PSH {
buf[n] = 'P'
n++
}
if tcp.ACK {
buf[n] = '.'
n++
}
if tcp.URG {
buf[n] = 'U'
n++
}
if n == 0 {
return "none"
}
return string(buf[:n])
}
func formatTCPOptions(opts []layers.TCPOption) string {
var parts []string
for _, opt := range opts {
switch opt.OptionType {
case layers.TCPOptionKindEndList:
return strings.Join(parts, ",")
case layers.TCPOptionKindNop:
parts = append(parts, "nop")
case layers.TCPOptionKindMSS:
if len(opt.OptionData) == 2 {
parts = append(parts, fmt.Sprintf("mss %d", binary.BigEndian.Uint16(opt.OptionData)))
}
case layers.TCPOptionKindWindowScale:
if len(opt.OptionData) == 1 {
parts = append(parts, fmt.Sprintf("wscale %d", opt.OptionData[0]))
}
case layers.TCPOptionKindSACKPermitted:
parts = append(parts, "sackOK")
case layers.TCPOptionKindSACK:
blocks := len(opt.OptionData) / 8
parts = append(parts, fmt.Sprintf("sack %d", blocks))
case layers.TCPOptionKindTimestamps:
if len(opt.OptionData) == 8 {
tsval := binary.BigEndian.Uint32(opt.OptionData[0:4])
tsecr := binary.BigEndian.Uint32(opt.OptionData[4:8])
parts = append(parts, fmt.Sprintf("TS val %d ecr %d", tsval, tsecr))
}
}
}
return strings.Join(parts, ",")
}
// --- Protocol annotation ---
// annotatePayload returns a protocol annotation string for known application protocols.
func annotatePayload(payload []byte) string {
if len(payload) < 4 {
return ""
}
s := string(payload)
// SSH banner: "SSH-2.0-OpenSSH_9.6\r\n"
if strings.HasPrefix(s, "SSH-") {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 256 {
return ": " + s[:end]
}
}
// TLS records
if ann := annotateTLS(payload); ann != "" {
return ": " + ann
}
// HTTP request or response
for _, method := range [...]string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "PATCH ", "OPTIONS ", "CONNECT "} {
if strings.HasPrefix(s, method) {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
return ": " + s[:end]
}
}
}
if strings.HasPrefix(s, "HTTP/") {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
return ": " + s[:end]
}
}
return ""
}
// annotateTLS returns a description for TLS handshake and alert records.
func annotateTLS(data []byte) string {
if len(data) < 6 {
return ""
}
switch data[0] {
case 0x16:
return annotateTLSHandshake(data)
case 0x15:
return annotateTLSAlert(data)
}
return ""
}
func annotateTLSHandshake(data []byte) string {
if len(data) < 10 {
return ""
}
switch data[5] {
case 0x01:
if sni := extractSNI(data); sni != "" {
return "TLS ClientHello SNI=" + sni
}
return "TLS ClientHello"
case 0x02:
return "TLS ServerHello"
}
return ""
}
func annotateTLSAlert(data []byte) string {
if len(data) < 7 {
return ""
}
severity := "warning"
if data[5] == 2 {
severity = "fatal"
}
return fmt.Sprintf("TLS Alert %s %s", severity, tlsAlertDesc(data[6]))
}
func tlsAlertDesc(code byte) string {
switch code {
case 0:
return "close_notify"
case 10:
return "unexpected_message"
case 40:
return "handshake_failure"
case 42:
return "bad_certificate"
case 43:
return "unsupported_certificate"
case 44:
return "certificate_revoked"
case 45:
return "certificate_expired"
case 48:
return "unknown_ca"
case 49:
return "access_denied"
case 50:
return "decode_error"
case 70:
return "protocol_version"
case 80:
return "internal_error"
case 86:
return "inappropriate_fallback"
case 90:
return "user_canceled"
case 112:
return "unrecognized_name"
default:
return fmt.Sprintf("alert(%d)", code)
}
}
// extractSNI parses a TLS ClientHello and returns the SNI server name.
func extractSNI(data []byte) string {
if len(data) < 6 || data[0] != 0x16 {
return ""
}
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
handshake := data[5:]
if len(handshake) > recordLen {
handshake = handshake[:recordLen]
}
if len(handshake) < 4 || handshake[0] != 0x01 {
return ""
}
hsLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3])
body := handshake[4:]
if len(body) > hsLen {
body = body[:hsLen]
}
extPos := clientHelloExtensionsOffset(body)
if extPos < 0 {
return ""
}
return findSNIExtension(body, extPos)
}
// clientHelloExtensionsOffset returns the byte offset where extensions begin
// within the ClientHello body, or -1 if the body is too short.
func clientHelloExtensionsOffset(body []byte) int {
if len(body) < 38 {
return -1
}
pos := 34
if pos >= len(body) {
return -1
}
pos += 1 + int(body[pos]) // session ID
if pos+2 > len(body) {
return -1
}
pos += 2 + int(binary.BigEndian.Uint16(body[pos:pos+2])) // cipher suites
if pos >= len(body) {
return -1
}
pos += 1 + int(body[pos]) // compression methods
if pos+2 > len(body) {
return -1
}
return pos
}
func findSNIExtension(body []byte, pos int) string {
extLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
pos += 2
extEnd := pos + extLen
if extEnd > len(body) {
extEnd = len(body)
}
for pos+4 <= extEnd {
extType := binary.BigEndian.Uint16(body[pos : pos+2])
eLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4]))
pos += 4
if pos+eLen > extEnd {
break
}
if extType == 0 && eLen >= 5 {
nameLen := int(binary.BigEndian.Uint16(body[pos+3 : pos+5]))
if pos+5+nameLen <= extEnd {
return string(body[pos+5 : pos+5+nameLen])
}
}
pos += eLen
}
return ""
}
func isDNSPort(src, dst uint16) bool {
return src == 53 || dst == 53 || src == 5353 || dst == 5353
}
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
func formatDNSPayload(payload []byte) string {
d := &layers.DNS{}
if err := d.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
return ""
}
rd := ""
if d.RD {
rd = "+"
}
if !d.QR {
return formatDNSQuery(d, rd, len(payload))
}
return formatDNSResponse(d, rd, len(payload))
}
func formatDNSQuery(d *layers.DNS, rd string, plen int) string {
if len(d.Questions) == 0 {
return fmt.Sprintf("%04x%s (%d)", d.ID, rd, plen)
}
q := d.Questions[0]
return fmt.Sprintf("%04x%s %s? %s. (%d)", d.ID, rd, q.Type, q.Name, plen)
}
func formatDNSResponse(d *layers.DNS, rd string, plen int) string {
anCount := d.ANCount
nsCount := d.NSCount
arCount := d.ARCount
if d.ResponseCode != layers.DNSResponseCodeNoErr {
return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen)
}
if anCount > 0 && len(d.Answers) > 0 {
rr := d.Answers[0]
if rdata := shortRData(&rr); rdata != "" {
return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen)
}
}
return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen)
}
func shortRData(rr *layers.DNSResourceRecord) string {
switch rr.Type {
case layers.DNSTypeA, layers.DNSTypeAAAA:
if rr.IP != nil {
return rr.IP.String()
}
case layers.DNSTypeCNAME:
if len(rr.CNAME) > 0 {
return string(rr.CNAME) + "."
}
case layers.DNSTypePTR:
if len(rr.PTR) > 0 {
return string(rr.PTR) + "."
}
case layers.DNSTypeNS:
if len(rr.NS) > 0 {
return string(rr.NS) + "."
}
case layers.DNSTypeMX:
return fmt.Sprintf("%d %s.", rr.MX.Preference, rr.MX.Name)
case layers.DNSTypeTXT:
if len(rr.TXTs) > 0 {
return fmt.Sprintf("%q", string(rr.TXTs[0]))
}
case layers.DNSTypeSRV:
return fmt.Sprintf("%d %d %d %s.", rr.SRV.Priority, rr.SRV.Weight, rr.SRV.Port, rr.SRV.Name)
}
return ""
}