- 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>
90 lines
4.3 KiB
Go
90 lines
4.3 KiB
Go
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")
|
|
}
|
|
}
|