Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e6c085ef9
commit
de075edd5d
48 files changed
+1331
-79
No files matched your search
@@ -30,6 +30,10 @@ Go module `housecarl`, `CGO_ENABLED=0`, Linux only for housecarld.
|
||||
on probation and rolled back unless confirmed (docs/design.md). An
|
||||
empty configuration applies nothing. But never force a rule that keeps
|
||||
SSH or the web UI open: the maintainer doesn't want one.
|
||||
* **Shadow mode changes no packet** (docs/shadow.md): anything new the
|
||||
renderer writes that drops, rejects, translates or alters packets needs
|
||||
its shadow form (counted, marked `[would …]`) or must be left out
|
||||
there. `TestShadow` fails on what slips through.
|
||||
* **Stay within nftables 1.1.3** (Debian 13). The dev machine's newer nft
|
||||
accepts things 1.1.3 refuses; the tests with `HOUSECARL_NFT` and the
|
||||
e2e test use 1.1.3.
|
||||
|
||||
@@ -8,6 +8,29 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Added
|
||||
|
||||
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
|
||||
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
||||
rejects and translates nothing. Drops and rejects are counted and accept
|
||||
instead (an accept only ends Housecarl's chain), policies accept and
|
||||
always log what they would drop, NAT only counts, MSS clamping and the
|
||||
flowtable are left out, and raw rules that do more than accept, drop or
|
||||
reject are left out with a warning (`shadow_raw_left_out`). Comments
|
||||
mark what would happen (`[would drop]`). `shadow.position`: Housecarl's
|
||||
filter chains run after the other firewall's (`filter + 10`, the
|
||||
default: what it would drop is what switching would break) or before
|
||||
(`- 10`); its NAT chains always first. Every change is checked by
|
||||
nftables in both forms, so leaving shadow mode can't fail. No
|
||||
probation; takeovers and closing connections are refused (409). The
|
||||
boot ruleset is the shadow one, and its fallback filters nothing. When
|
||||
a reload (`flush ruleset`) deletes Housecarl's table, housecarld puts it
|
||||
back (it listens to nftables' netlink notifications). `mode` in
|
||||
`/status`, `/healthz` and `/auth/whoami`; a banner in the web UI;
|
||||
would-be drops marked in the log (`shadow`) and the tracer
|
||||
(`would_drop`); `hc ruleset --enforcing` (`GET /ruleset?enforcing=1`)
|
||||
shows the ruleset for after. `shadow_unprotected` warns when no other
|
||||
table filters the input hook; `nftables_service` and `takeover_pending`
|
||||
aren't raised in shadow mode. The e2e test imports in shadow mode
|
||||
before taking over.
|
||||
- **Zone names at import:** `hc import --zone enp2s0=lan` (repeat;
|
||||
interfaces given one name share a zone; Zone names on the Import
|
||||
page), and a note listing each zone and where its name came from.
|
||||
|
||||
@@ -49,6 +49,10 @@ table of its own.
|
||||
ruleset into zones, sets, services, rules and port forwards, keeping the
|
||||
rules' order, and keeps what it can't translate as raw rules
|
||||
([docs/import.md](docs/import.md)).
|
||||
* **Try it first, in shadow mode.** Housecarl's ruleset runs beside the
|
||||
firewall you have, blocking nothing: its hits, log and tracer show what
|
||||
it would drop, so you see what switching over would break before you do
|
||||
([docs/shadow.md](docs/shadow.md)).
|
||||
* **Easy to watch.** A zone matrix of what is allowed between zones,
|
||||
statistics over time (traffic per zone and device, drops, the sources
|
||||
blocked most, connections; [docs/stats.md](docs/stats.md)), hits per rule, a live log of what was dropped and
|
||||
@@ -104,6 +108,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
|
||||
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
|
||||
* [import.md](docs/import.md): moving from an existing nftables ruleset
|
||||
* [shadow.md](docs/shadow.md): shadow mode, Housecarl beside your firewall, changing nothing
|
||||
* [operations.md](docs/operations.md): probation, the boot and its fallback, hits and the log, monitoring, backups, troubleshooting
|
||||
* [flowtable.md](docs/flowtable.md): the fast path for forwarded traffic, and when (not) to use it
|
||||
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
@@ -30,6 +31,9 @@ func statusCmd() *cobra.Command {
|
||||
}
|
||||
return output(st, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Housecarl:\t%s, up since %s\n", st.Build.Label, st.Started.Local().Format("2006-01-02 15:04"))
|
||||
if st.Mode == api.ModeShadow {
|
||||
fmt.Fprintf(w, "Mode:\tSHADOW: the ruleset blocks, rejects and translates nothing; the other firewall decides (Housecarl's chains run %s it)\n", st.ShadowPosition)
|
||||
}
|
||||
if st.Applied {
|
||||
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
|
||||
} else if st.Unconfigured {
|
||||
@@ -237,21 +241,28 @@ func onOff(name, v string) (bool, error) {
|
||||
// ---- the ruleset, counters, the log
|
||||
|
||||
func rulesetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var enforcing bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "ruleset", Short: "Print the nftables ruleset Housecarl builds", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var q url.Values
|
||||
if enforcing {
|
||||
q = url.Values{"enforcing": {"1"}}
|
||||
}
|
||||
var text string
|
||||
if err := c.Do(ctx(), "GET", "/ruleset", nil, nil, &text); err != nil {
|
||||
if err := c.Do(ctx(), "GET", "/ruleset", q, nil, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(text)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&enforcing, "enforcing", false, "in shadow mode: the ruleset loaded once it is off")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func countersCmd() *cobra.Command {
|
||||
@@ -346,6 +357,9 @@ func logCmd() *cobra.Command {
|
||||
if e.TCPFlags != "" {
|
||||
what += " " + e.TCPFlags
|
||||
}
|
||||
if e.Shadow && (e.Verdict == "drop" || e.Verdict == "reject") {
|
||||
what += " (shadow mode: would " + e.Verdict + ", let through)"
|
||||
}
|
||||
fmt.Printf("%s %-28s %-6s %-6s %s -> %s %s\n", e.Time.Local().Format("15:04:05"), strings.TrimSpace(e.Prefix), orDash(e.In), orDash(e.Out), src, dst, what)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,13 @@ func printPacket(w io.Writer, p api.TracePacket) {
|
||||
} else {
|
||||
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
|
||||
}
|
||||
if wd := p.WouldDrop; wd != nil {
|
||||
name := wd.Label
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(wd.Chain + " " + wd.Mark)
|
||||
}
|
||||
fmt.Fprintf(w, " => shadow mode: Housecarl would have dropped it, by %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
func matrixCmd() *cobra.Command {
|
||||
|
||||
+33
-1
@@ -38,6 +38,7 @@ import (
|
||||
"housecarl/internal/server"
|
||||
"housecarl/internal/stats"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/tablewatch"
|
||||
"housecarl/internal/trace"
|
||||
"housecarl/internal/validate"
|
||||
"housecarl/internal/version"
|
||||
@@ -153,8 +154,12 @@ func serveCmd() *cobra.Command {
|
||||
}
|
||||
host := &hostif.Host{}
|
||||
eng.Env = hostEnv(host)
|
||||
fw := &nft.Firewall{Runner: &nft.Runner{Command: nft.Wrapper()}, Interfaces: host.Exists, BootFile: a.cfg.Ruleset}
|
||||
fw := &nft.Firewall{Runner: &nft.Runner{Command: nft.Wrapper()}, Interfaces: host.Exists, BootFile: a.cfg.Ruleset,
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowBefore: a.cfg.Shadow.Position == config.ShadowBefore}
|
||||
eng.Firewall = fw
|
||||
if fw.Shadow {
|
||||
a.log.Warn("shadow mode: Housecarl's ruleset is loaded but blocks, rejects and translates nothing; the other firewall decides (docs/shadow.md)", "position", a.cfg.Shadow.Position)
|
||||
}
|
||||
for _, p := range eng.Problems() {
|
||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
||||
}
|
||||
@@ -175,6 +180,7 @@ func serveCmd() *cobra.Command {
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
|
||||
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
|
||||
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position,
|
||||
}, eng, fw, host, a.log, web.FS())
|
||||
// Put the ruleset in force: what was on probation when
|
||||
// housecarld stopped is rolled back first. An empty
|
||||
@@ -182,12 +188,15 @@ func serveCmd() *cobra.Command {
|
||||
// close the machine before any rule lets anyone in.
|
||||
// What the quotas used before housecarld (or the machine)
|
||||
// stopped comes back with the next apply.
|
||||
restoreQuotas := func() {
|
||||
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
||||
var u nft.QuotaUsage
|
||||
if json.Unmarshal([]byte(v), &u) == nil {
|
||||
fw.RestoreQuotas(&u)
|
||||
}
|
||||
}
|
||||
}
|
||||
restoreQuotas()
|
||||
if !srv.ResumeProbation(ctx) {
|
||||
if eng.State().Empty() {
|
||||
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
||||
@@ -215,6 +224,29 @@ func serveCmd() *cobra.Command {
|
||||
a.log.Warn("not watching the interfaces: a flowtable device that appears later is only added by the next change", "err", err)
|
||||
}
|
||||
}()
|
||||
// In shadow mode the other firewall's ruleset is reloaded now
|
||||
// and then, and a ruleset file usually starts with flush
|
||||
// ruleset, which deletes Housecarl's table too: put it back.
|
||||
if fw.Shadow {
|
||||
go func() {
|
||||
err := tablewatch.Watch(ctx, func() {
|
||||
if _, err := fw.Table(ctx); !errors.Is(err, nft.ErrNoTable) || eng.State().Empty() {
|
||||
return
|
||||
}
|
||||
// The quotas' usage went with the table: from
|
||||
// what was last saved.
|
||||
restoreQuotas()
|
||||
if err := eng.ApplyCurrent(ctx); err != nil {
|
||||
a.log.Error("Housecarl's table was deleted, and putting it back failed", "err", err)
|
||||
return
|
||||
}
|
||||
a.log.Info("Housecarl's table was deleted (flush ruleset?): put it back")
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Warn("not watching nftables: if Housecarl's table is deleted, only the next change or a restart puts it back", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||
go srv.RunQuotas(ctx)
|
||||
srv.SetConntrack(conntrack.Client{})
|
||||
|
||||
+9
-9
@@ -31,7 +31,7 @@ user name. [permissions.md](permissions.md) has what each call needs.
|
||||
| 401 | `unauthenticated`, `session_expired`, `login_failed` | |
|
||||
| 403 | `forbidden`, `csrf` | |
|
||||
| 404 | `not_found` | |
|
||||
| 409 | `conflict` | e.g. undoing a change after the objects changed again |
|
||||
| 409 | `conflict` | e.g. undoing a change after the objects changed again; a takeover or closing connections in shadow mode ([shadow.md](shadow.md)) |
|
||||
| 422 | `validation` | the change would make the configuration invalid; `problems` lists why ([validation.md](validation.md)) |
|
||||
| 422 | `firewall` | nftables refused the ruleset (`nft -c`), or applying it failed; the message has nftables' words. Nothing was changed |
|
||||
| 429 | `rate_limited` | |
|
||||
@@ -50,7 +50,7 @@ Calls that change the configuration answer:
|
||||
|
||||
* `confirm_by`: the change is on probation, and is rolled back at that
|
||||
time unless `POST /confirm` comes first. Missing for changes made
|
||||
through the socket.
|
||||
through the socket, and in shadow mode ([shadow.md](shadow.md)).
|
||||
* `confirm_token`: the client that made the change can confirm it by
|
||||
itself with `POST /confirm {"token"}`, from the same address, as the
|
||||
same user and token, over a new connection (the answer closes this
|
||||
@@ -74,19 +74,19 @@ Creating answers 201, other changes 200.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
|
||||
| `GET /status` | `build`, `started`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
||||
| `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `mode` (`enforcing`, or `shadow`: the ruleset blocks nothing, [shadow.md](shadow.md)), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
|
||||
| `GET /status` | `build`, `started`, `mode` (`enforcing` or `shadow`), `shadow_position` (`after` or `before`, in shadow mode), `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) |
|
||||
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
|
||||
| `GET /ruleset[?enforcing=1]` | the nftables ruleset Housecarl builds, as text. In shadow mode it is the shadow ruleset; `enforcing=1` gives the one loaded once shadow mode is off |
|
||||
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
|
||||
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}` |
|
||||
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}`. 409 in shadow mode |
|
||||
| `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` |
|
||||
| `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`; in shadow mode `would_drop`, the step where Housecarl would have dropped it |
|
||||
| `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), `verdict`, `shadow` (logged in shadow mode: a drop or reject is what would have been done). `after` (a time) for those since; `q` searches |
|
||||
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
|
||||
|
||||
### Settings
|
||||
@@ -211,7 +211,7 @@ history, but are audited.
|
||||
| `GET /history/{id}` | one, with each object before and after |
|
||||
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
|
||||
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "zones": {"enp2s0": "lan"}, "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
|
||||
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network |
|
||||
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network; 409 in shadow mode |
|
||||
|
||||
### Certificate
|
||||
|
||||
|
||||
+4
-4
@@ -131,13 +131,13 @@ by name.
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `status` | the ruleset (in force or not), changes on probation, problems, interfaces, other tables |
|
||||
| `status` | the mode (in shadow mode), the ruleset (in force or not), changes on probation, problems, interfaces, other tables |
|
||||
| `interfaces` | the machine's interfaces, their addresses and zones |
|
||||
| `ruleset` | the nftables ruleset Housecarl builds |
|
||||
| `ruleset [--enforcing]` | the nftables ruleset Housecarl builds; in shadow mode, `--enforcing` shows the one loaded once it is off ([shadow.md](shadow.md)) |
|
||||
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
|
||||
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
|
||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)); not in shadow mode |
|
||||
| `stats [--since 24h]` | traffic per zone, drops, the busiest rules, blocked sources, devices; `stats status`, `stats traffic`, `stats top devices\|blocked\|tried\|quota ID`, `stats rules [--unused]`, `stats connections` ([stats.md](stats.md#the-cli)) |
|
||||
| `matrix [FROM TO]` | what the rules do to new connections between zones; with FROM and TO, that pair's rules in order ([rules.md](rules.md#the-zone-matrix)) |
|
||||
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
||||
@@ -220,7 +220,7 @@ See [nat.md](nat.md).
|
||||
| `history show ID` | each object before and after |
|
||||
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
|
||||
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--zone IFACE=NAME]... [--replace]` | [import.md](import.md) |
|
||||
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators) |
|
||||
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators); not in shadow mode |
|
||||
|
||||
### cert
|
||||
|
||||
|
||||
@@ -30,9 +30,14 @@ reads the file when it starts.
|
||||
| `stats.database` | `/var/lib/housecarl/stats.db` | the statistics' own database; deleting it is safe |
|
||||
| `stats.retention.minute`, `.hour`, `.day` | `7d`, `90d`, `730d` | how long per-minute, hourly and daily figures are kept |
|
||||
| `stats.top_n` | `100` | entries kept per top list, per hour and per day |
|
||||
| `shadow.enabled` | `false` | shadow mode: Housecarl's ruleset is loaded beside the firewall you have, but blocks, rejects and translates nothing; its counters, log and tracer show what it would do. No change is on probation, and takeovers and closing connections are refused ([shadow.md](shadow.md)) |
|
||||
| `shadow.position` | `after` | where Housecarl's filter chains run in shadow mode: `after` the other firewall's (`filter + 10`: it sees only what that one lets through, so what it would drop is what switching would break) or `before` them (`filter - 10`: it sees every packet) |
|
||||
|
||||
Durations are written `15s`, `12h` or `30d`.
|
||||
|
||||
Shadow mode is here, and not a firewall setting, so that no user or API
|
||||
token can switch enforcement off; restart housecarld to switch.
|
||||
|
||||
The probation's length is a firewall setting, not here: `hc settings set
|
||||
--probation 5m`. So is counting traffic per device for the statistics:
|
||||
`hc settings set --stats-per-device on`.
|
||||
|
||||
@@ -100,6 +100,32 @@ Every rule carries a comment `hc:<mark>`, such as `hc:r12` for rule 12,
|
||||
`hc:f3` for port forward 3, `hc:b-established` for a built-in. Counters
|
||||
and log lines are traced back to their object through it.
|
||||
|
||||
## Shadow mode
|
||||
|
||||
With `shadow.enabled` in the configuration file ([shadow.md](shadow.md)),
|
||||
the same configuration is rendered so that it changes nothing (render's
|
||||
`Options.Shadow`): drops and rejects accept instead, policies accept,
|
||||
NAT only counts, and what alters packets (MSS clamping, the flowtable)
|
||||
and raw rules that do more than accept, drop or reject are left out.
|
||||
This rests on how nftables runs base chains: an `accept` only ends its
|
||||
own chain, and the packet goes on to the next one on the hook, other
|
||||
tables' included; only a drop is final. Another table, the real
|
||||
firewall, decides.
|
||||
|
||||
What would be dropped, rejected or translated is counted under the same
|
||||
marks, and its comment says so (`"hc:r12 [would drop] …"`), which is
|
||||
how the tracer tells it apart. The filter chains run at `filter + 10`
|
||||
(after the other firewall's: what Housecarl would drop is what switching
|
||||
over would break) or `- 10`; the NAT chains always at `- 10`, since the
|
||||
kernel asks NAT chains only until one translates.
|
||||
|
||||
Every change is checked by nftables in both forms, so leaving shadow
|
||||
mode can't fail on the ruleset. There is no probation, since nothing
|
||||
can lock anyone out; takeovers and closing connections, which would
|
||||
change traffic, are refused. A ruleset reload elsewhere (`flush
|
||||
ruleset`) deletes Housecarl's table too: housecarld listens to nftables'
|
||||
netlink notifications (internal/tablewatch) and puts it back.
|
||||
|
||||
## Not locking yourself out
|
||||
|
||||
A change made over the network is applied **on probation** (a changeset
|
||||
|
||||
+7
-2
@@ -11,6 +11,8 @@ internal/
|
||||
render/ State → the nftables ruleset text, with each line's object
|
||||
nft/ runs nft: check, apply (keeping dynamic set entries and counters), list, the boot file
|
||||
nflog/ reads logged packets from netlink log group 4171
|
||||
linkwatch/ netlink: interfaces that appear or go (the flowtable's devices)
|
||||
tablewatch/ netlink: a table deleted (shadow mode puts Housecarl's back; docs/shadow.md)
|
||||
importer/ nft -j list ruleset → State, with notes and raw rules (docs/import.md)
|
||||
hostif/ the machine's interfaces
|
||||
store/ SQLite (modernc.org/sqlite, no cgo): configuration, history, users, tokens, sessions, audit
|
||||
@@ -70,8 +72,11 @@ Newer nftables accepts things 1.1.3 doesn't (a meter with a timeout for
|
||||
`wan`, `enp2s0` and `wg0` inside it, IPv4 and IPv6;
|
||||
* "the internet" on the WAN side, a LAN client, and a "jellyfin" host.
|
||||
|
||||
It imports a copy of the maintainer's ruleset (lab addresses), takes
|
||||
over the old table, and checks with real traffic: port forwards with
|
||||
It imports a copy of the maintainer's ruleset (lab addresses) with
|
||||
housecarld in shadow mode beside it (traffic unchanged, a would-be drop
|
||||
logged and let through, the table back after the old ruleset is
|
||||
reloaded), restarts it enforcing, takes over the old table, and checks
|
||||
with real traffic: port forwards with
|
||||
hairpin NAT and the IPv6 pinhole, masquerading, antispoofing, the drop
|
||||
log, a lockout change that rolls back by itself, a restart, the boot
|
||||
ruleset after a flush, and that other tables are left alone. It makes
|
||||
|
||||
@@ -58,6 +58,12 @@ over half of the forwarding cost, which shows up as speed only when the
|
||||
CPU was the limit, and otherwise as a cooler, quieter, more responsive
|
||||
machine.
|
||||
|
||||
In shadow mode ([shadow.md](shadow.md)) Housecarl leaves its flowtable
|
||||
out: an offloaded connection skips every table's forward chain, the
|
||||
other firewall's too. If that firewall has a flowtable of its own, the
|
||||
same goes for Housecarl's forward chain: it only sees each connection's
|
||||
first packets.
|
||||
|
||||
## When not to use it
|
||||
|
||||
**When the CPU keeps up anyway.** If the speed test already reaches your
|
||||
|
||||
@@ -159,6 +159,11 @@ Housecarl's table is now in force **alongside** the old one: a packet
|
||||
must be accepted by both. Nothing should change for your traffic. If
|
||||
Housecarl already has a configuration, `--replace` replaces it.
|
||||
|
||||
To be sure first, import in **shadow mode** ([shadow.md](shadow.md)):
|
||||
Housecarl's ruleset is then loaded but drops nothing, and its log shows
|
||||
each packet the old ruleset lets through that Housecarl would drop. When
|
||||
there are none you mind, leave shadow mode and take over.
|
||||
|
||||
Imported over the network (not through the local socket), the import is
|
||||
on probation like any change: confirm it.
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ you are changing the firewall you are connected through.
|
||||
sudo systemctl disable nftables.service
|
||||
```
|
||||
|
||||
To try Housecarl beside your firewall first, turn on **shadow mode**
|
||||
before the import: it loads Housecarl's ruleset but blocks nothing,
|
||||
and shows what it would do ([shadow.md](shadow.md)).
|
||||
|
||||
**Or start from scratch**, through the local socket (no probation).
|
||||
The first change puts the ruleset in force, and the input and forward
|
||||
policies drop what no rule accepts, so let yourself in first:
|
||||
|
||||
@@ -7,6 +7,10 @@ public port are sent to the host, and let through the forward chain.
|
||||
One object does what takes three rules by hand (the DNAT, the forward
|
||||
accept, and the hairpin masquerade).
|
||||
|
||||
In shadow mode ([shadow.md](shadow.md)) NAT rules translate nothing:
|
||||
they count the connections they would translate, and the other
|
||||
firewall's NAT does the work.
|
||||
|
||||
```sh
|
||||
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
|
||||
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||
|
||||
+11
-2
@@ -28,7 +28,8 @@ edits) applies each edit at once instead. With hc, see
|
||||
## Changes on probation
|
||||
|
||||
Changes made over the network roll back unless confirmed
|
||||
([design.md](design.md#not-locking-yourself-out)). The web UI and `hc`
|
||||
([design.md](design.md#not-locking-yourself-out)); in shadow mode, where
|
||||
a change can't cut anyone off, none do ([shadow.md](shadow.md)). The web UI and `hc`
|
||||
confirm their own changes once they reach Housecarl again over a new
|
||||
connection, so normally nothing waits:
|
||||
|
||||
@@ -79,6 +80,9 @@ sudo cat /var/lib/housecarl/ruleset.nft
|
||||
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
|
||||
```
|
||||
|
||||
In shadow mode both files are shadow mode's, and the fallback filters
|
||||
nothing ([shadow.md](shadow.md#what-works-differently)).
|
||||
|
||||
Dynamic sets start empty after a reboot. The saved ruleset has no
|
||||
flowtable, since its devices may not exist yet: housecarld adds it when
|
||||
it starts, and whenever a flowtable device appears
|
||||
@@ -177,6 +181,10 @@ hc log --search 198.51.100.7
|
||||
10:02:15 hc forward policy drop: wan eth1 198.51.100.9:443 -> 192.168.1.20:51432 tcp
|
||||
```
|
||||
|
||||
In shadow mode ([shadow.md](shadow.md)) what the policies would drop is
|
||||
always logged, in all three chains, and drops are marked as what would
|
||||
have been done: the packets went on.
|
||||
|
||||
The packets go to netlink log group 4171, where housecarld reads them;
|
||||
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
|
||||
@@ -281,13 +289,14 @@ it is asked):
|
||||
|
||||
```sh
|
||||
$ curl -s https://firewall.example.net:8070/healthz
|
||||
{"ok":true,"version":"0.1.0","firewall":"applied","probation":0,"warnings":[]}
|
||||
{"ok":true,"version":"0.1.0","firewall":"applied","mode":"enforcing","probation":0,"warnings":[]}
|
||||
```
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `ok` | `true` with HTTP 200 when the firewall is as it should be; `false` with HTTP **503** otherwise |
|
||||
| `firewall` | `applied` (Housecarl's ruleset is in force), `unconfigured` (nothing configured yet, so nothing to apply), `fallback` (the [boot fallback](#if-the-ruleset-fails-to-load-at-boot) is in force), `missing` (no Housecarl table: flushed, or never applied) |
|
||||
| `mode` | `enforcing`, or `shadow`: Housecarl's ruleset is loaded but blocks nothing, and another firewall decides ([shadow.md](shadow.md)). `ok` doesn't depend on it; to be told when a machine isn't enforcing, also expect `"mode":"enforcing"` |
|
||||
| `probation` | changes waiting to be confirmed, rolled back unless they are |
|
||||
| `warnings` | codes of problems with the machine, as `hc status` shows them (`nftables_service`) |
|
||||
|
||||
|
||||
@@ -235,6 +235,11 @@ nftables names (`@trusted_v4`). The importer makes raw rules from
|
||||
what it can't translate. It goes into the ruleset as written: add
|
||||
`counter` to it to count its packets.
|
||||
|
||||
In shadow mode ([shadow.md](shadow.md)) a raw rule's final `drop` or
|
||||
`reject` becomes `accept`; a raw rule that does anything else to packets
|
||||
(NAT, `meta mark set`, `jump`, `notrack`, a verdict map…) is left out,
|
||||
with a warning (`shadow_raw_left_out`).
|
||||
|
||||
## Built-ins
|
||||
|
||||
Housecarl adds the rules every firewall needs before yours. Each can be
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Shadow mode
|
||||
|
||||
In shadow mode Housecarl runs beside the firewall you have now and
|
||||
changes nothing. Its ruleset is loaded, so its counters, log, tracer and
|
||||
statistics show what it *would* do with your real traffic, but it blocks,
|
||||
rejects and translates nothing. Your current firewall keeps deciding.
|
||||
|
||||
Use it to:
|
||||
|
||||
* **try Housecarl** on the real machine before you trust it with the
|
||||
firewall: the web UI, the API, `hc`, changesets, history;
|
||||
* **check an import**: import your ruleset ([import.md](import.md)) and
|
||||
watch whether Housecarl would drop anything your firewall lets through;
|
||||
* **develop against the API**: Steward, scripts or fail2ban can fill
|
||||
dynamic sets and see them used, with nothing at stake.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Shadow mode is set in `/etc/housecarl/housecarl.yaml`
|
||||
([configuration.md](configuration.md)), not through the API, so no user
|
||||
or token can switch enforcement off:
|
||||
|
||||
```yaml
|
||||
shadow:
|
||||
enabled: true
|
||||
position: after # or before; see below
|
||||
```
|
||||
|
||||
housecarld reads the file when it starts:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart housecarl
|
||||
sudo hc status # Mode: SHADOW …
|
||||
```
|
||||
|
||||
The web UI shows a banner on every page, `hc status` a `Mode:` line, and
|
||||
`/healthz` `"mode": "shadow"`.
|
||||
|
||||
## Why it can't let anything through
|
||||
|
||||
In nftables, `accept` only ends the chain it is in. The packet then
|
||||
goes on to the next base chain on the same hook, and other tables' chains
|
||||
are among those. Only a drop is final. Housecarl's table in shadow mode
|
||||
never drops, so it can't let through anything your firewall drops, and it
|
||||
can't block anything your firewall accepts.
|
||||
|
||||
## What changes in the ruleset
|
||||
|
||||
The same configuration is rendered, with everything that acts on packets
|
||||
turned into counting:
|
||||
|
||||
| | Enforcing | Shadow mode |
|
||||
|---|---|---|
|
||||
| Rules that drop or reject | `drop`, `reject` | counted, then `accept` (Housecarl's chain ends as it would have). Their comment says `[would drop]` or `[would reject]` |
|
||||
| The built-ins that drop (invalid packets, antispoofing) | `drop` | counted, `[would drop]` |
|
||||
| Policies | as configured | `accept`. What a drop policy would drop is counted and **always logged**, rate-limited, in all three chains |
|
||||
| Port forwards, source NAT, redirects | translate | counted only (`[would dnat]`, `[would masquerade]`, `[would snat]`, `[would redirect]`) |
|
||||
| MSS clamping | changes SYN packets | left out |
|
||||
| The flowtable | offloads connections | left out: offloaded connections skip *every* table's forward chain, your firewall's too |
|
||||
| Raw rules | as written | as written if they only match, count, log or accept. A final `drop` or `reject` becomes `accept`. Anything else that acts on packets (NAT, `set`, `jump`, `notrack`, a verdict map…) is left out, with a warning (`shadow_raw_left_out`) |
|
||||
| Everything else | | the same: accepts, rate limits, connection limits, quotas, dynamic sets, per-device counters, logging |
|
||||
|
||||
`hc ruleset` prints the shadow ruleset, the one loaded now.
|
||||
`hc ruleset --enforcing` prints the ruleset that will be loaded once
|
||||
shadow mode is off.
|
||||
|
||||
**Every change is checked by nftables twice**: the shadow ruleset, and the
|
||||
enforcing one. A change that nftables would refuse once shadow mode is off
|
||||
is refused now, so leaving shadow mode can't fail on that.
|
||||
|
||||
## Where Housecarl's chains run
|
||||
|
||||
Your firewall's chains are usually at priority `filter` (0), its raw
|
||||
chains at `raw`. `shadow.position` puts Housecarl's filter chains just
|
||||
next to them:
|
||||
|
||||
* **`after`** (the default) runs them at `filter + 10` (and `raw + 10`).
|
||||
Housecarl only sees what your firewall let through. So **every would-drop
|
||||
is traffic that would break if you switched over**: the list to work
|
||||
through before you do. What your firewall drops and Housecarl would
|
||||
accept (a hole) doesn't show; compare the rules for that.
|
||||
* **`before`** runs them at `filter - 10` (and `raw - 10`). Housecarl sees
|
||||
every packet, as it would when enforcing, and its hits and drops are the
|
||||
numbers you would get. Its would-drops include everything your firewall
|
||||
drops too (the internet's background noise).
|
||||
|
||||
If your firewall uses other priorities, say `filter + 20`, "after" is
|
||||
relative to `filter`, not to it: check with `nft list ruleset`.
|
||||
|
||||
NAT chains are different: the kernel asks NAT chains in turn only until
|
||||
one translates. Housecarl's NAT chains always run first (`dstnat - 10`,
|
||||
`srcnat - 10`), whatever the position, or they would never see a
|
||||
connection your firewall translates. They translate nothing, so your
|
||||
firewall's NAT still does the work.
|
||||
|
||||
## What you see
|
||||
|
||||
* **Hits** (`hc counters`, the Rules page): a drop rule's hits are what it
|
||||
would have dropped. NAT counters count connections (only a connection's
|
||||
first packet reaches the NAT chains).
|
||||
* **The log** (`hc log`, the Log page): every packet a drop policy would
|
||||
have dropped, and what rules with logging would have dropped, marked
|
||||
*would drop*. `hc log` adds "(shadow mode: would drop, let through)".
|
||||
* **The tracer** (`hc trace`, the Trace page): the verdict is your
|
||||
firewall's, the real one. When Housecarl would have dropped the packet,
|
||||
the trace says by which rule ("shadow mode: Housecarl would have
|
||||
dropped it, by …").
|
||||
* **Statistics** ([stats.md](stats.md)): drops, drops per chain and the
|
||||
blocked sources are what Housecarl would have dropped.
|
||||
|
||||
## What works differently
|
||||
|
||||
* **No probation.** A change can't cut anyone off, so changes from the
|
||||
network are applied and saved at once, with no confirmation. Changesets
|
||||
work as usual; their ruleset diff is of the shadow ruleset.
|
||||
* **No takeover, no closing connections.** `hc takeover` (deleting your
|
||||
firewall's tables) and `hc connections close` are refused (409,
|
||||
`hc` exits with 5): both would change your traffic.
|
||||
* **The boot.** `housecarl-ruleset.service` loads the shadow ruleset at
|
||||
boot. Its fallback ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot))
|
||||
is an empty table that filters nothing, since your firewall protects the
|
||||
machine.
|
||||
* **Your firewall reloading.** A ruleset file such as `/etc/nftables.conf`
|
||||
usually starts with `flush ruleset`, and Debian's `nftables.service`
|
||||
runs `nft flush ruleset` when it stops or restarts. Both delete
|
||||
Housecarl's table too. housecarld watches nftables' notifications and
|
||||
puts its table back within a few seconds (the journal says "Housecarl's
|
||||
table was deleted … put it back"). Dynamic sets lose their entries, and
|
||||
quotas go back to what was last saved (every five minutes).
|
||||
* **Warnings.** `nftables_service` and `takeover_pending` aren't raised:
|
||||
another firewall beside Housecarl is the point. Instead:
|
||||
`shadow_unprotected` when no other table has a filter chain on the input
|
||||
hook (nothing protects the machine), and `shadow_raw_left_out` for each
|
||||
raw rule left out ([validation.md](validation.md#the-machine)).
|
||||
* **Quotas count** what passes in shadow mode, and keep counting when
|
||||
you switch over.
|
||||
|
||||
## A trial, step by step
|
||||
|
||||
From a machine whose firewall is `/etc/nftables.conf` with `table inet main`:
|
||||
|
||||
1. Install Housecarl and create the administrator
|
||||
([install.md](install.md)).
|
||||
2. Turn shadow mode on in `/etc/housecarl/housecarl.yaml`, and start
|
||||
housecarld: `sudo systemctl enable --now housecarl`.
|
||||
3. Import your ruleset: `sudo hc import --defines /etc/nftables.conf`
|
||||
([import.md](import.md)). Leave `nftables.service` as it is.
|
||||
4. Use your network for a while, and watch `hc log -f`, `hc counters` and
|
||||
the dashboard. With `position: after`, each would-drop is something
|
||||
your firewall allows and Housecarl wouldn't: fix the rule in Housecarl,
|
||||
or decide it doesn't matter. `hc status` lists the raw rules left out.
|
||||
5. When nothing you need would be dropped, leave shadow mode.
|
||||
|
||||
## Leaving shadow mode
|
||||
|
||||
1. Look at what will be loaded: `sudo hc ruleset --enforcing`.
|
||||
2. Set `shadow.enabled: false` (or remove the section), and
|
||||
`sudo systemctl restart housecarl`. Housecarl's ruleset is now in
|
||||
force **alongside** your firewall: a packet must be accepted by both.
|
||||
The restart applies it at once, not on probation, but your old firewall
|
||||
is still there and still decides too.
|
||||
3. Take over: `sudo hc takeover "inet main"` deletes the old table (on
|
||||
probation over the network), and `sudo systemctl disable nftables`
|
||||
(without `--now`) retires it ([import.md](import.md#3-take-over)).
|
||||
|
||||
## Going back
|
||||
|
||||
Set `shadow.enabled: true` and restart housecarld. If Housecarl was your
|
||||
only firewall, **the machine is now unprotected**: load your other
|
||||
firewall first. Until one filters the input hook, `hc status` and
|
||||
`/healthz` warn (`shadow_unprotected`).
|
||||
@@ -100,6 +100,10 @@ The blocked lists only see what is **logged**: the input and forward
|
||||
policies' drops with `hc settings set --log-drops on`, and rules with
|
||||
logging. The drop counts above see everything.
|
||||
|
||||
In shadow mode ([shadow.md](shadow.md)) the drops, and the blocked
|
||||
lists, are what Housecarl would have dropped: the packets went on. What
|
||||
the policies would drop is always logged there.
|
||||
|
||||
A counter that goes down (an interface that came back, a quota reset)
|
||||
counts again from zero. What happens while housecarld is stopped isn't
|
||||
counted.
|
||||
|
||||
@@ -132,3 +132,8 @@ configuration: the dashboard and `hc status` show them.
|
||||
| `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`) |
|
||||
| `conntrack_full` | warning, *host* | the connection tracking table is over 80% full; once it is full, the kernel drops new connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens so many ([stats.md](stats.md#warnings)) |
|
||||
| `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 |
|
||||
| `shadow_unprotected` | warning, *host* | shadow mode, and no other table has a filter chain on the input hook: Housecarl's ruleset blocks nothing, and nothing else protects the machine. Load your firewall, or leave shadow mode ([shadow.md](shadow.md)) |
|
||||
| `shadow_raw_left_out` | warning, *host* | shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…). Its hits aren't counted, and the rules after it see what it would have taken ([shadow.md](shadow.md#what-changes-in-the-ruleset)) |
|
||||
|
||||
In shadow mode `nftables_service` and `takeover_pending` aren't raised:
|
||||
another firewall beside Housecarl is the point there.
|
||||
@@ -40,8 +40,18 @@ type Whoami struct {
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
CSRFToken string `json:"csrf_token,omitempty"`
|
||||
Build version.Build `json:"build"`
|
||||
// Mode is enforcing, or shadow (docs/shadow.md).
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// Modes: housecarld's ruleset decides, or (shadow mode, the
|
||||
// configuration file's shadow.enabled) it only shows what it would do,
|
||||
// beside another firewall.
|
||||
const (
|
||||
ModeEnforcing = "enforcing"
|
||||
ModeShadow = "shadow"
|
||||
)
|
||||
|
||||
// Login is the login request.
|
||||
type Login struct {
|
||||
Username string `json:"username"`
|
||||
@@ -155,6 +165,9 @@ type LogEntry struct {
|
||||
TCPFlags string `json:"tcp_flags,omitempty"`
|
||||
RuleMark string `json:"mark,omitempty"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
// Shadow: logged in shadow mode, where a drop or reject is what would
|
||||
// have been done; the packet went on.
|
||||
Shadow bool `json:"shadow,omitempty"`
|
||||
}
|
||||
|
||||
// Status is the dashboard's overview.
|
||||
@@ -162,6 +175,11 @@ type Status struct {
|
||||
Build version.Build `json:"build"`
|
||||
Started time.Time `json:"started"`
|
||||
Database string `json:"database,omitempty"`
|
||||
// Mode is enforcing or shadow; ShadowPosition (shadow mode) after or
|
||||
// before: where Housecarl's filter chains run beside the other
|
||||
// firewall's.
|
||||
Mode string `json:"mode"`
|
||||
ShadowPosition string `json:"shadow_position,omitempty"`
|
||||
Applied bool `json:"applied"`
|
||||
ApplyError string `json:"apply_error,omitempty"`
|
||||
// Unconfigured: the configuration is empty, and no ruleset was
|
||||
@@ -414,6 +432,9 @@ type TracePacket struct {
|
||||
// Verdict is accepted or dropped; DecidedBy the step that decided.
|
||||
Verdict string `json:"verdict"`
|
||||
DecidedBy TraceStep `json:"decided_by"`
|
||||
// WouldDrop, in shadow mode, is the step of Housecarl's where it
|
||||
// would have dropped (or rejected) the packet, had it been enforcing.
|
||||
WouldDrop *TraceStep `json:"would_drop,omitempty"`
|
||||
}
|
||||
|
||||
// TraceStatus is the trace running (or the last one).
|
||||
|
||||
@@ -41,8 +41,27 @@ type Config struct {
|
||||
Ruleset string `yaml:"ruleset"`
|
||||
// Stats keeps traffic, hits and drops over time (docs/stats.md).
|
||||
Stats Stats `yaml:"stats"`
|
||||
// Shadow loads the ruleset without letting it block, reject or
|
||||
// translate anything, beside the firewall in force (docs/shadow.md).
|
||||
Shadow Shadow `yaml:"shadow"`
|
||||
}
|
||||
|
||||
// Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs
|
||||
// and traces what it would do, while another firewall decides.
|
||||
type Shadow struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Position is where Housecarl's filter chains run: after the other
|
||||
// firewall's (it sees what that one lets through, so what it would
|
||||
// drop is what switching would break) or before (it sees everything).
|
||||
Position string `yaml:"position"`
|
||||
}
|
||||
|
||||
// Shadow positions.
|
||||
const (
|
||||
ShadowAfter = "after"
|
||||
ShadowBefore = "before"
|
||||
)
|
||||
|
||||
// Stats configures statistics: a database of their own, aggregates
|
||||
// only, pruned by age.
|
||||
type Stats struct {
|
||||
@@ -204,6 +223,7 @@ func Default() *Config {
|
||||
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
||||
Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100,
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
|
||||
Shadow: Shadow{Position: ShadowAfter},
|
||||
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
|
||||
}
|
||||
}
|
||||
@@ -275,6 +295,9 @@ func (c *Config) check() error {
|
||||
default:
|
||||
return fmt.Errorf("log.level must be debug, info, warn or error")
|
||||
}
|
||||
if c.Shadow.Position != ShadowAfter && c.Shadow.Position != ShadowBefore {
|
||||
return fmt.Errorf("shadow.position must be after or before")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -427,4 +450,16 @@ stats:
|
||||
hour: 90d # hourly figures and top lists
|
||||
day: 730d # daily figures and top lists
|
||||
top_n: 100 # entries kept per top list, per hour and day
|
||||
|
||||
# Shadow mode (docs/shadow.md): Housecarl's ruleset is loaded beside the
|
||||
# firewall you have now, but blocks, rejects and translates nothing. Its
|
||||
# counters, log and tracer show what it would do. No change is on
|
||||
# probation, and takeovers and closing connections are refused. housecarld
|
||||
# reads this when it starts: restart it to switch.
|
||||
shadow:
|
||||
enabled: false
|
||||
# after: Housecarl's filter chains run after the other firewall's, and
|
||||
# see only what it lets through: what Housecarl would drop is what
|
||||
# switching over would break. before: they see every packet.
|
||||
position: after
|
||||
`
|
||||
@@ -35,6 +35,7 @@ func TestErrors(t *testing.T) {
|
||||
"{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name",
|
||||
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
|
||||
"ruleset: relative.nft": "ruleset",
|
||||
"shadow: {position: x}": "shadow.position",
|
||||
}
|
||||
for yml, want := range cases {
|
||||
path := filepath.Join(t.TempDir(), "s.yaml")
|
||||
|
||||
+32
-6
@@ -409,6 +409,10 @@ type Firewall struct {
|
||||
// BootFile is where the confirmed ruleset is saved for the boot unit
|
||||
// (housecarl-ruleset.service; "" for none).
|
||||
BootFile string
|
||||
// Shadow is shadow mode: the ruleset changes nothing, beside another
|
||||
// firewall (render.Options; docs/shadow.md). ShadowBefore runs its
|
||||
// filter chains before that one's.
|
||||
Shadow, ShadowBefore bool
|
||||
|
||||
// Replacing the table resets its counters: base keeps the totals of
|
||||
// the tables replaced since housecarld started.
|
||||
@@ -478,7 +482,7 @@ func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool
|
||||
}
|
||||
|
||||
func (f *Firewall) options() render.Options {
|
||||
var o render.Options
|
||||
o := render.Options{Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}
|
||||
if f.Interfaces != nil {
|
||||
o.Interfaces = f.Interfaces()
|
||||
}
|
||||
@@ -488,9 +492,26 @@ func (f *Firewall) options() render.Options {
|
||||
// Render builds the ruleset for st as Apply would.
|
||||
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) }
|
||||
|
||||
// Check implements engine.Firewall.
|
||||
// RenderEnforcing builds the ruleset for st as Apply would outside shadow
|
||||
// mode.
|
||||
func (f *Firewall) RenderEnforcing(st *model.State) *render.Ruleset {
|
||||
o := f.options()
|
||||
o.Shadow, o.ShadowBefore = false, false
|
||||
return render.Render(st, o)
|
||||
}
|
||||
|
||||
// Check implements engine.Firewall. In shadow mode it checks the
|
||||
// enforcing ruleset too, so that leaving shadow mode can't fail.
|
||||
func (f *Firewall) Check(ctx context.Context, st *model.State) error {
|
||||
return f.Runner.Check(ctx, render.Render(st, f.options()))
|
||||
if err := f.Runner.Check(ctx, render.Render(st, f.options())); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Shadow {
|
||||
if err := f.Runner.Check(ctx, f.RenderEnforcing(st)); err != nil {
|
||||
return fmt.Errorf("the ruleset for when shadow mode is off: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply implements engine.Firewall.
|
||||
@@ -542,7 +563,8 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
|
||||
// no flowtable: it loads before the network comes up, when its devices
|
||||
// (ppp0, wg0) don't exist yet and nftables would refuse the whole file;
|
||||
// housecarld adds it once they do.
|
||||
// housecarld adds it once they do. In shadow mode both are shadow mode's:
|
||||
// the fallback then filters nothing.
|
||||
func (f *Firewall) SaveBoot(st *model.State) error {
|
||||
if f.BootFile == "" {
|
||||
return nil
|
||||
@@ -550,10 +572,14 @@ func (f *Firewall) SaveBoot(st *model.State) error {
|
||||
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(FallbackFile(f.BootFile), render.Fallback(st)); err != nil {
|
||||
fallback := render.Fallback(st)
|
||||
if f.Shadow {
|
||||
fallback = render.ShadowFallback()
|
||||
}
|
||||
if err := writeAtomic(FallbackFile(f.BootFile), fallback); err != nil {
|
||||
return err
|
||||
}
|
||||
rs := render.Render(st, render.Options{Interfaces: map[string]bool{}})
|
||||
rs := render.Render(st, render.Options{Interfaces: map[string]bool{}, Shadow: f.Shadow, ShadowBefore: f.ShadowBefore})
|
||||
return writeAtomic(f.BootFile, rs.Text)
|
||||
}
|
||||
|
||||
|
||||
@@ -365,3 +365,75 @@ func TestBootInContainer(t *testing.T) {
|
||||
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowInContainer: in shadow mode a drop counts but doesn't drop,
|
||||
// the ruleset for leaving shadow mode is checked too, and the boot files
|
||||
// are shadow mode's.
|
||||
func TestShadowInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-shadow-test"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
dir := t.TempDir()
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Shadow: true, BootFile: filepath.Join(dir, "ruleset.nft")}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
st.Settings.OutputPolicy = model.PolicyDrop
|
||||
st.Settings.InputPolicy = model.PolicyAccept
|
||||
st.Settings.Builtins.Loopback = false // it would accept the ping first
|
||||
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionReject, Match: model.Match{Destinations: []string{"127.0.0.2"}}}}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
ping := func() error {
|
||||
return exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
|
||||
}
|
||||
if err := ping(); err != nil {
|
||||
t.Errorf("shadow mode blocked a ping: %v", err)
|
||||
}
|
||||
tb, err := fw.Table(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tb.Counters["r3"].Packets == 0 {
|
||||
t.Errorf("what would be rejected isn't counted: %+v", tb.Counters)
|
||||
}
|
||||
// A rule shadow mode leaves out, which nftables refuses when enforcing.
|
||||
broken := st.Clone()
|
||||
broken.Rules = append(broken.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Raw: "tcp dport 22 jump nowhere", Comment: "jump"})
|
||||
if err := fw.Check(ctx, broken); err == nil || !strings.Contains(err.Error(), "shadow mode is off") || !strings.Contains(err.Error(), "input #4 (jump)") {
|
||||
t.Errorf("the ruleset for leaving shadow mode wasn't checked: %v", err)
|
||||
}
|
||||
// Enforcing, the same rule rejects.
|
||||
enforcing := &Firewall{Runner: fw.Runner}
|
||||
if err := enforcing.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ping() == nil {
|
||||
t.Error("enforcing didn't reject")
|
||||
}
|
||||
// The boot files: both load, and filter nothing.
|
||||
if err := fw.SaveBoot(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"ruleset.nft", "ruleset-fallback.nft"} {
|
||||
b, _ := os.ReadFile(filepath.Join(dir, f))
|
||||
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
|
||||
c.Stdin = strings.NewReader(string(b))
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s doesn't load: %v %s", f, err, out)
|
||||
}
|
||||
if err := ping(); err != nil {
|
||||
t.Errorf("%s blocked a ping: %v", f, err)
|
||||
}
|
||||
}
|
||||
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
|
||||
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
|
||||
}
|
||||
}
|
||||
+91
-16
@@ -8,6 +8,14 @@
|
||||
// counter and a comment "hc:<kind><id>", which is how counters are read
|
||||
// back, and Ruleset.Lines maps each line to its object, for nftables'
|
||||
// error messages.
|
||||
//
|
||||
// In shadow mode (Options.Shadow, docs/shadow.md) the same rules are
|
||||
// written so that they change nothing: drops and rejects accept instead
|
||||
// (an accept only ends Housecarl's chain; the packet goes on to other
|
||||
// tables' chains, which decide), policies accept, NAT only counts, and
|
||||
// what alters packets (MSS clamping, the flowtable) is left out. What
|
||||
// would be dropped, rejected or translated carries a marker in its
|
||||
// comment ("[would drop]"), which the tracer reads.
|
||||
package render
|
||||
|
||||
import (
|
||||
@@ -42,8 +50,18 @@ type Options struct {
|
||||
// DeviceSets are the per-device traffic counters' entries
|
||||
// (stats_up_v4 …), so a reload keeps them.
|
||||
DeviceSets map[string][]Element
|
||||
// Shadow renders for shadow mode: nothing is blocked, rejected,
|
||||
// translated or changed (see the package comment).
|
||||
Shadow bool
|
||||
// ShadowBefore runs the filter chains just before other tables' on
|
||||
// their hooks (priority - 10) instead of just after (+ 10).
|
||||
ShadowBefore bool
|
||||
}
|
||||
|
||||
// Would marks, in shadow mode, a rule's comment with what it would do:
|
||||
// "[would drop]".
|
||||
func Would(action string) string { return "[would " + action + "]" }
|
||||
|
||||
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||
// per-address quota's entry with what it used.
|
||||
type Element struct {
|
||||
@@ -112,6 +130,9 @@ type renderer struct {
|
||||
func (r *renderer) render() {
|
||||
w := r.w
|
||||
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
|
||||
if r.opt.Shadow {
|
||||
w.emit("", "# Shadow mode: nothing here drops, rejects or translates; other tables decide.")
|
||||
}
|
||||
w.emit("", "table inet %s {}", Table)
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
@@ -265,6 +286,10 @@ func addrType(v6 bool) string {
|
||||
}
|
||||
|
||||
func (r *renderer) flowtableDevices() []string {
|
||||
if r.opt.Shadow {
|
||||
// Offloaded connections skip every table's forward chain.
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, d := range r.st.Settings.FlowtableDevices {
|
||||
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
||||
@@ -277,8 +302,19 @@ func (r *renderer) flowtableDevices() []string {
|
||||
// ---- chains
|
||||
|
||||
func (r *renderer) chain(name, hook, prio, policy string) {
|
||||
nat := prio == "dstnat" || prio == "srcnat"
|
||||
if r.opt.Shadow {
|
||||
// NAT chains are asked in turn only until one translates: to
|
||||
// count anything, Housecarl's must come first.
|
||||
if nat || r.opt.ShadowBefore {
|
||||
prio += " - 10"
|
||||
} else {
|
||||
prio += " + 10"
|
||||
}
|
||||
policy = model.PolicyAccept
|
||||
}
|
||||
r.w.emit("", "\tchain %s {", name)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[prio == "dstnat" || prio == "srcnat"], hook, prio, policy)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[nat], hook, prio, policy)
|
||||
}
|
||||
|
||||
func (r *renderer) end() { r.w.emit("", "\t}") }
|
||||
@@ -287,6 +323,16 @@ func (r *renderer) builtin(name, format string, args ...any) {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" comment \"hc:b-%s\"", append(args, name)...)
|
||||
}
|
||||
|
||||
// builtinDrop writes a built-in that drops what it matches, or in shadow
|
||||
// mode counts it.
|
||||
func (r *renderer) builtinDrop(name, format string, args ...any) {
|
||||
if r.opt.Shadow {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" counter accept comment \"hc:b-%s %s\"", append(args, name, Would("drop"))...)
|
||||
return
|
||||
}
|
||||
r.builtin(name, format+" counter drop", args...)
|
||||
}
|
||||
|
||||
func (r *renderer) early() {
|
||||
st := r.st
|
||||
r.chain("early", "prerouting", "raw", "accept")
|
||||
@@ -302,10 +348,10 @@ func (r *renderer) early() {
|
||||
}
|
||||
}
|
||||
if len(v4) > 0 {
|
||||
r.builtin("antispoof", "iifname %s ip saddr { %s } counter drop", ifaces(ext), strings.Join(v4, ", "))
|
||||
r.builtinDrop("antispoof", "iifname %s ip saddr { %s }", ifaces(ext), strings.Join(v4, ", "))
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
||||
r.builtinDrop("antispoof6", "iifname %s ip6 saddr { %s }", ifaces(ext), strings.Join(v6, ", "))
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainEarly, true)
|
||||
@@ -325,7 +371,7 @@ func (r *renderer) connBuiltins() {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if b.DropInvalid {
|
||||
r.builtin("invalid", "ct state invalid counter drop")
|
||||
r.builtinDrop("invalid", "ct state invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +412,8 @@ func (r *renderer) forward(flowtable bool) {
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.MSSClamp && len(ext) > 0 {
|
||||
// Shadow mode changes no packet.
|
||||
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
|
||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||
}
|
||||
r.rules(model.ChainForward, true)
|
||||
@@ -404,15 +451,21 @@ func (r *renderer) output() {
|
||||
r.end()
|
||||
}
|
||||
|
||||
// policyTail counts (and optionally logs) what reaches the policy.
|
||||
// policyTail counts (and optionally logs) what reaches the policy. In
|
||||
// shadow mode, where the policy accepts, that is what it would drop, and
|
||||
// it is always logged: the log is how shadow mode shows it.
|
||||
func (r *renderer) policyTail(chain, policy string) {
|
||||
if policy != model.PolicyDrop {
|
||||
return
|
||||
}
|
||||
obj := "policy: " + chain
|
||||
if r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
if r.opt.Shadow || r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
r.w.emit(obj, "\t\tlimit rate 10/second burst 20 packets log prefix %s group %d", quote("hc "+chain+" policy drop: "), LogGroup)
|
||||
}
|
||||
if r.opt.Shadow {
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s %s\"", chain, Would("drop"))
|
||||
return
|
||||
}
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s\"", chain)
|
||||
}
|
||||
|
||||
@@ -428,7 +481,15 @@ func (r *renderer) rules(chain string, traffic bool) {
|
||||
obj := rule.Label()
|
||||
mark := fmt.Sprintf("r%d", rule.ID)
|
||||
if rule.Raw != "" {
|
||||
r.w.emit(obj, "\t\t%s", rule.Raw)
|
||||
raw := rule.Raw
|
||||
if r.opt.Shadow {
|
||||
var ok bool
|
||||
if raw, ok = ShadowRaw(raw); !ok {
|
||||
r.w.emit(obj, "\t\t# left out in shadow mode: %s", rule.Raw)
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s", raw)
|
||||
continue
|
||||
}
|
||||
var tail []string
|
||||
@@ -478,17 +539,22 @@ func (r *renderer) verdict(rule *model.Rule, mark string) string {
|
||||
}
|
||||
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
|
||||
}
|
||||
switch rule.Action {
|
||||
case model.ActionAccept, model.ActionDrop:
|
||||
text := rule.Comment
|
||||
switch {
|
||||
case r.opt.Shadow && (rule.Action == model.ActionDrop || rule.Action == model.ActionReject):
|
||||
// It ends Housecarl's chain as the drop would, and no more.
|
||||
v = append(v, model.ActionAccept)
|
||||
text = Would(rule.Action) + " " + text
|
||||
case rule.Action == model.ActionAccept, rule.Action == model.ActionDrop:
|
||||
v = append(v, rule.Action)
|
||||
case model.ActionReject:
|
||||
case rule.Action == model.ActionReject:
|
||||
if rule.RejectWith == "tcp-reset" {
|
||||
v = append(v, "reject with tcp reset")
|
||||
} else {
|
||||
v = append(v, "reject")
|
||||
}
|
||||
}
|
||||
v = append(v, "comment "+comment(mark, rule.Comment))
|
||||
v = append(v, "comment "+comment(mark, text))
|
||||
return strings.Join(v, " ")
|
||||
}
|
||||
|
||||
@@ -781,7 +847,7 @@ func (r *renderer) dstnat() {
|
||||
}
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter redirect to :%s comment %s", strings.Join(nonEmpty(line.parts), " "), to, comment(mark, x.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat("redirect to :"+to, mark, x.Comment))
|
||||
}
|
||||
}
|
||||
ext := st.ExternalInterfaces()
|
||||
@@ -811,7 +877,7 @@ func (r *renderer) dstnat() {
|
||||
for _, wh := range where {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter dnat ip to %s comment %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), to, comment(mark, f.Name+" "+f.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), r.nat("dnat ip to "+to, mark, f.Name+" "+f.Comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -885,7 +951,7 @@ func (r *renderer) srcnat() {
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter masquerade comment %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), r.nat("masquerade", mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,12 +983,21 @@ func (r *renderer) srcnat() {
|
||||
action = "snat ip to " + x.To
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter %s comment %s", strings.Join(nonEmpty(line.parts), " "), action, comment(mark, x.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat(action, mark, x.Comment))
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// nat writes a NAT rule's statement and comment; in shadow mode only the
|
||||
// comment, saying what it would do ("[would dnat]").
|
||||
func (r *renderer) nat(action, mark, text string) string {
|
||||
if r.opt.Shadow {
|
||||
return "comment " + comment(mark, Would(strings.Fields(action)[0])+" "+text)
|
||||
}
|
||||
return action + " comment " + comment(mark, text)
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
// hours writes a time window. nftables can't take a range past midnight,
|
||||
|
||||
@@ -2,6 +2,7 @@ package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -254,15 +255,25 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
|
||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
|
||||
q, qs := quotaOptions(st)
|
||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}})
|
||||
// Enforcing, and in shadow mode after and before other tables.
|
||||
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
|
||||
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
|
||||
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
|
||||
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
|
||||
}
|
||||
}
|
||||
nftCheck(t, cmd, "shadow fallback", ShadowFallback())
|
||||
}
|
||||
|
||||
func nftCheck(t *testing.T, cmd []string, name, text string) {
|
||||
t.Helper()
|
||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(rs.Text)
|
||||
c.Stdin = strings.NewReader(text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
||||
for i, l := range strings.Split(rs.Text, "\n") {
|
||||
for i, l := range strings.Split(text, "\n") {
|
||||
t.Logf("%3d %s", i+1, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// effects are the words of a raw rule's statements that act on packets
|
||||
// (or jump away). In shadow mode a raw rule with any of them is left out,
|
||||
// unless its only one is a final drop or reject, which becomes an accept.
|
||||
var effects = map[string]bool{
|
||||
"drop": true, "reject": true, "queue": true,
|
||||
"dnat": true, "snat": true, "masquerade": true, "redirect": true, "tproxy": true,
|
||||
"notrack": true, "dup": true, "fwd": true, "synproxy": true, "flow": true, "offload": true,
|
||||
"set": true, "reset": true, "jump": true, "goto": true, "return": true,
|
||||
}
|
||||
|
||||
// ShadowRaw rewrites a raw rule for shadow mode: as written when it only
|
||||
// matches, counts, logs or accepts; with its final drop or reject made an
|
||||
// accept (marked "[would drop]" in its comment); ok is false when it does
|
||||
// anything else to packets (NAT, setting a mark or header field, a jump,
|
||||
// a verdict map…), and must be left out.
|
||||
func ShadowRaw(raw string) (out string, ok bool) {
|
||||
toks := tokens(raw)
|
||||
verdict := -1
|
||||
for i := 0; i < len(toks); i++ {
|
||||
w := word(toks[i])
|
||||
if w == "comment" {
|
||||
i++ // its text
|
||||
continue
|
||||
}
|
||||
if !effects[w] {
|
||||
continue
|
||||
}
|
||||
if verdict >= 0 || (w != "drop" && w != "reject") {
|
||||
return "", false
|
||||
}
|
||||
verdict = i
|
||||
// What follows a verdict, up to the comment, must be a reject's
|
||||
// "with …".
|
||||
j := i + 1
|
||||
for j < len(toks) && word(toks[j]) != "comment" {
|
||||
j++
|
||||
}
|
||||
if j > i+1 && (w == "drop" || word(toks[i+1]) != "with") {
|
||||
return "", false
|
||||
}
|
||||
i = j - 1
|
||||
}
|
||||
if verdict < 0 {
|
||||
return raw, true
|
||||
}
|
||||
mark := Would(word(toks[verdict]))
|
||||
head := append(append([]string{}, toks[:verdict]...), "accept")
|
||||
for i := verdict + 1; i < len(toks); i++ {
|
||||
if word(toks[i]) != "comment" {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(toks) {
|
||||
text := strings.Trim(toks[i+1], `"`)
|
||||
return strings.Join(append(head, "comment", quote(strings.TrimSpace(mark+" "+text))), " "), true
|
||||
}
|
||||
}
|
||||
return strings.Join(append(head, "comment", quote(mark)), " "), true
|
||||
}
|
||||
|
||||
// tokens splits a rule into words, a quoted string being one.
|
||||
func tokens(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
quoted := false
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case c == '"':
|
||||
quoted = !quoted
|
||||
cur.WriteRune(c)
|
||||
case !quoted && (c == ' ' || c == '\t'):
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// word is a token as a keyword: without the punctuation of sets and maps
|
||||
// around it; a quoted string is no keyword.
|
||||
func word(t string) string {
|
||||
if strings.HasPrefix(t, `"`) {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Trim(t, ",{}:()"))
|
||||
}
|
||||
|
||||
// ShadowLeftOut lists the raw rules shadow mode leaves out (ShadowRaw).
|
||||
func ShadowLeftOut(st *model.State) []*model.Rule {
|
||||
var out []*model.Rule
|
||||
for _, r := range st.Rules {
|
||||
if r.Raw == "" || r.Disabled {
|
||||
continue
|
||||
}
|
||||
if _, ok := ShadowRaw(r.Raw); !ok {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ShadowFallback is the boot fallback in shadow mode: the other firewall
|
||||
// protects the machine, so it is an empty table that filters nothing,
|
||||
// with a rule outside every hook that tells housecarld it is in force.
|
||||
func ShadowFallback() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Housecarl's fallback in shadow mode: loaded at boot only if ruleset.nft\n")
|
||||
fmt.Fprintf(&b, "# fails to load. It filters nothing; the other firewall decides.\n")
|
||||
fmt.Fprintf(&b, "table inet %s {}\ndelete table inet %s\n", Table, Table)
|
||||
fmt.Fprintf(&b, "table inet %s {\n\tchain fallback {\n\t\tcounter comment \"hc:%s-shadow\"\n\t}\n}\n", Table, FallbackMark)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// TestShadow: in shadow mode nothing in the ruleset drops, rejects,
|
||||
// translates or changes a packet, and what would is counted and marked.
|
||||
func TestShadow(t *testing.T) {
|
||||
st := router()
|
||||
st.Rules = append(st.Rules,
|
||||
&model.Rule{ID: 950, Chain: model.ChainInput, Position: 99, Raw: `tcp dport 9999 counter reject with tcp reset comment "old"`},
|
||||
&model.Rule{ID: 951, Chain: model.ChainForward, Position: 99, Raw: "meta mark set 0x1 accept"})
|
||||
st.Settings.OutputPolicy = model.PolicyDrop
|
||||
text := Render(st, Options{Shadow: true}).Text
|
||||
// What acts on packets: verdicts, NAT, changes, the flowtable.
|
||||
acts := regexp.MustCompile(`(?m)(\b(drop|reject|dnat|snat|masquerade|redirect)\b|maxseg size set|flow add|flowtable|policy drop)[^"\n]*$`)
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
code, _, _ := strings.Cut(l, "comment ")
|
||||
code = strings.ReplaceAll(code, "ct status dnat", "") // a match
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "#") {
|
||||
continue
|
||||
}
|
||||
if m := acts.FindString(code); m != "" && !strings.Contains(code, "log prefix") {
|
||||
t.Errorf("acts on packets in shadow mode: %s", l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"type filter hook input priority filter + 10; policy accept;",
|
||||
"type filter hook prerouting priority raw + 10; policy accept;",
|
||||
"type nat hook prerouting priority dstnat - 10; policy accept;",
|
||||
"type nat hook postrouting priority srcnat - 10; policy accept;",
|
||||
`ct state invalid counter accept comment "hc:b-invalid [would drop]"`,
|
||||
`counter comment "hc:p-input [would drop]"`,
|
||||
`counter comment "hc:p-output [would drop]"`,
|
||||
`log prefix "hc output policy drop: "`,
|
||||
`[would reject]`,
|
||||
`[would dnat] jellyfin`,
|
||||
`[would masquerade]`,
|
||||
`[would redirect]`,
|
||||
`tcp dport 9999 counter accept comment "[would reject] old"`,
|
||||
"# left out in shadow mode: meta mark set 0x1 accept",
|
||||
"# Shadow mode:",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("no %q in\n%s", want, text)
|
||||
}
|
||||
}
|
||||
before := Render(st, Options{Shadow: true, ShadowBefore: true}).Text
|
||||
for _, want := range []string{"priority filter - 10;", "priority raw - 10;", "priority dstnat - 10;"} {
|
||||
if !strings.Contains(before, want) {
|
||||
t.Errorf("before: no %q", want)
|
||||
}
|
||||
}
|
||||
// Enforcing is unchanged by it.
|
||||
if strings.Contains(Render(st, Options{}).Text, "would") {
|
||||
t.Error("shadow markers when enforcing")
|
||||
}
|
||||
if got := ShadowLeftOut(st); len(got) != 1 || got[0].ID != 951 {
|
||||
t.Errorf("left out: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowRaw(t *testing.T) {
|
||||
cases := []struct{ in, out string }{
|
||||
{"meta skuid 0 counter accept", "meta skuid 0 counter accept"},
|
||||
{"tcp dport 22 drop", `tcp dport 22 accept comment "[would drop]"`},
|
||||
{`tcp dport 22 counter drop comment "no ssh"`, `tcp dport 22 counter accept comment "[would drop] no ssh"`},
|
||||
{"ip saddr 10.0.0.1 reject with icmpx type admin-prohibited", `ip saddr 10.0.0.1 accept comment "[would reject]"`},
|
||||
{"tcp dport 22 reject with tcp reset", `tcp dport 22 accept comment "[would reject]"`},
|
||||
{`log prefix "drop me: " group 5 accept`, `log prefix "drop me: " group 5 accept`},
|
||||
{"ip saddr { 10.0.0.1, 10.0.0.2 } counter", "ip saddr { 10.0.0.1, 10.0.0.2 } counter"},
|
||||
{"tcp dport vmap { 22 : drop, 80 : accept }", ""},
|
||||
{"meta mark set 1", ""},
|
||||
{"ip daddr 1.2.3.4 dnat ip to 10.0.0.1", ""},
|
||||
{"tcp dport 22 jump other", ""},
|
||||
{"drop accept", ""},
|
||||
{"meta nftrace set 1", ""},
|
||||
{"ct state new notrack", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out, ok := ShadowRaw(c.in)
|
||||
if ok != (c.out != "") || out != c.out {
|
||||
t.Errorf("%q: got %q %v, want %q", c.in, out, ok, c.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info(), Mode: s.mode()}
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
|
||||
@@ -505,6 +505,10 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
return err
|
||||
}
|
||||
rs := s.fw.Render(st)
|
||||
// In shadow mode: what would be loaded once it is off.
|
||||
if queryBool(r, "enforcing") {
|
||||
rs = s.fw.RenderEnforcing(st)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(rs.Text))
|
||||
return nil
|
||||
|
||||
@@ -19,6 +19,8 @@ type health struct {
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version"`
|
||||
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
|
||||
// Mode is enforcing, or shadow: the ruleset blocks nothing.
|
||||
Mode string `json:"mode"`
|
||||
// Probation: changes waiting to be confirmed, rolled back otherwise.
|
||||
Probation int `json:"probation"`
|
||||
// Warnings are the codes of the machine's problems (nftables_service).
|
||||
@@ -49,7 +51,7 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) checkHealth(r *http.Request) health {
|
||||
out := health{Version: version.String(), Warnings: []string{}}
|
||||
out := health{Version: version.String(), Warnings: []string{}, Mode: s.mode()}
|
||||
t, err := s.fw.Table(r.Context())
|
||||
switch {
|
||||
case err != nil && s.eng.State().Empty():
|
||||
@@ -63,7 +65,12 @@ func (s *Server) checkHealth(r *http.Request) health {
|
||||
}
|
||||
pr := s.probationStatus()
|
||||
out.Probation = len(pr.Pending) + pr.Takeovers
|
||||
for _, p := range s.hostProblems(r.Context(), nil) {
|
||||
// The other tables matter in shadow mode (shadow_unprotected).
|
||||
var ruleset []byte
|
||||
if s.cfg.Shadow {
|
||||
ruleset, _ = s.fw.ListRuleset(r.Context())
|
||||
}
|
||||
for _, p := range s.hostProblems(r.Context(), ruleset) {
|
||||
out.Warnings = append(out.Warnings, p.Code)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -53,7 +53,8 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
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).
|
||||
// status warns (takeover_pending; not in shadow mode, where that
|
||||
// is the point).
|
||||
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}
|
||||
@@ -117,6 +118,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
if err := notStaged(r, "a takeover"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.refuseInShadow("delete other tables (the firewall in force)"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TakeoverInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
|
||||
@@ -27,7 +27,10 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
st := s.eng.State()
|
||||
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
|
||||
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
|
||||
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}, Mode: s.mode()}
|
||||
if s.cfg.Shadow {
|
||||
out.ShadowPosition = s.cfg.ShadowPosition
|
||||
}
|
||||
if out.Problems == nil {
|
||||
out.Problems = []validate.Problem{}
|
||||
}
|
||||
@@ -46,10 +49,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
} else {
|
||||
out.Applied = true
|
||||
}
|
||||
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
||||
data, err := s.fw.ListRuleset(r.Context())
|
||||
if err == nil {
|
||||
out.OtherTables = otherTables(data)
|
||||
}
|
||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), data)...)
|
||||
out.Problems = append(out.Problems, s.statsProblems()...)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
@@ -57,6 +61,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
|
||||
const importedTableKey = "imported_table"
|
||||
|
||||
// shadowProblems are shadow mode's warnings about the machine.
|
||||
func (s *Server) shadowProblems(ruleset []byte) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
if ruleset != nil && len(inputFilters(ruleset)) == 0 {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_unprotected", Object: "shadow mode",
|
||||
Message: "housecarld is in shadow mode, where its ruleset blocks nothing, and no other table filters what reaches this machine (none has a filter chain on the input hook): nothing protects it; load your firewall, or leave shadow mode"})
|
||||
}
|
||||
for _, r := range render.ShadowLeftOut(s.eng.State()) {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_raw_left_out", Object: r.Label(),
|
||||
Message: "shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…), so its hits aren't counted and later rules see what it would have taken"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
|
||||
// enables it for sysinit.target).
|
||||
var nftablesUnitLinks = []string{
|
||||
@@ -66,8 +84,15 @@ var nftablesUnitLinks = []string{
|
||||
|
||||
// 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 {
|
||||
// an import came from, still in force beside Housecarl's. ruleset is the
|
||||
// machine's (nft -j list ruleset; nil: not read). In shadow mode those
|
||||
// are expected; the warnings are that nothing else filters, and the raw
|
||||
// rules left out.
|
||||
func (s *Server) hostProblems(ctx context.Context, ruleset []byte) []validate.Problem {
|
||||
if s.cfg.Shadow {
|
||||
return s.shadowProblems(ruleset)
|
||||
}
|
||||
others := otherTables(ruleset)
|
||||
var out []validate.Problem
|
||||
for _, link := range s.unitLinks {
|
||||
if _, err := os.Lstat(link); err == nil {
|
||||
@@ -300,6 +325,7 @@ func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) [
|
||||
// LogPacket records a packet the firewall logged (internal/nflog calls
|
||||
// it).
|
||||
func (s *Server) LogPacket(e api.LogEntry) {
|
||||
e.Shadow = s.cfg.Shadow
|
||||
s.logs.Add(e)
|
||||
if s.stats != nil {
|
||||
s.stats.Packet(e)
|
||||
|
||||
@@ -94,6 +94,9 @@ func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *aut
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.refuseInShadow("close connections"); err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.conntrack()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -227,6 +230,12 @@ func (s *Server) traceStatus() api.TraceStatus {
|
||||
p.Steps[j].Label = markLabel(st, p.Steps[j])
|
||||
}
|
||||
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
|
||||
if p.WouldDrop != nil {
|
||||
// A copy: the tracer still holds the step.
|
||||
wd := *p.WouldDrop
|
||||
wd.Label = markLabel(st, wd)
|
||||
p.WouldDrop = &wd
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
// In shadow mode a change can't cut anyone off: it blocks nothing.
|
||||
if s.cfg.Shadow || p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
s.saveBoot()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,29 @@ import (
|
||||
|
||||
// otherTables lists the tables in an `nft -j list ruleset` other than
|
||||
// Housecarl's ("inet main").
|
||||
// inputFilters lists the tables other than Housecarl's with a filter
|
||||
// chain on the input hook: what filters the machine in shadow mode.
|
||||
func inputFilters(data []byte) []string {
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
out := []string{}
|
||||
if json.Unmarshal(data, &doc) != nil {
|
||||
return out
|
||||
}
|
||||
for _, item := range doc.Nftables {
|
||||
raw, ok := item["chain"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var c struct{ Family, Table, Type, Hook string }
|
||||
if json.Unmarshal(raw, &c) == nil && c.Type == "filter" && c.Hook == "input" && !(c.Family == "inet" && (c.Table == "housecarl" || c.Table == trace.Table)) {
|
||||
out = append(out, c.Family+" "+c.Table)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func otherTables(data []byte) []string {
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
@@ -45,6 +46,11 @@ type Config struct {
|
||||
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
|
||||
// when off).
|
||||
ACME *certs.Manager
|
||||
// Shadow is shadow mode (docs/shadow.md): nothing goes on probation,
|
||||
// takeovers and closing connections are refused. ShadowPosition is
|
||||
// after or before, for the status.
|
||||
Shadow bool
|
||||
ShadowPosition string
|
||||
}
|
||||
|
||||
// Interfaces lists the host's interfaces with their addresses, and
|
||||
@@ -58,6 +64,8 @@ type Interfaces interface {
|
||||
// engine (internal/nft.Firewall).
|
||||
type Firewall interface {
|
||||
Render(st *model.State) *render.Ruleset
|
||||
// RenderEnforcing is the ruleset outside shadow mode.
|
||||
RenderEnforcing(st *model.State) *render.Ruleset
|
||||
Table(ctx context.Context) (*nft.Table, error)
|
||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||
DeleteElement(ctx context.Context, set, addr string) error
|
||||
@@ -131,6 +139,22 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
return s
|
||||
}
|
||||
|
||||
// mode is housecarld's mode: enforcing, or shadow.
|
||||
func (s *Server) mode() string {
|
||||
if s.cfg.Shadow {
|
||||
return api.ModeShadow
|
||||
}
|
||||
return api.ModeEnforcing
|
||||
}
|
||||
|
||||
// refuseInShadow refuses what would change traffic in shadow mode.
|
||||
func (s *Server) refuseInShadow(what string) error {
|
||||
if !s.cfg.Shadow {
|
||||
return nil
|
||||
}
|
||||
return conflict("housecarld is in shadow mode, where it changes nothing about traffic, so it doesn't %s; leave shadow mode first (shadow.enabled in housecarl.yaml, docs/shadow.md)", what)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler with all middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||
|
||||
@@ -41,6 +41,8 @@ type fakeFW struct {
|
||||
traced string
|
||||
traceLines []string
|
||||
fallback bool // the boot fallback is in force
|
||||
shadow bool
|
||||
ruleset string // nft -j list ruleset ("": a default)
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
@@ -58,7 +60,12 @@ func (f *fakeFW) last() *model.State {
|
||||
defer f.mu.Unlock()
|
||||
return f.applied[len(f.applied)-1]
|
||||
}
|
||||
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
|
||||
func (f *fakeFW) Render(st *model.State) *render.Ruleset {
|
||||
return render.Render(st, render.Options{Shadow: f.shadow})
|
||||
}
|
||||
func (f *fakeFW) RenderEnforcing(st *model.State) *render.Ruleset {
|
||||
return render.Render(st, render.Options{})
|
||||
}
|
||||
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -86,6 +93,9 @@ func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
|
||||
if f.ruleset != "" {
|
||||
return []byte(f.ruleset), nil
|
||||
}
|
||||
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
|
||||
}
|
||||
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
||||
@@ -142,6 +152,11 @@ type env struct {
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
return newEnvWith(t, Config{})
|
||||
}
|
||||
|
||||
func newEnvWith(t *testing.T, cfg Config) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(":memory:")
|
||||
@@ -159,10 +174,10 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw := &fakeFW{elements: map[string][]render.Element{}}
|
||||
fw := &fakeFW{elements: map[string][]render.Element{}, shadow: cfg.Shadow}
|
||||
eng.Firewall = fw
|
||||
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
|
||||
srv := New(Config{}, eng, fw, ifs, nil, nil)
|
||||
srv := New(cfg, eng, fw, ifs, nil, nil)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
|
||||
@@ -1060,3 +1075,105 @@ func TestStatsEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowMode: in shadow mode changes aren't on probation, what would
|
||||
// change traffic is refused, and the status, health and log say so.
|
||||
func TestShadowMode(t *testing.T) {
|
||||
e := newEnvWith(t, Config{Shadow: true, ShadowPosition: "after"})
|
||||
e.srv.SetConntrack(&fakeConns{})
|
||||
link := t.TempDir() + "/nftables.service"
|
||||
os.Symlink("/usr/lib/systemd/system/nftables.service", link)
|
||||
e.srv.unitLinks = []string{link}
|
||||
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || mr.ConfirmToken != "" || mr.ConfirmBy != nil {
|
||||
t.Errorf("a change: %d %+v", code, mr)
|
||||
}
|
||||
var pr api.Probation
|
||||
e.call(e.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 0 || e.fw.boot == nil {
|
||||
t.Errorf("on probation in shadow mode: %+v (saved for the boot: %v)", pr, e.fw.boot != nil)
|
||||
}
|
||||
var er api.ErrorBody
|
||||
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &er); code != 409 || !strings.Contains(er.Error.Message, "shadow mode") || len(e.fw.deleted) != 0 {
|
||||
t.Errorf("takeover: %d %+v %v", code, er, e.fw.deleted)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &er); code != 409 {
|
||||
t.Errorf("closing connections: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: model.ChainInput, Raw: "meta mark set 1"}, &er); code != 201 {
|
||||
t.Fatalf("a raw rule: %d %+v", code, er)
|
||||
}
|
||||
var st api.Status
|
||||
e.call(e.token, "GET", "/status", nil, &st)
|
||||
var codes []string
|
||||
for _, p := range st.Problems {
|
||||
codes = append(codes, p.Code)
|
||||
}
|
||||
c := strings.Join(codes, " ")
|
||||
if st.Mode != api.ModeShadow || st.ShadowPosition != "after" {
|
||||
t.Errorf("status mode: %q %q", st.Mode, st.ShadowPosition)
|
||||
}
|
||||
// Expected in shadow mode: the other firewall, and its service.
|
||||
if strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") || !strings.Contains(c, "shadow_unprotected") || !strings.Contains(c, "shadow_raw_left_out") {
|
||||
t.Errorf("warnings: %s", c)
|
||||
}
|
||||
e.fw.ruleset = `{"nftables":[{"chain":{"family":"inet","table":"main","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}}]}`
|
||||
e.call(e.token, "GET", "/status", nil, &st)
|
||||
for _, p := range st.Problems {
|
||||
if p.Code == "shadow_unprotected" {
|
||||
t.Error("unprotected while inet main filters")
|
||||
}
|
||||
}
|
||||
var h health
|
||||
resp, err := http.Get(e.ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&h)
|
||||
resp.Body.Close()
|
||||
if h.Mode != api.ModeShadow || !h.OK {
|
||||
t.Errorf("healthz: %+v", h)
|
||||
}
|
||||
var who api.Whoami
|
||||
e.call(e.token, "GET", "/auth/whoami", nil, &who)
|
||||
if who.Mode != api.ModeShadow {
|
||||
t.Errorf("whoami: %+v", who)
|
||||
}
|
||||
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Prefix: "hc input policy drop: ", Verdict: "drop"})
|
||||
var logs []api.LogEntry
|
||||
e.call(e.token, "GET", "/log", nil, &logs)
|
||||
if len(logs) != 1 || !logs[0].Shadow {
|
||||
t.Errorf("log: %+v", logs)
|
||||
}
|
||||
shadow := e.text("/ruleset")
|
||||
enforcing := e.text("/ruleset?enforcing=1")
|
||||
if !strings.Contains(shadow, "Shadow mode") || strings.Contains(enforcing, "Shadow mode") {
|
||||
t.Errorf("rulesets:\n%s\n---\n%s", shadow, enforcing)
|
||||
}
|
||||
// Enforcing, the same change goes on probation.
|
||||
n := newEnv(t)
|
||||
n.call(n.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
n.call(n.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 1 {
|
||||
t.Errorf("not on probation enforcing: %+v", pr)
|
||||
}
|
||||
n.call(n.token, "GET", "/status", nil, &st)
|
||||
if st.Mode != api.ModeEnforcing {
|
||||
t.Errorf("enforcing status mode: %q", st.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// text GETs a plain-text answer.
|
||||
func (e *env) text(path string) string {
|
||||
e.t.Helper()
|
||||
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+e.token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package tablewatch tells when an nftables table is deleted: by
|
||||
// `nft flush ruleset` (which a ruleset file such as /etc/nftables.conf
|
||||
// usually starts with, and which nftables.service runs when it stops), or
|
||||
// by hand. It listens to nftables' netlink notifications, as `nft monitor`
|
||||
// does, and calls back once they have been quiet for a moment, since a
|
||||
// ruleset reload brings many. In shadow mode housecarld then puts its
|
||||
// table back (docs/shadow.md).
|
||||
package tablewatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Settle is how long the notifications must be quiet before the callback.
|
||||
const Settle = 2 * time.Second
|
||||
|
||||
// Watch calls deleted after a table was deleted, until ctx ends. It
|
||||
// returns an error only if it can't listen at all.
|
||||
func Watch(ctx context.Context, deleted func()) error {
|
||||
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, unix.NETLINK_NETFILTER)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening a netlink socket: %w", err)
|
||||
}
|
||||
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK, Groups: 1 << (unix.NFNLGRP_NFTABLES - 1)}); err != nil {
|
||||
unix.Close(fd)
|
||||
return fmt.Errorf("listening to nftables' changes: %w", err)
|
||||
}
|
||||
// A non-blocking descriptor goes through Go's poller, so closing the
|
||||
// file ends a Read in progress.
|
||||
f := os.NewFile(uintptr(fd), "nfnetlink")
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
f.Close()
|
||||
}()
|
||||
events := make(chan struct{}, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 256<<10)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
switch {
|
||||
case err != nil && ctx.Err() != nil:
|
||||
close(events)
|
||||
return
|
||||
case errors.Is(err, unix.ENOBUFS):
|
||||
// Notifications were lost: one may have been a deletion.
|
||||
case err != nil:
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
case !DeletesTable(buf[:n]):
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case events <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
var timer <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-events:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
timer = time.After(Settle)
|
||||
case <-timer:
|
||||
timer = nil
|
||||
deleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeletesTable reports whether a netlink datagram holds a table being
|
||||
// deleted.
|
||||
func DeletesTable(b []byte) bool {
|
||||
want := uint16(unix.NFNL_SUBSYS_NFTABLES<<8 | unix.NFT_MSG_DELTABLE)
|
||||
for len(b) >= unix.NLMSG_HDRLEN {
|
||||
size := int(binary.NativeEndian.Uint32(b[0:4]))
|
||||
typ := binary.NativeEndian.Uint16(b[4:6])
|
||||
if size < unix.NLMSG_HDRLEN || size > len(b) {
|
||||
return false
|
||||
}
|
||||
if typ == want {
|
||||
return true
|
||||
}
|
||||
size = (size + unix.NLMSG_ALIGNTO - 1) &^ (unix.NLMSG_ALIGNTO - 1)
|
||||
if size >= len(b) {
|
||||
return false
|
||||
}
|
||||
b = b[size:]
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tablewatch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// msg is a netlink message of a type, with a few bytes of payload.
|
||||
func msg(typ uint16) []byte {
|
||||
b := make([]byte, unix.NLMSG_HDRLEN+4)
|
||||
binary.NativeEndian.PutUint32(b[0:4], uint32(len(b)))
|
||||
binary.NativeEndian.PutUint16(b[4:6], typ)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDeletesTable(t *testing.T) {
|
||||
nft := func(m uint16) uint16 { return unix.NFNL_SUBSYS_NFTABLES<<8 | m }
|
||||
newRule, delTable, delChain := msg(nft(unix.NFT_MSG_NEWRULE)), msg(nft(unix.NFT_MSG_DELTABLE)), msg(nft(unix.NFT_MSG_DELCHAIN))
|
||||
cases := []struct {
|
||||
b []byte
|
||||
want bool
|
||||
}{
|
||||
{delTable, true},
|
||||
{append(append([]byte{}, newRule...), delTable...), true},
|
||||
{append(append([]byte{}, newRule...), delChain...), false},
|
||||
{newRule, false},
|
||||
{delTable[:10], false},
|
||||
{nil, false},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := DeletesTable(c.b); got != c.want {
|
||||
t.Errorf("%d: %v", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -194,8 +194,21 @@ func (t *Tracer) Line(line string) {
|
||||
}
|
||||
}
|
||||
|
||||
// decide notes a packet's fate: the first drop (or reject) ends it.
|
||||
// wouldRe finds shadow mode's marker in a rule's comment (render.Would):
|
||||
// "[would drop]", "[would reject]".
|
||||
var wouldRe = regexp.MustCompile(`"hc:\S+ \[would (drop|reject)\]|"\[would (drop|reject)\]`)
|
||||
|
||||
// decide notes a packet's fate: the first drop (or reject) ends it. In
|
||||
// shadow mode Housecarl's rules accept what they would drop: that is
|
||||
// noted apart, and decides nothing.
|
||||
func decide(p *api.TracePacket, s api.TraceStep) {
|
||||
if s.Kind == "rule" && wouldRe.MatchString(s.Rule) {
|
||||
if p.WouldDrop == nil && p.Verdict != "dropped" {
|
||||
step := s
|
||||
p.WouldDrop = &step
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.Verdict == "dropped" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,3 +87,34 @@ func TestScript(t *testing.T) {
|
||||
t.Error("ICMP with a port")
|
||||
}
|
||||
}
|
||||
|
||||
// In shadow mode Housecarl's rules accept what they would drop: the
|
||||
// packet's fate is the other table's, and the would-be drop is noted.
|
||||
func TestShadowTrace(t *testing.T) {
|
||||
var tr Tracer
|
||||
for _, l := range strings.Split(`trace id 9 ip main 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 9 ip main input rule tcp dport 9999 accept (verdict accept)
|
||||
trace id 9 inet housecarl input rule tcp dport 9999 counter packets 1 bytes 60 accept comment "hc:r12 [would drop] block 9999" (verdict accept)
|
||||
trace id 10 ip main input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4001 tcp dport 7000 tcp flags == syn
|
||||
trace id 10 ip main input rule tcp dport 7000 accept (verdict accept)
|
||||
trace id 10 inet housecarl input rule counter packets 1 bytes 60 comment "hc:p-input [would drop]" (verdict continue)
|
||||
trace id 10 inet housecarl input policy accept
|
||||
trace id 11 ip main input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4002 tcp dport 22 tcp flags == syn
|
||||
trace id 11 ip main input rule tcp dport 22 accept (verdict accept)
|
||||
trace id 11 inet housecarl input rule tcp dport 22 counter packets 1 bytes 60 accept comment "hc:r3 ssh" (verdict accept)`, "\n") {
|
||||
tr.Line(l)
|
||||
}
|
||||
ps := tr.Packets()
|
||||
if len(ps) != 3 {
|
||||
t.Fatalf("packets: %+v", ps)
|
||||
}
|
||||
if p := ps[0]; p.Verdict != "accepted" || p.WouldDrop == nil || p.WouldDrop.Mark != "r12" || p.DecidedBy.Table != "ip main" {
|
||||
t.Errorf("first: %+v", p)
|
||||
}
|
||||
if p := ps[1]; p.Verdict != "accepted" || p.WouldDrop == nil || p.WouldDrop.Mark != "p-input" {
|
||||
t.Errorf("second: %+v", p)
|
||||
}
|
||||
if p := ps[2]; p.WouldDrop != nil || p.DecidedBy.Mark != "r3" {
|
||||
t.Errorf("third: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,11 @@ function layout() {
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, barEl, bannerEl, mainEl, footer(me.build)));
|
||||
// Shadow mode (housecarl.yaml): say so on every page.
|
||||
const shadow = me.mode === "shadow" ? h("div", { class: "probation-banner shadow-banner", role: "status" },
|
||||
h("strong", null, "Shadow mode:"), " Housecarl's ruleset is loaded but blocks, rejects and translates nothing; the other firewall decides. ",
|
||||
"What its counters, log, tracer and statistics call drops are what Housecarl would drop. Changes aren't on probation.") : null;
|
||||
mount(app, h("div", { class: "shell" }, header, shadow, barEl, bannerEl, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
refreshProbation();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The log: packets rules (with logging on) and the policies logged,
|
||||
// refreshed every few seconds.
|
||||
import { h, mount, input, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { h, mount, input, debounce, badge } from "../ui.js";
|
||||
import { get, state } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
@@ -16,11 +16,14 @@ export async function render(el, params, query) {
|
||||
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, es.map((e) => h("tr", null,
|
||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix),
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
||||
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
||||
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
||||
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
|
||||
h("div", { class: "empty" }, "Nothing logged yet. Turn logging on for a rule (Edit → Log), or for what the policies drop (Settings)."));
|
||||
h("div", { class: "empty" }, state.me.mode === "shadow"
|
||||
? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log."
|
||||
: "Nothing logged yet. Turn logging on for a rule (Edit → Log), or for what the policies drop (Settings)."));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el,
|
||||
|
||||
@@ -56,7 +56,9 @@ export async function render(el, params, query) {
|
||||
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" : "" },
|
||||
p.would_drop ? h("div", { class: "small" }, badge("shadow mode", "warn"), " Housecarl would have dropped it: ",
|
||||
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
|
||||
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "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)),
|
||||
|
||||
@@ -373,6 +373,7 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||
/* The probation banner: changes roll back unless confirmed. */
|
||||
.probation-banner { background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--warn); padding: 10px 24px; font-size: 14px; }
|
||||
.probation-banner .banner-actions { margin-left: 12px; white-space: nowrap; }
|
||||
.shadow-banner { background: var(--panel); border-bottom-color: var(--border); }
|
||||
/* List editors (zones, addresses, services). */
|
||||
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||
.list-row { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
+65
-12
@@ -2,12 +2,12 @@
|
||||
# End-to-end test with real traffic: a Debian 13 router container (nftables
|
||||
# 1.1.3) between an "internet" network and a LAN, IPv4 and IPv6, runs
|
||||
# housecarld. The maintainer's own router ruleset (internal/importer/
|
||||
# testdata/router.nft, with lab addresses) is loaded as it is today, imported,
|
||||
# taken over, and then checked with connections: port forwards, hairpin NAT,
|
||||
# the IPv6 pinhole, masquerading, antispoofing, the drop log, a change on
|
||||
# probation that cuts the client off and rolls itself back, a restart and the
|
||||
# boot ruleset. Needs Docker; no root on the host. Run from the repository
|
||||
# root:
|
||||
# testdata/router.nft, with lab addresses) is loaded as it is today,
|
||||
# imported with housecarld in shadow mode beside it, taken over, and then
|
||||
# checked with connections: port forwards, hairpin NAT, the IPv6 pinhole,
|
||||
# masquerading, antispoofing, the drop log, a change on probation that cuts
|
||||
# the client off and rolls itself back, a restart and the boot ruleset.
|
||||
# Needs Docker; no root on the host. Run from the repository root:
|
||||
#
|
||||
# make e2e or scripts/e2e.sh
|
||||
#
|
||||
@@ -102,7 +102,10 @@ backup: {dir: /w/backups}
|
||||
log: {level: debug}
|
||||
stats: {database: /w/data/stats.db}
|
||||
EOF
|
||||
start() { R sh -c '(HOUSECARL_STATS_INTERVAL=3s /w/housecarld -c /w/housecarl.yaml serve >> /w/srv.log 2>&1 &); for i in $(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1'; }
|
||||
{ cat "$WORK/housecarl.yaml"; echo "shadow: {enabled: true}"; } > "$WORK/housecarl-shadow.yaml"
|
||||
CONF=housecarl.yaml
|
||||
start() { R sh -c "(HOUSECARL_STATS_INTERVAL=3s /w/housecarld -c /w/$CONF serve >> /w/srv.log 2>&1 &); for i in \$(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1"; }
|
||||
stop() { docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1; }
|
||||
|
||||
# ---- today's router: the hand-written ruleset
|
||||
|
||||
@@ -113,11 +116,13 @@ R nft add table ip other
|
||||
run "the port forward works under it" 0 connect net $WAN4 8096
|
||||
contains "jellyfin"
|
||||
|
||||
# ---- import and take over
|
||||
# ---- import in shadow mode: beside today's ruleset, changing nothing
|
||||
|
||||
run "init" 0 R sh -c 'echo secret-pass-123 | /w/housecarld -c /w/housecarl.yaml init --password-stdin'
|
||||
run "start housecarld" 0 start
|
||||
CONF=housecarl-shadow.yaml
|
||||
run "start housecarld in shadow mode" 0 start
|
||||
run "status" 0 H status
|
||||
contains "Mode:.*SHADOW"
|
||||
contains "Other tables:.*inet main"
|
||||
run "two tables: say which" 6 H import --defines /w/router.nft -n
|
||||
contains "several tables"
|
||||
@@ -130,8 +135,56 @@ contains "lan (enp2s0: as given)"
|
||||
contains "vpn (wg0: as given)"
|
||||
run "import" 0 H import --table "inet main" --defines /w/router.nft
|
||||
contains "applied and saved"
|
||||
run "the shadow ruleset is in force" 0 R nft list table inet housecarl
|
||||
contains "would dnat\] jellyfin"
|
||||
contains "priority filter + 10; policy accept;"
|
||||
not_contains "dnat ip to"
|
||||
not_contains "maxseg size set"
|
||||
not_contains "flowtable"
|
||||
run "nothing in it drops, rejects or translates" 1 R sh -c "nft list table inet housecarl | grep -v 'log prefix' | sed 's/comment.*//; s/ct status dnat//' | grep -wE 'drop|reject|dnat|snat|masquerade|redirect'"
|
||||
run "no warning about today's ruleset beside it" 0 H status
|
||||
not_contains "takeover_pending"
|
||||
not_contains "shadow_unprotected"
|
||||
run "what it loads once shadow mode is off" 0 H ruleset --enforcing
|
||||
contains "dnat ip to 10.88.10.57:443"
|
||||
run "the port forward works, by today's ruleset" 0 connect net $WAN4 8096
|
||||
contains "jellyfin"
|
||||
run "and Housecarl's would-be DNAT counted it" 0 R nft list chain inet housecarl dstnat
|
||||
contains 'packets [1-9][0-9]* bytes [0-9]* comment "hc:f[0-9]* \[would dnat\] jellyfin'
|
||||
# A drop Housecarl has and today's ruleset doesn't: logged, let through.
|
||||
SR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 -a drop --log --first --comment "e2e shadow")
|
||||
: > "$WORK/net.log"
|
||||
run "a shadow drop lets the connection through" 0 docker exec ${P}-client sh -c "echo hi | nc -N -w 3 $NET4 9000"
|
||||
sleep 1
|
||||
run "it reached the internet" 0 grep -q "$WAN4" "$WORK/net.log"
|
||||
run "the log says it would have been dropped" 0 H log
|
||||
contains "would drop, let through"
|
||||
run "the shadow rule goes" 0 H rule delete -y "$SR"
|
||||
run "no takeover in shadow mode" 5 H takeover "inet main" -y
|
||||
contains "shadow mode"
|
||||
run "healthz" 0 R curl -sf http://127.0.0.1:8070/healthz
|
||||
contains '"mode":"shadow"'
|
||||
run "the boot ruleset is the shadow one" 0 R grep -q "would dnat" /w/data/ruleset.nft
|
||||
run "the boot fallback filters nothing" 1 R grep -wE "drop|masquerade" /w/data/ruleset-fallback.nft
|
||||
# Today's ruleset starts with flush ruleset: reloading it deletes
|
||||
# Housecarl's table, which housecarld puts back.
|
||||
run "today's ruleset reloaded" 0 R nft -f /w/router.nft
|
||||
R nft add table ip other
|
||||
sleep 4
|
||||
run "Housecarl's table is back" 0 R nft list table inet housecarl
|
||||
contains "would dnat"
|
||||
run "and logged" 0 R grep -q "put it back" /w/srv.log
|
||||
|
||||
# ---- enforcing, and take over
|
||||
|
||||
stop
|
||||
CONF=housecarl.yaml
|
||||
run "start housecarld enforcing" 0 start
|
||||
run "status" 0 H status
|
||||
not_contains "SHADOW"
|
||||
run "the ruleset is in force" 0 R nft list table inet housecarl
|
||||
contains "dnat ip to 10.88.10.57:443"
|
||||
not_contains "would"
|
||||
run "zones" 0 H zone list
|
||||
contains "wan.*wan.*yes"
|
||||
contains "dhcp.*enp2s0"
|
||||
@@ -267,7 +320,7 @@ run "used up: the device is cut off" 1 send 1000
|
||||
run "the usage" 0 H quotas
|
||||
contains "e2e quota.*1.0 MB each.*by hand"
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1; start
|
||||
stop; start
|
||||
run "after a restart, still used up" 1 send 1000
|
||||
run "the usage after a restart" 0 H quotas
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
@@ -408,7 +461,7 @@ contains 'iifname "enp2s0" udp dport 67'
|
||||
|
||||
# ---- restart and boot
|
||||
|
||||
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
|
||||
stop
|
||||
run "the ruleset stays without housecarld" 0 connect net $WAN4 8096
|
||||
contains "jellyfin"
|
||||
run "restart housecarld" 0 start
|
||||
@@ -446,7 +499,7 @@ contains '"firewall":"fallback"'
|
||||
contains " 503"
|
||||
run "status says so" 0 H status
|
||||
contains "NOT in force: the boot fallback"
|
||||
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
|
||||
stop
|
||||
run "housecarld starts" 0 start
|
||||
run "and puts the real ruleset back" 0 connect net $WAN4 8096
|
||||
contains "jellyfin"
|
||||
|
||||
Reference in new issue
Block a user