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:
1 parent
98e11a8bec
commit
a45c893830
34 files changed
+1830
-41
No files matched your search
@@ -29,14 +29,16 @@ import (
|
||||
// fakeFW stands in for nftables: it records the applied states and keeps
|
||||
// dynamic sets' entries.
|
||||
type fakeFW struct {
|
||||
mu sync.Mutex
|
||||
applied []*model.State
|
||||
boot *model.State
|
||||
elements map[string][]render.Element
|
||||
applyErr error
|
||||
deleted []string
|
||||
restored []string
|
||||
resets []string
|
||||
mu sync.Mutex
|
||||
applied []*model.State
|
||||
boot *model.State
|
||||
elements map[string][]render.Element
|
||||
applyErr error
|
||||
deleted []string
|
||||
restored []string
|
||||
resets []string
|
||||
traced string
|
||||
traceLines []string
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
@@ -96,6 +98,17 @@ func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error
|
||||
f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) Trace(ctx context.Context, script string, line func(string)) error {
|
||||
f.mu.Lock()
|
||||
f.traced = script
|
||||
lines := f.traceLines
|
||||
f.mu.Unlock()
|
||||
for _, l := range lines {
|
||||
line(l)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
|
||||
return "table " + fam + " " + name + " {}", nil
|
||||
}
|
||||
@@ -865,3 +878,91 @@ func TestHostProblems(t *testing.T) {
|
||||
t.Errorf("warned about a table that is gone: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeConns struct {
|
||||
list []api.Connection
|
||||
closed []api.Connection
|
||||
}
|
||||
|
||||
func (f *fakeConns) List(context.Context) ([]api.Connection, error) { return f.list, nil }
|
||||
func (f *fakeConns) Close(_ context.Context, c api.Connection) error {
|
||||
f.closed = append(f.closed, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnections: connections listed and searched; closed one by one, or
|
||||
// all a search finds; closing needs config:write, and is audited.
|
||||
func TestConnections(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var code int
|
||||
if code = e.call(e.token, "GET", "/connections", nil, nil); code != 503 {
|
||||
t.Errorf("without conntrack: %d", code)
|
||||
}
|
||||
fc := &fakeConns{list: []api.Connection{
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "192.168.1.50", SourcePort: 51432, Destination: "198.51.100.9", DestinationPort: 443, SNATTo: "203.0.113.10", State: "established"},
|
||||
{Family: "ipv4", Protocol: "udp", Source: "192.168.1.51", SourcePort: 5353, Destination: "192.168.1.1", DestinationPort: 53},
|
||||
{Family: "ipv6", Protocol: "tcp", Source: "2001:db8:1::50", SourcePort: 40000, Destination: "2001:db8:2::9", DestinationPort: 4430},
|
||||
}}
|
||||
e.srv.SetConntrack(fc)
|
||||
var cs []api.Connection
|
||||
e.call(e.token, "GET", "/connections?q=192.168.1.50", nil, &cs)
|
||||
if len(cs) != 1 || cs[0].DestinationPort != 443 {
|
||||
t.Errorf("search by address: %+v", cs)
|
||||
}
|
||||
e.call(e.token, "GET", "/connections?q=:443", nil, &cs)
|
||||
if len(cs) != 1 || cs[0].Source != "192.168.1.50" {
|
||||
t.Errorf("search by port (not :4430): %+v", cs)
|
||||
}
|
||||
var out map[string]int
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &out); code != 200 || out["closed"] != 2 || len(fc.closed) != 2 {
|
||||
t.Errorf("close a search: %d %+v %+v", code, out, fc.closed)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{}, nil); code != 400 {
|
||||
t.Errorf("close nothing said: %d", code)
|
||||
}
|
||||
var tok api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
|
||||
if code := e.call(tok.Secret, "GET", "/connections", nil, nil); code != 200 {
|
||||
t.Errorf("listing with log:read: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/connections/close", api.CloseConnections{Search: "udp"}, nil); code != 403 {
|
||||
t.Errorf("closing with log:read: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrace: a trace runs with the filter's table, collects packets named
|
||||
// after Housecarl's objects, and stops when asked; one at a time.
|
||||
func TestTrace(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"tcp/9999"}}, Action: "drop", Comment: "block 9999"}, &mr)
|
||||
var rule model.Rule
|
||||
json.Unmarshal(mr.Object, &rule)
|
||||
e.fw.traceLines = []string{
|
||||
`trace id 1 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4000 tcp dport 9999 tcp flags == syn`,
|
||||
`trace id 1 inet housecarl input rule ct state established,related accept comment "hc:b-established" (verdict continue)`,
|
||||
fmt.Sprintf(`trace id 1 inet housecarl input rule tcp dport 9999 counter drop comment "hc:r%d block 9999" (verdict drop)`, rule.ID),
|
||||
}
|
||||
var st api.TraceStatus
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 9999, Seconds: 30}, &st); code != 200 || !st.Running {
|
||||
t.Fatalf("start: %d %+v", code, st)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 22}, nil); code != 409 {
|
||||
t.Errorf("a second trace: %d", code)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
e.call(e.token, "GET", "/trace", nil, &st)
|
||||
if len(st.Packets) != 1 || st.Packets[0].Verdict != "dropped" || st.Packets[0].DecidedBy.Label != "rule input #"+itoa(rule.ID)+" (block 9999)" ||
|
||||
st.Packets[0].Steps[0].Label != "built-in: established" {
|
||||
t.Errorf("packets: %+v", st.Packets)
|
||||
}
|
||||
if !strings.Contains(e.fw.traced, "th dport 9999") {
|
||||
t.Errorf("the trace table: %s", e.fw.traced)
|
||||
}
|
||||
if code := e.call(e.token, "DELETE", "/trace", nil, &st); code != 200 || st.Running || len(st.Packets) != 1 {
|
||||
t.Errorf("stop: %d %+v", code, st)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{}, nil); code != 400 {
|
||||
t.Errorf("tracing everything: %d", code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user