Files
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:11:01 +01:00

101 lines
2.6 KiB
Go

// Package tablewatch tells when an nftables table is deleted: by
// `nft flush ruleset` (which a ruleset file such as /etc/nftables.conf
// usually starts with, and which nftables.service runs when it stops), or
// by hand. It listens to nftables' netlink notifications, as `nft monitor`
// does, and calls back once they have been quiet for a moment, since a
// ruleset reload brings many. In shadow mode housecarld then puts its
// table back (docs/shadow.md).
package tablewatch
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"time"
"golang.org/x/sys/unix"
)
// Settle is how long the notifications must be quiet before the callback.
const Settle = 2 * time.Second
// Watch calls deleted after a table was deleted, until ctx ends. It
// returns an error only if it can't listen at all.
func Watch(ctx context.Context, deleted func()) error {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, unix.NETLINK_NETFILTER)
if err != nil {
return fmt.Errorf("opening a netlink socket: %w", err)
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK, Groups: 1 << (unix.NFNLGRP_NFTABLES - 1)}); err != nil {
unix.Close(fd)
return fmt.Errorf("listening to nftables' changes: %w", err)
}
// A non-blocking descriptor goes through Go's poller, so closing the
// file ends a Read in progress.
f := os.NewFile(uintptr(fd), "nfnetlink")
go func() {
<-ctx.Done()
f.Close()
}()
events := make(chan struct{}, 1)
go func() {
buf := make([]byte, 256<<10)
for {
n, err := f.Read(buf)
switch {
case err != nil && ctx.Err() != nil:
close(events)
return
case errors.Is(err, unix.ENOBUFS):
// Notifications were lost: one may have been a deletion.
case err != nil:
time.Sleep(time.Second)
continue
case !DeletesTable(buf[:n]):
continue
}
select {
case events <- struct{}{}:
default:
}
}
}()
var timer <-chan time.Time
for {
select {
case _, ok := <-events:
if !ok {
return nil
}
timer = time.After(Settle)
case <-timer:
timer = nil
deleted()
}
}
}
// DeletesTable reports whether a netlink datagram holds a table being
// deleted.
func DeletesTable(b []byte) bool {
want := uint16(unix.NFNL_SUBSYS_NFTABLES<<8 | unix.NFT_MSG_DELTABLE)
for len(b) >= unix.NLMSG_HDRLEN {
size := int(binary.NativeEndian.Uint32(b[0:4]))
typ := binary.NativeEndian.Uint16(b[4:6])
if size < unix.NLMSG_HDRLEN || size > len(b) {
return false
}
if typ == want {
return true
}
size = (size + unix.NLMSG_ALIGNTO - 1) &^ (unix.NLMSG_ALIGNTO - 1)
if size >= len(b) {
return false
}
b = b[size:]
}
return false
}