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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 18:11:01 +01:00
1 parent 1e6c085ef9
commit de075edd5d
48 files changed
+1345 -93

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
// 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
}
+37
View File
@@ -0,0 +1,37 @@
package tablewatch
import (
"encoding/binary"
"testing"
"golang.org/x/sys/unix"
)
// msg is a netlink message of a type, with a few bytes of payload.
func msg(typ uint16) []byte {
b := make([]byte, unix.NLMSG_HDRLEN+4)
binary.NativeEndian.PutUint32(b[0:4], uint32(len(b)))
binary.NativeEndian.PutUint16(b[4:6], typ)
return b
}
func TestDeletesTable(t *testing.T) {
nft := func(m uint16) uint16 { return unix.NFNL_SUBSYS_NFTABLES<<8 | m }
newRule, delTable, delChain := msg(nft(unix.NFT_MSG_NEWRULE)), msg(nft(unix.NFT_MSG_DELTABLE)), msg(nft(unix.NFT_MSG_DELCHAIN))
cases := []struct {
b []byte
want bool
}{
{delTable, true},
{append(append([]byte{}, newRule...), delTable...), true},
{append(append([]byte{}, newRule...), delChain...), false},
{newRule, false},
{delTable[:10], false},
{nil, false},
}
for i, c := range cases {
if got := DeletesTable(c.b); got != c.want {
t.Errorf("%d: %v", i, got)
}
}
}