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

+1 -1
View File
@@ -191,7 +191,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
}
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), interfaceCmd())
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), interfaceCmd())
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
+7 -2
View File
@@ -99,7 +99,7 @@ type ruleFlags struct {
states, statuses, days []string
hours, limit, quota string
connLimit, groupV4, groupV6 int
log, disabled bool
log, disabled, everyPacket bool
before, after int64
first bool
}
@@ -122,6 +122,7 @@ func (r *ruleFlags) register(f *pflag.FlagSet) {
f.StringVar(&r.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
f.StringVar(&r.comment, "comment", "", "a comment")
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
f.BoolVar(&r.everyPacket, "every-packet", false, "check every packet, so a drop or reject also stops connections already open")
}
var amountRe = regexp.MustCompile(`^(\d+)\s*([a-zA-Z]*)$`)
@@ -244,7 +245,7 @@ func humanBytes(n int64) string {
func (r *ruleFlags) rule(chain string) (model.Rule, error) {
rule := model.Rule{Chain: chain, Match: r.match(), Action: r.action, RejectWith: r.rejectWith, CtState: r.states, CtStatus: r.statuses,
Days: r.days, ConnLimit: r.connLimit, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
Days: r.days, ConnLimit: r.connLimit, EveryPacket: r.everyPacket, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
if r.hours != "" {
from, to, ok := strings.Cut(r.hours, "-")
if !ok {
@@ -383,6 +384,7 @@ output (from it) or early (before connection tracking: cheap drops).
"status": {"ct_status", r.CtStatus}, "days": {"days", r.Days}, "conn-limit": {"conn_limit", r.ConnLimit},
"log": {"log", r.Log}, "log-prefix": {"log_prefix", r.LogPrefix}, "raw": {"raw", r.Raw}, "comment": {"comment", r.Comment},
"disabled": {"disabled", r.Disabled}, "group-v4": {"group_v4", r.GroupV4}, "group-v6": {"group_v6", r.GroupV6},
"every-packet": {"every_packet", r.EveryPacket},
} {
if fl.Changed(flag) {
p[field[0].(string)] = field[1]
@@ -507,6 +509,9 @@ func ruleExtras(r model.Rule) string {
if r.ConnLimit > 0 {
x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit))
}
if r.EveryPacket {
x = append(x, "every packet")
}
if len(x) == 0 {
return ""
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
)
// Traffic as it happens: connections the kernel tracks, and tracing
// packets through the ruleset.
func connectionsCmd() *cobra.Command {
var search string
var limit int
cmd := &cobra.Command{
Use: "connections", Aliases: []string{"conns"}, Short: "The connections the firewall tracks, and closing them",
Long: `Lists the connections the kernel tracks: what the "established" built-in
lets through without checking the rules again. A rule added later doesn't
stop them by itself; close them, and their next packet is checked by the
rules as it stands.
hc connections --search 192.168.1.50
hc connections --search ":443 tcp"
hc connections close --search 192.168.1.50`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[[]api.Connection]("/connections", q("q", search, "limit", strconv.Itoa(limit)))
if err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
for _, c := range cs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout))
}
})
},
}
cmd.Flags().StringVar(&search, "search", "", "only those with all these words: addresses, :port, protocol, state")
cmd.Flags().IntVar(&limit, "limit", 500, "at most this many")
var csearch string
var yes bool
closeCmd := &cobra.Command{
Use: "close --search TEXT", Short: "Close connections: the rules check them again with their next packet", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(csearch) == "" {
return fmt.Errorf("say which: --search 192.168.1.50 (see 'hc connections --search' first)")
}
if !confirm("Close every connection matching "+strconvQuote(csearch)+"?", yes) {
return fmt.Errorf("nothing closed")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/connections/close", nil, api.CloseConnections{Search: csearch}, &out); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("closed %s\n", plural(out["closed"], "connection"))
}
return nil
},
}
closeCmd.Flags().StringVar(&csearch, "search", "", "the connections to close, as for 'hc connections --search'")
closeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(closeCmd)
return cmd
}
func strconvQuote(s string) string { return strconv.Quote(s) }
func hostPort(addr string, port int) string {
if port == 0 {
return addr
}
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// natText: "to 192.168.1.10:443" (a port forward), "as 203.0.113.10"
// (masqueraded).
func natText(c api.Connection) string {
var parts []string
if c.DNATTo != "" {
port := c.DNATPort
if port == 0 {
port = c.DestinationPort
}
parts = append(parts, "to "+hostPort(c.DNATTo, port))
}
if c.SNATTo != "" {
parts = append(parts, "as "+c.SNATTo)
}
return strings.Join(parts, ", ")
}
func traceCmd() *cobra.Command {
var in api.TraceInput
cmd := &cobra.Command{
Use: "trace", Short: "Follow packets through every table's rules: what each met, and what decided",
Long: `Traces packets from or to an address, on a port (either way), through the
whole ruleset, Housecarl's and every other table's, for a while: each
rule and policy they meet, and what accepted or dropped them. Ctrl-C
stops it.
hc trace --address 192.168.1.50 --port 443
hc trace --port 22 --seconds 120
hc trace --address 2001:db8:1::/64 --protocol icmpv6`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.TraceStatus
if err := c.Do(ctx(), "POST", "/trace", nil, in, &st); err != nil {
return err
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "tracing until %s (Ctrl-C stops)\n", st.Ends.Local().Format("15:04:05"))
}
sig, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
shown := 0
for {
select {
case <-sig.Done():
c.Do(ctx(), "DELETE", "/trace", nil, nil, &st)
case <-time.After(time.Second):
if err := c.Do(ctx(), "GET", "/trace", nil, nil, &st); err != nil {
return err
}
}
for ; shown < len(st.Packets); shown++ {
printPacket(os.Stdout, st.Packets[shown])
}
if !st.Running || sig.Err() != nil {
break
}
}
if st.Error != "" {
return fmt.Errorf("the trace failed: %s", st.Error)
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "%d packets traced\n", len(st.Packets))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&in.Address, "address", "", "packets from or to this address or prefix")
f.IntVar(&in.Port, "port", 0, "packets from or to this port")
f.StringVar(&in.Protocol, "protocol", "", "tcp, udp, sctp, icmp or icmpv6 (default any)")
f.IntVar(&in.Seconds, "seconds", 60, "how long (5 to 600)")
return cmd
}
// printPacket writes a traced packet: its headers, each step, its fate.
func printPacket(w io.Writer, p api.TracePacket) {
what := p.Protocol
if p.TCPFlags != "" {
what += " " + p.TCPFlags
}
if p.ICMPType != "" {
what += " " + p.ICMPType
}
where := ""
if p.In != "" {
where += " in " + p.In
}
if p.Out != "" {
where += " out " + p.Out
}
fmt.Fprintf(w, "%s %s -> %s %s%s\n", p.Time.Local().Format("15:04:05.000"), hostPort(p.Source, p.SourcePort), hostPort(p.Destination, p.DestinationPort), what, where)
for _, s := range p.Steps {
name := s.Label
if name == "" {
if s.Kind == "policy" {
name = s.Chain + " policy"
} else {
name = s.Rule
}
}
fmt.Fprintf(w, " %s %s: %s -> %s\n", s.Table, s.Chain, name, s.Verdict)
}
decided := p.DecidedBy.Label
if decided == "" {
decided = strings.TrimSpace(p.DecidedBy.Table + " " + p.DecidedBy.Chain + " " + p.DecidedBy.Kind)
}
if decided == "" {
fmt.Fprintf(w, " => %s\n", strings.ToUpper(p.Verdict))
} else {
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
}
}
+5
View File
@@ -26,6 +26,7 @@ import (
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/config"
"housecarl/internal/conntrack"
"housecarl/internal/engine"
"housecarl/internal/hostif"
"housecarl/internal/jarl"
@@ -35,6 +36,7 @@ import (
"housecarl/internal/render"
"housecarl/internal/server"
"housecarl/internal/store"
"housecarl/internal/trace"
"housecarl/internal/validate"
"housecarl/internal/version"
"housecarl/internal/web"
@@ -195,6 +197,9 @@ func serveCmd() *cobra.Command {
}
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx)
srv.SetConntrack(conntrack.Client{})
// A trace left behind by a crash.
fw.DeleteTable(ctx, "inet", trace.Table)
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {