Connections, "every packet" rules, and a packet tracer

- Connections: the kernel's tracked connections, read and closed over
  ctnetlink (internal/conntrack, raw netlink like the drop log), with
  their NAT and TCP state; closing one makes the rules check its next
  packet again (GET /connections, POST /connections/close; hc
  connections; Traffic → Connections).
- Rules can check every packet (every_packet, --every-packet): before
  established connections are accepted, so a drop or reject also stops
  connections already open. They can't accept.
- A packet tracer (internal/trace): a table of its own, inet
  housecarl-trace, marks the packets a filter picks, `nft monitor trace`
  reports every rule and policy they meet in every table, and the tracer
  names what accepted or dropped them, Housecarl's rules by name (POST,
  GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time,
  at most 200 packets and 20 a second; the table goes when it ends.

e2e: 157 checks, among them an upload a normal block doesn't stop and an
every-packet one does, and a trace naming the blocking rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 20:22:34 +01:00
1 parent 98e11a8bec
commit a45c893830
34 files changed
+1830 -41

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
package trace
import (
"strings"
"testing"
"housecarl/internal/api"
)
// Lines nft 1.1.3's `nft monitor trace` printed for a connection to a
// blocked port, and for one that loopback accepted.
const sample = `trace id 38ff3662 inet housecarl-trace pre packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl-trace pre rule ip saddr 198.51.100.7 meta nftrace set 1 (verdict continue)
trace id 38ff3662 inet housecarl-trace pre policy accept
trace id 38ff3662 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl input rule ct state established,related counter packets 0 bytes 0 accept comment "hc:b-established" (verdict continue)
trace id 38ff3662 inet housecarl input rule tcp dport 9999 counter packets 1 bytes 60 drop comment "hc:r12 block 9999" (verdict drop)
trace id a232d9a0 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip protocol 6 tcp sport 43168 tcp dport 22 tcp flags == syn
trace id a232d9a0 inet housecarl output policy accept
trace id a232d9a0 ip other input rule ip saddr 127.0.0.1 accept (verdict accept)
trace id 77 inet housecarl forward packet: iif "lan" oif "wan" ip saddr 192.168.1.20 ip daddr 198.51.100.10 tcp sport 3500 tcp dport 7000 tcp flags == syn
trace id 77 inet housecarl forward rule ip saddr @trusted_v4 counter accept comment "hc:r40 trusted" (verdict accept)
trace id 77 inet housecarl srcnat rule oifname "wan" counter masquerade comment "hc:s91" (verdict accept)`
func TestTracer(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(sample, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 3 {
t.Fatalf("packets: %+v", ps)
}
p := ps[0]
if p.In != "wan" || p.Source != "198.51.100.7" || p.DestinationPort != 9999 || p.Protocol != "tcp" || p.TCPFlags != "syn" {
t.Errorf("headers: %+v", p)
}
if len(p.Steps) != 2 || p.Verdict != "dropped" || p.DecidedBy.Mark != "r12" || p.DecidedBy.Chain != "input" {
t.Errorf("steps: %+v", p)
}
if q := ps[1]; q.Verdict != "accepted" || q.Out != "lo" || len(q.Steps) != 2 || q.DecidedBy.Kind != "policy" {
t.Errorf("second: %+v", q)
}
// Accepted by the forward rule, not by source NAT, which came after.
if q := ps[2]; q.Verdict != "accepted" || q.DecidedBy.Mark != "r40" {
t.Errorf("third: decided by %+v", q.DecidedBy)
}
}
// The kernel reuses trace ids: a connection's next packet can come with
// the same one. Entering a chain again starts another packet.
func TestReusedID(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(`trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl output policy accept
trace id 5 inet housecarl srcnat packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl srcnat policy accept
trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == ack
trace id 5 inet housecarl output policy accept`, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 2 || len(ps[0].Steps) != 2 || len(ps[1].Steps) != 1 || ps[1].TCPFlags != "ack" {
t.Errorf("packets: %+v", ps)
}
}
func TestScript(t *testing.T) {
s, err := Script(api.TraceInput{Address: "192.168.1.50", Port: 443})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"table inet housecarl-trace {}\ndelete table inet housecarl-trace\n",
"type filter hook prerouting priority -301; policy accept;",
"ip saddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th dport 443 limit rate 20/second meta nftrace set 1",
"ip daddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th sport 443 limit rate 20/second meta nftrace set 1",
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in\n%s", want, s)
}
}
if _, err := Script(api.TraceInput{}); err == nil {
t.Error("tracing everything")
}
if _, err := Script(api.TraceInput{Port: 22, Protocol: "icmp"}); err == nil {
t.Error("ICMP with a port")
}
}