diff --git a/CHANGELOG.md b/CHANGELOG.md index 931a8cf..f0c861b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ the way Jarl and Steward are. - **Warnings about the machine:** while `nftables.service` is enabled (it loads `/etc/nftables.conf` at boot, and flushes every table when stopped), and while the table an import came from still applies. +- **Connections and tracing:** the connections the kernel tracks, with + their NAT, and closing them so the rules check them again (Traffic → + Connections, `hc connections`); a rule option, "every packet", for + blocks that stop connections already open; and a packet tracer + (Traffic → Trace, `hc trace`) that follows packets through every + table's rules and names what accepted or dropped them. - **Hits and the log:** packet and byte counters for every rule, NAT rule, built-in and policy; packets logged by rules and policies, read from netlink log group 4171 (`hc log -f`, the Log page). diff --git a/README.md b/README.md index 7d41db7..752994f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ table of its own. rules' order, and keeps what it can't translate as raw rules ([docs/import.md](docs/import.md)). * **Easy to watch.** Hits per rule, a live log of what was dropped and - why, and the history of every change, field by field, with undo. + why, the connections the firewall tracks (and closing them), a packet + tracer that names the rule that dropped something, in any table, and + the history of every change, field by field, with undo. * **Its own table only.** Docker, Incus and libvirt keep theirs. The confirmed ruleset is loaded at boot, before the network comes up, even if housecarld doesn't start. diff --git a/cmd/hc/main.go b/cmd/hc/main.go index 6b8b906..12e6049 100644 --- a/cmd/hc/main.go +++ b/cmd/hc/main.go @@ -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 diff --git a/cmd/hc/rules.go b/cmd/hc/rules.go index 2f3ce17..8be50a1 100644 --- a/cmd/hc/rules.go +++ b/cmd/hc/rules.go @@ -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 "" } diff --git a/cmd/hc/traffic.go b/cmd/hc/traffic.go new file mode 100644 index 0000000..a191d92 --- /dev/null +++ b/cmd/hc/traffic.go @@ -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) + } +} diff --git a/cmd/housecarld/main.go b/cmd/housecarld/main.go index d2af153..9163feb 100644 --- a/cmd/housecarld/main.go +++ b/cmd/housecarld/main.go @@ -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() { diff --git a/docs/api.md b/docs/api.md index fd1534d..1f0b48d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,6 +80,11 @@ Creating answers 201, other changes 200. | `GET /ruleset` | the nftables ruleset Housecarl builds, as text | | `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started | | `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` | +| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state | +| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}` | +| `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs | +| `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by` | +| `DELETE /trace` | stop it | | `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches | | `GET /catalog` | built-in services, chains, actions, roles, the log group | diff --git a/docs/cli.md b/docs/cli.md index ea1d6ae..40d6540 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -137,6 +137,8 @@ by name. | `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) | | `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets | | `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) | +| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) | +| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) | ### zone, set, service @@ -182,7 +184,7 @@ The match flags (`rule add`, `snat add`, `redirect add`): And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit 10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`, `--quota 50gbytes[,over][,per-source|,per-destination][,daily|,weekly|,monthly=DAY]`, -`--group-v4 N`, `--group-v6 N`, `--conn-limit N`, `-a +`--group-v4 N`, `--group-v6 N`, `--conn-limit N`, `--every-packet`, `-a accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`, `--log-prefix`, `--raw RULE`, `--comment`, `--disabled` ([rules.md](rules.md#rules)). diff --git a/docs/operations.md b/docs/operations.md index 6068404..4d97590 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -133,16 +133,62 @@ the last 2000 are kept in memory. They aren't written to the kernel log or the journal. A rule's log isn't rate-limited: on a busy rule, give it a limit, or log only new connections (`--state new`). +## Connections + +The built-in that accepts established connections lets their packets +through without checking the rules again: a rule added later doesn't +stop a connection already open. Traffic → Connections (`hc connections`) +lists what the kernel tracks, with NAT (`to 192.168.1.10:443`: a port +forward; `as 203.0.113.10`: masqueraded) and the time left: + +```sh +hc connections --search 192.168.1.50 +hc connections --search ":443 tcp" +hc connections close --search 192.168.1.50 +``` + +Closing a connection deletes its tracking entry: its next packet is +checked by the rules as they are now. One they block is stopped; one they +allow carries on (TCP connections are picked up again mid-stream). + +To make a block stop open connections by itself, tick "every packet" on +the rule (`--every-packet`): it is checked before established connections +are accepted, like rules with a quota ([rules.md](rules.md#rules)). + +## Tracing + +Traffic → Trace (`hc trace`) follows packets through the whole ruleset: +Housecarl's rules and every other table (Docker's, fail2ban's, an old +`inet main`), each rule and policy they meet, and what accepted or +dropped them: + +``` +$ hc trace --address 192.168.1.50 --port 443 --seconds 60 +14:02:13.120 192.168.1.50:51432 -> 198.51.100.9:443 tcp syn in eth1 out wan + inet housecarl forward: built-in: mss-clamp -> continue + inet housecarl forward: rule forward #12 (kids at night) -> drop + => DROPPED by rule forward #12 (kids at night) +``` + +It traces packets from or to the address, on the port (either way), for +5 to 600 seconds, at most 200 packets and 20 a second. It works with +nftables' own tracing: a table of its own (`inet housecarl-trace`, +hooked before everything else) marks the packets, and `nft monitor +trace` reports them; the table goes when the trace ends. One trace runs +at a time. + ## When something doesn't get through -1. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule +1. Trace it (above): it names the rule or policy that dropped it, in + whichever table. +2. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule accepted it), or by a rule (its number is in the line)? -2. `hc rule list forward` (or `input`): hits on the rule you expect? -3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces +3. `hc rule list forward` (or `input`): hits on the rule you expect? +4. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces and the sets' families. -4. `hc status`: other tables. A Docker or Incus table, or an old one, +5. `hc status`: other tables. A Docker or Incus table, or an old one, can drop what Housecarl accepts. -5. Port forwards: `hc forward list` hits, and the inside host's own +6. Port forwards: `hc forward list` hits, and the inside host's own firewall and default route (answers must go back through Housecarl). ## Backups diff --git a/docs/permissions.md b/docs/permissions.md index a85b459..1789098 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -7,9 +7,9 @@ else gets **grants**, `resource:access`: | Resource | read | write | |---|---|---| -| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back | +| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back; close connections; start and stop traces | | `sets` | dynamic sets' entries | add and remove entries | -| `log` | hit counters and the log | – | +| `log` | hit counters, the log, connections, traces | – | | `audit` | the audit log | – | | `*` | all of the above | | diff --git a/docs/rules.md b/docs/rules.md index 4ded18a..b8c3950 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -119,6 +119,10 @@ The action is `accept`, `drop`, `reject` (an ICMP error, or with `--reject-with tcp-reset` a TCP reset) or `log` (log and go on). `--log` logs the packets of any rule ([operations.md](operations.md#the-log)). `--disabled` (or `hc rule disable ID`) keeps a rule without using it. +`--every-packet` ("every packet" in the web UI) checks a drop or reject +against every packet, before established connections are accepted, so it +also stops connections already open, not just new ones; it can't accept +([operations.md](operations.md#connections)). Examples: diff --git a/docs/validation.md b/docs/validation.md index 47ffb5b..7eb3ae5 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -93,7 +93,8 @@ These apply to rules, source NAT and redirects. | `rule_quota_reset` | error | a reset that isn't daily, weekly or monthly; a day with another reset than monthly; a day outside 1-31 | | `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) | | `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log | -| `flowtable_traffic` | warning | a forward rule with a quota or a byte rate while the flowtable is on: it only counts each connection's first packets | +| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log | +| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets | | `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject | | `log_prefix` | error | a log prefix over 100 characters | diff --git a/internal/api/types.go b/internal/api/types.go index d52a146..9c51e2b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -330,3 +330,95 @@ type QuotaAddress struct { type ConfirmInput struct { Token string `json:"token,omitempty"` } + +// Connection is a connection the kernel tracks (conntrack), as it was +// first seen: Source:SourcePort → Destination:DestinationPort. NAT shows +// as DNATTo (where it was really sent: a port forward) and SNATTo (the +// source it went out with: masquerading). +type Connection struct { + ID uint32 `json:"id,omitempty"` + Family string `json:"family"` + Protocol string `json:"protocol"` + Source string `json:"source"` + SourcePort int `json:"source_port,omitempty"` + Destination string `json:"destination"` + DestinationPort int `json:"destination_port,omitempty"` + ICMPID int `json:"icmp_id,omitempty"` + ICMPType int `json:"icmp_type,omitempty"` + ICMPCode int `json:"icmp_code,omitempty"` + DNATTo string `json:"dnat_to,omitempty"` + DNATPort int `json:"dnat_port,omitempty"` + SNATTo string `json:"snat_to,omitempty"` + // State is TCP's (established, time-wait, …); Assured: traffic went + // both ways. + State string `json:"state,omitempty"` + Assured bool `json:"assured,omitempty"` + // Timeout is the seconds left until it is forgotten unless more + // traffic comes. + Timeout int `json:"timeout"` + Mark uint32 `json:"mark,omitempty"` + // Packets and Bytes, both ways, when the kernel counts them + // (net.netfilter.nf_conntrack_acct). + Packets uint64 `json:"packets,omitempty"` + Bytes uint64 `json:"bytes,omitempty"` +} + +// CloseConnections closes connections: these, or every one Search finds +// (as GET /connections?q= does). +type CloseConnections struct { + Connections []Connection `json:"connections,omitempty"` + Search string `json:"search,omitempty"` +} + +// TraceInput says what to trace: packets from or to Address (an address +// or prefix), on Port (either way), of Protocol, for Seconds (0: 60). +type TraceInput struct { + Address string `json:"address,omitempty"` + Port int `json:"port,omitempty"` + Protocol string `json:"protocol,omitempty"` + Seconds int `json:"seconds,omitempty"` +} + +// TraceStep is one rule or policy a traced packet met, in any table. +type TraceStep struct { + Table string `json:"table"` // "inet housecarl", "ip nat"… + Chain string `json:"chain"` + Kind string `json:"kind"` // rule or policy + // Rule is the rule as nftables writes it; Mark its Housecarl mark + // ("r12", "b-established"), if it is one of Housecarl's. + Rule string `json:"rule,omitempty"` + Mark string `json:"mark,omitempty"` + Verdict string `json:"verdict"` // accept, drop, continue, jump X… + // Label names Housecarl's object ("rule input #12 (ssh)"). + Label string `json:"label,omitempty"` +} + +// TracePacket is one traced packet: its headers as it arrived, every +// step, and its fate. +type TracePacket struct { + ID string `json:"id"` + Time time.Time `json:"time"` + In string `json:"in,omitempty"` + Out string `json:"out,omitempty"` + Protocol string `json:"protocol,omitempty"` + Source string `json:"source,omitempty"` + SourcePort int `json:"source_port,omitempty"` + Destination string `json:"destination,omitempty"` + DestinationPort int `json:"destination_port,omitempty"` + TCPFlags string `json:"tcp_flags,omitempty"` + ICMPType string `json:"icmp_type,omitempty"` + Steps []TraceStep `json:"steps"` + // Verdict is accepted or dropped; DecidedBy the step that decided. + Verdict string `json:"verdict"` + DecidedBy TraceStep `json:"decided_by"` +} + +// TraceStatus is the trace running (or the last one). +type TraceStatus struct { + Running bool `json:"running"` + Input TraceInput `json:"input"` + Started time.Time `json:"started"` + Ends time.Time `json:"ends"` + Packets []TracePacket `json:"packets"` + Error string `json:"error,omitempty"` +} diff --git a/internal/conntrack/conntrack.go b/internal/conntrack/conntrack.go new file mode 100644 index 0000000..321d7f5 --- /dev/null +++ b/internal/conntrack/conntrack.go @@ -0,0 +1,351 @@ +// Package conntrack lists and closes the kernel's tracked connections, +// through ctnetlink (the netfilter netlink subsystem for connection +// tracking): what the "established" built-in lets through, and what a +// change of rules doesn't stop by itself. +// +// Closing a connection deletes its tracking entry. Its next packet is +// then checked by the rules again, as a new connection would be: one the +// rules now block is stopped, one they allow carries on (TCP connections +// are picked up again mid-stream). +package conntrack + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net/netip" + "sync/atomic" + "syscall" + "time" + + "golang.org/x/sys/unix" + + "housecarl/internal/api" +) + +const ( + subsysCtnetlink = 1 + msgNew = 0 + msgGet = 1 + msgDelete = 2 + + ctaTupleOrig = 1 + ctaTupleReply = 2 + ctaStatus = 3 + ctaProtoinfo = 4 + ctaTimeout = 7 + ctaMark = 8 + ctaCountersOrig = 9 + ctaCountersReply = 10 + ctaID = 12 + + ctaTupleIP = 1 + ctaTupleProto = 2 + + ctaIPv4Src = 1 + ctaIPv4Dst = 2 + ctaIPv6Src = 3 + ctaIPv6Dst = 4 + + ctaProtoNum = 1 + ctaProtoSrcPort = 2 + ctaProtoDstPort = 3 + ctaProtoICMPID = 4 + ctaProtoICMPType = 5 + ctaProtoICMPCode = 6 + ctaProtoICMP6ID = 7 + ctaProtoICMP6Typ = 8 + ctaProtoICMP6Cod = 9 + + ctaProtoinfoTCP = 1 + ctaProtoinfoTCPState = 1 + + ctaCountersPackets = 1 + ctaCountersBytes = 2 + + statusAssured = 1 << 2 + statusSrcNAT = 1 << 4 + statusDstNAT = 1 << 5 + + nlaNested = 0x8000 + nlaTypeMask = 0x3fff +) + +var tcpStates = []string{"none", "syn-sent", "syn-recv", "established", "fin-wait", "close-wait", "last-ack", "time-wait", "close", "syn-sent2"} + +var protoNames = map[uint8]string{1: "icmp", 6: "tcp", 17: "udp", 58: "icmpv6", 132: "sctp", 136: "udplite", 33: "dccp", 47: "gre"} + +// Client talks to ctnetlink. The zero value works. +type Client struct{} + +var seq atomic.Uint32 + +// List returns every tracked connection, IPv4 and IPv6. +func (Client) List(ctx context.Context) ([]api.Connection, error) { + msgs, err := request(ctx, msgGet, unix.NLM_F_REQUEST|unix.NLM_F_DUMP, unix.AF_UNSPEC, nil) + if err != nil { + return nil, err + } + out := []api.Connection{} + for _, m := range msgs { + if c, ok := parse(m); ok { + out = append(out, c) + } + } + return out, nil +} + +// Close deletes a connection's tracking entry, found by its original +// direction (addresses, protocol, ports or ICMP id). Gone already is fine. +func (Client) Close(ctx context.Context, c api.Connection) error { + src, err1 := netip.ParseAddr(c.Source) + dst, err2 := netip.ParseAddr(c.Destination) + if err1 != nil || err2 != nil || src.Is4() != dst.Is4() { + return fmt.Errorf("connection %s -> %s: not a pair of addresses", c.Source, c.Destination) + } + num := protoNumber(c.Protocol) + if num == 0 { + return fmt.Errorf("connection %s -> %s: unknown protocol %q", c.Source, c.Destination, c.Protocol) + } + family, ipSrc, ipDst := uint8(unix.AF_INET), uint16(ctaIPv4Src), uint16(ctaIPv4Dst) + if src.Is6() { + family, ipSrc, ipDst = unix.AF_INET6, ctaIPv6Src, ctaIPv6Dst + } + ip := attr(ipSrc, src.AsSlice()) + ip = append(ip, attr(ipDst, dst.AsSlice())...) + proto := attr(ctaProtoNum, []byte{num}) + switch num { + case 1: + proto = append(proto, attr(ctaProtoICMPID, be16(uint16(c.ICMPID)))...) + proto = append(proto, attr(ctaProtoICMPType, []byte{uint8(c.ICMPType)})...) + proto = append(proto, attr(ctaProtoICMPCode, []byte{uint8(c.ICMPCode)})...) + case 58: + proto = append(proto, attr(ctaProtoICMP6ID, be16(uint16(c.ICMPID)))...) + proto = append(proto, attr(ctaProtoICMP6Typ, []byte{uint8(c.ICMPType)})...) + proto = append(proto, attr(ctaProtoICMP6Cod, []byte{uint8(c.ICMPCode)})...) + default: + proto = append(proto, attr(ctaProtoSrcPort, be16(uint16(c.SourcePort)))...) + proto = append(proto, attr(ctaProtoDstPort, be16(uint16(c.DestinationPort)))...) + } + tuple := attr(ctaTupleIP|nlaNested, ip) + tuple = append(tuple, attr(ctaTupleProto|nlaNested, proto)...) + _, err := request(ctx, msgDelete, unix.NLM_F_REQUEST|unix.NLM_F_ACK, family, attr(ctaTupleOrig|nlaNested, tuple)) + if errors.Is(err, unix.ENOENT) { + return nil + } + return err +} + +func protoNumber(name string) uint8 { + for n, s := range protoNames { + if s == name { + return n + } + } + return 0 +} + +// ---- netlink + +func request(ctx context.Context, msg uint16, flags uint16, family uint8, payload []byte) ([][]byte, error) { + fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_NETFILTER) + if err != nil { + return nil, err + } + defer unix.Close(fd) + if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { + return nil, err + } + deadline := time.Now().Add(10 * time.Second) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + tv := unix.NsecToTimeval(time.Until(deadline).Nanoseconds()) + unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 1<<22) + + s := seq.Add(1) + b := make([]byte, unix.SizeofNlMsghdr+4, unix.SizeofNlMsghdr+4+len(payload)) + binary.NativeEndian.PutUint16(b[4:], subsysCtnetlink<<8|msg) + binary.NativeEndian.PutUint16(b[6:], flags) + binary.NativeEndian.PutUint32(b[8:], s) + b[16] = family // nfgenmsg: family, version 0, res_id 0 + b = append(b, payload...) + binary.NativeEndian.PutUint32(b[0:], uint32(len(b))) + if err := unix.Sendto(fd, b, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { + return nil, err + } + var out [][]byte + buf := make([]byte, 1<<16) + for { + n, _, err := unix.Recvfrom(fd, buf, 0) + if err != nil { + if errors.Is(err, unix.EAGAIN) { + return nil, errors.New("no answer from connection tracking (is nf_conntrack loaded?)") + } + return nil, err + } + for off := 0; off+unix.SizeofNlMsghdr <= n; { + l := int(binary.NativeEndian.Uint32(buf[off:])) + typ := binary.NativeEndian.Uint16(buf[off+4:]) + if l < unix.SizeofNlMsghdr || off+l > n { + return nil, errors.New("connection tracking: a malformed answer") + } + body := buf[off+unix.SizeofNlMsghdr : off+l] + switch typ { + case unix.NLMSG_DONE: + return out, nil + case unix.NLMSG_ERROR: + if len(body) >= 4 { + if code := int32(binary.NativeEndian.Uint32(body)); code != 0 { + return nil, syscall.Errno(-code) + } + } + return out, nil // the acknowledgement + default: + out = append(out, append([]byte(nil), body...)) + } + off += (l + 3) &^ 3 + } + if flags&unix.NLM_F_DUMP == 0 && flags&unix.NLM_F_ACK == 0 { + return out, nil + } + } +} + +func attr(typ uint16, data []byte) []byte { + l := 4 + len(data) + b := make([]byte, (l+3)&^3) + binary.NativeEndian.PutUint16(b[0:], uint16(l)) + binary.NativeEndian.PutUint16(b[2:], typ) + copy(b[4:], data) + return b +} + +func be16(v uint16) []byte { + b := make([]byte, 2) + binary.BigEndian.PutUint16(b, v) + return b +} + +// attrs splits netlink attributes: type (without the nested flag) → data. +func attrs(b []byte) map[uint16][]byte { + m := map[uint16][]byte{} + for len(b) >= 4 { + l := int(binary.NativeEndian.Uint16(b[0:])) + typ := binary.NativeEndian.Uint16(b[2:]) & nlaTypeMask + if l < 4 || l > len(b) { + break + } + m[typ] = b[4:l] + b = b[min((l+3)&^3, len(b)):] + } + return m +} + +// ---- parsing + +type tuple struct { + src, dst netip.Addr + proto uint8 + sport, dport uint16 + icmpID uint16 + icmpType, icmpCd uint8 +} + +func parseTuple(b []byte) (tuple, bool) { + var t tuple + a := attrs(b) + ip := attrs(a[ctaTupleIP]) + if v, ok := ip[ctaIPv4Src]; ok { + t.src, _ = netip.AddrFromSlice(v) + t.dst, _ = netip.AddrFromSlice(ip[ctaIPv4Dst]) + } else if v, ok := ip[ctaIPv6Src]; ok { + t.src, _ = netip.AddrFromSlice(v) + t.dst, _ = netip.AddrFromSlice(ip[ctaIPv6Dst]) + } + p := attrs(a[ctaTupleProto]) + if v := p[ctaProtoNum]; len(v) == 1 { + t.proto = v[0] + } + if v := p[ctaProtoSrcPort]; len(v) == 2 { + t.sport = binary.BigEndian.Uint16(v) + } + if v := p[ctaProtoDstPort]; len(v) == 2 { + t.dport = binary.BigEndian.Uint16(v) + } + for _, k := range [][3]uint16{{ctaProtoICMPID, ctaProtoICMPType, ctaProtoICMPCode}, {ctaProtoICMP6ID, ctaProtoICMP6Typ, ctaProtoICMP6Cod}} { + if v := p[k[0]]; len(v) == 2 { + t.icmpID = binary.BigEndian.Uint16(v) + } + if v := p[k[1]]; len(v) == 1 { + t.icmpType = v[0] + } + if v := p[k[2]]; len(v) == 1 { + t.icmpCd = v[0] + } + } + return t, t.src.IsValid() && t.dst.IsValid() +} + +// parse reads one connection (an nfgenmsg and its attributes). +func parse(m []byte) (api.Connection, bool) { + if len(m) < 4 { + return api.Connection{}, false + } + a := attrs(m[4:]) + orig, ok1 := parseTuple(a[ctaTupleOrig]) + reply, ok2 := parseTuple(a[ctaTupleReply]) + if !ok1 || !ok2 { + return api.Connection{}, false + } + c := api.Connection{ + Family: "ipv4", Protocol: protoNames[orig.proto], + Source: orig.src.String(), SourcePort: int(orig.sport), Destination: orig.dst.String(), DestinationPort: int(orig.dport), + ICMPID: int(orig.icmpID), ICMPType: int(orig.icmpType), ICMPCode: int(orig.icmpCd), + } + if orig.src.Is6() { + c.Family = "ipv6" + } + if c.Protocol == "" { + c.Protocol = fmt.Sprint(orig.proto) + } + // NAT shows in the reply direction: it comes from where the packets + // really went, to where they really came from. + if reply.src != orig.dst || reply.sport != orig.dport { + c.DNATTo = reply.src.String() + if reply.sport != orig.dport { + c.DNATPort = int(reply.sport) + } + } + if reply.dst != orig.src || reply.dport != orig.sport { + c.SNATTo = reply.dst.String() + } + if v := a[ctaStatus]; len(v) == 4 { + st := binary.BigEndian.Uint32(v) + c.Assured = st&statusAssured != 0 + } + if v := a[ctaTimeout]; len(v) == 4 { + c.Timeout = int(binary.BigEndian.Uint32(v)) + } + if v := a[ctaMark]; len(v) == 4 { + c.Mark = binary.BigEndian.Uint32(v) + } + if v := a[ctaID]; len(v) == 4 { + c.ID = binary.BigEndian.Uint32(v) + } + if v := attrs(attrs(a[ctaProtoinfo])[ctaProtoinfoTCP])[ctaProtoinfoTCPState]; len(v) == 1 && int(v[0]) < len(tcpStates) { + c.State = tcpStates[v[0]] + } + for _, k := range []uint16{ctaCountersOrig, ctaCountersReply} { + ct := attrs(a[k]) + if v := ct[ctaCountersPackets]; len(v) == 8 { + c.Packets += binary.BigEndian.Uint64(v) + } + if v := ct[ctaCountersBytes]; len(v) == 8 { + c.Bytes += binary.BigEndian.Uint64(v) + } + } + return c, true +} diff --git a/internal/conntrack/conntrack_test.go b/internal/conntrack/conntrack_test.go new file mode 100644 index 0000000..b6a87b7 --- /dev/null +++ b/internal/conntrack/conntrack_test.go @@ -0,0 +1,51 @@ +package conntrack + +import ( + "encoding/binary" + "net/netip" + "testing" +) + +func nested(typ uint16, parts ...[]byte) []byte { + var b []byte + for _, p := range parts { + b = append(b, p...) + } + return attr(typ|nlaNested, b) +} + +func tupleAttr(typ uint16, src, dst string, proto uint8, sport, dport uint16) []byte { + s, d := netip.MustParseAddr(src), netip.MustParseAddr(dst) + return nested(typ, + nested(ctaTupleIP, attr(ctaIPv4Src, s.AsSlice()), attr(ctaIPv4Dst, d.AsSlice())), + nested(ctaTupleProto, attr(ctaProtoNum, []byte{proto}), attr(ctaProtoSrcPort, be16(sport)), attr(ctaProtoDstPort, be16(dport)))) +} + +// A port-forwarded TCP connection from the internet, as ctnetlink +// describes it: the reply comes from the inside host. +func TestParse(t *testing.T) { + status, timeout := make([]byte, 4), make([]byte, 4) + binary.BigEndian.PutUint32(status, statusAssured|statusDstNAT) + binary.BigEndian.PutUint32(timeout, 431999) + msg := []byte{2, 0, 0, 0} // nfgenmsg: AF_INET + msg = append(msg, tupleAttr(ctaTupleOrig, "198.51.100.7", "203.0.113.10", 6, 51432, 8443)...) + msg = append(msg, tupleAttr(ctaTupleReply, "192.168.1.10", "198.51.100.7", 6, 443, 51432)...) + msg = append(msg, attr(ctaStatus, status)...) + msg = append(msg, attr(ctaTimeout, timeout)...) + msg = append(msg, nested(ctaProtoinfo, nested(ctaProtoinfoTCP, attr(ctaProtoinfoTCPState, []byte{3})))...) + c, ok := parse(msg) + if !ok { + t.Fatal("not parsed") + } + if c.Protocol != "tcp" || c.Source != "198.51.100.7" || c.SourcePort != 51432 || c.Destination != "203.0.113.10" || c.DestinationPort != 8443 || + c.DNATTo != "192.168.1.10" || c.DNATPort != 443 || c.SNATTo != "" || c.State != "established" || !c.Assured || c.Timeout != 431999 { + t.Errorf("parsed %+v", c) + } + // Masqueraded on the way out: the reply comes back to the WAN address. + msg = []byte{2, 0, 0, 0} + msg = append(msg, tupleAttr(ctaTupleOrig, "192.168.1.20", "198.51.100.9", 17, 5353, 53)...) + msg = append(msg, tupleAttr(ctaTupleReply, "198.51.100.9", "203.0.113.10", 17, 53, 5353)...) + if c, _ := parse(msg); c.SNATTo != "203.0.113.10" || c.DNATTo != "" || c.Protocol != "udp" { + t.Errorf("masqueraded: %+v", c) + } +} diff --git a/internal/model/objects.go b/internal/model/objects.go index 41ef1f4..5df8ad5 100644 --- a/internal/model/objects.go +++ b/internal/model/objects.go @@ -141,9 +141,13 @@ type Rule struct { // GroupV4 and GroupV6 group addresses for the per-address limits, // connection limit and quota: by prefix length (0: the default, each // IPv4 address alone, IPv6 by /64, since one device has several). - GroupV4 int `json:"group_v4,omitempty"` - GroupV6 int `json:"group_v6,omitempty"` - Action string `json:"action"` + GroupV4 int `json:"group_v4,omitempty"` + GroupV6 int `json:"group_v6,omitempty"` + // EveryPacket checks the rule against every packet, before established + // connections are accepted, so a drop or reject also stops connections + // already open, not just new ones. + EveryPacket bool `json:"every_packet,omitempty"` + Action string `json:"action"` // RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset. RejectWith string `json:"reject_with,omitempty"` Log bool `json:"log,omitempty"` @@ -252,6 +256,12 @@ func (r *Rule) CountsTraffic() bool { return bytes } +// ChecksEveryPacket reports whether a rule is checked before the built-in +// that accepts established connections: it counts traffic, or asks to. +func (r *Rule) ChecksEveryPacket() bool { + return r.Raw == "" && (r.EveryPacket || r.CountsTraffic()) +} + // Groups returns a rule's address grouping, defaults applied. func (r *Rule) Groups() (v4, v6 int) { v4, v6 = r.GroupV4, r.GroupV6 diff --git a/internal/nft/nft.go b/internal/nft/nft.go index 896ff4e..73fa82f 100644 --- a/internal/nft/nft.go +++ b/internal/nft/nft.go @@ -8,6 +8,7 @@ package nft import ( + "bufio" "bytes" "context" "encoding/json" @@ -24,6 +25,7 @@ import ( "housecarl/internal/model" "housecarl/internal/render" + "housecarl/internal/trace" ) // Runner runs nft. @@ -541,6 +543,41 @@ func (f *Firewall) ApplyScript(ctx context.Context, script string) error { return f.Runner.ApplyScript(ctx, script) } +// Trace follows packets: it starts `nft monitor trace`, puts the tracing +// table (script) in, and passes each line to line until ctx ends; then +// it takes the table out again, whatever happened. +func (f *Firewall) Trace(ctx context.Context, script string, line func(string)) error { + c := f.Runner.cmd(ctx, "monitor", "trace") + out, err := c.StdoutPipe() + if err != nil { + return err + } + var errb bytes.Buffer + c.Stderr = &errb + if err := c.Start(); err != nil { + return err + } + defer f.Runner.run(context.Background(), "", "delete", "table", "inet", trace.Table) + if err := f.Runner.ApplyScript(ctx, script); err != nil { + c.Process.Kill() + c.Wait() + return fmt.Errorf("starting the trace: %w", err) + } + sc := bufio.NewScanner(out) + sc.Buffer(make([]byte, 64<<10), 1<<20) + for sc.Scan() { + line(sc.Text()) + } + err = c.Wait() + if ctx.Err() != nil { + return nil // stopped, or its time was up + } + if err != nil && errb.Len() > 0 { + return fmt.Errorf("nft monitor trace: %s", strings.TrimSpace(errb.String())) + } + return err +} + // DeleteTable deletes another table (takeover). func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error { if name == render.Table && family == "inet" { diff --git a/internal/render/render.go b/internal/render/render.go index ae3f542..3ce231e 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -364,7 +364,7 @@ func (r *renderer) policyTail(chain, policy string) { // or the others. func (r *renderer) rules(chain string, traffic bool) { for _, rule := range r.st.Rules { - if rule.Chain != chain || rule.Disabled || rule.CountsTraffic() != traffic { + if rule.Chain != chain || rule.Disabled || rule.ChecksEveryPacket() != traffic { continue } obj := rule.Label() diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 1be7257..bfb443c 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -76,6 +76,7 @@ func router() *model.State { Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionDrop, Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}, Comment: "device quota"}) + rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"10.0.10.66"}}, Action: model.ActionReject, EveryPacket: true, Comment: "blocked now"}) rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop}) rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"}) st.Forwards = []*model.PortForward{ @@ -186,6 +187,9 @@ func TestRenderRouter(t *testing.T) { if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est { t.Error("the byte rate rule comes after established") } + if now := strings.Index(fwd, "blocked now"); now < 0 || now > est { + t.Error("the every-packet rule comes after established") + } // The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN // isn't wanted (its sources are global). if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") { diff --git a/internal/server/h_traffic.go b/internal/server/h_traffic.go new file mode 100644 index 0000000..7e32139 --- /dev/null +++ b/internal/server/h_traffic.go @@ -0,0 +1,309 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "housecarl/internal/api" + "housecarl/internal/auth" + "housecarl/internal/model" + "housecarl/internal/trace" +) + +// Traffic as it happens: the connections the kernel tracks (and closing +// them, so the rules check them again), and tracing packets through the +// whole ruleset. + +// Conntrack lists and closes tracked connections (internal/conntrack). +type Conntrack interface { + List(ctx context.Context) ([]api.Connection, error) + Close(ctx context.Context, c api.Connection) error +} + +// SetConntrack gives the server the kernel's connection tracking. +func (s *Server) SetConntrack(c Conntrack) { s.conns = c } + +func (s *Server) conntrack() (Conntrack, error) { + if s.conns == nil { + return nil, &httpError{http.StatusServiceUnavailable, "unavailable", "connection tracking isn't available here"} + } + return s.conns, nil +} + +// connMatches: a search in a connection's addresses, ports (":443"), +// protocol, state and NAT addresses. +func connMatches(c api.Connection, q string) bool { + if q == "" { + return true + } + hay := strings.ToLower(strings.Join([]string{c.Protocol, c.Source, ":" + strconv.Itoa(c.SourcePort), c.Destination, ":" + strconv.Itoa(c.DestinationPort), + c.DNATTo, c.SNATTo, c.State}, " ") + " ") + for _, w := range strings.Fields(strings.ToLower(q)) { + if strings.HasPrefix(w, ":") { + w += " " // a whole port + } + if !strings.Contains(hay, w) { + return false + } + } + return true +} + +func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResLog, false); err != nil { + return err + } + ct, err := s.conntrack() + if err != nil { + return err + } + all, err := ct.List(r.Context()) + if err != nil { + return fmt.Errorf("listing connections: %w", err) + } + q := r.URL.Query().Get("q") + out := []api.Connection{} + for _, c := range all { + if connMatches(c, q) { + out = append(out, c) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Source != out[j].Source { + return out[i].Source < out[j].Source + } + return out[i].Destination < out[j].Destination + }) + if n := queryInt(r, "limit", 1000); n > 0 && len(out) > n { + out = out[:n] + } + w.Header().Set("X-Total-Count", strconv.Itoa(len(all))) + writeJSON(w, http.StatusOK, out) + return nil +} + +// closeConnections deletes tracking entries: the rules check those +// connections again with their next packet. +func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResConfig, true); err != nil { + return err + } + ct, err := s.conntrack() + if err != nil { + return err + } + var in api.CloseConnections + if err := decodeJSON(r, &in); err != nil { + return err + } + list := in.Connections + if strings.TrimSpace(in.Search) != "" { + all, err := ct.List(r.Context()) + if err != nil { + return fmt.Errorf("listing connections: %w", err) + } + for _, c := range all { + if connMatches(c, in.Search) { + list = append(list, c) + } + } + } else if len(list) == 0 { + return badRequest("say which connections: a list, or a search") + } + closed := 0 + for _, c := range list { + if err := ct.Close(r.Context(), c); err != nil { + return fmt.Errorf("closing %s %s -> %s: %w", c.Protocol, c.Source, c.Destination, err) + } + closed++ + } + what := in.Search + if what == "" && len(list) == 1 { + what = fmt.Sprintf("%s %s:%d -> %s:%d", list[0].Protocol, list[0].Source, list[0].SourcePort, list[0].Destination, list[0].DestinationPort) + } + s.eng.Audit(r.Context(), p, "connections.close", what, "success", fmt.Sprintf("%d closed", closed)) + writeJSON(w, http.StatusOK, map[string]int{"closed": closed}) + return nil +} + +// ---- tracing + +type tracing struct { + mu sync.Mutex + status api.TraceStatus + tracer *trace.Tracer + cancel context.CancelFunc +} + +func (s *Server) startTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResConfig, true); err != nil { + return err + } + var in api.TraceInput + if err := decodeJSON(r, &in); err != nil { + return err + } + if in.Seconds == 0 { + in.Seconds = 60 + } + if in.Seconds < 5 || in.Seconds > 600 { + return badRequest("trace for 5 to 600 seconds") + } + script, err := trace.Script(in) + if err != nil { + return badRequest("%v", err) + } + t := &s.tracing + t.mu.Lock() + if t.status.Running { + t.mu.Unlock() + return conflict("a trace is running already (started %s); stop it first", t.status.Started.Format("15:04:05")) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(in.Seconds)*time.Second) + now := s.nowFn() + t.status = api.TraceStatus{Running: true, Input: in, Started: now, Ends: now.Add(time.Duration(in.Seconds) * time.Second), Packets: []api.TracePacket{}} + t.tracer = &trace.Tracer{Now: s.nowFn} + t.cancel = cancel + t.mu.Unlock() + s.eng.Audit(r.Context(), p, "trace.start", traceWhat(in), "success", fmt.Sprintf("%d seconds", in.Seconds)) + go func() { + defer cancel() + err := s.fw.Trace(ctx, script, func(line string) { + t.mu.Lock() + defer t.mu.Unlock() + t.tracer.Line(line) + if t.tracer.Full() { + cancel() + } + }) + t.mu.Lock() + defer t.mu.Unlock() + t.status.Running = false + if err != nil { + t.status.Error = err.Error() + s.log.Warn("tracing failed", "err", err) + } + }() + writeJSON(w, http.StatusOK, s.traceStatus()) + return nil +} + +func traceWhat(in api.TraceInput) string { + var parts []string + if in.Address != "" { + parts = append(parts, in.Address) + } + if in.Protocol != "" { + parts = append(parts, in.Protocol) + } + if in.Port != 0 { + parts = append(parts, "port "+strconv.Itoa(in.Port)) + } + return strings.Join(parts, " ") +} + +// traceStatus is the trace with each step named after Housecarl's object. +func (s *Server) traceStatus() api.TraceStatus { + t := &s.tracing + t.mu.Lock() + out := t.status + if t.tracer != nil { + out.Packets = t.tracer.Packets() + } + t.mu.Unlock() + if out.Packets == nil { + out.Packets = []api.TracePacket{} + } + st := s.eng.State() + for i := range out.Packets { + p := &out.Packets[i] + for j := range p.Steps { + p.Steps[j].Label = markLabel(st, p.Steps[j]) + } + p.DecidedBy.Label = markLabel(st, p.DecidedBy) + } + return out +} + +// markLabel names the Housecarl object behind a step: "rule input #12 +// (ssh)", "built-in: established", "port forward web", "input policy". +func markLabel(st *model.State, step api.TraceStep) string { + if step.Kind == "policy" { + if step.Table == "inet housecarl" { + return step.Chain + " policy" + } + return "" + } + m := step.Mark + kind, id := "", int64(0) + switch { + case m == "": + return "" + case strings.HasPrefix(m, "b-"): + return "built-in: " + strings.TrimPrefix(m, "b-") + case strings.HasPrefix(m, "r"): + kind = model.KindRule + case strings.HasPrefix(m, "f"): + kind = model.KindForward + case strings.HasPrefix(m, "s"): + kind = model.KindSNAT + case strings.HasPrefix(m, "d"): + kind = model.KindRedirect + default: + return "" + } + num := strings.TrimLeft(m[1:], "0123456789") + id, _ = strconv.ParseInt(strings.TrimSuffix(m[1:], num), 10, 64) + if o := st.ByID(kind, id); o != nil { + label := o.Label() // "source NAT #3 (…)", "redirect #4", a forward's name… + switch { + case kind == model.KindRule: + label = "rule " + label + case kind == model.KindForward && !strings.HasPrefix(label, "port forward"): + label = "port forward " + label + } + if num != "" { + label += " (" + strings.TrimPrefix(num, "-") + ")" + } + return label + } + return "" +} + +func (s *Server) getTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResLog, false); err != nil { + return err + } + writeJSON(w, http.StatusOK, s.traceStatus()) + return nil +} + +func (s *Server) stopTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.ResConfig, true); err != nil { + return err + } + t := &s.tracing + t.mu.Lock() + if t.cancel != nil && t.status.Running { + t.cancel() + } + t.mu.Unlock() + // Let the trace end, so the answer shows it stopped. + for i := 0; i < 50; i++ { + t.mu.Lock() + running := t.status.Running + t.mu.Unlock() + if !running { + break + } + time.Sleep(20 * time.Millisecond) + } + writeJSON(w, http.StatusOK, s.traceStatus()) + return nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 4cdf19f..1a6743f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -42,6 +42,11 @@ func (s *Server) routes() { m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement)) m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement)) m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas)) + m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections)) + m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections)) + m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace)) + m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace)) + m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace)) m.HandleFunc("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota)) m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets)) m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset)) diff --git a/internal/server/routes_cfg.go b/internal/server/routes_cfg.go index 3ed4ba3..afdd8f4 100644 --- a/internal/server/routes_cfg.go +++ b/internal/server/routes_cfg.go @@ -1,6 +1,10 @@ package server -import "encoding/json" +import ( + "encoding/json" + + "housecarl/internal/trace" +) // otherTables lists the tables in an `nft -j list ruleset` other than // Housecarl's ("inet main"). @@ -18,7 +22,7 @@ func otherTables(data []byte) []string { continue } var t struct{ Family, Name string } - if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && t.Name == "housecarl") { + if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && (t.Name == "housecarl" || t.Name == trace.Table)) { out = append(out, t.Family+" "+t.Name) } } diff --git a/internal/server/server.go b/internal/server/server.go index 0216704..c5413fc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,6 +67,8 @@ type Firewall interface { SaveBoot(st *model.State) error // ResetQuota starts a rule's quota again (perAddress: its sets). ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error + // Trace follows packets (internal/trace) until ctx ends. + Trace(ctx context.Context, script string, line func(string)) error } // Server is the HTTP server. @@ -91,6 +93,10 @@ type Server struct { // unitLinks are where an enabled nftables.service shows (tests change // them). unitLinks []string + // conns is the kernel's connection tracking (nil: unavailable). + conns Conntrack + // tracing is the packet trace running (or the last one). + tracing tracing // csMu orders changes to changesets (staging, applying, discarding). csMu sync.Mutex nowFn func() time.Time diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4506474..ddb5881 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/internal/trace/trace.go b/internal/trace/trace.go new file mode 100644 index 0000000..4274999 --- /dev/null +++ b/internal/trace/trace.go @@ -0,0 +1,258 @@ +// Package trace follows packets through the whole ruleset, every table's +// chains, with nftables' own tracing: a table of its own (housecarl-trace, +// hooked before everything else) marks the packets a filter picks +// (meta nftrace set 1), and `nft monitor trace` reports each chain and +// rule they pass, and what decided their fate. Packets are counted per +// trace id: one packet through the hooks it crosses. +package trace + +import ( + "fmt" + "net/netip" + "regexp" + "strconv" + "strings" + "time" + + "housecarl/internal/api" +) + +// Table is the tracing table's name (family inet). +const Table = "housecarl-trace" + +// Script builds the tracing table for a filter: packets from or to the +// address, on the port (either way), of the protocol. A limit keeps a +// flood from swamping the trace. +func Script(in api.TraceInput) (string, error) { + var addr netip.Prefix + if in.Address != "" { + p, err := netip.ParsePrefix(in.Address) + if err != nil { + a, err2 := netip.ParseAddr(in.Address) + if err2 != nil { + return "", fmt.Errorf("address %q: give an address or a prefix (192.168.1.50, 2001:db8::/64)", in.Address) + } + p = netip.PrefixFrom(a, a.BitLen()) + } + addr = p.Masked() + } + if in.Port < 0 || in.Port > 65535 { + return "", fmt.Errorf("port %d: 1 to 65535", in.Port) + } + proto := strings.ToLower(in.Protocol) + switch proto { + case "", "tcp", "udp", "sctp", "icmp", "icmpv6": + default: + return "", fmt.Errorf("protocol %q: tcp, udp, sctp, icmp, icmpv6, or nothing for any", in.Protocol) + } + if !addr.IsValid() && in.Port == 0 { + return "", fmt.Errorf("say what to trace: an address, a port, or both") + } + if in.Port != 0 && (proto == "icmp" || proto == "icmpv6") { + return "", fmt.Errorf("ICMP has no ports") + } + var l4 string + switch { + case proto != "": + l4 = "meta l4proto " + proto + case in.Port != 0: + l4 = "meta l4proto { tcp, udp, sctp }" + } + addrs := []string{""} + if addr.IsValid() { + ip := "ip" + if addr.Addr().Is6() { + ip = "ip6" + } + addrs = []string{ip + " saddr " + addr.String(), ip + " daddr " + addr.String()} + } + ports := []string{""} + if in.Port != 0 { + ports = []string{fmt.Sprintf("th dport %d", in.Port), fmt.Sprintf("th sport %d", in.Port)} + } + var rules []string + for _, a := range addrs { + for _, p := range ports { + parts := []string{} + for _, x := range []string{a, l4, p, "limit rate 20/second", "meta nftrace set 1"} { + if x != "" { + parts = append(parts, x) + } + } + rules = append(rules, "\t\t"+strings.Join(parts, " ")) + } + } + body := strings.Join(rules, "\n") + return fmt.Sprintf(`table inet %[1]s {} +delete table inet %[1]s +table inet %[1]s { + chain pre { + type filter hook prerouting priority -301; policy accept; +%[2]s + } + chain out { + type filter hook output priority -301; policy accept; +%[2]s + } +} +`, Table, body), nil +} + +// ---- reading `nft monitor trace` + +// "trace id 38ff3662 inet housecarl input rule … (verdict accept)" +var lineRe = regexp.MustCompile(`^trace id (\S+) (\S+) (\S+) (\S+) (packet: (.*)|rule (.*) \(verdict ([^)]*)\)|policy (\S+)|(.*))$`) + +var ( + fieldRe = regexp.MustCompile(`\b(iif|oif) "([^"]*)"`) + addrRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (\S+)`) + portRe = regexp.MustCompile(`\b(tcp|udp|sctp) (sport|dport) (\d+)`) + flagsRe = regexp.MustCompile(`\btcp flags == (\S+)`) + icmpRe = regexp.MustCompile(`\b(icmp|icmpv6) type (\S+)`) + protoRe = regexp.MustCompile(`\bmeta l4proto (\S+)|\bip protocol (\S+)|\bip6 nexthdr (\S+)`) + markRe = regexp.MustCompile(`comment "hc:(\S+)`) +) + +// Tracer collects packets from trace lines. +type Tracer struct { + // Max is how many packets it keeps (0: 200). + Max int + Now func() time.Time + packets []*api.TracePacket + byID map[string]*api.TracePacket + // seen are the chains a packet entered: the kernel reuses trace ids + // (they come from the packet's buffer), so entering one again means + // another packet. + seen map[*api.TracePacket]map[string]bool +} + +// Packets returns what was traced so far, oldest first. +func (t *Tracer) Packets() []api.TracePacket { + out := make([]api.TracePacket, 0, len(t.packets)) + for _, p := range t.packets { + out = append(out, *p) + } + return out +} + +// Full reports whether it keeps no more packets. +func (t *Tracer) Full() bool { + max := t.Max + if max == 0 { + max = 200 + } + return len(t.packets) >= max +} + +// Line reads one line of `nft monitor trace`. The tracing table's own +// steps are left out. +func (t *Tracer) Line(line string) { + m := lineRe.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + return + } + id, table, chain := m[1], m[2]+" "+m[3], m[4] + if m[3] == Table { + return + } + if t.byID == nil { + t.byID, t.seen = map[string]*api.TracePacket{}, map[*api.TracePacket]map[string]bool{} + } + p := t.byID[id] + isPacket := m[6] != "" || strings.HasPrefix(m[5], "packet:") + if p != nil && isPacket && t.seen[p][table+" "+chain] { + p = nil // the same id, another packet + } + if p == nil { + if t.Full() { + return + } + now := time.Now + if t.Now != nil { + now = t.Now + } + p = &api.TracePacket{ID: id, Time: now(), Verdict: "accepted"} + t.byID[id] = p + t.seen[p] = map[string]bool{} + t.packets = append(t.packets, p) + } + switch { + case isPacket: + t.seen[p][table+" "+chain] = true + readPacket(p, m[6]) + case m[7] != "": + step := api.TraceStep{Table: table, Chain: chain, Kind: "rule", Rule: m[7], Verdict: m[8]} + if mk := markRe.FindStringSubmatch(m[7]); mk != nil { + step.Mark = strings.TrimSuffix(mk[1], `"`) + } + p.Steps = append(p.Steps, step) + decide(p, step) + case m[9] != "": + step := api.TraceStep{Table: table, Chain: chain, Kind: "policy", Verdict: m[9]} + p.Steps = append(p.Steps, step) + decide(p, step) + } +} + +// decide notes a packet's fate: the first drop (or reject) ends it. +func decide(p *api.TracePacket, s api.TraceStep) { + if p.Verdict == "dropped" { + return + } + switch strings.Fields(s.Verdict + " ")[0] { + case "drop", "reject": + p.Verdict, p.DecidedBy = "dropped", s + case "accept": + // What let it through is a filter chain's accept: Housecarl's + // input, forward or output (NAT chains accept everything they + // see). Another table's accept counts when Housecarl's says + // nothing. + filter := s.Table == "inet housecarl" && (s.Chain == "input" || s.Chain == "forward" || s.Chain == "output") + if filter || p.DecidedBy.Verdict == "" && s.Table != "inet housecarl" { + p.DecidedBy = s + } + } +} + +// readPacket fills in a packet's headers from its "packet:" line (once: +// the first hook's view, before any NAT). +func readPacket(p *api.TracePacket, f string) { + for _, m := range fieldRe.FindAllStringSubmatch(f, -1) { + if m[1] == "iif" && p.In == "" { + p.In = m[2] + } + if m[1] == "oif" && p.Out == "" { + p.Out = m[2] + } + } + if p.Source != "" { + return + } + for _, m := range addrRe.FindAllStringSubmatch(f, -1) { + if m[2] == "saddr" { + p.Source = m[3] + } else { + p.Destination = m[3] + } + } + for _, m := range portRe.FindAllStringSubmatch(f, -1) { + p.Protocol = m[1] + n, _ := strconv.Atoi(m[3]) + if m[2] == "sport" { + p.SourcePort = n + } else { + p.DestinationPort = n + } + } + if m := flagsRe.FindStringSubmatch(f); m != nil { + p.TCPFlags = m[1] + } + if m := icmpRe.FindStringSubmatch(f); m != nil { + p.Protocol, p.ICMPType = m[1], m[2] + } + if p.Protocol == "" { + if m := protoRe.FindStringSubmatch(f); m != nil { + p.Protocol = strings.TrimSpace(m[1] + m[2] + m[3]) + } + } +} diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go new file mode 100644 index 0000000..9e1f999 --- /dev/null +++ b/internal/trace/trace_test.go @@ -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") + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index fac5021..e966071 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -106,6 +106,7 @@ func rawIgnores(r *model.Rule) []string { {"address grouping", r.GroupV4 != 0 || r.GroupV6 != 0}, {"log", r.Log || r.LogPrefix != ""}, {"reject with", r.RejectWith != ""}, + {"every packet", r.EveryPacket}, } { if x.set { out = append(out, x.name) @@ -434,11 +435,14 @@ func (c *checker) rule(r *model.Rule) { c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)") } } - if r.CountsTraffic() && r.Action == model.ActionAccept { + switch { + case r.CountsTraffic() && r.Action == model.ActionAccept: c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own") + case r.ChecksEveryPacket() && r.Action == model.ActionAccept: + c.add(Error, "rule_every_packet_accept", obj, "a rule checked against every packet comes before established connections are accepted, so accepting there would skip every later rule; it can drop, reject or log") } - if r.CountsTraffic() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled { - c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it") + if r.ChecksEveryPacket() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled { + c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this rule only sees each connection's first packets; turn the flowtable off (settings) for it to see all of them") } if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 { c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6") diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 1c8a319..40dc3ec 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -104,6 +104,7 @@ func TestCodes(t *testing.T) { {"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "daily", Day: 3} }}, {"rule_group", func(st *model.State) { rule(st).GroupV6 = 129 }}, {"rule_traffic_accept", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1 << 20} }}, + {"rule_every_packet_accept", func(st *model.State) { rule(st).EveryPacket = true }}, {"flowtable_traffic", func(st *model.State) { st.Settings.FlowtableDevices = []string{"wan"} st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true} diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 7b1bfbf..4a3c585 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -3,6 +3,8 @@ import { h, mount, toast, errorBox, modal } from "./ui.js"; import { wordmark, logo } from "./brand.js"; import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js"; import * as changes from "./pages/changes.js"; +import * as connections from "./pages/connections.js"; +import * as tracePage from "./pages/trace.js"; import * as dashboard from "./pages/dashboard.js"; import * as rules from "./pages/rules.js"; import * as nat from "./pages/nat.js"; @@ -26,6 +28,8 @@ const routes = [ { pattern: /^sets$/, page: sets, nav: "sets" }, { pattern: /^services$/, page: services, nav: "services" }, { pattern: /^log$/, page: log, nav: "log" }, + { pattern: /^connections$/, page: connections, nav: "connections" }, + { pattern: /^trace$/, page: tracePage, nav: "trace" }, { pattern: /^settings$/, page: settings, nav: "settings" }, { pattern: /^import$/, page: importPage, nav: "import" }, { pattern: /^changes$/, page: changes, nav: "changes" }, @@ -47,7 +51,11 @@ const navItems = [ ["sets", "#/sets", "Address sets", "config"], ["services", "#/services", "Services", "config"], ] }, - ["log", "#/log", "Log", "log"], + { menu: "Traffic", items: [ + ["log", "#/log", "Log", "log"], + ["connections", "#/connections", "Connections", "log"], + ["trace", "#/trace", "Trace", "log"], + ] }, ["changes", "#/changes", "Changes", "config"], ["history", "#/history", "History", "config"], { menu: "Server", items: [ diff --git a/internal/web/static/assets/pages/connections.js b/internal/web/static/assets/pages/connections.js new file mode 100644 index 0000000..01c1998 --- /dev/null +++ b/internal/web/static/assets/pages/connections.js @@ -0,0 +1,49 @@ +// Connections: what the kernel tracks, and closing them so the rules +// check them again. +import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js"; +import { get, api, can } from "../api.js"; +import { onLeave } from "../app.js"; +import { fmtSeconds } from "../widgets.js"; + +export async function render(el, params, query) { + const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" }); + const out = h("div"); + const count = h("span", { class: "muted small" }); + const write = can("config", true); + let shown = []; + const hostPort = (a, p) => !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p; + const close = async (list, what) => { + if (!await confirmDialog("Close connections", "Close " + what + "? Their next packet is checked by the rules as they are now: what they block is stopped, what they allow carries on.", "Close", "danger")) return; + try { + const r = await api("POST", "/connections/close", { body: list ? { connections: list } : { search: search.value.trim() } }); + toast("Closed " + r.closed + (r.closed === 1 ? " connection" : " connections")); + draw(); + } catch (e) { toast(e.message, "err"); } + }; + const draw = async () => { + shown = await get("/connections", { q: search.value.trim(), limit: 500 }); + count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections"; + mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))), + h("tbody", null, shown.map((c) => h("tr", null, + h("td", null, c.protocol), + h("td", { class: "mono" }, hostPort(c.source, c.source_port)), + h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)), + h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""), + h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]), + h("td", { class: "small" }, fmtSeconds(c.timeout)), + h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) : + h("div", { class: "empty" }, search.value.trim() ? "No connection matches." : "No connections tracked.")); + }; + search.addEventListener("input", debounce(draw, 300)); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Connections"), + h("div", { class: "muted" }, "The connections the firewall tracks. Their packets are let through by the built-in for established connections, without the rules being checked again, so a rule added later doesn't stop them. Close them, and their next packet is checked by the rules as they are now.")), + write ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => close(null, search.value.trim() ? "every connection matching “" + search.value.trim() + "”" : "every connection") }, "Close all shown")) : null), + h("div", { class: "filter-row" }, search, count), + h("div", { class: "panel" }, out)); + await draw(); + const t = setInterval(() => { if (!document.hidden && document.activeElement !== search) draw().catch(() => {}); }, 5000); + onLeave(() => clearInterval(t)); +} diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index da93fd2..bbbbe01 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -27,7 +27,7 @@ export async function render(el, params, query) { const chain = query.get("chain") || "input"; const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]); // As nftables checks them: the rules that count traffic first. - const rules = [...all.filter(countsTraffic), ...all.filter((r) => !countsTraffic(r))]; + const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))]; const write = can("config", true); const info = chains.find((c) => c[0] === chain) || chains[0]; const policy = settings[chain + "_policy"]; @@ -42,7 +42,7 @@ export async function render(el, params, query) { table([ { label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, { label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)] - : [countsTraffic(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] }, + : [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] }, { label: "Action", render: actionBadge }, { label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Comment", render: (r) => r.comment }, @@ -50,7 +50,7 @@ export async function render(el, params, query) { if (r.pending === "deleted") return badge("deleted", "err"); // Moves stay among the rules of the same kind (counting traffic, // or not): nftables checks those first. - const rs = live(rules).filter((x) => countsTraffic(x) === countsTraffic(r)), i = rs.indexOf(r); + const rs = live(rules).filter((x) => everyPacket(x) === everyPacket(r)), i = rs.indexOf(r); return [ h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ", h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ", @@ -60,14 +60,15 @@ export async function render(el, params, query) { ]; } } : null, ].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }), - rules.some(countsTraffic) ? h("div", { class: "muted small" }, "Rules marked “every packet” have a quota or a byte rate: they are checked before established connections are accepted, so they count all of a connection's traffic, not only its first packet. They can drop, reject or log, not accept.") : null, + rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null, h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"), policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); } -// countsTraffic: a quota or a byte rate (as model.Rule.CountsTraffic). -function countsTraffic(r) { - return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit))); +// everyPacket: checked before established connections are accepted (as +// model.Rule.ChecksEveryPacket): asked to, or a quota or a byte rate. +function everyPacket(r) { + return !r.raw && (!!r.every_packet || !!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit))); } // What each built-in does (a switch covers all its rules). @@ -207,7 +208,11 @@ export async function ruleDialog(r, chain) { const match = matchEditor(r, n); const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action); const rejectWith = select([["", "with an ICMP error"], ["tcp-reset", "with a TCP reset (TCP rules only)"]], r.reject_with || ""); - const showReject = () => rejectWith.classList.toggle("hidden", action.value !== "reject"); + const every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet); + const showReject = () => { + rejectWith.classList.toggle("hidden", action.value !== "reject"); + every.classList.toggle("hidden", action.value === "accept"); + }; action.addEventListener("change", showReject); showReject(); const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" }); @@ -306,7 +311,8 @@ export async function ruleDialog(r, chain) { // Written in nftables, a rule is its text: the fields it replaces go // (and the other parts close). const matchNodes = match.flat().filter(Boolean); - const actionField = field("Action", h("div", null, action, " ", rejectWith)).filter(Boolean); + const actionField = field("Action", h("div", null, action, " ", rejectWith, every), + "Every packet: checked before established connections are accepted, so blocking takes effect on what is already connected.").filter(Boolean); const logField = field("Log", h("div", null, log, logPrefix)).filter(Boolean); const addersLabel = h("label"); sections.raw.onShow = (on) => { @@ -352,7 +358,8 @@ export async function ruleDialog(r, chain) { per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination", reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null, group_v4: isRaw ? 0 : parseInt(group4.value || "0", 10), group_v6: isRaw ? 0 : parseInt(group6.value || "0", 10), - conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, log: !isRaw && log.input.checked, log_prefix: isRaw ? "" : logPrefix.value.trim(), + conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, + every_packet: !isRaw && action.value !== "accept" && every.input.checked, log: !isRaw && log.input.checked, log_prefix: isRaw ? "" : logPrefix.value.trim(), raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() }; if (r.id) v.disabled = disabled.input.checked; try { diff --git a/internal/web/static/assets/pages/trace.js b/internal/web/static/assets/pages/trace.js new file mode 100644 index 0000000..9163236 --- /dev/null +++ b/internal/web/static/assets/pages/trace.js @@ -0,0 +1,77 @@ +// Trace: follow packets through every table's rules, and see what +// decided their fate. +import { h, mount, input, select, field, errorBox, badge } from "../ui.js"; +import { get, api, can } from "../api.js"; +import { onLeave } from "../app.js"; + +export async function render(el) { + const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono" }); + const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 }); + const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" }); + const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 }); + const err = h("div"); + const status = h("div"); + const packets = h("div"); + const write = can("config", true); + const startBtn = h("button", { class: "primary", onclick: start }, "Start tracing"); + const stopBtn = h("button", { class: "hidden", onclick: stop }, "Stop"); + let timer = null; + + async function start() { + mount(err); + try { + await api("POST", "/trace", { body: { address: address.value.trim(), port: parseInt(port.value || "0", 10), protocol: protocol.value, seconds: parseInt(seconds.value || "60", 10) } }); + poll(); + } catch (e) { mount(err, errorBox(e)); } + } + async function stop() { + try { show(await api("DELETE", "/trace")); } catch (e) { mount(err, errorBox(e)); } + } + async function poll() { + clearTimeout(timer); + let st; + try { st = await get("/trace"); } catch (e) { mount(err, errorBox(e)); return; } + show(st); + if (st.running) timer = setTimeout(poll, 1000); + } + function show(st) { + startBtn.classList.toggle("hidden", st.running || !write); + stopBtn.classList.toggle("hidden", !st.running || !write); + const what = [st.input.address, st.input.protocol, st.input.port ? "port " + st.input.port : ""].filter(Boolean).join(" "); + mount(status, st.started && st.started !== "0001-01-01T00:00:00Z" ? h("div", { class: "msg " + (st.error ? "err" : st.running ? "info" : "ok") }, + st.running ? ["Tracing ", h("strong", null, what), " until " + new Date(st.ends).toLocaleTimeString() + ". Make the traffic happen now."] + : ["Traced ", h("strong", null, what), ": " + (st.packets.length === 1 ? "1 packet" : st.packets.length + " packets") + "."], + st.error ? h("div", null, st.error) : null) : null); + mount(packets, st.packets.length ? st.packets.slice().reverse().map(packet) + : st.running ? h("div", { class: "empty" }, "No packets yet.") : null); + } + const hostPort = (a, p) => !a ? "" : !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p; + function packet(p) { + const head = [p.protocol, p.tcp_flags, p.icmp_type].filter(Boolean).join(" "); + const decided = p.decided_by.label || [p.decided_by.table, p.decided_by.chain, p.decided_by.kind].filter(Boolean).join(" "); + return h("div", { class: "panel trace-packet" }, + h("div", { class: "trace-head" }, + badge(p.verdict, p.verdict === "dropped" ? "err" : "ok"), " ", + h("span", { class: "mono" }, hostPort(p.source, p.source_port), " → ", hostPort(p.destination, p.destination_port)), " ", + h("span", { class: "muted" }, head, p.in ? " · in " + p.in : "", p.out ? " · out " + p.out : ""), + h("span", { class: "muted small trace-time" }, new Date(p.time).toLocaleTimeString())), + decided ? h("div", { class: "small" }, p.verdict === "dropped" ? "Dropped by " : "Accepted by ", h("strong", null, decided)) : null, + h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" }, + h("td", { class: "muted small" }, s.table + " " + s.chain), + h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label) + : s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)), + h("td", { class: "small" }, s.verdict)))))); + } + + mount(el, + h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Trace"), + h("div", { class: "muted" }, "Follows packets through the whole ruleset, Housecarl's rules and every other table (Docker, fail2ban…): each rule and policy they meet, and what accepted or dropped them. For “why doesn't this get through?”"))), + write ? h("div", { class: "panel" }, h("div", { class: "form-grid" }, + field("Address", address, "Packets from or to it (either way). An address or a prefix."), + field("Port", h("div", null, port, " ", protocol), "Packets from or to it. Empty: any."), + field("For", h("div", null, seconds, " seconds"), "At most 600; it stops by itself after 200 packets.")), + h("div", { class: "actions form-actions" }, startBtn, stopBtn), err) : null, + status, packets); + await poll(); + onLeave(() => clearTimeout(timer)); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 80ef38f..6d2b105 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -412,3 +412,10 @@ select.inline { width: auto; display: inline-block; } /* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */ .adders { display: flex; flex-wrap: wrap; gap: 6px; } button.close-section { padding: 0 6px; margin-left: 4px; font-size: 11px; line-height: 1.5; } +/* Trace: one panel per packet, its steps below. */ +.trace-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 4px; } +.trace-time { margin-left: auto; } +table.trace-steps { width: 100%; margin-top: 6px; } +table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); } +tr.step-drop td { background: var(--del); } +tr.step-accept td { background: var(--add); } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 7ea211b..ba5fdb3 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -272,6 +272,41 @@ run "the reset is audited" 0 H audit --action quota. contains "quota.reset" run "the quota rule goes" 0 H rule delete -y "$QR" +# ---- connections and tracing + +# A connection that stays open, masqueraded on its way out. +docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000" +sleep 1 +run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000" +contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4" +run "closed" 0 H connections close --search "$CLIENT4 :9000" -y +contains "closed [1-9][0-9]* connection" +run "gone" 0 H connections --search "$CLIENT4 :9000" +not_contains ":9000" +# A block added while traffic flows: it stops new connections, not this +# one, unless it checks every packet. +docker exec -d ${P}-client sh -c "rm -f /tmp/ncdone; yes | nc $NET4 9000; touch /tmp/ncdone" +sleep 1 +BR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 -a reject --reject-with tcp-reset --comment "e2e block" --first) +run "a block for new connections" 0 test -n "$BR" +sleep 2 +run "the open upload carries on" 1 docker exec ${P}-client test -f /tmp/ncdone +run "the block checks every packet now" 0 H rule update "$BR" --every-packet +sleep 2 +run "the upload is cut" 0 docker exec ${P}-client test -f /tmp/ncdone +# Tracing a new connection: dropped, by that rule. +docker exec -d ${P}-router sh -c "/w/hc trace --address $CLIENT4 --port 9000 --seconds 6 > /w/trace.out 2>&1" +sleep 2 +connect client $NET4 9000 >/dev/null 2>&1 +sleep 6 +run "the trace" 0 cat "$WORK/trace.out" +contains "$CLIENT4:[0-9]* -> $NET4:9000 tcp syn" +contains "inet housecarl forward: rule forward #$BR (e2e block) -> drop" +contains "=> DROPPED by rule forward #$BR (e2e block)" +run "no trace table is left" 0 R nft list tables +not_contains "housecarl-trace" +run "the block goes" 0 H rule delete -y "$BR" + # ---- the drop log run "log drops from the internet to the API" 0 H rule add input --in wan --service tcp/8070 -a drop --log --first