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:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -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 "", ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user