Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d813ce13db | ||
|
|
de075edd5d |
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
|
on probation and rolled back unless confirmed (docs/design.md). An
|
||||||
empty configuration applies nothing. But never force a rule that keeps
|
empty configuration applies nothing. But never force a rule that keeps
|
||||||
SSH or the web UI open: the maintainer doesn't want one.
|
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
|
* **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
|
accepts things 1.1.3 refuses; the tests with `HOUSECARL_NFT` and the
|
||||||
e2e test use 1.1.3.
|
e2e test use 1.1.3.
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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;
|
- **Zone names at import:** `hc import --zone enp2s0=lan` (repeat;
|
||||||
interfaces given one name share a zone; Zone names on the Import
|
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.
|
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
|
ruleset into zones, sets, services, rules and port forwards, keeping the
|
||||||
rules' order, and keeps what it can't translate as raw rules
|
rules' order, and keeps what it can't translate as raw rules
|
||||||
([docs/import.md](docs/import.md)).
|
([docs/import.md](docs/import.md)).
|
||||||
|
* **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,
|
* **Easy to watch.** A zone matrix of what is allowed between zones,
|
||||||
statistics over time (traffic per zone and device, drops, the sources
|
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
|
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
|
* [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
|
* [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
|
* [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
|
* [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
|
* [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
|
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
|
||||||
@@ -118,13 +123,14 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Housecarl 0.1.1 is in testing (0.1.1-rc.1): zones, address sets and
|
Housecarl 0.1.1 is in testing (0.1.1-rc.2): zones, address sets and
|
||||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||||
network changes (confirmed by the client that made them), the ruleset
|
network changes (confirmed by the client that made them), the ruleset
|
||||||
at boot with a fail-closed fallback, hits and the drop log, nftables
|
at boot with a fail-closed fallback, hits and the drop log, nftables
|
||||||
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
||||||
bandwidth limits and quotas, a web UI, REST API and CLI.
|
bandwidth limits and quotas, shadow mode beside another firewall, a web
|
||||||
|
UI, REST API and CLI.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -30,6 +31,9 @@ func statusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
return output(st, func(w io.Writer) {
|
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"))
|
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 {
|
if st.Applied {
|
||||||
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
|
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
|
||||||
} else if st.Unconfigured {
|
} else if st.Unconfigured {
|
||||||
@@ -237,21 +241,28 @@ func onOff(name, v string) (bool, error) {
|
|||||||
// ---- the ruleset, counters, the log
|
// ---- the ruleset, counters, the log
|
||||||
|
|
||||||
func rulesetCmd() *cobra.Command {
|
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,
|
Use: "ruleset", Short: "Print the nftables ruleset Housecarl builds", Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var q url.Values
|
||||||
|
if enforcing {
|
||||||
|
q = url.Values{"enforcing": {"1"}}
|
||||||
|
}
|
||||||
var text string
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Print(text)
|
fmt.Print(text)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&enforcing, "enforcing", false, "in shadow mode: the ruleset loaded once it is off")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func countersCmd() *cobra.Command {
|
func countersCmd() *cobra.Command {
|
||||||
@@ -346,6 +357,9 @@ func logCmd() *cobra.Command {
|
|||||||
if e.TCPFlags != "" {
|
if e.TCPFlags != "" {
|
||||||
what += " " + 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)
|
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 {
|
} else {
|
||||||
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
|
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 {
|
func matrixCmd() *cobra.Command {
|
||||||
|
|||||||
+37
-5
@@ -38,6 +38,7 @@ import (
|
|||||||
"housecarl/internal/server"
|
"housecarl/internal/server"
|
||||||
"housecarl/internal/stats"
|
"housecarl/internal/stats"
|
||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
|
"housecarl/internal/tablewatch"
|
||||||
"housecarl/internal/trace"
|
"housecarl/internal/trace"
|
||||||
"housecarl/internal/validate"
|
"housecarl/internal/validate"
|
||||||
"housecarl/internal/version"
|
"housecarl/internal/version"
|
||||||
@@ -153,8 +154,12 @@ func serveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
host := &hostif.Host{}
|
host := &hostif.Host{}
|
||||||
eng.Env = hostEnv(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
|
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() {
|
for _, p := range eng.Problems() {
|
||||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
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(),
|
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),
|
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,
|
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())
|
}, eng, fw, host, a.log, web.FS())
|
||||||
// Put the ruleset in force: what was on probation when
|
// Put the ruleset in force: what was on probation when
|
||||||
// housecarld stopped is rolled back first. An empty
|
// 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.
|
// close the machine before any rule lets anyone in.
|
||||||
// What the quotas used before housecarld (or the machine)
|
// What the quotas used before housecarld (or the machine)
|
||||||
// stopped comes back with the next apply.
|
// stopped comes back with the next apply.
|
||||||
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
restoreQuotas := func() {
|
||||||
var u nft.QuotaUsage
|
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
||||||
if json.Unmarshal([]byte(v), &u) == nil {
|
var u nft.QuotaUsage
|
||||||
fw.RestoreQuotas(&u)
|
if json.Unmarshal([]byte(v), &u) == nil {
|
||||||
|
fw.RestoreQuotas(&u)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
restoreQuotas()
|
||||||
if !srv.ResumeProbation(ctx) {
|
if !srv.ResumeProbation(ctx) {
|
||||||
if eng.State().Empty() {
|
if eng.State().Empty() {
|
||||||
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
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)
|
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 nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||||
go srv.RunQuotas(ctx)
|
go srv.RunQuotas(ctx)
|
||||||
srv.SetConntrack(conntrack.Client{})
|
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` | |
|
| 401 | `unauthenticated`, `session_expired`, `login_failed` | |
|
||||||
| 403 | `forbidden`, `csrf` | |
|
| 403 | `forbidden`, `csrf` | |
|
||||||
| 404 | `not_found` | |
|
| 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 | `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 |
|
| 422 | `firewall` | nftables refused the ruleset (`nft -c`), or applying it failed; the message has nftables' words. Nothing was changed |
|
||||||
| 429 | `rate_limited` | |
|
| 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
|
* `confirm_by`: the change is on probation, and is rolled back at that
|
||||||
time unless `POST /confirm` comes first. Missing for changes made
|
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
|
* `confirm_token`: the client that made the change can confirm it by
|
||||||
itself with `POST /confirm {"token"}`, from the same address, as the
|
itself with `POST /confirm {"token"}`, from the same address, as the
|
||||||
same user and token, over a new connection (the answer closes this
|
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 /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`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
| `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 /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 /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 /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||||
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||||
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -211,7 +211,7 @@ history, but are audited.
|
|||||||
| `GET /history/{id}` | one, with each object before and after |
|
| `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 /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 /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
|
### Certificate
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -131,13 +131,13 @@ by name.
|
|||||||
|
|
||||||
| Command | |
|
| 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 |
|
| `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`) |
|
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
|
||||||
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
||||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
|
| `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)) |
|
| `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)) |
|
| `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)) |
|
| `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 show ID` | each object before and after |
|
||||||
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
|
| `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) |
|
| `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
|
### 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.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.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 |
|
| `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`.
|
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
|
The probation's length is a firewall setting, not here: `hc settings set
|
||||||
--probation 5m`. So is counting traffic per device for the statistics:
|
--probation 5m`. So is counting traffic per device for the statistics:
|
||||||
`hc settings set --stats-per-device on`.
|
`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
|
`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.
|
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
|
## Not locking yourself out
|
||||||
|
|
||||||
A change made over the network is applied **on probation** (a changeset
|
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
|
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
|
nft/ runs nft: check, apply (keeping dynamic set entries and counters), list, the boot file
|
||||||
nflog/ reads logged packets from netlink log group 4171
|
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)
|
importer/ nft -j list ruleset → State, with notes and raw rules (docs/import.md)
|
||||||
hostif/ the machine's interfaces
|
hostif/ the machine's interfaces
|
||||||
store/ SQLite (modernc.org/sqlite, no cgo): configuration, history, users, tokens, sessions, audit
|
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;
|
`wan`, `enp2s0` and `wg0` inside it, IPv4 and IPv6;
|
||||||
* "the internet" on the WAN side, a LAN client, and a "jellyfin" host.
|
* "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
|
It imports a copy of the maintainer's ruleset (lab addresses) with
|
||||||
over the old table, and checks with real traffic: port forwards 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
|
hairpin NAT and the IPv6 pinhole, masquerading, antispoofing, the drop
|
||||||
log, a lockout change that rolls back by itself, a restart, the boot
|
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
|
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
|
CPU was the limit, and otherwise as a cooler, quieter, more responsive
|
||||||
machine.
|
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 not to use it
|
||||||
|
|
||||||
**When the CPU keeps up anyway.** If the speed test already reaches your
|
**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
|
must be accepted by both. Nothing should change for your traffic. If
|
||||||
Housecarl already has a configuration, `--replace` replaces it.
|
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
|
Imported over the network (not through the local socket), the import is
|
||||||
on probation like any change: confirm it.
|
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
|
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).
|
**Or start from scratch**, through the local socket (no probation).
|
||||||
The first change puts the ruleset in force, and the input and forward
|
The first change puts the ruleset in force, and the input and forward
|
||||||
policies drop what no rule accepts, so let yourself in first:
|
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
|
One object does what takes three rules by hand (the DNAT, the forward
|
||||||
accept, and the hairpin masquerade).
|
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
|
```sh
|
||||||
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
|
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
|
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 on probation
|
||||||
|
|
||||||
Changes made over the network roll back unless confirmed
|
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
|
confirm their own changes once they reach Housecarl again over a new
|
||||||
connection, so normally nothing waits:
|
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
|
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
|
Dynamic sets start empty after a reboot. The saved ruleset has no
|
||||||
flowtable, since its devices may not exist yet: housecarld adds it when
|
flowtable, since its devices may not exist yet: housecarld adds it when
|
||||||
it starts, and whenever a flowtable device appears
|
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
|
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 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
|
the last 2000 are kept in memory. They aren't written to the kernel log
|
||||||
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
|
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
|
||||||
@@ -281,13 +289,14 @@ it is asked):
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ curl -s https://firewall.example.net:8070/healthz
|
$ 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 | |
|
| Field | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ok` | `true` with HTTP 200 when the firewall is as it should be; `false` with HTTP **503** otherwise |
|
| `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) |
|
| `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 |
|
| `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`) |
|
| `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
|
what it can't translate. It goes into the ruleset as written: add
|
||||||
`counter` to it to count its packets.
|
`counter` to it to count its packets.
|
||||||
|
|
||||||
|
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
|
## Built-ins
|
||||||
|
|
||||||
Housecarl adds the rules every firewall needs before yours. Each can be
|
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
|
policies' drops with `hc settings set --log-drops on`, and rules with
|
||||||
logging. The drop counts above see everything.
|
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)
|
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
|
counts again from zero. What happens while housecarld is stopped isn't
|
||||||
counted.
|
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`) |
|
| `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)) |
|
| `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 |
|
| `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.
|
||||||
+26
-5
@@ -40,8 +40,18 @@ type Whoami struct {
|
|||||||
TokenID int64 `json:"token_id,omitempty"`
|
TokenID int64 `json:"token_id,omitempty"`
|
||||||
CSRFToken string `json:"csrf_token,omitempty"`
|
CSRFToken string `json:"csrf_token,omitempty"`
|
||||||
Build version.Build `json:"build"`
|
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.
|
// Login is the login request.
|
||||||
type Login struct {
|
type Login struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -155,15 +165,23 @@ type LogEntry struct {
|
|||||||
TCPFlags string `json:"tcp_flags,omitempty"`
|
TCPFlags string `json:"tcp_flags,omitempty"`
|
||||||
RuleMark string `json:"mark,omitempty"`
|
RuleMark string `json:"mark,omitempty"`
|
||||||
Verdict string `json:"verdict,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.
|
// Status is the dashboard's overview.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Build version.Build `json:"build"`
|
Build version.Build `json:"build"`
|
||||||
Started time.Time `json:"started"`
|
Started time.Time `json:"started"`
|
||||||
Database string `json:"database,omitempty"`
|
Database string `json:"database,omitempty"`
|
||||||
Applied bool `json:"applied"`
|
// Mode is enforcing or shadow; ShadowPosition (shadow mode) after or
|
||||||
ApplyError string `json:"apply_error,omitempty"`
|
// 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
|
// Unconfigured: the configuration is empty, and no ruleset was
|
||||||
// applied yet.
|
// applied yet.
|
||||||
Unconfigured bool `json:"unconfigured,omitempty"`
|
Unconfigured bool `json:"unconfigured,omitempty"`
|
||||||
@@ -414,6 +432,9 @@ type TracePacket struct {
|
|||||||
// Verdict is accepted or dropped; DecidedBy the step that decided.
|
// Verdict is accepted or dropped; DecidedBy the step that decided.
|
||||||
Verdict string `json:"verdict"`
|
Verdict string `json:"verdict"`
|
||||||
DecidedBy TraceStep `json:"decided_by"`
|
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).
|
// TraceStatus is the trace running (or the last one).
|
||||||
|
|||||||
@@ -41,8 +41,27 @@ type Config struct {
|
|||||||
Ruleset string `yaml:"ruleset"`
|
Ruleset string `yaml:"ruleset"`
|
||||||
// Stats keeps traffic, hits and drops over time (docs/stats.md).
|
// Stats keeps traffic, hits and drops over time (docs/stats.md).
|
||||||
Stats Stats `yaml:"stats"`
|
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
|
// Stats configures statistics: a database of their own, aggregates
|
||||||
// only, pruned by age.
|
// only, pruned by age.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -204,7 +223,8 @@ func Default() *Config {
|
|||||||
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
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,
|
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)}},
|
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
|
||||||
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}},
|
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:
|
default:
|
||||||
return fmt.Errorf("log.level must be debug, info, warn or error")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,4 +450,16 @@ stats:
|
|||||||
hour: 90d # hourly figures and top lists
|
hour: 90d # hourly figures and top lists
|
||||||
day: 730d # daily figures and top lists
|
day: 730d # daily figures and top lists
|
||||||
top_n: 100 # entries kept per top list, per hour and day
|
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: '*.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://",
|
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
|
||||||
"ruleset: relative.nft": "ruleset",
|
"ruleset: relative.nft": "ruleset",
|
||||||
|
"shadow: {position: x}": "shadow.position",
|
||||||
}
|
}
|
||||||
for yml, want := range cases {
|
for yml, want := range cases {
|
||||||
path := filepath.Join(t.TempDir(), "s.yaml")
|
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
|
// BootFile is where the confirmed ruleset is saved for the boot unit
|
||||||
// (housecarl-ruleset.service; "" for none).
|
// (housecarl-ruleset.service; "" for none).
|
||||||
BootFile string
|
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
|
// Replacing the table resets its counters: base keeps the totals of
|
||||||
// the tables replaced since housecarld started.
|
// 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 {
|
func (f *Firewall) options() render.Options {
|
||||||
var o render.Options
|
o := render.Options{Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}
|
||||||
if f.Interfaces != nil {
|
if f.Interfaces != nil {
|
||||||
o.Interfaces = f.Interfaces()
|
o.Interfaces = f.Interfaces()
|
||||||
}
|
}
|
||||||
@@ -488,9 +492,26 @@ func (f *Firewall) options() render.Options {
|
|||||||
// Render builds the ruleset for st as Apply would.
|
// Render builds the ruleset for st as Apply would.
|
||||||
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) }
|
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 {
|
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.
|
// 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
|
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
|
||||||
// no flowtable: it loads before the network comes up, when its devices
|
// 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;
|
// (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 {
|
func (f *Firewall) SaveBoot(st *model.State) error {
|
||||||
if f.BootFile == "" {
|
if f.BootFile == "" {
|
||||||
return nil
|
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 {
|
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||||
return err
|
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
|
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)
|
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)
|
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
|
// 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'
|
// back, and Ruleset.Lines maps each line to its object, for nftables'
|
||||||
// error messages.
|
// 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
|
package render
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -42,8 +50,18 @@ type Options struct {
|
|||||||
// DeviceSets are the per-device traffic counters' entries
|
// DeviceSets are the per-device traffic counters' entries
|
||||||
// (stats_up_v4 …), so a reload keeps them.
|
// (stats_up_v4 …), so a reload keeps them.
|
||||||
DeviceSets map[string][]Element
|
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
|
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||||
// per-address quota's entry with what it used.
|
// per-address quota's entry with what it used.
|
||||||
type Element struct {
|
type Element struct {
|
||||||
@@ -112,6 +130,9 @@ type renderer struct {
|
|||||||
func (r *renderer) render() {
|
func (r *renderer) render() {
|
||||||
w := r.w
|
w := r.w
|
||||||
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
|
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("", "table inet %s {}", Table)
|
||||||
w.emit("", "delete table inet %s", Table)
|
w.emit("", "delete table inet %s", Table)
|
||||||
w.emit("", "table inet %s {", Table)
|
w.emit("", "table inet %s {", Table)
|
||||||
@@ -265,6 +286,10 @@ func addrType(v6 bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *renderer) flowtableDevices() []string {
|
func (r *renderer) flowtableDevices() []string {
|
||||||
|
if r.opt.Shadow {
|
||||||
|
// Offloaded connections skip every table's forward chain.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for _, d := range r.st.Settings.FlowtableDevices {
|
for _, d := range r.st.Settings.FlowtableDevices {
|
||||||
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
||||||
@@ -277,8 +302,19 @@ func (r *renderer) flowtableDevices() []string {
|
|||||||
// ---- chains
|
// ---- chains
|
||||||
|
|
||||||
func (r *renderer) chain(name, hook, prio, policy string) {
|
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("", "\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}") }
|
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)...)
|
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() {
|
func (r *renderer) early() {
|
||||||
st := r.st
|
st := r.st
|
||||||
r.chain("early", "prerouting", "raw", "accept")
|
r.chain("early", "prerouting", "raw", "accept")
|
||||||
@@ -302,10 +348,10 @@ func (r *renderer) early() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(v4) > 0 {
|
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 {
|
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)
|
r.rules(model.ChainEarly, true)
|
||||||
@@ -325,7 +371,7 @@ func (r *renderer) connBuiltins() {
|
|||||||
r.builtin("established", "ct state established,related counter accept")
|
r.builtin("established", "ct state established,related counter accept")
|
||||||
}
|
}
|
||||||
if b.DropInvalid {
|
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
|
b := st.Settings.Builtins
|
||||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||||
ext := st.ExternalInterfaces()
|
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.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||||
}
|
}
|
||||||
r.rules(model.ChainForward, true)
|
r.rules(model.ChainForward, true)
|
||||||
@@ -404,15 +451,21 @@ func (r *renderer) output() {
|
|||||||
r.end()
|
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) {
|
func (r *renderer) policyTail(chain, policy string) {
|
||||||
if policy != model.PolicyDrop {
|
if policy != model.PolicyDrop {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
obj := "policy: " + chain
|
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)
|
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)
|
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()
|
obj := rule.Label()
|
||||||
mark := fmt.Sprintf("r%d", rule.ID)
|
mark := fmt.Sprintf("r%d", rule.ID)
|
||||||
if rule.Raw != "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
var tail []string
|
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))
|
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
|
||||||
}
|
}
|
||||||
switch rule.Action {
|
text := rule.Comment
|
||||||
case model.ActionAccept, model.ActionDrop:
|
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)
|
v = append(v, rule.Action)
|
||||||
case model.ActionReject:
|
case rule.Action == model.ActionReject:
|
||||||
if rule.RejectWith == "tcp-reset" {
|
if rule.RejectWith == "tcp-reset" {
|
||||||
v = append(v, "reject with tcp reset")
|
v = append(v, "reject with tcp reset")
|
||||||
} else {
|
} else {
|
||||||
v = append(v, "reject")
|
v = append(v, "reject")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v = append(v, "comment "+comment(mark, rule.Comment))
|
v = append(v, "comment "+comment(mark, text))
|
||||||
return strings.Join(v, " ")
|
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()
|
ext := st.ExternalInterfaces()
|
||||||
@@ -811,7 +877,7 @@ func (r *renderer) dstnat() {
|
|||||||
for _, wh := range where {
|
for _, wh := range where {
|
||||||
for _, sa := range srcAlts {
|
for _, sa := range srcAlts {
|
||||||
for _, p := range f.Protocols {
|
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 _, sa := range srcAlts {
|
||||||
for _, p := range f.Protocols {
|
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
|
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()
|
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
|
// ---- helpers
|
||||||
|
|
||||||
// hours writes a time window. nftables can't take a range past midnight,
|
// hours writes a time window. nftables can't take a range past midnight,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package render
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -254,15 +255,25 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
|||||||
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
|
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
|
||||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
|
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
|
||||||
q, qs := quotaOptions(st)
|
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,
|
// Enforcing, and in shadow mode after and before other tables.
|
||||||
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}}}})
|
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
|
||||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||||
c.Stdin = strings.NewReader(rs.Text)
|
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}}},
|
||||||
if out, err := c.CombinedOutput(); err != nil {
|
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
|
||||||
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
|
||||||
for i, l := range strings.Split(rs.Text, "\n") {
|
}
|
||||||
t.Logf("%3d %s", i+1, l)
|
}
|
||||||
}
|
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(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(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 {
|
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 {
|
if se != nil {
|
||||||
out.CSRFToken = se.CSRFToken
|
out.CSRFToken = se.CSRFToken
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,6 +505,10 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rs := s.fw.Render(st)
|
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.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Write([]byte(rs.Text))
|
w.Write([]byte(rs.Text))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type health struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
|
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: changes waiting to be confirmed, rolled back otherwise.
|
||||||
Probation int `json:"probation"`
|
Probation int `json:"probation"`
|
||||||
// Warnings are the codes of the machine's problems (nftables_service).
|
// 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 {
|
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())
|
t, err := s.fw.Table(r.Context())
|
||||||
switch {
|
switch {
|
||||||
case err != nil && s.eng.State().Empty():
|
case err != nil && s.eng.State().Empty():
|
||||||
@@ -63,7 +65,12 @@ func (s *Server) checkHealth(r *http.Request) health {
|
|||||||
}
|
}
|
||||||
pr := s.probationStatus()
|
pr := s.probationStatus()
|
||||||
out.Probation = len(pr.Pending) + pr.Takeovers
|
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)
|
out.Warnings = append(out.Warnings, p.Code)
|
||||||
}
|
}
|
||||||
return out
|
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)
|
s.afterChange(r.Context(), p, er)
|
||||||
if er.Applied {
|
if er.Applied {
|
||||||
// Until it is taken over, the old table still applies too: the
|
// 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)
|
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
|
||||||
}
|
}
|
||||||
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
||||||
@@ -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 {
|
if err := notStaged(r, "a takeover"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.refuseInShadow("delete other tables (the firewall in force)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var in api.TakeoverInput
|
var in api.TakeoverInput
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
}
|
}
|
||||||
st := s.eng.State()
|
st := s.eng.State()
|
||||||
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
|
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 {
|
if out.Problems == nil {
|
||||||
out.Problems = []validate.Problem{}
|
out.Problems = []validate.Problem{}
|
||||||
}
|
}
|
||||||
@@ -46,10 +49,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
} else {
|
} else {
|
||||||
out.Applied = true
|
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.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()...)
|
out.Problems = append(out.Problems, s.statsProblems()...)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
@@ -57,6 +61,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
|
|
||||||
const importedTableKey = "imported_table"
|
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
|
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
|
||||||
// enables it for sysinit.target).
|
// enables it for sysinit.target).
|
||||||
var nftablesUnitLinks = []string{
|
var nftablesUnitLinks = []string{
|
||||||
@@ -66,8 +84,15 @@ var nftablesUnitLinks = []string{
|
|||||||
|
|
||||||
// hostProblems are warnings about the machine rather than the
|
// hostProblems are warnings about the machine rather than the
|
||||||
// configuration: another ruleset that still loads at boot, or the table
|
// configuration: another ruleset that still loads at boot, or the table
|
||||||
// an import came from, still in force beside Housecarl's.
|
// an import came from, still in force beside Housecarl's. ruleset is the
|
||||||
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
|
// 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
|
var out []validate.Problem
|
||||||
for _, link := range s.unitLinks {
|
for _, link := range s.unitLinks {
|
||||||
if _, err := os.Lstat(link); err == nil {
|
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
|
// LogPacket records a packet the firewall logged (internal/nflog calls
|
||||||
// it).
|
// it).
|
||||||
func (s *Server) LogPacket(e api.LogEntry) {
|
func (s *Server) LogPacket(e api.LogEntry) {
|
||||||
|
e.Shadow = s.cfg.Shadow
|
||||||
s.logs.Add(e)
|
s.logs.Add(e)
|
||||||
if s.stats != nil {
|
if s.stats != nil {
|
||||||
s.stats.Packet(e)
|
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 {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.refuseInShadow("close connections"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
ct, err := s.conntrack()
|
ct, err := s.conntrack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -227,6 +230,12 @@ func (s *Server) traceStatus() api.TraceStatus {
|
|||||||
p.Steps[j].Label = markLabel(st, p.Steps[j])
|
p.Steps[j].Label = markLabel(st, p.Steps[j])
|
||||||
}
|
}
|
||||||
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
|||||||
pr := &s.probation
|
pr := &s.probation
|
||||||
pr.mu.Lock()
|
pr.mu.Lock()
|
||||||
defer pr.mu.Unlock()
|
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()
|
s.saveBoot()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ import (
|
|||||||
|
|
||||||
// otherTables lists the tables in an `nft -j list ruleset` other than
|
// otherTables lists the tables in an `nft -j list ruleset` other than
|
||||||
// Housecarl's ("inet main").
|
// Housecarl's ("inet main").
|
||||||
|
// 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 {
|
func otherTables(data []byte) []string {
|
||||||
var doc struct {
|
var doc struct {
|
||||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
"housecarl/internal/auth"
|
"housecarl/internal/auth"
|
||||||
"housecarl/internal/certs"
|
"housecarl/internal/certs"
|
||||||
"housecarl/internal/engine"
|
"housecarl/internal/engine"
|
||||||
@@ -45,6 +46,11 @@ type Config struct {
|
|||||||
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
|
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
|
||||||
// when off).
|
// when off).
|
||||||
ACME *certs.Manager
|
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
|
// Interfaces lists the host's interfaces with their addresses, and
|
||||||
@@ -58,6 +64,8 @@ type Interfaces interface {
|
|||||||
// engine (internal/nft.Firewall).
|
// engine (internal/nft.Firewall).
|
||||||
type Firewall interface {
|
type Firewall interface {
|
||||||
Render(st *model.State) *render.Ruleset
|
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)
|
Table(ctx context.Context) (*nft.Table, error)
|
||||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||||
DeleteElement(ctx context.Context, set, addr string) 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
|
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.
|
// Handler returns the HTTP handler with all middleware.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type fakeFW struct {
|
|||||||
traced string
|
traced string
|
||||||
traceLines []string
|
traceLines []string
|
||||||
fallback bool // the boot fallback is in force
|
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 }
|
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()
|
defer f.mu.Unlock()
|
||||||
return f.applied[len(f.applied)-1]
|
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) {
|
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -86,6 +93,9 @@ func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
|
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
|
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
|
||||||
}
|
}
|
||||||
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
||||||
@@ -142,6 +152,11 @@ type env struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newEnv(t *testing.T) *env {
|
func newEnv(t *testing.T) *env {
|
||||||
|
t.Helper()
|
||||||
|
return newEnvWith(t, Config{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvWith(t *testing.T, cfg Config) *env {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
st, err := store.Open(":memory:")
|
st, err := store.Open(":memory:")
|
||||||
@@ -159,10 +174,10 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fw := &fakeFW{elements: map[string][]render.Element{}}
|
fw := &fakeFW{elements: map[string][]render.Element{}, shadow: cfg.Shadow}
|
||||||
eng.Firewall = fw
|
eng.Firewall = fw
|
||||||
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
|
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())
|
ts := httptest.NewServer(srv.Handler())
|
||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
|
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) {
|
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" {
|
if p.Verdict == "dropped" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,3 +87,34 @@ func TestScript(t *testing.T) {
|
|||||||
t.Error("ICMP with a port")
|
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,
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
], { 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();
|
renderNav();
|
||||||
refreshProbation();
|
refreshProbation();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The log: packets rules (with logging on) and the policies logged,
|
// The log: packets rules (with logging on) and the policies logged,
|
||||||
// refreshed every few seconds.
|
// refreshed every few seconds.
|
||||||
import { h, mount, input, debounce } from "../ui.js";
|
import { h, mount, input, debounce, badge } from "../ui.js";
|
||||||
import { get } from "../api.js";
|
import { get, state } from "../api.js";
|
||||||
import { onLeave } from "../app.js";
|
import { onLeave } from "../app.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
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("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("tbody", null, es.map((e) => h("tr", null,
|
||||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
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" }, 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", { 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("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));
|
search.addEventListener("input", debounce(draw, 300));
|
||||||
mount(el,
|
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" }, head, p.in ? " · in " + p.in : "", p.out ? " · out " + p.out : ""),
|
||||||
h("span", { class: "muted small trace-time" }, new Date(p.time).toLocaleTimeString())),
|
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,
|
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", { 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)
|
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)),
|
: 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. */
|
/* 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 { 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; }
|
.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 editors (zones, addresses, services). */
|
||||||
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||||
.list-row { display: flex; gap: 6px; align-items: center; }
|
.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
|
# 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
|
# 1.1.3) between an "internet" network and a LAN, IPv4 and IPv6, runs
|
||||||
# housecarld. The maintainer's own router ruleset (internal/importer/
|
# housecarld. The maintainer's own router ruleset (internal/importer/
|
||||||
# testdata/router.nft, with lab addresses) is loaded as it is today, imported,
|
# testdata/router.nft, with lab addresses) is loaded as it is today,
|
||||||
# taken over, and then checked with connections: port forwards, hairpin NAT,
|
# imported with housecarld in shadow mode beside it, taken over, and then
|
||||||
# the IPv6 pinhole, masquerading, antispoofing, the drop log, a change on
|
# checked with connections: port forwards, hairpin NAT, the IPv6 pinhole,
|
||||||
# probation that cuts the client off and rolls itself back, a restart and the
|
# masquerading, antispoofing, the drop log, a change on probation that cuts
|
||||||
# boot ruleset. Needs Docker; no root on the host. Run from the repository
|
# the client off and rolls itself back, a restart and the boot ruleset.
|
||||||
# root:
|
# Needs Docker; no root on the host. Run from the repository root:
|
||||||
#
|
#
|
||||||
# make e2e or scripts/e2e.sh
|
# make e2e or scripts/e2e.sh
|
||||||
#
|
#
|
||||||
@@ -102,7 +102,10 @@ backup: {dir: /w/backups}
|
|||||||
log: {level: debug}
|
log: {level: debug}
|
||||||
stats: {database: /w/data/stats.db}
|
stats: {database: /w/data/stats.db}
|
||||||
EOF
|
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
|
# ---- 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
|
run "the port forward works under it" 0 connect net $WAN4 8096
|
||||||
contains "jellyfin"
|
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 "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
|
run "status" 0 H status
|
||||||
|
contains "Mode:.*SHADOW"
|
||||||
contains "Other tables:.*inet main"
|
contains "Other tables:.*inet main"
|
||||||
run "two tables: say which" 6 H import --defines /w/router.nft -n
|
run "two tables: say which" 6 H import --defines /w/router.nft -n
|
||||||
contains "several tables"
|
contains "several tables"
|
||||||
@@ -130,8 +135,56 @@ contains "lan (enp2s0: as given)"
|
|||||||
contains "vpn (wg0: as given)"
|
contains "vpn (wg0: as given)"
|
||||||
run "import" 0 H import --table "inet main" --defines /w/router.nft
|
run "import" 0 H import --table "inet main" --defines /w/router.nft
|
||||||
contains "applied and saved"
|
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
|
run "the ruleset is in force" 0 R nft list table inet housecarl
|
||||||
contains "dnat ip to 10.88.10.57:443"
|
contains "dnat ip to 10.88.10.57:443"
|
||||||
|
not_contains "would"
|
||||||
run "zones" 0 H zone list
|
run "zones" 0 H zone list
|
||||||
contains "wan.*wan.*yes"
|
contains "wan.*wan.*yes"
|
||||||
contains "dhcp.*enp2s0"
|
contains "dhcp.*enp2s0"
|
||||||
@@ -267,7 +320,7 @@ run "used up: the device is cut off" 1 send 1000
|
|||||||
run "the usage" 0 H quotas
|
run "the usage" 0 H quotas
|
||||||
contains "e2e quota.*1.0 MB each.*by hand"
|
contains "e2e quota.*1.0 MB each.*by hand"
|
||||||
contains "$CLIENT4 *1\.[0-9] MB"
|
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 "after a restart, still used up" 1 send 1000
|
||||||
run "the usage after a restart" 0 H quotas
|
run "the usage after a restart" 0 H quotas
|
||||||
contains "$CLIENT4 *1\.[0-9] MB"
|
contains "$CLIENT4 *1\.[0-9] MB"
|
||||||
@@ -408,7 +461,7 @@ contains 'iifname "enp2s0" udp dport 67'
|
|||||||
|
|
||||||
# ---- restart and boot
|
# ---- 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
|
run "the ruleset stays without housecarld" 0 connect net $WAN4 8096
|
||||||
contains "jellyfin"
|
contains "jellyfin"
|
||||||
run "restart housecarld" 0 start
|
run "restart housecarld" 0 start
|
||||||
@@ -446,7 +499,7 @@ contains '"firewall":"fallback"'
|
|||||||
contains " 503"
|
contains " 503"
|
||||||
run "status says so" 0 H status
|
run "status says so" 0 H status
|
||||||
contains "NOT in force: the boot fallback"
|
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 "housecarld starts" 0 start
|
||||||
run "and puts the real ruleset back" 0 connect net $WAN4 8096
|
run "and puts the real ruleset back" 0 connect net $WAN4 8096
|
||||||
contains "jellyfin"
|
contains "jellyfin"
|
||||||
|
|||||||
Reference in new issue
Block a user