Compare commits

..
Author SHA1 Message Date
abrown38andClaude Opus 5.5 e9cbb3e4e9 Release 0.1.0-rc.5
release / release (push) Successful in 1m5s
Housecarl 0.1.0's fifth release candidate. Since rc.4: the connections
the firewall tracks, and closing them; rules that check every packet,
so a block stops connections already open; and a packet tracer that
follows packets through every table's rules. make test (with nft
1.1.3), make e2e (157 checks) and the package test pass.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00
abrown38andClaude Opus 5.5 98e11a8bec Release 0.1.0-rc.4
release / release (push) Successful in 1m0s
Housecarl 0.1.0's fourth release candidate. Since rc.3: undo is staged
like any edit, each chain's built-ins have a window of their own, the
dashboard warns while nftables.service is enabled or an imported table
still applies, and raw rules and dynamic sets refuse fields they used to
ignore silently. make test (with nft 1.1.3), make e2e (140 checks) and
the package test pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00
48 changed files with 2238 additions and 99 deletions

No files matched your search

+13 -2
View File
@@ -35,7 +35,8 @@ the way Jarl and Steward are.
review shows the problems, nftables' check and the diff of the ruleset. review shows the problems, nftables' check and the diff of the ruleset.
The web UI collects edits as pending changes by default (pending rows, The web UI collects edits as pending changes by default (pending rows,
a bar with Review and Apply now, a Changes page), with a switch for live a bar with Review and Apply now, a Changes page), with a switch for live
edits; hc stages with `-C ID` or `HOUSECARL_CHANGESET`, and has `hc edits, and Undo in History is staged like any edit; hc stages with
`-C ID` or `HOUSECARL_CHANGESET`, and has `hc
changeset create/list/show/diff/apply/discard/remove`. An applied changeset create/list/show/diff/apply/discard/remove`. An applied
changeset is on probation like any change. `--dry-run` shows the changeset is on probation like any change. `--dry-run` shows the
ruleset's diff too (docs/design.md, docs/cli.md). ruleset's diff too (docs/design.md, docs/cli.md).
@@ -66,6 +67,15 @@ the way Jarl and Steward are.
- **At boot:** the confirmed ruleset is loaded by - **At boot:** the confirmed ruleset is loaded by
`housecarl-ruleset.service` before the network comes up, without `housecarl-ruleset.service` before the network comes up, without
housecarld. An empty configuration applies nothing. housecarld. An empty configuration applies nothing.
- **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, - **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 built-in and policy; packets logged by rules and policies, read from
netlink log group 4171 (`hc log -f`, the Log page). netlink log group 4171 (`hc log -f`, the Log page).
@@ -81,7 +91,8 @@ the way Jarl and Steward are.
chain with hits and reordering, NAT, zones, sets, services, the log, chain with hits and reordering, NAT, zones, sets, services, the log,
settings, import, history with undo; the rule dialog keeps the less settings, import, history with undo; the rule dialog keeps the less
used parts, such as rate limits, quotas and time windows, behind "+" used parts, such as rate limits, quotas and time windows, behind "+"
buttons), with users, roles, scoped API buttons; each chain's built-ins are listed, with their hits, and
switched in a window), with users, roles, scoped API
tokens, sessions, the audit log, and the web UI's certificate from tokens, sessions, the audit log, and the web UI's certificate from
Let's Encrypt through Jarl, as in Steward. Let's Encrypt through Jarl, as in Steward.
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a - **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
+4 -2
View File
@@ -49,7 +49,9 @@ table of its own.
rules' order, and keeps what it can't translate as raw rules rules' order, and keeps what it can't translate as raw rules
([docs/import.md](docs/import.md)). ([docs/import.md](docs/import.md)).
* **Easy to watch.** Hits per rule, a live log of what was dropped and * **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 * **Its own table only.** Docker, Incus and libvirt keep theirs. The
confirmed ruleset is loaded at boot, before the network comes up, even confirmed ruleset is loaded at boot, before the network comes up, even
if housecarld doesn't start. if housecarld doesn't start.
@@ -111,7 +113,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.0 is in testing (0.1.0-rc.3): zones, address sets and Housecarl 0.1.0 is in testing (0.1.0-rc.5): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.3 0.1.0-rc.5
+1 -1
View File
@@ -191,7 +191,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
} }
} }
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(), 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("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root return root
+15 -3
View File
@@ -99,7 +99,7 @@ type ruleFlags struct {
states, statuses, days []string states, statuses, days []string
hours, limit, quota string hours, limit, quota string
connLimit, groupV4, groupV6 int connLimit, groupV4, groupV6 int
log, disabled bool log, disabled, everyPacket bool
before, after int64 before, after int64
first bool 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.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
f.StringVar(&r.comment, "comment", "", "a comment") f.StringVar(&r.comment, "comment", "", "a comment")
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it") 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]*)$`) 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) { 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, 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 != "" { if r.hours != "" {
from, to, ok := strings.Cut(r.hours, "-") from, to, ok := strings.Cut(r.hours, "-")
if !ok { if !ok {
@@ -285,6 +286,9 @@ func ruleCmd() *cobra.Command {
} }
extra := ruleExtras(r) extra := ruleExtras(r)
action := r.Action action := r.Action
if r.Raw != "" {
action = "-" // the text's own
}
if r.Log { if r.Log {
action += "+log" action += "+log"
} }
@@ -298,7 +302,11 @@ func ruleCmd() *cobra.Command {
} }
cmd.AddCommand(ls) cmd.AddCommand(ls)
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) { cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action) if r.Raw == "" {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
} else {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
}
if r.Raw != "" { if r.Raw != "" {
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw) fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
} }
@@ -376,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}, "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}, "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}, "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) { if fl.Changed(flag) {
p[field[0].(string)] = field[1] p[field[0].(string)] = field[1]
@@ -500,6 +509,9 @@ func ruleExtras(r model.Rule) string {
if r.ConnLimit > 0 { if r.ConnLimit > 0 {
x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit)) x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit))
} }
if r.EveryPacket {
x = append(x, "every packet")
}
if len(x) == 0 { if len(x) == 0 {
return "" return ""
} }
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
)
// Traffic as it happens: connections the kernel tracks, and tracing
// packets through the ruleset.
func connectionsCmd() *cobra.Command {
var search string
var limit int
cmd := &cobra.Command{
Use: "connections", Aliases: []string{"conns"}, Short: "The connections the firewall tracks, and closing them",
Long: `Lists the connections the kernel tracks: what the "established" built-in
lets through without checking the rules again. A rule added later doesn't
stop them by itself; close them, and their next packet is checked by the
rules as it stands.
hc connections --search 192.168.1.50
hc connections --search ":443 tcp"
hc connections close --search 192.168.1.50`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[[]api.Connection]("/connections", q("q", search, "limit", strconv.Itoa(limit)))
if err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
for _, c := range cs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout))
}
})
},
}
cmd.Flags().StringVar(&search, "search", "", "only those with all these words: addresses, :port, protocol, state")
cmd.Flags().IntVar(&limit, "limit", 500, "at most this many")
var csearch string
var yes bool
closeCmd := &cobra.Command{
Use: "close --search TEXT", Short: "Close connections: the rules check them again with their next packet", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(csearch) == "" {
return fmt.Errorf("say which: --search 192.168.1.50 (see 'hc connections --search' first)")
}
if !confirm("Close every connection matching "+strconvQuote(csearch)+"?", yes) {
return fmt.Errorf("nothing closed")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/connections/close", nil, api.CloseConnections{Search: csearch}, &out); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("closed %s\n", plural(out["closed"], "connection"))
}
return nil
},
}
closeCmd.Flags().StringVar(&csearch, "search", "", "the connections to close, as for 'hc connections --search'")
closeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(closeCmd)
return cmd
}
func strconvQuote(s string) string { return strconv.Quote(s) }
func hostPort(addr string, port int) string {
if port == 0 {
return addr
}
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// natText: "to 192.168.1.10:443" (a port forward), "as 203.0.113.10"
// (masqueraded).
func natText(c api.Connection) string {
var parts []string
if c.DNATTo != "" {
port := c.DNATPort
if port == 0 {
port = c.DestinationPort
}
parts = append(parts, "to "+hostPort(c.DNATTo, port))
}
if c.SNATTo != "" {
parts = append(parts, "as "+c.SNATTo)
}
return strings.Join(parts, ", ")
}
func traceCmd() *cobra.Command {
var in api.TraceInput
cmd := &cobra.Command{
Use: "trace", Short: "Follow packets through every table's rules: what each met, and what decided",
Long: `Traces packets from or to an address, on a port (either way), through the
whole ruleset, Housecarl's and every other table's, for a while: each
rule and policy they meet, and what accepted or dropped them. Ctrl-C
stops it.
hc trace --address 192.168.1.50 --port 443
hc trace --port 22 --seconds 120
hc trace --address 2001:db8:1::/64 --protocol icmpv6`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.TraceStatus
if err := c.Do(ctx(), "POST", "/trace", nil, in, &st); err != nil {
return err
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "tracing until %s (Ctrl-C stops)\n", st.Ends.Local().Format("15:04:05"))
}
sig, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
shown := 0
for {
select {
case <-sig.Done():
c.Do(ctx(), "DELETE", "/trace", nil, nil, &st)
case <-time.After(time.Second):
if err := c.Do(ctx(), "GET", "/trace", nil, nil, &st); err != nil {
return err
}
}
for ; shown < len(st.Packets); shown++ {
printPacket(os.Stdout, st.Packets[shown])
}
if !st.Running || sig.Err() != nil {
break
}
}
if st.Error != "" {
return fmt.Errorf("the trace failed: %s", st.Error)
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "%d packets traced\n", len(st.Packets))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&in.Address, "address", "", "packets from or to this address or prefix")
f.IntVar(&in.Port, "port", 0, "packets from or to this port")
f.StringVar(&in.Protocol, "protocol", "", "tcp, udp, sctp, icmp or icmpv6 (default any)")
f.IntVar(&in.Seconds, "seconds", 60, "how long (5 to 600)")
return cmd
}
// printPacket writes a traced packet: its headers, each step, its fate.
func printPacket(w io.Writer, p api.TracePacket) {
what := p.Protocol
if p.TCPFlags != "" {
what += " " + p.TCPFlags
}
if p.ICMPType != "" {
what += " " + p.ICMPType
}
where := ""
if p.In != "" {
where += " in " + p.In
}
if p.Out != "" {
where += " out " + p.Out
}
fmt.Fprintf(w, "%s %s -> %s %s%s\n", p.Time.Local().Format("15:04:05.000"), hostPort(p.Source, p.SourcePort), hostPort(p.Destination, p.DestinationPort), what, where)
for _, s := range p.Steps {
name := s.Label
if name == "" {
if s.Kind == "policy" {
name = s.Chain + " policy"
} else {
name = s.Rule
}
}
fmt.Fprintf(w, " %s %s: %s -> %s\n", s.Table, s.Chain, name, s.Verdict)
}
decided := p.DecidedBy.Label
if decided == "" {
decided = strings.TrimSpace(p.DecidedBy.Table + " " + p.DecidedBy.Chain + " " + p.DecidedBy.Kind)
}
if decided == "" {
fmt.Fprintf(w, " => %s\n", strings.ToUpper(p.Verdict))
} else {
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
}
}
+5
View File
@@ -26,6 +26,7 @@ import (
"housecarl/internal/auth" "housecarl/internal/auth"
"housecarl/internal/certs" "housecarl/internal/certs"
"housecarl/internal/config" "housecarl/internal/config"
"housecarl/internal/conntrack"
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/hostif" "housecarl/internal/hostif"
"housecarl/internal/jarl" "housecarl/internal/jarl"
@@ -35,6 +36,7 @@ import (
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/server" "housecarl/internal/server"
"housecarl/internal/store" "housecarl/internal/store"
"housecarl/internal/trace"
"housecarl/internal/validate" "housecarl/internal/validate"
"housecarl/internal/version" "housecarl/internal/version"
"housecarl/internal/web" "housecarl/internal/web"
@@ -195,6 +197,9 @@ func serveCmd() *cobra.Command {
} }
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket) go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx) 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) hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP) signal.Notify(hup, syscall.SIGHUP)
go func() { go func() {
+9 -1
View File
@@ -80,6 +80,11 @@ Creating answers 201, other changes 200.
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text | | `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 /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 /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 /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 | | `GET /catalog` | built-in services, chains, actions, roles, the log group |
@@ -157,7 +162,10 @@ Changes staged to be reviewed and applied together ([design.md](design.md#review
Staging (`?changeset=ID` on any change to objects or settings) checks Staging (`?changeset=ID` on any change to objects or settings) checks
the changeset with the new operation, and nftables checks its ruleset; the changeset with the new operation, and nftables checks its ruleset;
an error refuses the operation. Only open changesets take operations, and an error refuses the operation. Only open changesets take operations, and
only their owner's (or an administrator's). Undo, `/import/nft` and only their owner's (or an administrator's). An undo
(`POST /history/{id}/revert?changeset=ID`) is staged as one operation,
which checks when it is replayed that nothing it touches changed since;
applied, History links it to the entry it undoes. `/import/nft` and
`/takeover` can't be staged: with a changeset named they answer 400. `/takeover` can't be staged: with a changeset named they answer 400.
Reads of objects, settings and `/ruleset` with `?changeset=ID` show the Reads of objects, settings and `/ruleset` with `?changeset=ID` show the
+6 -4
View File
@@ -117,9 +117,9 @@ it, because an exported ID easily outlives its changeset:
`-C ID` and an ID argument are used as given (housecarld still refuses a `-C ID` and an ID argument are used as given (housecarld still refuses a
changeset that isn't open, or another user's unless you are an changeset that isn't open, or another user's unless you are an
administrator). Only configuration changes are staged: dynamic set administrator). Only configuration changes are staged: dynamic set
entries, `confirm` and `rollback` are made at once, and `history undo`, entries, `confirm` and `rollback` are made at once; `history undo` is
`import` and `takeover` can't be staged, so they are refused while a staged like any change; `import` and `takeover` can't be staged, so they
changeset is named. Changesets staged in the web UI show up here too, are refused while a changeset is named. Changesets staged in the web UI show up here too,
and the other way round. and the other way round.
## Commands ## Commands
@@ -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`) | | `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 | | `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)) | | `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 ### zone, set, service
@@ -182,7 +184,7 @@ The match flags (`rule add`, `snat add`, `redirect add`):
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`, 10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`,
`--quota 50gbytes[,over][,per-source|,per-destination][,daily|,weekly|,monthly=DAY]`, `--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`, accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled` `--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
([rules.md](rules.md#rules)). ([rules.md](rules.md#rules)).
+3 -2
View File
@@ -75,8 +75,9 @@ to be reviewed and applied together with others:
| CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. | | CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. |
| API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. | | API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. |
Undo, imports and takeovers can't be staged; with a changeset named, they An undo is staged like any edit (in review mode, the web UI's Undo
are refused rather than applied. collects it for review). Imports and takeovers can't be staged; with a
changeset named, they are refused rather than applied.
## Its own table only ## Its own table only
+9 -1
View File
@@ -107,7 +107,10 @@ the old table:
sudo hc takeover "inet main" sudo hc takeover "inet main"
``` ```
From now on only Housecarl's rules apply. Over the network, this is on From now on only Housecarl's rules apply. Until you do, the dashboard
and `hc status` warn that the imported table still applies
(`takeover_pending`); they also warn while `nftables.service` is enabled
(`nftables_service`). Over the network, this is on
probation too: unless confirmed, the old table comes back. probation too: unless confirmed, the old table comes back.
## 4. The boot ## 4. The boot
@@ -122,6 +125,11 @@ sudo systemctl disable nftables.service
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
``` ```
Disable it, but **don't stop it** (no `systemctl stop nftables`, no
`disable --now`): Debian's unit stops by running `nft flush ruleset`,
which deletes every table, Housecarl's and Docker's included, at once.
Disabled, it simply doesn't run at the next boot.
`housecarl-ruleset.service` runs after `nftables.service` in any case, `housecarl-ruleset.service` runs after `nftables.service` in any case,
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
keep adding their own tables as before. keep adding their own tables as before.
+66 -9
View File
@@ -80,10 +80,21 @@ sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
Dynamic sets start empty after a reboot. Dynamic sets start empty after a reboot.
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf` Keep Debian's `nftables.service` disabled (`systemctl disable
without `flush ruleset`: otherwise it deletes Housecarl's table at nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
boot. `housecarl-ruleset.service` runs after it, which covers the stopping or restarting it runs `nft flush ruleset`, which deletes every
ordinary case. table, Housecarl's included. `housecarl-ruleset.service` runs after it
at boot, which covers the ordinary case, but not a restart of
`nftables.service` later. Housecarl never reads or writes
`/etc/nftables.conf`; its ruleset is saved in
`/var/lib/housecarl/ruleset.nft`. The dashboard and `hc status` warn
while `nftables.service` is enabled.
Other programs' tables are left alone: fail2ban's (`inet f2b-table`),
Docker's, Incus's. Housecarl replaces only its own table, so they keep
their rules through every Housecarl change and restart, and nothing
needs restarting in a particular order. A packet must get past every
table on its hook, so a drop in any of them wins.
## Hits ## Hits
@@ -122,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 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`). 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 ## 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)? 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 rule list forward` (or `input`): hits on the rule you expect?
3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces 4. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
and the sets' families. 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. 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). firewall and default route (answers must go back through Housecarl).
## Backups ## Backups
+2 -2
View File
@@ -7,9 +7,9 @@ else gets **grants**, `resource:access`:
| Resource | read | write | | 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 | | `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 | – | | `audit` | the audit log | – |
| `*` | all of the above | | | `*` | all of the above | |
+13 -3
View File
@@ -38,7 +38,10 @@ directly (`--from 192.168.1.3`); a set is better when several rules share
them. them.
A **dynamic** set's entries are added at run time, each with a timeout, A **dynamic** set's entries are added at run time, each with a timeout,
and nftables removes them when it runs out, even with housecarld stopped: and nftables removes them when it runs out, even with housecarld stopped.
It has no configured entries (validation refuses them); its timeout is
the default for entries added without one, and without a timeout they
stay until removed:
```sh ```sh
hc set add guests --dynamic --timeout 24h hc set add guests --dynamic --timeout 24h
@@ -116,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). `--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)). `--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. `--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: Examples:
@@ -193,8 +200,11 @@ hand, placed like any other:
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept' hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
``` ```
It is checked by nftables with the rest of the ruleset, and can use the It is used exactly as written: its text replaces the rule's zones,
sets' nftables names (`@trusted_v4`). The importer makes raw rules from addresses, services, action, limits and log, so a raw rule with any of
those set is refused (`raw_conflict`); write them in the text. It is
checked by nftables with the rest of the ruleset, and can use the sets'
nftables names (`@trusted_v4`). The importer makes raw rules from
what it can't translate. It goes into the ruleset as written: add what it can't translate. It goes into the ruleset as written: add
`counter` to it to count its packets. `counter` to it to count its packets.
+14 -1
View File
@@ -51,6 +51,7 @@ are unique per kind, ignoring case.
|---|---|---| |---|---|---|
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range | | `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing | | `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second | | `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
| `service_empty` | error | a service with no entries | | `service_empty` | error | a service with no entries |
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows | | `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
@@ -77,6 +78,7 @@ These apply to rules, source NAT and redirects.
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` | | `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` | | `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
| `raw_rule` | error | a raw rule over several lines, or with `;` | | `raw_rule` | error | a raw rule over several lines, or with `;` |
| `raw_conflict` | error | a raw rule with other fields set (zones, addresses, services, limits, log…): it is used as written, so they would be ignored |
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking | | `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
| `early_reject` | error | reject in the early chain: drop instead | | `early_reject` | error | reject in the early chain: drop instead |
| `ct_state`, `ct_status` | error | an unknown connection state or status | | `ct_state`, `ct_status` | error | an unknown connection state or status |
@@ -91,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_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_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 | | `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 | | `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 | | `log_prefix` | error | a log prefix over 100 characters |
@@ -115,3 +118,13 @@ These apply to rules, source NAT and redirects.
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated | | `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports | | `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
| `redirect_to_port` | error | the local port isn't one port | | `redirect_to_port` | error | the local port isn't one port |
## The machine
These come from housecarld looking at the machine, not at the
configuration: the dashboard and `hc status` show them.
| Code | | When |
|---|---|---|
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
+92
View File
@@ -330,3 +330,95 @@ type QuotaAddress struct {
type ConfirmInput struct { type ConfirmInput struct {
Token string `json:"token,omitempty"` 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"`
}
+3 -3
View File
@@ -39,9 +39,9 @@ type Client struct {
} }
// Staged reports whether a request is a configuration change that a // Staged reports whether a request is a configuration change that a
// changeset takes: objects and settings, and the changes that can't be // changeset takes: objects, settings and undo, and the changes that can't
// staged (undo, import, takeover), which the server then refuses rather // be staged (import, takeover), which the server then refuses rather than
// than apply them. Changesets' own calls and dynamic set entries aren't. // apply them. Changesets' own calls and dynamic set entries aren't.
func Staged(method, path string) bool { func Staged(method, path string) bool {
if method == http.MethodGet || strings.Contains(path, "/elements") { if method == http.MethodGet || strings.Contains(path, "/elements") {
return false return false
+351
View File
@@ -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
}
+51
View File
@@ -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)
}
}
+23 -6
View File
@@ -137,6 +137,9 @@ type Edit func(st *model.State, newID func() int64) error
type Options struct { type Options struct {
DryRun bool DryRun bool
Via string // web, cli, api, import Via string // web, cli, api, import
// RevertOf is the history entry the change undoes (a changeset with a
// staged undo), so History links the two.
RevertOf int64
} }
// Apply makes a change: edit runs on a copy of the configuration, the // Apply makes a change: edit runs on a copy of the configuration, the
@@ -161,7 +164,7 @@ func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description strin
if idErr != nil { if idErr != nil {
return nil, idErr return nil, idErr
} }
return e.commit(ctx, p, description, opts, next, 0) return e.commit(ctx, p, description, opts, next, opts.RevertOf)
} }
// commit validates next and saves it as the new state. // commit validates next and saves it as the new state.
@@ -264,8 +267,22 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
} }
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
cur := objects(e.state)
next := e.state.Clone() next := e.state.Clone()
if err := Undo(next, h); err != nil {
return nil, err
}
return e.commit(ctx, p, UndoDescription(h), opts, next, h.ID)
}
// UndoDescription is an undo's description: "undo 12: create rule …".
func UndoDescription(h *model.HistoryEntry) string {
return fmt.Sprintf("undo %d: %s", h.ID, h.Description)
}
// Undo applies a history entry backwards to st, if every object it
// changed is still as it left it (a change made since would be lost).
func Undo(st *model.State, h *model.HistoryEntry) error {
cur := objects(st)
for i := len(h.Changes) - 1; i >= 0; i-- { for i := len(h.Changes) - 1; i >= 0; i-- {
c := h.Changes[i] c := h.Changes[i]
now := cur[key{c.Kind, c.ID}] now := cur[key{c.Kind, c.ID}]
@@ -274,13 +291,13 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
if what == "" { if what == "" {
what = fmt.Sprintf("%s %d", c.Kind, c.ID) what = fmt.Sprintf("%s %d", c.Kind, c.ID)
} }
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id) return Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, h.ID)
} }
if err := put(next, c.Kind, c.ID, c.Before); err != nil { if err := put(st, c.Kind, c.ID, c.Before); err != nil {
return nil, err return err
} }
} }
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID) return nil
} }
// Replace makes the configuration next as a whole (imports). // Replace makes the configuration next as a whole (imports).
+5 -2
View File
@@ -39,6 +39,8 @@ type Input struct {
// Result is the imported configuration and what to know about it. // Result is the imported configuration and what to know about it.
type Result struct { type Result struct {
State *model.State State *model.State
// Table is the table imported ("inet main").
Table string
Notes []string Notes []string
// Raw lists the rules that became raw rules. // Raw lists the rules that became raw rules.
Raw []string Raw []string
@@ -206,7 +208,7 @@ func Import(in Input) (*Result, error) {
im.run() im.run()
im.nameForwards() im.nameForwards()
im.st.Renumber() im.st.Renumber()
return &Result{State: im.st, Notes: im.notes, Raw: im.raw}, nil return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
} }
func tableList(ts []nftTable) string { func tableList(ts []nftTable) string {
@@ -1464,7 +1466,8 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
if r.Action == "" { if r.Action == "" {
r.Action = model.ActionAccept r.Action = model.ActionAccept
} }
r.Match, r.Raw = model.Match{}, text // Only the text counts: what was parsed of it goes.
*r = model.Rule{ID: r.ID, Chain: r.Chain, Position: r.Position, Comment: r.Comment, Action: r.Action, Raw: text}
im.st.Rules = append(im.st.Rules, r) im.st.Rules = append(im.st.Rules, r)
why := strings.Join(p.unsupported, ", ") why := strings.Join(p.unsupported, ", ")
if why == "" { if why == "" {
+4 -1
View File
@@ -35,13 +35,16 @@ const (
OpDelete = "delete" OpDelete = "delete"
OpMove = "move" OpMove = "move"
OpSettings = "settings" OpSettings = "settings"
// OpRevert undoes a history entry: Data is the entry (its changes),
// ID its number.
OpRevert = "revert"
) )
// Op is one change, as the API received it, so it can be stored and // Op is one change, as the API received it, so it can be stored and
// replayed on whatever the configuration is when it is applied. // replayed on whatever the configuration is when it is applied.
type Op struct { type Op struct {
Seq int `json:"seq"` Seq int `json:"seq"`
Op string `json:"op"` // create, update, delete, move, settings Op string `json:"op"` // create, update, delete, move, settings, revert
Kind string `json:"kind,omitempty"` Kind string `json:"kind,omitempty"`
// Ref names the object (ID, or name) for update, delete and move. // Ref names the object (ID, or name) for update, delete and move.
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
+13 -3
View File
@@ -141,9 +141,13 @@ type Rule struct {
// GroupV4 and GroupV6 group addresses for the per-address limits, // GroupV4 and GroupV6 group addresses for the per-address limits,
// connection limit and quota: by prefix length (0: the default, each // connection limit and quota: by prefix length (0: the default, each
// IPv4 address alone, IPv6 by /64, since one device has several). // IPv4 address alone, IPv6 by /64, since one device has several).
GroupV4 int `json:"group_v4,omitempty"` GroupV4 int `json:"group_v4,omitempty"`
GroupV6 int `json:"group_v6,omitempty"` GroupV6 int `json:"group_v6,omitempty"`
Action string `json:"action"` // 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: "" (ICMP port/admin unreachable as fits) or tcp-reset.
RejectWith string `json:"reject_with,omitempty"` RejectWith string `json:"reject_with,omitempty"`
Log bool `json:"log,omitempty"` Log bool `json:"log,omitempty"`
@@ -252,6 +256,12 @@ func (r *Rule) CountsTraffic() bool {
return bytes 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. // Groups returns a rule's address grouping, defaults applied.
func (r *Rule) Groups() (v4, v6 int) { func (r *Rule) Groups() (v4, v6 int) {
v4, v6 = r.GroupV4, r.GroupV6 v4, v6 = r.GroupV4, r.GroupV6
+37
View File
@@ -8,6 +8,7 @@
package nft package nft
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -24,6 +25,7 @@ import (
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/trace"
) )
// Runner runs nft. // Runner runs nft.
@@ -541,6 +543,41 @@ func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
return f.Runner.ApplyScript(ctx, script) 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). // DeleteTable deletes another table (takeover).
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error { func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
if name == render.Table && family == "inet" { if name == render.Table && family == "inet" {
+1 -1
View File
@@ -364,7 +364,7 @@ func (r *renderer) policyTail(chain, policy string) {
// or the others. // or the others.
func (r *renderer) rules(chain string, traffic bool) { func (r *renderer) rules(chain string, traffic bool) {
for _, rule := range r.st.Rules { 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 continue
} }
obj := rule.Label() obj := rule.Label()
+4
View File
@@ -76,6 +76,7 @@ func router() *model.State {
Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"}) 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, 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"}) 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.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"}) rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
st.Forwards = []*model.PortForward{ 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 { if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est {
t.Error("the byte rate rule comes after established") 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 // The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
// isn't wanted (its sources are global). // isn't wanted (its sources are global).
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") { if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
+18 -3
View File
@@ -36,8 +36,8 @@ func changesetParam(r *http.Request) (int64, error) {
return id, nil return id, nil
} }
// notStaged refuses a changeset on a change that can't be staged (undo, // notStaged refuses a changeset on a change that can't be staged (an
// import, takeover): it would otherwise be applied at once, unexpectedly. // import, a takeover): it would otherwise be applied at once, unexpectedly.
func notStaged(r *http.Request, what string) error { func notStaged(r *http.Request, what string) error {
if id, err := changesetParam(r); err != nil || id == 0 { if id, err := changesetParam(r); err != nil || id == 0 {
return err return err
@@ -102,6 +102,11 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
return nil return nil
} }
op.Summary = engine.Describe(changes) op.Summary = engine.Describe(changes)
if op.Op == model.OpRevert {
var h model.HistoryEntry
json.Unmarshal(op.Data, &h)
op.Summary = engine.UndoDescription(&h)
}
op.AddedAt = s.nowFn() op.AddedAt = s.nowFn()
for _, o := range cs.Ops { for _, o := range cs.Ops {
if o.Seq >= op.Seq { if o.Seq >= op.Seq {
@@ -353,7 +358,17 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
if len(cs.Ops) == 0 { if len(cs.Ops) == 0 {
return badRequest("changeset %d has no changes to apply", id) return badRequest("changeset %d has no changes to apply", id)
} }
res, err := s.eng.Apply(r.Context(), p, cs.Description, engine.Options{Via: via(r, p)}, func(st *model.State, _ func() int64) error { opts := engine.Options{Via: via(r, p)}
desc := cs.Description
for _, o := range cs.Ops {
if o.Op == model.OpRevert {
opts.RevertOf = o.ID // History links the undo
if desc == "" && len(cs.Ops) == 1 {
desc = o.Summary
}
}
}
res, err := s.eng.Apply(r.Context(), p, desc, opts, func(st *model.State, _ func() int64) error {
return replay(st, cs.Ops) return replay(st, cs.Ops)
}) })
if err != nil { if err != nil {
+17 -3
View File
@@ -469,13 +469,27 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
if err := require(p, auth.ResConfig, true); err != nil { if err := require(p, auth.ResConfig, true); err != nil {
return err return err
} }
if err := notStaged(r, "an undo"); err != nil {
return err
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
return err return err
} }
// Staged, an undo is an operation of its own, reviewed like any.
if csID, err := changesetParam(r); err != nil {
return err
} else if csID != 0 {
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
if h.RevertedBy != 0 {
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
}
data, _ := json.Marshal(h)
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
}
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}) res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
if err != nil { if err != nil {
s.writeErr(w, r, err, res) s.writeErr(w, r, err, res)
+5
View File
@@ -51,6 +51,11 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil return nil
} }
s.afterChange(r.Context(), p, er) s.afterChange(r.Context(), p, er)
if er.Applied {
// Until it is taken over, the old table still applies too: the
// status warns (takeover_pending).
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
}
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings} out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
if out.Changes == nil { if out.Changes == nil {
out.Changes = []model.Change{} out.Changes = []model.Change{}
+36
View File
@@ -1,7 +1,10 @@
package server package server
import ( import (
"context"
"fmt"
"net/http" "net/http"
"os"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -44,10 +47,43 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
if data, err := s.fw.ListRuleset(r.Context()); err == nil { if data, err := s.fw.ListRuleset(r.Context()); err == nil {
out.OtherTables = otherTables(data) out.OtherTables = otherTables(data)
} }
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
const importedTableKey = "imported_table"
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
// enables it for sysinit.target).
var nftablesUnitLinks = []string{
"/etc/systemd/system/sysinit.target.wants/nftables.service",
"/etc/systemd/system/multi-user.target.wants/nftables.service",
}
// hostProblems are warnings about the machine rather than the
// configuration: another ruleset that still loads at boot, or the table
// an import came from, still in force beside Housecarl's.
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
var out []validate.Problem
for _, link := range s.unitLinks {
if _, err := os.Lstat(link); err == nil {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"})
break
}
}
if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" {
for _, o := range others {
if o == t {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t,
Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)})
}
}
}
return out
}
// hostInterfaces lists the machine's interfaces with their zones. // hostInterfaces lists the machine's interfaces with their zones.
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface { func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
out := []api.HostInterface{} out := []api.HostInterface{}
+309
View File
@@ -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
}
+6
View File
@@ -31,6 +31,12 @@ func applyOp(st *model.State, op *model.Op) (int64, error) {
return moveOp(st, op) return moveOp(st, op)
case model.OpSettings: case model.OpSettings:
return 0, settingsOp(st, op) return 0, settingsOp(st, op)
case model.OpRevert:
var h model.HistoryEntry
if err := json.Unmarshal(op.Data, &h); err != nil {
return 0, badRequest("revert: %v", err)
}
return 0, engine.Undo(st, &h)
} }
return 0, badRequest("unknown operation %q", op.Op) return 0, badRequest("unknown operation %q", op.Op)
} }
+5
View File
@@ -42,6 +42,11 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement)) 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("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas)) 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("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota))
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets)) m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset)) m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
+6 -2
View File
@@ -1,6 +1,10 @@
package server package server
import "encoding/json" import (
"encoding/json"
"housecarl/internal/trace"
)
// otherTables lists the tables in an `nft -j list ruleset` other than // otherTables lists the tables in an `nft -j list ruleset` other than
// Housecarl's ("inet main"). // Housecarl's ("inet main").
@@ -18,7 +22,7 @@ func otherTables(data []byte) []string {
continue continue
} }
var t struct{ Family, Name string } 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) out = append(out, t.Family+" "+t.Name)
} }
} }
+10 -1
View File
@@ -67,6 +67,8 @@ type Firewall interface {
SaveBoot(st *model.State) error SaveBoot(st *model.State) error
// ResetQuota starts a rule's quota again (perAddress: its sets). // ResetQuota starts a rule's quota again (perAddress: its sets).
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error 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. // Server is the HTTP server.
@@ -88,6 +90,13 @@ type Server struct {
probation probation probation probation
logs *logRing logs *logRing
// 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 orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex csMu sync.Mutex
nowFn func() time.Time nowFn func() time.Time
@@ -113,7 +122,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
log = slog.Default() log = slog.Default()
} }
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now} web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
s.routes() s.routes()
return s return s
} }
+179 -8
View File
@@ -29,14 +29,16 @@ import (
// fakeFW stands in for nftables: it records the applied states and keeps // fakeFW stands in for nftables: it records the applied states and keeps
// dynamic sets' entries. // dynamic sets' entries.
type fakeFW struct { type fakeFW struct {
mu sync.Mutex mu sync.Mutex
applied []*model.State applied []*model.State
boot *model.State boot *model.State
elements map[string][]render.Element elements map[string][]render.Element
applyErr error applyErr error
deleted []string deleted []string
restored []string restored []string
resets []string resets []string
traced string
traceLines []string
} }
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil } 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)) f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
return nil 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) { func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
return "table " + fam + " " + name + " {}", nil return "table " + fam + " " + name + " {}", nil
} }
@@ -795,3 +808,161 @@ func TestAutoConfirm(t *testing.T) {
t.Errorf("manual confirmation: %+v", mr) t.Errorf("manual confirmation: %+v", mr)
} }
} }
// TestStagedUndo: an undo staged in a changeset changes nothing until the
// changeset is applied; then History links the two entries.
func TestStagedUndo(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &mr)
e.call(e.token, "POST", "/confirm", nil, nil)
made := mr.HistoryID
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &mr); code != 200 || !mr.Staged {
t.Fatalf("staging the undo: %d %+v", code, mr)
}
if op := mr.Changeset.Ops[0]; op.Op != model.OpRevert || !strings.HasPrefix(op.Summary, "undo "+itoa(made)+":") {
t.Errorf("the operation: %+v", op)
}
if len(e.eng.State().Zones) != 1 {
t.Fatal("a staged undo was applied")
}
// Staging it again can't work: the changeset already undoes it.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &eb); code != 409 {
t.Errorf("a second undo: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.eng.State().Zones) != 0 {
t.Error("the undo wasn't applied")
}
h, _ := e.srv.store.GetHistory(context.Background(), mr.HistoryID)
orig, _ := e.srv.store.GetHistory(context.Background(), made)
if h.RevertOf != made || orig.RevertedBy != h.ID || !strings.HasPrefix(h.Description, "undo ") {
t.Errorf("history: %+v, original %+v", h, orig)
}
}
// TestHostProblems: an enabled nftables.service, and an imported table
// still in force, are warned about.
func TestHostProblems(t *testing.T) {
e := newEnv(t)
dir := t.TempDir()
link := dir + "/nftables.service"
e.srv.unitLinks = []string{link}
codes := func() string {
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var out []string
for _, p := range st.Problems {
out = append(out, p.Code)
}
return strings.Join(out, " ")
}
if c := codes(); strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") {
t.Errorf("warned without cause: %s", c)
}
os.Symlink("/usr/lib/systemd/system/nftables.service", link) // dangling is fine: Lstat
// The fake ruleset lists inet main: imported from it, it still applies.
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
if c := codes(); !strings.Contains(c, "nftables_service") || !strings.Contains(c, "takeover_pending") {
t.Errorf("not warned: %s", c)
}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet gone")
if c := codes(); strings.Contains(c, "takeover_pending") {
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)
}
}
+258
View File
@@ -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])
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package trace
import (
"strings"
"testing"
"housecarl/internal/api"
)
// Lines nft 1.1.3's `nft monitor trace` printed for a connection to a
// blocked port, and for one that loopback accepted.
const sample = `trace id 38ff3662 inet housecarl-trace pre packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl-trace pre rule ip saddr 198.51.100.7 meta nftrace set 1 (verdict continue)
trace id 38ff3662 inet housecarl-trace pre policy accept
trace id 38ff3662 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl input rule ct state established,related counter packets 0 bytes 0 accept comment "hc:b-established" (verdict continue)
trace id 38ff3662 inet housecarl input rule tcp dport 9999 counter packets 1 bytes 60 drop comment "hc:r12 block 9999" (verdict drop)
trace id a232d9a0 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip protocol 6 tcp sport 43168 tcp dport 22 tcp flags == syn
trace id a232d9a0 inet housecarl output policy accept
trace id a232d9a0 ip other input rule ip saddr 127.0.0.1 accept (verdict accept)
trace id 77 inet housecarl forward packet: iif "lan" oif "wan" ip saddr 192.168.1.20 ip daddr 198.51.100.10 tcp sport 3500 tcp dport 7000 tcp flags == syn
trace id 77 inet housecarl forward rule ip saddr @trusted_v4 counter accept comment "hc:r40 trusted" (verdict accept)
trace id 77 inet housecarl srcnat rule oifname "wan" counter masquerade comment "hc:s91" (verdict accept)`
func TestTracer(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(sample, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 3 {
t.Fatalf("packets: %+v", ps)
}
p := ps[0]
if p.In != "wan" || p.Source != "198.51.100.7" || p.DestinationPort != 9999 || p.Protocol != "tcp" || p.TCPFlags != "syn" {
t.Errorf("headers: %+v", p)
}
if len(p.Steps) != 2 || p.Verdict != "dropped" || p.DecidedBy.Mark != "r12" || p.DecidedBy.Chain != "input" {
t.Errorf("steps: %+v", p)
}
if q := ps[1]; q.Verdict != "accepted" || q.Out != "lo" || len(q.Steps) != 2 || q.DecidedBy.Kind != "policy" {
t.Errorf("second: %+v", q)
}
// Accepted by the forward rule, not by source NAT, which came after.
if q := ps[2]; q.Verdict != "accepted" || q.DecidedBy.Mark != "r40" {
t.Errorf("third: decided by %+v", q.DecidedBy)
}
}
// The kernel reuses trace ids: a connection's next packet can come with
// the same one. Entering a chain again starts another packet.
func TestReusedID(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(`trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl output policy accept
trace id 5 inet housecarl srcnat packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl srcnat policy accept
trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == ack
trace id 5 inet housecarl output policy accept`, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 2 || len(ps[0].Steps) != 2 || len(ps[1].Steps) != 1 || ps[1].TCPFlags != "ack" {
t.Errorf("packets: %+v", ps)
}
}
func TestScript(t *testing.T) {
s, err := Script(api.TraceInput{Address: "192.168.1.50", Port: 443})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"table inet housecarl-trace {}\ndelete table inet housecarl-trace\n",
"type filter hook prerouting priority -301; policy accept;",
"ip saddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th dport 443 limit rate 20/second meta nftrace set 1",
"ip daddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th sport 443 limit rate 20/second meta nftrace set 1",
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in\n%s", want, s)
}
}
if _, err := Script(api.TraceInput{}); err == nil {
t.Error("tracing everything")
}
if _, err := Script(api.TraceInput{Port: 22, Protocol: "icmp"}); err == nil {
t.Error("ICMP with a port")
}
}
+42 -3
View File
@@ -85,6 +85,36 @@ func Check(st *model.State, env *Env) []Problem {
return c.out return c.out
} }
// rawIgnores names what a raw rule has besides its text, which the
// ruleset wouldn't use (the action too is the text's; it isn't checked).
func rawIgnores(r *model.Rule) []string {
m := r.Match
var out []string
for _, x := range []struct {
name string
set bool
}{
{"zones", len(m.InZones) > 0 || len(m.OutZones) > 0},
{"addresses", len(m.Sources) > 0 || len(m.Destinations) > 0},
{"services", len(m.Services) > 0},
{"family", m.Family != ""},
{"connection state", len(r.CtState) > 0 || len(r.CtStatus) > 0},
{"time window", len(r.Days) > 0 || r.HourFrom != "" || r.HourTo != ""},
{"rate limit", r.Limit != nil},
{"connection limit", r.ConnLimit != 0},
{"quota", r.Quota != nil},
{"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)
}
}
return out
}
// maxByteRate is the largest byte rate per period nftables (1.1.3) can // maxByteRate is the largest byte rate per period nftables (1.1.3) can
// hold: the kernel's token bucket multiplies the rate by the period. // hold: the kernel's token bucket multiplies the rate by the period.
var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503} var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503}
@@ -202,6 +232,9 @@ func (c *checker) sets() {
c.add(Error, "set_entry", obj, "%v", err) c.add(Error, "set_entry", obj, "%v", err)
} }
} }
if len(s.Entries) > 0 && s.Dynamic {
c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic")
}
if len(s.Entries) == 0 && !s.Dynamic { if len(s.Entries) == 0 && !s.Dynamic {
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing") c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
} }
@@ -331,6 +364,9 @@ func (c *checker) rule(r *model.Rule) {
if strings.ContainsAny(r.Raw, ";\n\r") { if strings.ContainsAny(r.Raw, ";\n\r") {
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;") c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
} }
if ignored := rawIgnores(r); len(ignored) > 0 {
c.add(Error, "raw_conflict", obj, "a raw rule is used as written, so its %s would be ignored: write them in the nftables rule, or leave them out", strings.Join(ignored, ", "))
}
return return
} }
in := r.Chain != model.ChainOutput in := r.Chain != model.ChainOutput
@@ -399,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)") 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") 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 { 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 quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it") 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 { 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") c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6")
+3
View File
@@ -66,6 +66,7 @@ func TestCodes(t *testing.T) {
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }}, {"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }}, {"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }}, {"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }}, {"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }}, {"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }}, {"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
@@ -83,6 +84,7 @@ func TestCodes(t *testing.T) {
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }}, {"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }}, {"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }}, {"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
{"raw_conflict", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept" }}, // it still has sources and services
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }}, {"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }}, {"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }}, {"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
@@ -102,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_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_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_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) { {"flowtable_traffic", func(st *model.State) {
st.Settings.FlowtableDevices = []string{"wan"} st.Settings.FlowtableDevices = []string{"wan"}
st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true} st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true}
+9 -1
View File
@@ -3,6 +3,8 @@ import { h, mount, toast, errorBox, modal } from "./ui.js";
import { wordmark, logo } from "./brand.js"; import { wordmark, logo } from "./brand.js";
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js"; import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
import * as changes from "./pages/changes.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 dashboard from "./pages/dashboard.js";
import * as rules from "./pages/rules.js"; import * as rules from "./pages/rules.js";
import * as nat from "./pages/nat.js"; import * as nat from "./pages/nat.js";
@@ -26,6 +28,8 @@ const routes = [
{ pattern: /^sets$/, page: sets, nav: "sets" }, { pattern: /^sets$/, page: sets, nav: "sets" },
{ pattern: /^services$/, page: services, nav: "services" }, { pattern: /^services$/, page: services, nav: "services" },
{ pattern: /^log$/, page: log, nav: "log" }, { 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: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^import$/, page: importPage, nav: "import" }, { pattern: /^import$/, page: importPage, nav: "import" },
{ pattern: /^changes$/, page: changes, nav: "changes" }, { pattern: /^changes$/, page: changes, nav: "changes" },
@@ -47,7 +51,11 @@ const navItems = [
["sets", "#/sets", "Address sets", "config"], ["sets", "#/sets", "Address sets", "config"],
["services", "#/services", "Services", "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"], ["changes", "#/changes", "Changes", "config"],
["history", "#/history", "History", "config"], ["history", "#/history", "History", "config"],
{ menu: "Server", items: [ { menu: "Server", items: [
@@ -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));
}
+4 -4
View File
@@ -1,6 +1,6 @@
// History: every configuration change, with undo. // History: every configuration change, with undo.
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js"; import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js"; import { get, change, can, state } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
export async function render(el, params) { export async function render(el, params) {
@@ -20,10 +20,10 @@ export async function render(el, params) {
} }
async function undo(e) { async function undo(e) {
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return; if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too." + (!state.immediate ? " It is added to your pending changes, to review and apply." : ""), "Undo")) return;
try { try {
// An undo can't be staged: it is applied at once. // Like any edit: staged for review, or applied at once in live mode.
await change("POST", "/history/" + e.id + "/revert", undefined, { immediate: true }); await change("POST", "/history/" + e.id + "/revert");
rerender(); rerender();
} catch (err) { modal("Can't undo", errorBox(err)); } } catch (err) { modal("Can't undo", errorBox(err)); }
} }
+102 -22
View File
@@ -27,25 +27,22 @@ export async function render(el, params, query) {
const chain = query.get("chain") || "input"; const chain = query.get("chain") || "input";
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]); 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. // 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 write = can("config", true);
const info = chains.find((c) => c[0] === chain) || chains[0]; const info = chains.find((c) => c[0] === chain) || chains[0];
const policy = settings[chain + "_policy"]; const policy = settings[chain + "_policy"];
const bi = (builtins[chain] || []).filter(([flag]) => flag === "" || settings.builtins[flag]); const actionBadge = (r) => r.raw ? badge("as written", "") : badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
const actionBadge = (r) => badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])), h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null), write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null),
h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))), h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))),
h("div", { class: "panel" }, h("div", { class: "panel" },
bi.length ? h("div", { class: "builtins muted small" }, h("strong", null, "Added by Housecarl first: "), builtinsRow(chain, settings, cs),
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
h("a", { href: "#/settings" }, "settings")) : null,
table([ table([
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, { 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)] { 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: "Action", render: actionBadge },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment }, { label: "Comment", render: (r) => r.comment },
@@ -53,7 +50,7 @@ export async function render(el, params, query) {
if (r.pending === "deleted") return badge("deleted", "err"); if (r.pending === "deleted") return badge("deleted", "err");
// Moves stay among the rules of the same kind (counting traffic, // Moves stay among the rules of the same kind (counting traffic,
// or not): nftables checks those first. // 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 [ 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 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 }) }, "↓"), " ", h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
@@ -63,14 +60,72 @@ export async function render(el, params, query) {
]; ];
} } : null, } } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }), ].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"), h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
} }
// countsTraffic: a quota or a byte rate (as model.Rule.CountsTraffic). // everyPacket: checked before established connections are accepted (as
function countsTraffic(r) { // model.Rule.ChecksEveryPacket): asked to, or a quota or a byte rate.
return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit))); 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).
const builtinText = {
loopback: "Accept everything on lo (the firewall talking to itself)",
established: "Accept established and related connections: replies, and the rest of connections a rule let in",
drop_invalid: "Drop invalid packets: what connection tracking can't place",
icmpv6: "Accept the ICMPv6 that IPv6 needs: errors, echo, neighbour discovery, MLD (RFC 4890)",
ping_internal: "Accept ping to the firewall from internal zones",
dhcpv6_client: "Accept DHCPv6 replies on external zones (your ISP's prefix delegation)",
antispoof: "Drop sources that can't come from outside, on external zones (Settings: the lists and your prefixes)",
mss_clamp: "Clamp TCP's MSS to the route's MTU on external zones (needed with PPPoE)",
};
// builtinsRow sums up the chain's built-ins in one line, with a button to
// see and switch them.
function builtinsRow(chain, settings, cs) {
const list = builtins[chain] || [];
const on = list.filter(([flag]) => flag === "" || settings.builtins[flag]);
if (!list.length) return null;
return h("div", { class: "builtins muted small" },
on.length ? (on.length === 1 ? "Housecarl adds 1 rule before yours. " : "Housecarl adds " + on.length + " rules before yours. ") : "Housecarl adds no rules before yours here. ",
h("button", { class: "small", onclick: () => builtinsDialog(chain, settings, cs) }, "Built-ins…"));
}
// builtinsDialog lists the chain's built-ins: what each does, its hits,
// and a switch (a setting, so it covers every chain the built-in is in).
async function builtinsDialog(chain, settings, cs) {
const list = builtins[chain] || [];
const flags = [...new Set(list.map(([flag]) => flag))];
const write = can("config", true);
const boxes = {};
const rows = flags.map((flag) => {
const marks = list.filter(([f]) => f === flag).map(([, mark]) => mark).filter(Boolean);
const box = flag ? h("input", { type: "checkbox", checked: settings.builtins[flag], disabled: !write }) : null;
if (box) boxes[flag] = box;
return { flag, marks, box, text: flag ? builtinText[flag] : list.find(([f]) => f === flag)[2] };
});
const err = h("div");
const body = h("div", null,
h("p", { class: "muted" }, "Checked in this order, before your rules (rules with a quota or a byte rate come before the established built-in, so they see every packet). Each is a setting: switching one changes every chain it is in."),
table([
{ label: "On", render: (x) => x.box || h("span", { class: "muted small" }, "always") },
{ label: "Built-in", render: (x) => x.flag ? x.text : [x.text, h("div", { class: "muted small" }, "Part of each port forward.")] },
{ label: "Hits", render: (x) => x.marks.length ? hits(cs, ...x.marks.map((m) => "b-" + m)) : h("span", { class: "muted" }, "–") },
], rows),
err);
await modal("Built-ins of the " + chain + " chain", body, write ? [
{ label: "Cancel", value: null },
{ label: "Save", kind: "primary", onclick: async () => {
mount(err);
const b = {};
for (const [flag, box] of Object.entries(boxes)) if (box.checked !== settings.builtins[flag]) b[flag] = box.checked;
if (!Object.keys(b).length) return true;
try { await change("PATCH", "/settings", { builtins: b }); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; }
} },
] : [{ label: "Close", value: null }], { wide: true });
} }
function extras(r, q) { function extras(r, q) {
@@ -149,11 +204,15 @@ async function del(r) {
export async function ruleDialog(r, chain) { export async function ruleDialog(r, chain) {
const n = await names(); const n = await names();
r = r || { chain, action: "accept" }; r = r || { chain, action: "accept" };
const ch = select([["input", "input (to the firewall)"], ["forward", "forward (through it)"], ["output", "output (from it)"], ["early", "early (before conntrack)"]], r.chain || chain); const ch = select(["input", "forward", "output", "early"], r.chain || chain);
const match = matchEditor(r, n); const match = matchEditor(r, n);
const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action); 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 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); action.addEventListener("change", showReject);
showReject(); showReject();
const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" }); const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" });
@@ -205,6 +264,7 @@ export async function ruleDialog(r, chain) {
sec.open = on; sec.open = on;
for (const n of nodes) n.classList.toggle("hidden", !on); for (const n of nodes) n.classList.toggle("hidden", !on);
add.classList.toggle("hidden", on); add.classList.toggle("hidden", on);
if (sec.onShow) sec.onShow(on);
if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); } if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); }
updateGroup(); updateGroup();
}; };
@@ -234,7 +294,7 @@ export async function ruleDialog(r, chain) {
field("Connection limit", conn, "Match a source that already has more than this many connections."), field("Connection limit", conn, "Match a source that already has more than this many connections."),
]); ]);
const rawFields = section("raw", "Write it in nftables", !!r.raw, [ const rawFields = section("raw", "Write it in nftables", !!r.raw, [
field("Raw nftables", raw, "Instead of the matches above: one nftables rule, for what Housecarl can't say. Checked by nftables."), field("Raw nftables", raw, "One nftables rule, for what Housecarl can't say, used as written: it replaces the zones, addresses, services, action, limits and log (write them in it). Checked by nftables."),
]); ]);
// Grouping only means something with a per-address limit, quota or // Grouping only means something with a per-address limit, quota or
// connection limit. // connection limit.
@@ -248,14 +308,31 @@ export async function ruleDialog(r, chain) {
limitKey.addEventListener("change", updateGroup); limitKey.addEventListener("change", updateGroup);
quotaKey.addEventListener("change", updateGroup); quotaKey.addEventListener("change", updateGroup);
// 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, 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) => {
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
for (const [k, sec] of Object.entries(sections)) {
if (k === "raw") continue;
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
if (on) sec.open = false;
}
for (const b of adders.children) if (!b.textContent.includes("nftables")) b.classList.toggle("hidden", on);
updateGroup();
};
const body = h("div", null, const body = h("div", null,
h("div", { class: "form-grid" }, h("div", { class: "form-grid" },
field("Chain", ch), field("Chain", ch),
...match.flat(), ...matchNodes,
field("Action", h("div", null, action, " ", rejectWith)), ...actionField,
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields, ...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
h("label"), adders, addersLabel, adders,
field("Log", h("div", null, log, logPrefix)), ...logField,
field("Comment", comment), field("Comment", comment),
r.id ? [h("label"), disabled] : null), r.id ? [h("label"), disabled] : null),
err); err);
@@ -270,7 +347,9 @@ export async function ruleDialog(r, chain) {
async function save(dry) { async function save(dry) {
mount(err); mount(err);
const on = (k) => sections[k].open; const on = (k) => sections[k].open;
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "", const isRaw = on("raw");
const m = isRaw ? { in_zones: [], not_in: false, out_zones: [], not_out: false, sources: [], not_sources: false, destinations: [], not_destinations: false, services: [], family: "" } : match.value();
const v = { chain: ch.value, ...m, action: isRaw ? "accept" : action.value, reject_with: !isRaw && action.value === "reject" ? rejectWith.value : "",
ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [], ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [],
days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "", days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "",
limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
@@ -278,8 +357,9 @@ export async function ruleDialog(r, chain) {
quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked, quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination", per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null, reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null,
group_v4: parseInt(group4.value || "0", 10), group_v6: parseInt(group6.value || "0", 10), 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: log.input.checked, log_prefix: 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() }; raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() };
if (r.id) v.disabled = disabled.input.checked; if (r.id) v.disabled = disabled.input.checked;
try { try {
+13 -3
View File
@@ -31,19 +31,29 @@ async function setDialog(s) {
s = s || { entries: [] }; s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "trusted" }); const name = input({ value: s.name || "", placeholder: "trusted" });
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" }); const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic); const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
// A dynamic set has no configured entries; only it has a timeout.
const entryFields = field("Entries", list).filter(Boolean);
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
const showKind = () => {
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
};
dynamic.input.addEventListener("change", showKind);
showKind();
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" }); const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
const comment = input({ value: s.comment || "" }); const comment = input({ value: s.comment || "" });
const err = h("div"); const err = h("div");
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" }, await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Entries", list), h("label"), dynamic, field("Entry timeout", timeout, "A dynamic set's default."), field("Comment", comment)), err), [ field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => { { label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
mount(err); mount(err);
const t = parseSeconds(timeout.value); const t = parseSeconds(timeout.value);
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; } if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
try { try {
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: list.value(), dynamic: dynamic.input.checked, timeout: t, comment: comment.value.trim() }); await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
rerender(); rerender();
return true; return true;
} catch (e) { mount(err, errorBox(e)); return false; } } catch (e) { mount(err, errorBox(e)); return false; }
+77
View File
@@ -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));
}
+7
View File
@@ -412,3 +412,10 @@ select.inline { width: auto; display: inline-block; }
/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */ /* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */
.adders { display: flex; flex-wrap: wrap; gap: 6px; } .adders { display: flex; flex-wrap: wrap; gap: 6px; }
button.close-section { padding: 0 6px; margin-left: 4px; font-size: 11px; line-height: 1.5; } 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); }
+41 -1
View File
@@ -240,9 +240,14 @@ contains "^jellyfin$"
run "a stale HOUSECARL_CHANGESET is refused" 1 CE zone add stale -i stale0 run "a stale HOUSECARL_CHANGESET is refused" 1 CE zone add stale -i stale0
contains "is applied" contains "is applied"
CS2=$(H changeset create -q) CS2=$(H changeset create -q)
run "an undo can't be staged" 6 H -C "$CS2" history undo 1 UNDO=$(H zone add undome -i undo0 | sed -n 's/.*history \([0-9]*\);.*/\1/p')
run "an undo is staged" 0 H -C "$CS2" history undo "$UNDO"
contains "staged in changeset $CS2"
run "not applied yet: the zone is there" 0 H zone show undome
run "an import can't be staged" 6 sh -c "echo '{\"nftables\":[]}' | docker exec -i ${P}-router /w/hc -C $CS2 import -"
contains "can't be staged" contains "can't be staged"
run "discarded" 0 H changeset discard "$CS2" run "discarded" 0 H changeset discard "$CS2"
run "the zone stays" 0 H zone show undome
run "a dry run shows the ruleset's diff" 0 H -n set add e2e_dry 10.1.2.3 run "a dry run shows the ruleset's diff" 0 H -n set add e2e_dry 10.1.2.3
contains "^+.*set e2e_dry_v4" contains "^+.*set e2e_dry_v4"
@@ -267,6 +272,41 @@ run "the reset is audited" 0 H audit --action quota.
contains "quota.reset" contains "quota.reset"
run "the quota rule goes" 0 H rule delete -y "$QR" 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 # ---- 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 run "log drops from the internet to the API" 0 H rule add input --in wan --service tcp/8070 -a drop --log --first