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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+320
View File
@@ -0,0 +1,320 @@
// 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 "", ""
}
+50
View File
@@ -0,0 +1,50 @@
package nflog
import (
"encoding/binary"
"testing"
"golang.org/x/sys/unix"
)
// message builds an nfnetlink_log packet message as the kernel sends it.
func message(family byte, attrs ...[]byte) []byte {
body := []byte{family, 0, 0x10, 0x4b} // nfgenmsg: family, version, group 4171
for _, a := range attrs {
body = append(body, a...)
}
msg := make([]byte, headerLen, headerLen+len(body))
binary.LittleEndian.PutUint32(msg, uint32(headerLen+len(body)))
binary.LittleEndian.PutUint16(msg[4:], subsysULOG<<8|msgPacket)
return append(msg, body...)
}
func be32(v uint32) []byte { b := make([]byte, 4); binary.BigEndian.PutUint32(b, v); return b }
func TestParse(t *testing.T) {
// An IPv4 TCP SYN from 203.0.113.9:40000 to 217.155.8.194:22.
ip := []byte{0x45, 0, 0, 60, 0, 0, 0, 0, 64, 6, 0, 0, 203, 0, 113, 9, 217, 155, 8, 194}
tcp := []byte{0x9c, 0x40, 0, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0x50, 0x02, 0, 0, 0, 0, 0, 0}
m1 := message(unix.AF_INET, attr(attrPrefix, []byte("hc input #12 drop: \x00")), attr(attrInDev, be32(3)), attr(attrPayload, append(ip, tcp...)))
// An IPv6 echo request, logged by the forward policy.
ip6 := make([]byte, 40)
ip6[0], ip6[6] = 0x60, 58
ip6[8], ip6[9], ip6[23] = 0x2a, 0x02, 0x01 // source 2a02::1
ip6[24], ip6[25], ip6[39] = 0x20, 0x01, 0x57 // destination 2001::57
m2 := message(unix.AF_INET6, attr(attrPrefix, []byte("hc forward policy drop: \x00")), attr(attrOutDev, be32(4)), attr(attrPayload, append(ip6, 128, 0, 0, 0)))
names := map[uint32]string{3: "wan", 4: "enp2s0"}
es := Parse(append(m1, m2...), func(i uint32) string { return names[i] })
if len(es) != 2 {
t.Fatalf("%d entries", len(es))
}
e := es[0]
if e.Family != "ipv4" || e.Source != "203.0.113.9" || e.Dest != "217.155.8.194" || e.Protocol != "tcp" || e.SrcPort != 40000 ||
e.DestPort != 22 || e.TCPFlags != "syn" || e.In != "wan" || e.RuleMark != "r12" || e.Verdict != "drop" || e.Length != 60 {
t.Errorf("v4 entry %+v", e)
}
e = es[1]
if e.Family != "ipv6" || e.Source != "2a02::1" || e.Dest != "2001::57" || e.Protocol != "icmpv6" || e.ICMPType != "echo-request" ||
e.Out != "enp2s0" || e.RuleMark != "p-forward" {
t.Errorf("v6 entry %+v", e)
}
}