Files
housecarl/internal/nflog/nflog.go
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:57:14 +01:00

321 lines
8.1 KiB
Go

// Package nflog reads the packets the ruleset logs (log ... group N),
// through nfnetlink_log: housecarld binds the group and decodes each
// packet's prefix, interfaces and headers for the drop log.
package nflog
import (
"context"
"encoding/binary"
"fmt"
"log/slog"
"net"
"net/netip"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
"housecarl/internal/api"
)
const (
subsysULOG = 4
msgPacket = 0
msgConfig = 1
cfgCmd = 1
cfgMode = 2
cmdBind = 1
copyPacket = 2
attrPacketHdr = 1
attrTimestamp = 3
attrInDev = 4
attrOutDev = 5
attrPayload = 9
attrPrefix = 10
nfnetlinkV0 = 0
headerLen = unix.SizeofNlMsghdr
nfgenmsgLen = 4
copyRangeBytes = 128 // headers are all we read
)
// Listen binds the log group and passes each logged packet to handle until
// ctx ends. Errors (no CAP_NET_ADMIN, say) are logged once: the log stays
// empty, nothing else is affected.
func Listen(ctx context.Context, group uint16, log *slog.Logger, handle func(api.LogEntry)) {
fd, err := open(group)
if err != nil {
log.Warn("can't read logged packets, so the log stays empty", "group", group, "err", err)
return
}
go func() {
<-ctx.Done()
unix.Close(fd)
}()
names := &ifnames{m: map[uint32]string{}}
buf := make([]byte, 1<<16)
for {
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
if ctx.Err() != nil {
return
}
if err == unix.ENOBUFS {
continue // packets were lost; go on
}
log.Warn("reading logged packets failed", "err", err)
return
}
for _, e := range Parse(buf[:n], names.name) {
handle(e)
}
}
}
func open(group uint16) (int, error) {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_NETFILTER)
if err != nil {
return -1, err
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
unix.Close(fd)
return -1, err
}
// Bind the group, then ask for packets (their headers).
cmd := attr(cfgCmd, []byte{cmdBind})
mode := make([]byte, 6)
binary.BigEndian.PutUint32(mode, copyRangeBytes)
mode[4] = copyPacket
for _, a := range [][]byte{cmd, attr(cfgMode, mode)} {
if err := request(fd, group, a); err != nil {
unix.Close(fd)
return -1, fmt.Errorf("binding nflog group %d: %w", group, err)
}
}
return fd, nil
}
func attr(typ uint16, data []byte) []byte {
l := 4 + len(data)
b := make([]byte, (l+3)&^3)
binary.LittleEndian.PutUint16(b, uint16(l))
binary.LittleEndian.PutUint16(b[2:], typ)
copy(b[4:], data)
return b
}
// request sends a config message and waits for its acknowledgement.
func request(fd int, group uint16, a []byte) error {
msg := make([]byte, headerLen+nfgenmsgLen+len(a))
binary.LittleEndian.PutUint32(msg, uint32(len(msg)))
binary.LittleEndian.PutUint16(msg[4:], subsysULOG<<8|msgConfig)
binary.LittleEndian.PutUint16(msg[6:], unix.NLM_F_REQUEST|unix.NLM_F_ACK)
msg[headerLen] = unix.AF_UNSPEC
msg[headerLen+1] = nfnetlinkV0
binary.BigEndian.PutUint16(msg[headerLen+2:], group)
copy(msg[headerLen+nfgenmsgLen:], a)
if err := unix.Sendto(fd, msg, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
return err
}
buf := make([]byte, 4096)
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
return err
}
if n >= headerLen+4 && binary.LittleEndian.Uint16(buf[4:]) == unix.NLMSG_ERROR {
if code := int32(binary.LittleEndian.Uint32(buf[headerLen:])); code != 0 {
return unix.Errno(-code)
}
}
return nil
}
type ifnames struct {
mu sync.Mutex
m map[uint32]string
}
func (c *ifnames) name(i uint32) string {
c.mu.Lock()
defer c.mu.Unlock()
if n, ok := c.m[i]; ok {
return n
}
n := fmt.Sprint(i)
if ifi, err := net.InterfaceByIndex(int(i)); err == nil {
n = ifi.Name
}
c.m[i] = n
return n
}
// Parse decodes the logged packets in a netlink datagram; ifname turns
// interface indexes into names.
func Parse(b []byte, ifname func(uint32) string) []api.LogEntry {
var out []api.LogEntry
for len(b) >= headerLen {
l := int(binary.LittleEndian.Uint32(b))
typ := binary.LittleEndian.Uint16(b[4:])
if l < headerLen || l > len(b) {
break
}
if typ == subsysULOG<<8|msgPacket && l >= headerLen+nfgenmsgLen {
if e, ok := packet(b[headerLen:l], ifname); ok {
out = append(out, e)
}
}
step := (l + 3) &^ 3
if step >= len(b) {
break
}
b = b[step:]
}
return out
}
func packet(b []byte, ifname func(uint32) string) (api.LogEntry, bool) {
e := api.LogEntry{Time: time.Now()}
switch b[0] {
case unix.AF_INET:
e.Family = "ipv4"
case unix.AF_INET6:
e.Family = "ipv6"
}
attrs := b[nfgenmsgLen:]
var payload []byte
for len(attrs) >= 4 {
l := int(binary.LittleEndian.Uint16(attrs))
typ := binary.LittleEndian.Uint16(attrs[2:]) & 0x3fff
if l < 4 || l > len(attrs) {
break
}
v := attrs[4:l]
switch typ {
case attrPrefix:
e.Prefix = strings.TrimRight(string(v), "\x00")
case attrInDev:
if len(v) >= 4 {
e.In = ifname(binary.BigEndian.Uint32(v))
}
case attrOutDev:
if len(v) >= 4 {
e.Out = ifname(binary.BigEndian.Uint32(v))
}
case attrTimestamp:
if len(v) >= 16 {
sec, usec := binary.BigEndian.Uint64(v), binary.BigEndian.Uint64(v[8:])
e.Time = time.Unix(int64(sec), int64(usec)*1000)
}
case attrPacketHdr:
if len(v) >= 2 && e.Family == "" {
switch binary.BigEndian.Uint16(v) {
case 0x0800:
e.Family = "ipv4"
case 0x86dd:
e.Family = "ipv6"
}
}
case attrPayload:
payload = v
}
attrs = attrs[min((l+3)&^3, len(attrs)):]
}
decodePayload(&e, payload)
e.RuleMark, e.Verdict = markOf(e.Prefix)
return e, e.Prefix != "" || payload != nil
}
var icmpNames = map[byte]string{0: "echo-reply", 3: "destination-unreachable", 5: "redirect", 8: "echo-request", 11: "time-exceeded", 12: "parameter-problem"}
var icmp6Names = map[byte]string{1: "destination-unreachable", 2: "packet-too-big", 3: "time-exceeded", 4: "parameter-problem", 128: "echo-request", 129: "echo-reply",
133: "nd-router-solicit", 134: "nd-router-advert", 135: "nd-neighbor-solicit", 136: "nd-neighbor-advert"}
func decodePayload(e *api.LogEntry, p []byte) {
var proto byte
var l4 []byte
switch {
case len(p) >= 20 && p[0]>>4 == 4:
e.Family = "ipv4"
ihl := int(p[0]&0x0f) * 4
e.Length = int(binary.BigEndian.Uint16(p[2:]))
proto = p[9]
e.Source = netip.AddrFrom4([4]byte(p[12:16])).String()
e.Dest = netip.AddrFrom4([4]byte(p[16:20])).String()
if ihl <= len(p) {
l4 = p[ihl:]
}
case len(p) >= 40 && p[0]>>4 == 6:
e.Family = "ipv6"
e.Length = int(binary.BigEndian.Uint16(p[4:])) + 40
proto = p[6]
e.Source = netip.AddrFrom16([16]byte(p[8:24])).String()
e.Dest = netip.AddrFrom16([16]byte(p[24:40])).String()
l4 = p[40:]
default:
return
}
switch proto {
case 6:
e.Protocol = "tcp"
if len(l4) >= 14 {
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
e.TCPFlags = tcpFlags(l4[13])
}
case 17:
e.Protocol = "udp"
if len(l4) >= 4 {
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
}
case 132:
e.Protocol = "sctp"
if len(l4) >= 4 {
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
}
case 1:
e.Protocol = "icmp"
if len(l4) >= 1 {
e.ICMPType = icmpNames[l4[0]]
if e.ICMPType == "" {
e.ICMPType = fmt.Sprint(l4[0])
}
}
case 58:
e.Protocol = "icmpv6"
if len(l4) >= 1 {
e.ICMPType = icmp6Names[l4[0]]
if e.ICMPType == "" {
e.ICMPType = fmt.Sprint(l4[0])
}
}
default:
e.Protocol = fmt.Sprint(proto)
}
}
func tcpFlags(f byte) string {
var out []string
for i, n := range []string{"fin", "syn", "rst", "psh", "ack", "urg"} {
if f&(1<<i) != 0 {
out = append(out, n)
}
}
return strings.Join(out, ",")
}
// Housecarl's log prefixes: "hc input #12 drop: " and
// "hc forward policy drop: ".
var (
rulePrefix = regexp.MustCompile(`^hc \w+ #(\d+) (\w+):`)
policyPrefix = regexp.MustCompile(`^hc (\w+) policy (\w+):`)
)
func markOf(prefix string) (mark, verdict string) {
if m := rulePrefix.FindStringSubmatch(prefix); m != nil {
return "r" + m[1], m[2]
}
if m := policyPrefix.FindStringSubmatch(prefix); m != nil {
return "p-" + m[1], m[2]
}
return "", ""
}