Compare commits
4
Commits
v0.1.0
...
v0.1.1-rc.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d813ce13db | ||
|
|
de075edd5d | ||
|
|
1e6c085ef9 | ||
|
|
47bae07d42 |
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.
|
||||||
|
|||||||
@@ -6,6 +6,68 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
|
||||||
|
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
||||||
|
rejects and translates nothing. Drops and rejects are counted and accept
|
||||||
|
instead (an accept only ends Housecarl's chain), policies accept and
|
||||||
|
always log what they would drop, NAT only counts, MSS clamping and the
|
||||||
|
flowtable are left out, and raw rules that do more than accept, drop or
|
||||||
|
reject are left out with a warning (`shadow_raw_left_out`). Comments
|
||||||
|
mark what would happen (`[would drop]`). `shadow.position`: Housecarl's
|
||||||
|
filter chains run after the other firewall's (`filter + 10`, the
|
||||||
|
default: what it would drop is what switching would break) or before
|
||||||
|
(`- 10`); its NAT chains always first. Every change is checked by
|
||||||
|
nftables in both forms, so leaving shadow mode can't fail. No
|
||||||
|
probation; takeovers and closing connections are refused (409). The
|
||||||
|
boot ruleset is the shadow one, and its fallback filters nothing. When
|
||||||
|
a reload (`flush ruleset`) deletes Housecarl's table, housecarld puts it
|
||||||
|
back (it listens to nftables' netlink notifications). `mode` in
|
||||||
|
`/status`, `/healthz` and `/auth/whoami`; a banner in the web UI;
|
||||||
|
would-be drops marked in the log (`shadow`) and the tracer
|
||||||
|
(`would_drop`); `hc ruleset --enforcing` (`GET /ruleset?enforcing=1`)
|
||||||
|
shows the ruleset for after. `shadow_unprotected` warns when no other
|
||||||
|
table filters the input hook; `nftables_service` and `takeover_pending`
|
||||||
|
aren't raised in shadow mode. The e2e test imports in shadow mode
|
||||||
|
before taking over.
|
||||||
|
- **Zone names at import:** `hc import --zone enp2s0=lan` (repeat;
|
||||||
|
interfaces given one name share a zone; Zone names on the Import
|
||||||
|
page), and a note listing each zone and where its name came from.
|
||||||
|
Defines holding a VLAN sub-interface (`enp2s0.20`) name their zone,
|
||||||
|
and `_ifname`, `_device` and `_nic` are taken off define names too
|
||||||
|
(docs/import.md, "How things are named").
|
||||||
|
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
||||||
|
several ports becomes one port forward per port.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Import: two base chains on one hook had their rules imported twice,
|
||||||
|
and the second chain's leading built-ins were moved above the first
|
||||||
|
chain's rules; they now follow each other by priority (read in a
|
||||||
|
fixed order), with the stricter policy, and a note.
|
||||||
|
- Import: a rule inside a jumped-to chain that stays raw kept only its
|
||||||
|
own text and so applied to everything; the jump's condition now goes
|
||||||
|
in front. A jump and a rule matching the same field are combined only
|
||||||
|
when one's addresses lie within the other's; else the rule stays raw.
|
||||||
|
A jump's ports, ICMP types, connection status and hop limit were
|
||||||
|
dropped; a limit or log on the jump is no longer silently lost.
|
||||||
|
- Import: a second match on the same field (`ip saddr A ip saddr != B`)
|
||||||
|
replaced the first; `!=` on the hop limit and TCP flags was read as
|
||||||
|
`==`. Such rules now stay raw, as written.
|
||||||
|
- Import: a forward-chain accept to a forwarded host and port without
|
||||||
|
`ct status dnat` was folded into the port forward, which only lets
|
||||||
|
forwarded connections through; it stays a rule now. An IPv6 accept
|
||||||
|
limited to an interface no longer becomes a pinhole open to all.
|
||||||
|
- Import: raw rules naming a set of the old table made nftables refuse
|
||||||
|
the ruleset; address sets are now renamed `NAME_v4`/`NAME_v6`, other
|
||||||
|
sets written out with their elements, and what can't be is left out
|
||||||
|
with a note. Rules naming a set that isn't imported stay raw instead
|
||||||
|
of failing validation.
|
||||||
|
- Import: a DNAT to an IPv6 address, or with matches a port forward
|
||||||
|
can't hold (an interface excluded, a limit, a log), is left out with a
|
||||||
|
note instead of being imported without them.
|
||||||
|
|
||||||
## [0.1.0] - 2026-10-19
|
## [0.1.0] - 2026-10-19
|
||||||
|
|
||||||
The first version of Housecarl, a firewall manager for nftables, built
|
The first version of Housecarl, a firewall manager for nftables, built
|
||||||
|
|||||||
@@ -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.0 is released: 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
|
||||||
|
|
||||||
|
|||||||
+29
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +448,7 @@ func rollbackCmd() *cobra.Command {
|
|||||||
func importCmd() *cobra.Command {
|
func importCmd() *cobra.Command {
|
||||||
var replace bool
|
var replace bool
|
||||||
var table, defines, textFile string
|
var table, defines, textFile string
|
||||||
|
var zones []string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "import [FILE|-]",
|
Use: "import [FILE|-]",
|
||||||
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
|
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
|
||||||
@@ -445,7 +460,9 @@ forwards and NAT. The input is nft's JSON; on the firewall:
|
|||||||
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
|
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
|
||||||
itself (root on the firewall); from elsewhere, give the JSON as FILE and
|
itself (root on the firewall); from elsewhere, give the JSON as FILE and
|
||||||
the text with --text. --defines reads the ruleset file's define lines, to
|
the text with --text. --defines reads the ruleset file's define lines, to
|
||||||
name the sets as you did (admins, lan_net, …). -n shows the result without
|
name the sets as you did (admins, lan_net, …), and the zones; --zone
|
||||||
|
enp2s0=lan names one yourself (repeat; interfaces given one name share a
|
||||||
|
zone). -n shows the result without
|
||||||
saving; what can't be expressed becomes raw rules, listed with notes.
|
saving; what can't be expressed becomes raw rules, listed with notes.
|
||||||
Once it looks right, import without -n, check it with 'hc ruleset', and
|
Once it looks right, import without -n, check it with 'hc ruleset', and
|
||||||
'hc takeover inet main' deletes the old table.`,
|
'hc takeover inet main' deletes the old table.`,
|
||||||
@@ -473,7 +490,14 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace}
|
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace, Zones: map[string]string{}}
|
||||||
|
for _, z := range zones {
|
||||||
|
iface, name, ok := strings.Cut(z, "=")
|
||||||
|
if !ok || iface == "" || name == "" {
|
||||||
|
return fmt.Errorf("--zone %q: write it as INTERFACE=NAME (enp2s0=lan)", z)
|
||||||
|
}
|
||||||
|
in.Zones[iface] = name
|
||||||
|
}
|
||||||
if defines != "" {
|
if defines != "" {
|
||||||
b, err := os.ReadFile(defines)
|
b, err := os.ReadFile(defines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -508,6 +532,7 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
|
|||||||
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
|
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
|
||||||
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
|
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
|
||||||
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
|
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
|
||||||
|
cmd.Flags().StringArrayVar(&zones, "zone", nil, "name an interface's zone: INTERFACE=NAME (repeat)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+33
-1
@@ -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.
|
||||||
|
restoreQuotas := func() {
|
||||||
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
||||||
var u nft.QuotaUsage
|
var u nft.QuotaUsage
|
||||||
if json.Unmarshal([]byte(v), &u) == nil {
|
if json.Unmarshal([]byte(v), &u) == nil {
|
||||||
fw.RestoreQuotas(&u)
|
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{})
|
||||||
|
|||||||
+10
-10
@@ -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
|
||||||
@@ -210,8 +210,8 @@ history, but are audited.
|
|||||||
| `GET /history[?limit=&before=]` | changes, newest first |
|
| `GET /history[?limit=&before=]` | changes, newest first |
|
||||||
| `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", "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
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -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)) |
|
||||||
@@ -219,8 +219,8 @@ See [nat.md](nat.md).
|
|||||||
| `history list [--limit N]` | changes, newest first |
|
| `history list [--limit N]` | changes, newest first |
|
||||||
| `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] [--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
|
||||||
|
|||||||
+81
-15
@@ -18,11 +18,9 @@ shows what it would make, without saving anything: zones, sets,
|
|||||||
services, rules, port forwards and NAT, then **notes** on every guess it
|
services, rules, port forwards and NAT, then **notes** on every guess it
|
||||||
made, and the rules it **kept raw**.
|
made, and the rules it **kept raw**.
|
||||||
|
|
||||||
`--defines` reads your ruleset file's `define` lines, so sets get your
|
`--defines` reads your ruleset file's `define` lines, so sets and zones
|
||||||
names (`define admins = { ... }` becomes the set `admins`).
|
get your names ([How things are named](#how-things-are-named)).
|
||||||
Without it, sets are named after what they hold. Interfaces named by a
|
`--zone enp2s0=lan` names a zone yourself.
|
||||||
define name their zone too (`define wan_interface = "ppp0"` makes the
|
|
||||||
zone `wan`); otherwise a zone is named after its interface.
|
|
||||||
|
|
||||||
From another machine, give the JSON as a file, and the text with
|
From another machine, give the JSON as a file, and the text with
|
||||||
`--text`:
|
`--text`:
|
||||||
@@ -37,6 +35,46 @@ With several tables, `--table "inet main"` picks one; by default every
|
|||||||
table but Housecarl's is read. The web UI has the same under Server →
|
table but Housecarl's is read. The web UI has the same under Server →
|
||||||
Import a ruleset.
|
Import a ruleset.
|
||||||
|
|
||||||
|
## How things are named
|
||||||
|
|
||||||
|
**Zones** are named in this order:
|
||||||
|
|
||||||
|
1. **As you say**: `--zone enp2s0=lan` (repeat it; Zone names on the
|
||||||
|
web UI's Import page). Interfaces given the same name share one zone
|
||||||
|
(`--zone enp2s0=lan --zone enp3s0=lan`).
|
||||||
|
2. **After a define holding the interface**, with a trailing `_int`,
|
||||||
|
`_if`, `_iface`, `_ifname`, `_interface`, `_dev`, `_device` or `_nic`
|
||||||
|
taken off: `define wan_int = "ppp0"` makes the zone `wan`, `define
|
||||||
|
iot_if = enp2s0.20` (a VLAN) the zone `iot`.
|
||||||
|
3. **After the interface itself**: `enp2s0`.
|
||||||
|
|
||||||
|
If the name is taken, the zone is called `INTERFACE_zone`. A define
|
||||||
|
names what it was *for* in your old ruleset, which isn't always what the
|
||||||
|
network is (`define dhcp_int = enp2s0` makes `dhcp`, for your LAN). A
|
||||||
|
note lists each zone and where its name came from; rename one with
|
||||||
|
`--zone` and import again, or afterwards with `hc zone update dhcp
|
||||||
|
--name lan` (its rules follow). Name zones for what the network is for:
|
||||||
|
`lan`, `iot`, `guest`, `servers`, `vpn`, `wan`.
|
||||||
|
|
||||||
|
**Address sets**: a named set of the table keeps its name; a define
|
||||||
|
holding a list of addresses, or one prefix, gives its name to a set
|
||||||
|
(`define trusted = { 10.0.10.0/24, 10.1.1.0/24 }` makes `trusted`,
|
||||||
|
wherever those addresses appear together). A single address stays an
|
||||||
|
address. When two defines hold the same addresses, the first by name
|
||||||
|
wins.
|
||||||
|
|
||||||
|
**Services**: a define holding several ports names a service (`define
|
||||||
|
web_ports = { 80, 443 }` makes `web_ports`); one port stays a port
|
||||||
|
(`tcp/22`).
|
||||||
|
|
||||||
|
**Port forwards** are named after their comment, without a leading
|
||||||
|
"Port forward" (`comment "Port Forward Jellyfin"` makes `jellyfin`);
|
||||||
|
several ports in one DNAT become one forward each (`web-80`, `web-443`),
|
||||||
|
and a DNAT map's elements are named after the map (`forwards-443`).
|
||||||
|
|
||||||
|
Names that aren't valid are made so: other characters become `_` (`-`
|
||||||
|
in forward names), and a name must start with a letter.
|
||||||
|
|
||||||
## What becomes what
|
## What becomes what
|
||||||
|
|
||||||
* **Interfaces** become zones. Zones are marked external when the rules
|
* **Interfaces** become zones. Zones are marked external when the rules
|
||||||
@@ -45,7 +83,11 @@ Import a ruleset.
|
|||||||
* **Sets and defines** of addresses become address sets; port lists
|
* **Sets and defines** of addresses become address sets; port lists
|
||||||
become services.
|
become services.
|
||||||
* **Jumps and gotos** to your own chains are flattened: the jump's
|
* **Jumps and gotos** to your own chains are flattened: the jump's
|
||||||
condition is added to each rule of the chain, in place.
|
condition is added to each rule of the chain, in place. When the jump
|
||||||
|
and a rule both match sources (or destinations), the narrower is kept
|
||||||
|
if one lies within the other; otherwise, and for any rule there that
|
||||||
|
stays raw, the jump's condition is written in front of the raw rule's
|
||||||
|
text, so it still only applies where the jump did.
|
||||||
* **Rules** keep their order. A rule that is a built-in (loopback,
|
* **Rules** keep their order. A rule that is a built-in (loopback,
|
||||||
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
|
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
|
||||||
rule, antispoofing) becomes that built-in only when it sits where the
|
rule, antispoofing) becomes that built-in only when it sits where the
|
||||||
@@ -56,10 +98,15 @@ Import a ruleset.
|
|||||||
stays a rule keeps the ICMPv6 accepts after it behind it, as they were.
|
stays a rule keeps the ICMPv6 accepts after it behind it, as they were.
|
||||||
Built-ins your ruleset doesn't have are turned off.
|
Built-ins your ruleset doesn't have are turned off.
|
||||||
* **DNAT** rules become port forwards, TCP and UDP of the same port
|
* **DNAT** rules become port forwards, TCP and UDP of the same port
|
||||||
merged into one. The forward-chain accepts that let them through
|
merged into one, one per port when a rule has several. The
|
||||||
become part of the port forward, and a masquerade for inside sources
|
forward-chain accepts that let them through (`ct status dnat`) become
|
||||||
to the same host becomes its hairpin. An IPv6 accept to the same port
|
part of the port forward; an accept to the same host and port without
|
||||||
becomes the forward's IPv6 pinhole when there is only one candidate.
|
`ct status dnat` lets in more than forwarded connections, and stays a
|
||||||
|
rule. A masquerade for inside sources to the same host becomes its
|
||||||
|
hairpin. An IPv6 accept to the same port, from everywhere, becomes the
|
||||||
|
forward's IPv6 pinhole when there is only one candidate. A DNAT to an
|
||||||
|
IPv6 address, or with matches a port forward has no field for, is left
|
||||||
|
out with a note.
|
||||||
* **Maps are expanded**, one element at a time. A DNAT map
|
* **Maps are expanded**, one element at a time. A DNAT map
|
||||||
(`dnat ip to tcp dport map @forwards`, named or written in the rule)
|
(`dnat ip to tcp dport map @forwards`, named or written in the rule)
|
||||||
becomes one port forward per element; forwards from a named map are
|
becomes one port forward per element; forwards from a named map are
|
||||||
@@ -78,13 +125,27 @@ Import a ruleset.
|
|||||||
Byte rates and quotas are imported too; a quota imported resets only by
|
Byte rates and quotas are imported too; a quota imported resets only by
|
||||||
hand until you give it a period. A rule with a quota or a byte rate that
|
hand until you give it a period. A rule with a quota or a byte rate that
|
||||||
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
|
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
|
||||||
|
* **Sets with `flags timeout`** (filled by a program or a rule) become
|
||||||
|
dynamic sets, without their entries of the moment: whatever fills them
|
||||||
|
must fill Housecarl's from then on, as a note says.
|
||||||
* **Policies** become the chains' policies.
|
* **Policies** become the chains' policies.
|
||||||
* **Anything else** becomes a raw rule with its original text, in place.
|
* **Anything else** becomes a raw rule with its original text, in place:
|
||||||
A raw rule naming a set of the old table needs the new set name:
|
a match Housecarl has no field for, two matches on the same field
|
||||||
Housecarl's are `NAME_v4` and `NAME_v6`, and a note says so.
|
(`ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24`), `!=` where Housecarl
|
||||||
|
only has `==`, a set of interface names. Its set references are made
|
||||||
|
to work in Housecarl's table: an imported address set becomes
|
||||||
|
`@NAME_v4` or `@NAME_v6`, any other set (interface names, ports) is
|
||||||
|
written out with its elements. A raw rule naming a set that can't be
|
||||||
|
(a meter, an empty set) is left out with a note.
|
||||||
|
* **Logging rules** log to Housecarl's log (`hc log`), not the kernel's.
|
||||||
|
|
||||||
Chains on hooks Housecarl doesn't manage (ingress, a bridge table) are
|
Several base chains on one hook (`input` at priority -10 and at 0) are
|
||||||
left out, with a note.
|
put one after the other, by priority, into Housecarl's one chain, with
|
||||||
|
the stricter policy; a note says to check it, since a packet had to get
|
||||||
|
past each, and an accept in the first no longer ends there. A prerouting
|
||||||
|
filter chain after connection tracking becomes the early chain, which
|
||||||
|
runs before it: a note says so. Chains on hooks Housecarl doesn't manage
|
||||||
|
(ingress, a bridge table) are left out, with a note.
|
||||||
|
|
||||||
## 2. Import
|
## 2. Import
|
||||||
|
|
||||||
@@ -98,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.
|
||||||
@@ -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,6 +165,9 @@ 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.
|
||||||
@@ -162,6 +175,11 @@ 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"`
|
||||||
|
// Mode is enforcing or shadow; ShadowPosition (shadow mode) after or
|
||||||
|
// before: where Housecarl's filter chains run beside the other
|
||||||
|
// firewall's.
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ShadowPosition string `json:"shadow_position,omitempty"`
|
||||||
Applied bool `json:"applied"`
|
Applied bool `json:"applied"`
|
||||||
ApplyError string `json:"apply_error,omitempty"`
|
ApplyError string `json:"apply_error,omitempty"`
|
||||||
// Unconfigured: the configuration is empty, and no ruleset was
|
// Unconfigured: the configuration is empty, and no ruleset was
|
||||||
@@ -198,6 +216,9 @@ type ImportInput struct {
|
|||||||
// Replace replaces Housecarl's configuration; without it, the import
|
// Replace replaces Housecarl's configuration; without it, the import
|
||||||
// is refused when there is one.
|
// is refused when there is one.
|
||||||
Replace bool `json:"replace,omitempty"`
|
Replace bool `json:"replace,omitempty"`
|
||||||
|
// Zones names the zones by interface ({"enp2s0": "lan"}); interfaces
|
||||||
|
// given the same name share a zone.
|
||||||
|
Zones map[string]string `json:"zones,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportResult reports an import.
|
// ImportResult reports an import.
|
||||||
@@ -411,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,6 +223,7 @@ 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)}},
|
||||||
|
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}},
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"housecarl/internal/render"
|
||||||
|
"housecarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImportAudit: rulesets the importer must not change the meaning of
|
||||||
|
// (testdata/audit.nft, as nft 1.1.3 lists it).
|
||||||
|
func TestImportAudit(t *testing.T) {
|
||||||
|
js, _ := os.ReadFile("testdata/audit.json")
|
||||||
|
txt, _ := os.ReadFile("testdata/audit.txt")
|
||||||
|
defs, _ := os.ReadFile("testdata/audit.nft")
|
||||||
|
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs), Zones: map[string]string{"enp2s0.20": "iot", "eth9": "x"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := res.State
|
||||||
|
notes := strings.Join(res.Notes, "\n")
|
||||||
|
text := render.Render(st, render.Options{}).Text
|
||||||
|
has := func(what, s string) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(text, s) {
|
||||||
|
t.Errorf("%s: no %q in\n%s", what, s, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two chains on the input hook: their rules once each, in priority
|
||||||
|
// order; established stays a rule after the first chain's rule.
|
||||||
|
if n := strings.Count(text, "192.0.2.66"); n != 1 {
|
||||||
|
t.Errorf("the early input chain's rule %d times", n)
|
||||||
|
}
|
||||||
|
if strings.Index(text, "192.0.2.66") > strings.Index(text, "ct state established,related counter accept comment \"hc:r") ||
|
||||||
|
!strings.Contains(notes, "both hook input") {
|
||||||
|
t.Errorf("chains on one hook:\n%s", text)
|
||||||
|
}
|
||||||
|
// A second match on a field, and != on the hop limit: raw, as written.
|
||||||
|
has("two source matches", "ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop")
|
||||||
|
has("hop limit", "ip6 hoplimit != 255 drop")
|
||||||
|
// A jump: a rule within it is the rule's own; one outside it, or raw,
|
||||||
|
// keeps the jump's match in front.
|
||||||
|
has("within the jump", `ip saddr 10.9.0.5 tcp dport 22 counter accept comment "hc:r`)
|
||||||
|
has("outside the jump", "ip saddr 10.9.0.0/24 ip saddr 10.8.0.1 tcp dport 23 accept")
|
||||||
|
has("raw under the jump", "ip saddr 10.9.0.0/24 meta mark 0x00000001 accept")
|
||||||
|
// Sets: a timeout set is dynamic; an interface set is written out in
|
||||||
|
// the raw rule that names it.
|
||||||
|
if s := st.Set("blocklist"); s == nil || !s.Dynamic || s.Timeout != 3600 {
|
||||||
|
t.Errorf("blocklist: %+v", s)
|
||||||
|
}
|
||||||
|
has("dynamic set", "ip saddr @blocklist_v4 counter drop")
|
||||||
|
has("interface set", `iifname { "enp2s0", "enp3s0" } accept`)
|
||||||
|
// An accept to a forwarded host that isn't only for forwarded
|
||||||
|
// connections stays a rule; a DNAT of two ports, two forwards.
|
||||||
|
has("not only forwarded", `ip daddr 10.0.10.37 tcp dport 443 counter accept comment "hc:r`)
|
||||||
|
if len(st.Forwards) != 2 || st.Forwards[0].Name != "web-80" || st.Forwards[1].Name != "web-443" {
|
||||||
|
t.Errorf("forwards %+v", st.Forwards)
|
||||||
|
}
|
||||||
|
// Zones: as given (a VLAN), from defines, and why.
|
||||||
|
if z := st.Zone("iot"); z == nil || z.Interfaces[0] != "enp2s0.20" {
|
||||||
|
t.Errorf("zone iot: %+v", st.Zones)
|
||||||
|
}
|
||||||
|
if st.Zone("lan") == nil || st.Zone("wan") == nil {
|
||||||
|
t.Errorf("zones %+v", st.Zones)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"iot (enp2s0.20: as given)", "lan (enp2s0: the define lan_if)", "--zone eth9=x: no rule names"} {
|
||||||
|
if !strings.Contains(notes, want) {
|
||||||
|
t.Errorf("no note %q in\n%s", want, notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
|
||||||
|
t.Errorf("problems: %+v", validate.Errors(ps))
|
||||||
|
}
|
||||||
|
// And nftables takes it.
|
||||||
|
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
|
||||||
|
if len(cmd) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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("nft -c: %v\n%s\n%s", err, out, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidIface: interface names a define may hold, VLANs too.
|
||||||
|
func TestValidIface(t *testing.T) {
|
||||||
|
for s, want := range map[string]bool{"enp2s0": true, "wan": true, "enp2s0.20": true, "br0.4094": true, "veth*": true,
|
||||||
|
"10.0.10.5": false, "example.com": false, "a.b.c": false, "enp2s0.20.30": false, "averyveryverylongname": false} {
|
||||||
|
if validIface(s) != want {
|
||||||
|
t.Errorf("validIface(%q) = %v", s, !want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+458
-83
@@ -20,6 +20,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
|
"housecarl/internal/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Input is what to import.
|
// Input is what to import.
|
||||||
@@ -34,6 +35,9 @@ type Input struct {
|
|||||||
// Table is the table to import ("inet main"); empty: the only table
|
// Table is the table to import ("inet main"); empty: the only table
|
||||||
// that isn't Housecarl's.
|
// that isn't Housecarl's.
|
||||||
Table string
|
Table string
|
||||||
|
// Zones names zones by interface ("enp2s0": "lan"), over the names
|
||||||
|
// the importer would choose; interfaces given one name share a zone.
|
||||||
|
Zones map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is the imported configuration and what to know about it.
|
// Result is the imported configuration and what to know about it.
|
||||||
@@ -81,6 +85,8 @@ type nftSet struct {
|
|||||||
Table string `json:"table"`
|
Table string `json:"table"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type json.RawMessage `json:"type"`
|
Type json.RawMessage `json:"type"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
Elem []json.RawMessage `json:"elem"`
|
Elem []json.RawMessage `json:"elem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +127,8 @@ type importer struct {
|
|||||||
meters map[string]bool // sets used as meters
|
meters map[string]bool // sets used as meters
|
||||||
noted map[string]bool
|
noted map[string]bool
|
||||||
positions map[string]float64
|
positions map[string]float64
|
||||||
|
// zoneFrom says where each zone's name came from, for the note.
|
||||||
|
zoneFrom map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (im *importer) note(format string, args ...any) {
|
func (im *importer) note(format string, args ...any) {
|
||||||
@@ -136,7 +144,7 @@ func Import(in Input) (*Result, error) {
|
|||||||
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
|
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
|
||||||
}
|
}
|
||||||
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
|
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
|
||||||
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
|
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}, zoneFrom: map[string]string{}}
|
||||||
var tables []nftTable
|
var tables []nftTable
|
||||||
for _, item := range doc.Nftables {
|
for _, item := range doc.Nftables {
|
||||||
if raw, ok := item["table"]; ok {
|
if raw, ok := item["table"]; ok {
|
||||||
@@ -207,6 +215,7 @@ func Import(in Input) (*Result, error) {
|
|||||||
im.importSets()
|
im.importSets()
|
||||||
im.run()
|
im.run()
|
||||||
im.nameForwards()
|
im.nameForwards()
|
||||||
|
im.noteZones()
|
||||||
im.st.Renumber()
|
im.st.Renumber()
|
||||||
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
|
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
|
||||||
}
|
}
|
||||||
@@ -309,10 +318,13 @@ func key(vals []string) string {
|
|||||||
return strings.Join(s, ",")
|
return strings.Join(s, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.@-]{0,14}\*?$`)
|
var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_@-]{0,14}\*?$`)
|
||||||
|
|
||||||
|
// vlanRe is a VLAN sub-interface: enp2s0.20.
|
||||||
|
var vlanRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_@-]*\.[0-9]{1,4}$`)
|
||||||
|
|
||||||
func validIface(s string) bool {
|
func validIface(s string) bool {
|
||||||
return ifaceRe.MatchString(s) && !strings.ContainsAny(s, ".:") || s == "lo"
|
return len(s) <= 15 && (ifaceRe.MatchString(s) || vlanRe.MatchString(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- sets
|
// ---- sets
|
||||||
@@ -360,12 +372,22 @@ func (im *importer) importSets() {
|
|||||||
for _, e := range s.Elem {
|
for _, e := range s.Elem {
|
||||||
entries = append(entries, values(e)...)
|
entries = append(entries, values(e)...)
|
||||||
}
|
}
|
||||||
if existing := im.st.Set(n); existing != nil {
|
name := setName(n)
|
||||||
|
if contains(s.Flags, "timeout") || contains(s.Flags, "dynamic") {
|
||||||
|
// Filled at run time (by a program, or a rule): a dynamic set.
|
||||||
|
// Its entries now are passing state, not configuration.
|
||||||
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Dynamic: true, Timeout: s.Timeout})
|
||||||
|
im.used[name] = true
|
||||||
|
im.note("set %s has entries that come and go (flags %s): it became a dynamic set, without its %d entries now; whatever fills it must now fill Housecarl's (hc set entries add %s ADDRESS, the API, or nft add element inet housecarl %s_v4 / %s_v6)",
|
||||||
|
n, strings.Join(s.Flags, ","), len(entries), name, name, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing := im.st.Set(name); existing != nil {
|
||||||
existing.Entries = append(existing.Entries, entries...)
|
existing.Entries = append(existing.Entries, entries...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: setName(n), Entries: entries})
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: entries})
|
||||||
im.used[n] = true
|
im.used[name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +419,10 @@ func (im *importer) namedSet(entries []string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// zone returns the zone of an interface, making it on first use.
|
// zone returns the zone of an interface, making it on first use. Its
|
||||||
|
// name: the one given for it (Input.Zones); else the name of a define
|
||||||
|
// holding the interface ("define lan_if = enp2s0": lan); else the
|
||||||
|
// interface's own.
|
||||||
func (im *importer) zone(iface string) string {
|
func (im *importer) zone(iface string) string {
|
||||||
for _, z := range im.st.Zones {
|
for _, z := range im.st.Zones {
|
||||||
for _, i := range z.Interfaces {
|
for _, i := range z.Interfaces {
|
||||||
@@ -406,21 +431,78 @@ func (im *importer) zone(iface string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
name := iface
|
if given, ok := im.in.Zones[iface]; ok {
|
||||||
|
name := setName(given)
|
||||||
|
// Interfaces given one name share its zone.
|
||||||
|
if z := im.st.Zone(name); z != nil && im.zoneFrom[name] == "given" {
|
||||||
|
z.Interfaces = append(z.Interfaces, iface)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if im.st.Zone(name) == nil {
|
||||||
|
return im.newZone(name, iface, "given")
|
||||||
|
}
|
||||||
|
im.note("--zone %s=%s: the zone %s is taken (by %s), so %s is named as below", iface, given, name, strings.Join(im.st.Zone(name).Interfaces, ", "), iface)
|
||||||
|
}
|
||||||
|
name, from := iface, "its interface's name"
|
||||||
if d, ok := im.ifNames[iface]; ok {
|
if d, ok := im.ifNames[iface]; ok {
|
||||||
name = d
|
name, from = d, "the define "+d
|
||||||
for _, suf := range []string{"_interface", "_iface", "_int", "_if", "_dev"} {
|
for _, suf := range []string{"_interface", "_ifname", "_iface", "_device", "_int", "_nic", "_if", "_dev"} {
|
||||||
name = strings.TrimSuffix(name, suf)
|
name = strings.TrimSuffix(name, suf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
name = setName(strings.TrimSuffix(name, "*"))
|
name = setName(strings.TrimSuffix(name, "*"))
|
||||||
if im.st.Zone(name) != nil {
|
if im.st.Zone(name) != nil {
|
||||||
name = setName(iface + "_zone")
|
name, from = setName(iface+"_zone"), "its interface's name (the other was taken)"
|
||||||
}
|
}
|
||||||
|
return im.newZone(name, iface, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zone2 is the zone an interface is in, or "" (making none).
|
||||||
|
func (im *importer) zoneOfIface(iface string) string {
|
||||||
|
for _, z := range im.st.Zones {
|
||||||
|
if contains(z.Interfaces, iface) {
|
||||||
|
return z.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (im *importer) newZone(name, iface, from string) string {
|
||||||
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
|
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
|
||||||
|
im.zoneFrom[name] = from
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noteZones says how each zone was named, and how to change it.
|
||||||
|
func (im *importer) noteZones() {
|
||||||
|
ifaces := make([]string, 0, len(im.in.Zones))
|
||||||
|
for i := range im.in.Zones {
|
||||||
|
ifaces = append(ifaces, i)
|
||||||
|
}
|
||||||
|
sort.Strings(ifaces)
|
||||||
|
for _, i := range ifaces {
|
||||||
|
if im.zoneOfIface(i) == "" {
|
||||||
|
im.note("--zone %s=%s: no rule names the interface %s, so there is no zone for it", i, im.in.Zones[i], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(im.st.Zones) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, z := range im.st.Zones {
|
||||||
|
from := im.zoneFrom[z.Name]
|
||||||
|
if from == "given" {
|
||||||
|
from = "as given"
|
||||||
|
}
|
||||||
|
ext := ""
|
||||||
|
if z.External {
|
||||||
|
ext = ", external"
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s (%s%s: %s)", z.Name, strings.Join(z.Interfaces, ", "), ext, from))
|
||||||
|
}
|
||||||
|
im.note("zones: %s; name them yourself with --zone INTERFACE=NAME (lan, iot, guest: what the network is for), or later with hc zone update NAME --name NEW", strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
func (im *importer) external(iface string) {
|
func (im *importer) external(iface string) {
|
||||||
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
|
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
|
||||||
z.External = true
|
z.External = true
|
||||||
@@ -522,6 +604,11 @@ type parsed struct {
|
|||||||
// name: a port forward's name, when the rule's comment won't do
|
// name: a port forward's name, when the rule's comment won't do
|
||||||
// (one of a map's elements).
|
// (one of a map's elements).
|
||||||
name string
|
name string
|
||||||
|
// inJump: the rule came from a chain jumped to; jumpText is what the
|
||||||
|
// jumps to it match, as nftables text (none when it isn't known), put
|
||||||
|
// in front of the rule's own text if it stays raw.
|
||||||
|
inJump bool
|
||||||
|
jumpText string
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
|
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
|
||||||
@@ -550,7 +637,11 @@ func parse(r nftRule) *parsed {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *parsed) bad(what string) { p.unsupported = append(p.unsupported, what) }
|
func (p *parsed) bad(what string) {
|
||||||
|
if !contains(p.unsupported, what) {
|
||||||
|
p.unsupported = append(p.unsupported, what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *parsed) expr(k string, v json.RawMessage) {
|
func (p *parsed) expr(k string, v json.RawMessage) {
|
||||||
switch k {
|
switch k {
|
||||||
@@ -839,19 +930,24 @@ func (p *parsed) match(m match) {
|
|||||||
if meta, ok := left["meta"]; ok {
|
if meta, ok := left["meta"]; ok {
|
||||||
var x struct{ Key string }
|
var x struct{ Key string }
|
||||||
json.Unmarshal(meta, &x)
|
json.Unmarshal(meta, &x)
|
||||||
switch x.Key {
|
switch {
|
||||||
case "iifname":
|
case (x.Key == "iifname" || x.Key == "oifname") && anySet(right):
|
||||||
|
p.bad("meta " + x.Key + " in a named set")
|
||||||
|
case x.Key == "iifname" && len(p.inIf) > 0, x.Key == "oifname" && len(p.outIf) > 0:
|
||||||
|
// Both must hold; Housecarl's rule has one list: kept raw.
|
||||||
|
p.bad("two matches on meta " + x.Key)
|
||||||
|
case x.Key == "iifname":
|
||||||
p.inIf, p.notIn = right, not
|
p.inIf, p.notIn = right, not
|
||||||
case "oifname":
|
case x.Key == "oifname":
|
||||||
p.outIf, p.notOut = right, not
|
p.outIf, p.notOut = right, not
|
||||||
case "nfproto":
|
case x.Key == "nfproto":
|
||||||
if len(right) == 1 && !not {
|
if len(right) == 1 && !not && (p.family == "" || p.family == right[0]) {
|
||||||
p.family = right[0]
|
p.family = right[0]
|
||||||
} else {
|
} else {
|
||||||
p.bad("meta nfproto")
|
p.bad("meta nfproto")
|
||||||
}
|
}
|
||||||
case "l4proto":
|
case x.Key == "l4proto":
|
||||||
if len(right) == 1 && !not {
|
if len(right) == 1 && !not && (p.proto == "" || p.proto == right[0]) {
|
||||||
p.proto = right[0]
|
p.proto = right[0]
|
||||||
} else {
|
} else {
|
||||||
p.bad("meta l4proto with several protocols")
|
p.bad("meta l4proto with several protocols")
|
||||||
@@ -864,23 +960,29 @@ func (p *parsed) match(m match) {
|
|||||||
if pl, ok := left["payload"]; ok {
|
if pl, ok := left["payload"]; ok {
|
||||||
var x struct{ Protocol, Field string }
|
var x struct{ Protocol, Field string }
|
||||||
json.Unmarshal(pl, &x)
|
json.Unmarshal(pl, &x)
|
||||||
|
fam := map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
|
||||||
switch {
|
switch {
|
||||||
|
// A second match on a field Housecarl keeps one list for (both
|
||||||
|
// must hold): the rule stays raw, as written.
|
||||||
|
case x.Field == "saddr" && len(p.src) > 0, x.Field == "daddr" && len(p.dst) > 0,
|
||||||
|
x.Field == "dport" && len(p.dports) > 0, x.Field == "type" && len(p.icmp) > 0:
|
||||||
|
p.bad("two matches on " + x.Protocol + " " + x.Field)
|
||||||
|
case fam != "" && p.family != "" && p.family != fam && (x.Field == "saddr" || x.Field == "daddr"):
|
||||||
|
p.bad("both IPv4 and IPv6 addresses")
|
||||||
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
|
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
|
||||||
if p.family == "" {
|
p.family = fam
|
||||||
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
|
|
||||||
}
|
|
||||||
if x.Field == "saddr" {
|
if x.Field == "saddr" {
|
||||||
p.src, p.notSrc = right, not
|
p.src, p.notSrc = right, not
|
||||||
} else {
|
} else {
|
||||||
p.dst, p.notDst = right, not
|
p.dst, p.notDst = right, not
|
||||||
}
|
}
|
||||||
case (x.Protocol == "tcp" || x.Protocol == "udp" || x.Protocol == "sctp") && x.Field == "dport" && !not:
|
case (x.Protocol == "tcp" || x.Protocol == "udp" || x.Protocol == "sctp") && x.Field == "dport" && !not && (p.proto == "" || p.proto == x.Protocol):
|
||||||
p.proto, p.dports = x.Protocol, right
|
p.proto, p.dports = x.Protocol, right
|
||||||
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn":
|
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn" && !not:
|
||||||
p.tcpSyn = true
|
p.tcpSyn = true
|
||||||
case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not:
|
case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not:
|
||||||
p.icmpProto, p.icmp = x.Protocol, right
|
p.icmpProto, p.icmp = x.Protocol, right
|
||||||
case x.Protocol == "ip6" && x.Field == "hoplimit" && len(right) == 1 && right[0] == "255":
|
case x.Protocol == "ip6" && x.Field == "hoplimit" && len(right) == 1 && right[0] == "255" && !not:
|
||||||
p.hop255 = true
|
p.hop255 = true
|
||||||
default:
|
default:
|
||||||
p.bad(x.Protocol + " " + x.Field)
|
p.bad(x.Protocol + " " + x.Field)
|
||||||
@@ -891,9 +993,9 @@ func (p *parsed) match(m match) {
|
|||||||
var x struct{ Key string }
|
var x struct{ Key string }
|
||||||
json.Unmarshal(ct, &x)
|
json.Unmarshal(ct, &x)
|
||||||
switch {
|
switch {
|
||||||
case x.Key == "state" && !not:
|
case x.Key == "state" && !not && len(p.ctState) == 0:
|
||||||
p.ctState = right
|
p.ctState = right
|
||||||
case x.Key == "status" && !not:
|
case x.Key == "status" && !not && len(p.ctStatus) == 0:
|
||||||
p.ctStatus = right
|
p.ctStatus = right
|
||||||
default:
|
default:
|
||||||
p.bad("ct " + x.Key)
|
p.bad("ct " + x.Key)
|
||||||
@@ -905,40 +1007,80 @@ func (p *parsed) match(m match) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anySet reports whether a match's values name a set ("@name").
|
||||||
|
func anySet(vals []string) bool {
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.HasPrefix(v, "@") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ---- converting
|
// ---- converting
|
||||||
|
|
||||||
func (im *importer) run() {
|
func (im *importer) run() {
|
||||||
type base struct {
|
type base struct {
|
||||||
name, kind string
|
name, kind string
|
||||||
|
prio int
|
||||||
}
|
}
|
||||||
var bases []base
|
var bases []base
|
||||||
for name, c := range im.chains {
|
names := make([]string, 0, len(im.chains))
|
||||||
|
for name := range im.chains {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
policies := map[string][]string{}
|
||||||
|
for _, name := range names {
|
||||||
|
c := im.chains[name]
|
||||||
switch {
|
switch {
|
||||||
case c.Type == "filter" && c.Hook == "prerouting":
|
case c.Type == "filter" && c.Hook == "prerouting":
|
||||||
bases = append(bases, base{name, model.ChainEarly})
|
bases = append(bases, base{name, model.ChainEarly, c.Prio})
|
||||||
|
if c.Prio > -200 {
|
||||||
|
im.note("chain %s hooks prerouting at priority %d, after connection tracking; Housecarl's early chain runs before it (raw), where connection states aren't known yet: check its rules", name, c.Prio)
|
||||||
|
}
|
||||||
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
|
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
|
||||||
bases = append(bases, base{name, c.Hook})
|
bases = append(bases, base{name, c.Hook, c.Prio})
|
||||||
|
policies[c.Hook] = append(policies[c.Hook], c.Policy)
|
||||||
case c.Type == "nat" && c.Hook == "prerouting":
|
case c.Type == "nat" && c.Hook == "prerouting":
|
||||||
bases = append(bases, base{name, "dstnat"})
|
bases = append(bases, base{name, "dstnat", c.Prio})
|
||||||
case c.Type == "nat" && c.Hook == "postrouting":
|
case c.Type == "nat" && c.Hook == "postrouting":
|
||||||
bases = append(bases, base{name, "srcnat"})
|
bases = append(bases, base{name, "srcnat", c.Prio})
|
||||||
case c.Hook != "":
|
case c.Hook != "":
|
||||||
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
|
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Several chains on one hook: a packet must get past each (an accept
|
||||||
|
// ends only its own chain), and any policy drop drops it.
|
||||||
set := &im.st.Settings
|
set := &im.st.Settings
|
||||||
switch {
|
for hook, ps := range policies {
|
||||||
case c.Hook == "input" && c.Type == "filter":
|
policy := model.PolicyAccept
|
||||||
set.InputPolicy = c.Policy
|
if contains(ps, model.PolicyDrop) {
|
||||||
case c.Hook == "forward" && c.Type == "filter":
|
policy = model.PolicyDrop
|
||||||
set.ForwardPolicy = c.Policy
|
}
|
||||||
case c.Hook == "output" && c.Type == "filter":
|
switch hook {
|
||||||
set.OutputPolicy = c.Policy
|
case "input":
|
||||||
|
set.InputPolicy = policy
|
||||||
|
case "forward":
|
||||||
|
set.ForwardPolicy = policy
|
||||||
|
case "output":
|
||||||
|
set.OutputPolicy = policy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NAT first: the filter chains' port-forward accepts are folded into
|
// NAT first: the filter chains' port-forward accepts are folded into
|
||||||
// the port forwards.
|
// the port forwards. Chains on the same hook in priority order.
|
||||||
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
|
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
|
||||||
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
|
sort.SliceStable(bases, func(i, j int) bool {
|
||||||
|
if bases[i].kind != bases[j].kind {
|
||||||
|
return order[bases[i].kind] < order[bases[j].kind]
|
||||||
|
}
|
||||||
|
return bases[i].prio < bases[j].prio
|
||||||
|
})
|
||||||
|
for i := 1; i < len(bases); i++ {
|
||||||
|
if b, a := bases[i], bases[i-1]; b.kind == a.kind && b.kind != "dstnat" && b.kind != "srcnat" {
|
||||||
|
im.note("chains %s and %s both hook %s: a packet must get past each, but Housecarl has one %s chain, so their rules follow each other (by priority); that only does the same if what %s accepts, %s would accept too: check it", a.name, b.name, b.kind, b.kind, a.name, b.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
im.st.Settings.Builtins = model.Builtins{}
|
im.st.Settings.Builtins = model.Builtins{}
|
||||||
found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
|
found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
|
||||||
heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
|
heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
|
||||||
@@ -956,8 +1098,12 @@ func (im *importer) run() {
|
|||||||
im.srcnat(p)
|
im.srcnat(p)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if !contains(chains, b.kind) {
|
||||||
chains = append(chains, b.kind)
|
chains = append(chains, b.kind)
|
||||||
prefix := true
|
}
|
||||||
|
// Built-ins come first only at the head of the hook's first
|
||||||
|
// chain (or of one after a chain of nothing but built-ins).
|
||||||
|
prefix := len(rest[b.kind]) == 0
|
||||||
for _, p := range rules {
|
for _, p := range rules {
|
||||||
if bi := im.builtinOf(b.kind, p); bi != "" {
|
if bi := im.builtinOf(b.kind, p); bi != "" {
|
||||||
if found[bi] == nil {
|
if found[bi] == nil {
|
||||||
@@ -1016,6 +1162,7 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
|||||||
}
|
}
|
||||||
jumpMatch := *p
|
jumpMatch := *p
|
||||||
jumpMatch.verdict, jumpMatch.target = "", ""
|
jumpMatch.verdict, jumpMatch.target = "", ""
|
||||||
|
jumpMatch.jumpText = joinText(p.jumpText, im.condText(p))
|
||||||
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
|
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
|
||||||
if p.verdict == "goto" {
|
if p.verdict == "goto" {
|
||||||
im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain)
|
im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain)
|
||||||
@@ -1147,36 +1294,153 @@ func hasMatch(p, prefix *parsed) bool {
|
|||||||
len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp)
|
len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge puts a jump's matches in front of a jumped-to rule's. When both
|
// condText is a jump rule's text without its verdict, counter and
|
||||||
// match the same field, the rule's own (narrower, usually) is kept and
|
// comment: what it matches ("ip saddr 10.50.50.0/24"); "" when there is
|
||||||
// noted.
|
// no text for it.
|
||||||
|
var verdictRe = regexp.MustCompile(`\s*\b(jump|goto)\s+\S+\s*$`)
|
||||||
|
|
||||||
|
func (im *importer) condText(p *parsed) string {
|
||||||
|
text, ok := im.text[p.handle]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
||||||
|
text = text[:i]
|
||||||
|
}
|
||||||
|
text = verdictRe.ReplaceAllString(strings.TrimSpace(text), "")
|
||||||
|
return strings.Join(strings.Fields(strings.ReplaceAll(" "+text+" ", " counter ", " ")), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinText(a, b string) string {
|
||||||
|
return strings.TrimSpace(a + " " + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge puts a jump's matches in front of a jumped-to rule's: the rule
|
||||||
|
// applies where both match. When both match the same field, that holds as
|
||||||
|
// one list only when one's values lie within the other's (the narrower is
|
||||||
|
// kept); otherwise the rule is kept raw, after the jump's text.
|
||||||
func (im *importer) merge(prefix, p *parsed) *parsed {
|
func (im *importer) merge(prefix, p *parsed) *parsed {
|
||||||
q := *p
|
q := *p
|
||||||
|
q.inJump, q.jumpText = true, prefix.jumpText
|
||||||
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
|
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
|
||||||
fill := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
both := func(what string) { q.bad("both the jump to it and the rule match " + what) }
|
||||||
if len(*dst) == 0 && len(src) > 0 {
|
addrs := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
||||||
|
switch {
|
||||||
|
case len(src) == 0:
|
||||||
|
case len(*dst) == 0:
|
||||||
*dst, *not = src, srcNot
|
*dst, *not = src, srcNot
|
||||||
} else if len(*dst) > 0 && len(src) > 0 && key(*dst) != key(src) {
|
case *not || srcNot:
|
||||||
im.note("rule handle %d: both the jump to it and the rule match %s; the rule's own (%s) are kept, the jump's (%s) dropped: check it",
|
if key(*dst) != key(src) || *not != srcNot {
|
||||||
p.handle, what, strings.Join(*dst, ", "), strings.Join(src, ", "))
|
both(what)
|
||||||
|
}
|
||||||
|
case im.within(*dst, src):
|
||||||
|
// The rule's own are narrower: they say it all.
|
||||||
|
case im.within(src, *dst):
|
||||||
|
*dst = src
|
||||||
|
default:
|
||||||
|
both(what)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fill("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
|
same := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
||||||
fill("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
|
switch {
|
||||||
fill("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
|
case len(src) == 0:
|
||||||
fill("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
|
case len(*dst) == 0:
|
||||||
if q.family == "" {
|
*dst, *not = src, srcNot
|
||||||
|
case key(*dst) != key(src) || *not != srcNot:
|
||||||
|
both(what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var noNot bool
|
||||||
|
same("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
|
||||||
|
same("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
|
||||||
|
addrs("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
|
||||||
|
addrs("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
|
||||||
|
same("connection states", &q.ctState, &noNot, prefix.ctState, false)
|
||||||
|
same("connection status", &q.ctStatus, &noNot, prefix.ctStatus, false)
|
||||||
|
switch {
|
||||||
|
case prefix.family == "":
|
||||||
|
case q.family == "":
|
||||||
q.family = prefix.family
|
q.family = prefix.family
|
||||||
|
case q.family != prefix.family:
|
||||||
|
both("an address family")
|
||||||
}
|
}
|
||||||
if len(q.ctState) == 0 {
|
switch {
|
||||||
q.ctState = prefix.ctState
|
case prefix.proto == "":
|
||||||
}
|
case q.proto == "":
|
||||||
if q.proto == "" {
|
|
||||||
q.proto, q.dports = prefix.proto, prefix.dports
|
q.proto, q.dports = prefix.proto, prefix.dports
|
||||||
|
case q.proto != prefix.proto:
|
||||||
|
both("a protocol")
|
||||||
|
default:
|
||||||
|
same("ports", &q.dports, &noNot, prefix.dports, false)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case prefix.icmpProto == "":
|
||||||
|
case q.icmpProto == "":
|
||||||
|
q.icmpProto, q.icmp = prefix.icmpProto, prefix.icmp
|
||||||
|
case q.icmpProto != prefix.icmpProto || key(q.icmp) != key(prefix.icmp):
|
||||||
|
both("ICMP types")
|
||||||
|
}
|
||||||
|
q.hop255 = q.hop255 || prefix.hop255
|
||||||
|
q.tcpSyn = q.tcpSyn || prefix.tcpSyn
|
||||||
|
if prefix.limit != nil || prefix.quota != nil || prefix.connLimit > 0 || prefix.log {
|
||||||
|
// Once in the jump, these count or log each packet once; on every
|
||||||
|
// rule they would act once per rule.
|
||||||
|
q.bad("a jump with a limit, quota or log")
|
||||||
}
|
}
|
||||||
return &q
|
return &q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// within reports whether every address of inner lies within outer's
|
||||||
|
// (literal addresses and prefixes, or sets of the table).
|
||||||
|
func (im *importer) within(inner, outer []string) bool {
|
||||||
|
resolve := func(list []string) ([]netip.Prefix, bool) {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, e := range list {
|
||||||
|
vals := []string{e}
|
||||||
|
if strings.HasPrefix(e, "@") {
|
||||||
|
s, ok := im.sets[e[1:]]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
vals = nil
|
||||||
|
for _, el := range s.Elem {
|
||||||
|
vals = append(vals, values(el)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range vals {
|
||||||
|
pf, err := netip.ParsePrefix(v)
|
||||||
|
if err != nil {
|
||||||
|
a, err := netip.ParseAddr(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false // a range, or something else
|
||||||
|
}
|
||||||
|
pf = netip.PrefixFrom(a, a.BitLen())
|
||||||
|
}
|
||||||
|
out = append(out, pf.Masked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, len(out) > 0
|
||||||
|
}
|
||||||
|
in, ok1 := resolve(inner)
|
||||||
|
out, ok2 := resolve(outer)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range in {
|
||||||
|
found := false
|
||||||
|
for _, b := range out {
|
||||||
|
if b.Bits() <= a.Bits() && b.Contains(a.Addr()) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ---- built-ins
|
// ---- built-ins
|
||||||
|
|
||||||
var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true}
|
var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true}
|
||||||
@@ -1402,8 +1666,19 @@ func (im *importer) zones(ifaces []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkSets marks a rule naming a set that wasn't imported (not of
|
||||||
|
// addresses, or a meter) unsupported: it stays raw.
|
||||||
|
func (im *importer) checkSets(p *parsed) {
|
||||||
|
for _, e := range append(append([]string{}, p.src...), p.dst...) {
|
||||||
|
if n, ok := strings.CutPrefix(e, "@"); ok && (im.meters[n] || im.st.Set(setName(n)) == nil) {
|
||||||
|
p.bad("the set @" + n + ", which isn't an address set Housecarl imported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// toMatch builds a Match; ok is false when something can't be said.
|
// toMatch builds a Match; ok is false when something can't be said.
|
||||||
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
|
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
|
||||||
|
im.checkSets(p)
|
||||||
svcs, ok := im.services(p)
|
svcs, ok := im.services(p)
|
||||||
if !ok || len(p.unsupported) > 0 {
|
if !ok || len(p.unsupported) > 0 {
|
||||||
return model.Match{}, false
|
return model.Match{}, false
|
||||||
@@ -1441,6 +1716,10 @@ func (im *importer) rule(chain string, p *parsed) {
|
|||||||
default:
|
default:
|
||||||
p.bad(p.verdict)
|
p.bad(p.verdict)
|
||||||
}
|
}
|
||||||
|
if p.log && !im.noted["log"] {
|
||||||
|
im.noted["log"] = true
|
||||||
|
im.note("rules that log now log to Housecarl's log (hc log, Traffic → Log), not the kernel log or the journal")
|
||||||
|
}
|
||||||
m, ok := im.toMatch(p)
|
m, ok := im.toMatch(p)
|
||||||
if r.CountsTraffic() && r.Action == model.ActionAccept {
|
if r.CountsTraffic() && r.Action == model.ActionAccept {
|
||||||
// Housecarl checks these before established connections are
|
// Housecarl checks these before established connections are
|
||||||
@@ -1471,6 +1750,14 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
|||||||
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
||||||
text = strings.TrimSpace(text[:i])
|
text = strings.TrimSpace(text[:i])
|
||||||
}
|
}
|
||||||
|
// From a chain jumped to: only where the jumps matched.
|
||||||
|
if p.inJump {
|
||||||
|
if p.jumpText == "" {
|
||||||
|
im.note("rule handle %d of chain %s couldn't be expressed (%s), and the jump to it has no text to keep it raw with: add it by hand: %s", p.handle, r.Chain, strings.Join(p.unsupported, ", "), text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text = p.jumpText + " " + text
|
||||||
|
}
|
||||||
// A named map isn't carried over: a raw rule naming it would make
|
// A named map isn't carried over: a raw rule naming it would make
|
||||||
// nftables refuse the whole ruleset.
|
// nftables refuse the whole ruleset.
|
||||||
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
|
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
|
||||||
@@ -1480,6 +1767,12 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
text, bad := im.setRefs(text)
|
||||||
|
if bad != "" {
|
||||||
|
im.note("rule handle %d of chain %s uses the set @%s, which Housecarl's table doesn't have (%s): left out, add it by hand: %s",
|
||||||
|
p.handle, r.Chain, bad, strings.Join(p.unsupported, ", "), text)
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Action == "" {
|
if r.Action == "" {
|
||||||
r.Action = model.ActionAccept
|
r.Action = model.ActionAccept
|
||||||
}
|
}
|
||||||
@@ -1491,41 +1784,86 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
|||||||
why = "a match Housecarl has no field for"
|
why = "a match Housecarl has no field for"
|
||||||
}
|
}
|
||||||
im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why))
|
im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why))
|
||||||
if strings.Contains(text, "@") {
|
|
||||||
im.note("raw rule %q names a set of the old table: Housecarl's sets are NAME_v4 and NAME_v6", text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var addrSetRefRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (!= )?@([A-Za-z0-9_]+)`)
|
||||||
|
|
||||||
|
// setRefs makes a raw rule's set references work in Housecarl's table:
|
||||||
|
// an imported address set is NAME_v4 or NAME_v6 there, by the family the
|
||||||
|
// rule matches; another set of the old table (interface names, ports) is
|
||||||
|
// written out as an anonymous set of its elements. It returns the name of
|
||||||
|
// a set it couldn't resolve (a meter, an empty or unknown one).
|
||||||
|
func (im *importer) setRefs(text string) (string, string) {
|
||||||
|
text = addrSetRefRe.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
sm := addrSetRefRe.FindStringSubmatch(m)
|
||||||
|
name := setName(sm[4])
|
||||||
|
if im.meters[sm[4]] || im.st.Set(name) == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s %s@%s", sm[1], sm[2], sm[3], render.SetName(name, sm[1] == "ip6"))
|
||||||
|
})
|
||||||
|
bad := ""
|
||||||
|
text = mapRefRe.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
n := m[1:]
|
||||||
|
if strings.HasSuffix(n, "_v4") || strings.HasSuffix(n, "_v6") {
|
||||||
|
if im.st.Set(strings.TrimSuffix(strings.TrimSuffix(n, "_v4"), "_v6")) != nil {
|
||||||
|
return m // rewritten above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set, ok := im.sets[n]
|
||||||
|
if !ok || im.meters[n] || len(set.Elem) == 0 || contains(set.Flags, "timeout") || contains(set.Flags, "dynamic") {
|
||||||
|
bad = n
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
var typ string
|
||||||
|
json.Unmarshal(set.Type, &typ)
|
||||||
|
var elems []string
|
||||||
|
for _, e := range set.Elem {
|
||||||
|
for _, v := range values(e) {
|
||||||
|
if typ == "ifname" || typ == "string" {
|
||||||
|
v = quote(v)
|
||||||
|
}
|
||||||
|
elems = append(elems, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{ " + strings.Join(elems, ", ") + " }"
|
||||||
|
})
|
||||||
|
return text, bad
|
||||||
|
}
|
||||||
|
|
||||||
|
func quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, "") + `"` }
|
||||||
|
|
||||||
// ---- NAT
|
// ---- NAT
|
||||||
|
|
||||||
func (im *importer) dstnat(p *parsed) {
|
func (im *importer) dstnat(p *parsed) {
|
||||||
|
im.checkSets(p)
|
||||||
|
if a, err := netip.ParseAddr(p.dnatAddr); err == nil && a.Is6() {
|
||||||
|
p.bad("a DNAT to an IPv6 address (Housecarl's port forwards translate IPv4; IPv6 gets a pinhole)")
|
||||||
|
}
|
||||||
|
if p.notIn || len(p.outIf) > 0 || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" || len(p.icmp) > 0 {
|
||||||
|
p.bad("a DNAT with matches a port forward has no field for")
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) == 1 && !p.notDst && !p.notSrc:
|
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) > 0 && !p.notDst && !p.notSrc:
|
||||||
ports, err := model.NormalizePorts(p.dports[0])
|
var ports []string
|
||||||
|
for _, d := range p.dports {
|
||||||
|
n, err := model.NormalizePorts(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
p.bad("the port " + d)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
|
ports = append(ports, n)
|
||||||
if p.name != "" {
|
|
||||||
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
|
|
||||||
}
|
}
|
||||||
if f.ToPort == ports {
|
if len(p.unsupported) > 0 {
|
||||||
f.ToPort = ""
|
break
|
||||||
|
}
|
||||||
|
// "tcp dport { 80, 443 } dnat to X": a forward for each port.
|
||||||
|
for _, port := range ports {
|
||||||
|
im.portForward(p, port, len(ports) > 1)
|
||||||
}
|
}
|
||||||
f.PublicAddresses = im.addrRefs(p.dst)
|
|
||||||
f.Sources = im.addrRefs(p.src)
|
|
||||||
for _, i := range p.inIf {
|
for _, i := range p.inIf {
|
||||||
im.external(i)
|
im.external(i)
|
||||||
}
|
}
|
||||||
// The same forward for another protocol: one forward, both.
|
|
||||||
for _, o := range im.st.Forwards {
|
|
||||||
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
|
|
||||||
o.Protocols = append(o.Protocols, p.proto)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
|
|
||||||
im.st.Forwards = append(im.st.Forwards, f)
|
|
||||||
return
|
return
|
||||||
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
|
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
|
||||||
m, ok := im.toMatch(p)
|
m, ok := im.toMatch(p)
|
||||||
@@ -1534,7 +1872,35 @@ func (im *importer) dstnat(p *parsed) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
im.note("the dstnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
im.note("the dstnat rule handle %d couldn't be imported (%s): add it by hand: %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
||||||
|
}
|
||||||
|
|
||||||
|
// portForward makes the port forward for one public port of a DNAT rule
|
||||||
|
// (several: the port goes into its name).
|
||||||
|
func (im *importer) portForward(p *parsed, ports string, several bool) {
|
||||||
|
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
|
||||||
|
if p.name != "" {
|
||||||
|
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
|
||||||
|
}
|
||||||
|
if several && f.Name != "" {
|
||||||
|
f.Name += "-" + ports
|
||||||
|
}
|
||||||
|
if f.ToPort == ports {
|
||||||
|
f.ToPort = ""
|
||||||
|
}
|
||||||
|
f.PublicAddresses = im.addrRefs(p.dst)
|
||||||
|
f.Sources = im.addrRefs(p.src)
|
||||||
|
// The same forward for another protocol: one forward, both.
|
||||||
|
for _, o := range im.st.Forwards {
|
||||||
|
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
|
||||||
|
if !contains(o.Protocols, p.proto) {
|
||||||
|
o.Protocols = append(o.Protocols, p.proto)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
|
||||||
|
im.st.Forwards = append(im.st.Forwards, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`)
|
var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`)
|
||||||
@@ -1570,6 +1936,7 @@ func (im *importer) nameForwards() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (im *importer) srcnat(p *parsed) {
|
func (im *importer) srcnat(p *parsed) {
|
||||||
|
im.checkSets(p)
|
||||||
if len(p.unsupported) > 0 {
|
if len(p.unsupported) > 0 {
|
||||||
im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
||||||
return
|
return
|
||||||
@@ -1618,7 +1985,8 @@ func (im *importer) srcnat(p *parsed) {
|
|||||||
// absorbForward folds a port forward's own forward-chain accept (and an
|
// absorbForward folds a port forward's own forward-chain accept (and an
|
||||||
// IPv6 pinhole on its port) into the port forward.
|
// IPv6 pinhole on its port) into the port forward.
|
||||||
func (im *importer) absorbForward(p *parsed) bool {
|
func (im *importer) absorbForward(p *parsed) bool {
|
||||||
if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 {
|
if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 ||
|
||||||
|
p.notIn || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
port, err := model.NormalizePorts(p.dports[0])
|
port, err := model.NormalizePorts(p.dports[0])
|
||||||
@@ -1627,6 +1995,12 @@ func (im *importer) absorbForward(p *parsed) bool {
|
|||||||
}
|
}
|
||||||
dst := p.dst[0]
|
dst := p.dst[0]
|
||||||
if p.family == "ipv4" || !strings.Contains(dst, ":") {
|
if p.family == "ipv4" || !strings.Contains(dst, ":") {
|
||||||
|
// The forward's own accept takes only what was port forwarded: an
|
||||||
|
// accept of anything to that host and port stays a rule (inside
|
||||||
|
// clients reach the host directly through it).
|
||||||
|
if key(p.ctStatus) != "dnat" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, f := range im.st.Forwards {
|
for _, f := range im.st.Forwards {
|
||||||
inside := f.ToPort
|
inside := f.ToPort
|
||||||
if inside == "" {
|
if inside == "" {
|
||||||
@@ -1653,7 +2027,8 @@ func (im *importer) absorbForward(p *parsed) bool {
|
|||||||
cands = append(cands, f)
|
cands = append(cands, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(cands) == 1 && len(p.ctStatus) == 0 {
|
// The pinhole lets in from everywhere: only an accept that did too.
|
||||||
|
if len(cands) == 1 && len(p.ctStatus) == 0 && len(p.inIf) == 0 && len(p.ctState) == 0 {
|
||||||
cands[0].To6 = dst
|
cands[0].To6 = dst
|
||||||
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
|
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -115,9 +115,16 @@ func TestImportRouter(t *testing.T) {
|
|||||||
if !wgDNS {
|
if !wgDNS {
|
||||||
t.Errorf("wg0_input not inlined")
|
t.Errorf("wg0_input not inlined")
|
||||||
}
|
}
|
||||||
// The jump's and the rule's sources both: the rule's are kept, noted.
|
// The jump's and the rule's sources both: the rule's lie within the
|
||||||
if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") {
|
// jump's, so they say it all, without a note.
|
||||||
t.Errorf("no note on the overlapping sources: %q", res.Notes)
|
var wgSSH bool
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if r.Chain == model.ChainInput && strings.Join(r.Services, ",") == "tcp/22" && strings.Join(r.Sources, ",") == "10.50.50.3,10.50.50.6" {
|
||||||
|
wgSSH = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !wgSSH || strings.Contains(strings.Join(res.Notes, "\n"), "jump") {
|
||||||
|
t.Errorf("the WireGuard SSH rule: found %v, notes %q", wgSSH, res.Notes)
|
||||||
}
|
}
|
||||||
// The imported configuration is valid, and renders.
|
// The imported configuration is valid, and renders.
|
||||||
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
|
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "audit", "handle": 1}}, {"chain": {"family": "inet", "table": "audit", "name": "early", "handle": 1, "type": "filter", "hook": "input", "prio": -10, "policy": "accept"}}, {"chain": {"family": "inet", "table": "audit", "name": "input", "handle": 2, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "audit", "name": "admin", "handle": 3}}, {"chain": {"family": "inet", "table": "audit", "name": "forward", "handle": 4, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "audit", "name": "prerouting", "handle": 5, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"set": {"family": "inet", "name": "blocklist", "table": "audit", "type": "ipv4_addr", "handle": 6, "flags": ["timeout"], "timeout": 3600}}, {"set": {"family": "inet", "name": "lans", "table": "audit", "type": "ifname", "handle": 7, "elem": ["enp2s0", "enp3s0"]}}, {"rule": {"family": "inet", "table": "audit", "chain": "early", "handle": 8, "comment": "an early input chain", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "192.0.2.66"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 9, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 10, "comment": "dynamic", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@blocklist"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 11, "comment": "an interface set", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "@lans"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 12, "comment": "two source matches", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.0.0.0", "len": 8}}}}, {"match": {"op": "!=", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.0.10.0", "len": 24}}}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 13, "comment": "hop limit not 255", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": "nd-neighbor-solicit"}}, {"match": {"op": "!=", "left": {"payload": {"protocol": "ip6", "field": "hoplimit"}}, "right": 255}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 14, "comment": "DNS from the IoT VLAN", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "enp2s0.20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 53}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 15, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.9.0.0", "len": 24}}}}, {"jump": {"target": "admin"}}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 16, "comment": "within the jump", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "10.9.0.5"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 17, "comment": "outside the jump", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "10.8.0.1"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 23}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 18, "comment": "raw under the jump", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "mark"}}, "right": 1}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "handle": 19, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "handle": 20, "comment": "not only forwarded", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "daddr"}}, "right": "10.0.10.37"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 443}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "hanLine truncated
|
||||||
Vendored
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#! /sbin/nft -f
|
||||||
|
# Cases the importer must not get wrong (importer_test.go TestImportAudit).
|
||||||
|
|
||||||
|
define wan_if = "wan"
|
||||||
|
define lan_if = "enp2s0"
|
||||||
|
define iot_if = "enp2s0.20"
|
||||||
|
define admins = { 10.9.0.0/24 }
|
||||||
|
|
||||||
|
table inet audit {
|
||||||
|
set blocklist {
|
||||||
|
type ipv4_addr
|
||||||
|
flags timeout
|
||||||
|
timeout 1h
|
||||||
|
}
|
||||||
|
set lans {
|
||||||
|
type ifname
|
||||||
|
elements = { "enp2s0", "enp3s0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
chain early {
|
||||||
|
type filter hook input priority -10; policy accept;
|
||||||
|
ip saddr 192.0.2.66 drop comment "an early input chain"
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept
|
||||||
|
ip saddr @blocklist drop comment "dynamic"
|
||||||
|
iifname @lans accept comment "an interface set"
|
||||||
|
ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop comment "two source matches"
|
||||||
|
icmpv6 type nd-neighbor-solicit ip6 hoplimit != 255 drop comment "hop limit not 255"
|
||||||
|
iifname $iot_if udp dport 53 accept comment "DNS from the IoT VLAN"
|
||||||
|
ip saddr $admins jump admin
|
||||||
|
}
|
||||||
|
|
||||||
|
chain admin {
|
||||||
|
ip saddr 10.9.0.5 tcp dport 22 accept comment "within the jump"
|
||||||
|
ip saddr 10.8.0.1 tcp dport 23 accept comment "outside the jump"
|
||||||
|
meta mark 0x00000001 accept comment "raw under the jump"
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter; policy drop;
|
||||||
|
ct state established,related accept
|
||||||
|
ip daddr 10.0.10.37 tcp dport 443 accept comment "not only forwarded"
|
||||||
|
iifname $lan_if oifname $wan_if accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain prerouting {
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
ip daddr 203.0.113.1 tcp dport { 80, 443 } dnat ip to 10.0.10.37 comment "web"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
table inet audit { # handle 1
|
||||||
|
set blocklist { # handle 6
|
||||||
|
type ipv4_addr
|
||||||
|
timeout 1h
|
||||||
|
}
|
||||||
|
|
||||||
|
set lans { # handle 7
|
||||||
|
type ifname
|
||||||
|
elements = { "enp2s0",
|
||||||
|
"enp3s0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
chain early { # handle 1
|
||||||
|
type filter hook input priority filter - 10; policy accept;
|
||||||
|
ip saddr 192.0.2.66 drop comment "an early input chain" # handle 8
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input { # handle 2
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept # handle 9
|
||||||
|
ip saddr @blocklist drop comment "dynamic" # handle 10
|
||||||
|
iifname @lans accept comment "an interface set" # handle 11
|
||||||
|
ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop comment "two source matches" # handle 12
|
||||||
|
icmpv6 type nd-neighbor-solicit ip6 hoplimit != 255 drop comment "hop limit not 255" # handle 13
|
||||||
|
iifname "enp2s0.20" udp dport 53 accept comment "DNS from the IoT VLAN" # handle 14
|
||||||
|
ip saddr 10.9.0.0/24 jump admin # handle 15
|
||||||
|
}
|
||||||
|
|
||||||
|
chain admin { # handle 3
|
||||||
|
ip saddr 10.9.0.5 tcp dport 22 accept comment "within the jump" # handle 16
|
||||||
|
ip saddr 10.8.0.1 tcp dport 23 accept comment "outside the jump" # handle 17
|
||||||
|
meta mark 0x00000001 accept comment "raw under the jump" # handle 18
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward { # handle 4
|
||||||
|
type filter hook forward priority filter; policy drop;
|
||||||
|
ct state established,related accept # handle 19
|
||||||
|
ip daddr 10.0.10.37 tcp dport 443 accept comment "not only forwarded" # handle 20
|
||||||
|
iifname "enp2s0" oifname "wan" accept # handle 21
|
||||||
|
}
|
||||||
|
|
||||||
|
chain prerouting { # handle 5
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
ip daddr 203.0.113.1 tcp dport { 80, 443 } dnat ip to 10.0.10.37 comment "web" # handle 23
|
||||||
|
}
|
||||||
|
}
|
||||||
+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}} {
|
||||||
|
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||||
|
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
|
||||||
|
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
|
||||||
|
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nftCheck(t, cmd, "shadow fallback", ShadowFallback())
|
||||||
|
}
|
||||||
|
|
||||||
|
func nftCheck(t *testing.T, cmd []string, name, text string) {
|
||||||
|
t.Helper()
|
||||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
c.Stdin = strings.NewReader(rs.Text)
|
c.Stdin = strings.NewReader(text)
|
||||||
if out, err := c.CombinedOutput(); err != nil {
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
||||||
for i, l := range strings.Split(rs.Text, "\n") {
|
for i, l := range strings.Split(text, "\n") {
|
||||||
t.Logf("%3d %s", i+1, l)
|
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
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table})
|
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table, Zones: in.Zones})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ export async function render(el) {
|
|||||||
const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" });
|
const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" });
|
||||||
const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" });
|
const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" });
|
||||||
const table = input({ placeholder: "inet main (when there are several)", class: "mono" });
|
const table = input({ placeholder: "inet main (when there are several)", class: "mono" });
|
||||||
|
const zones = input({ placeholder: "enp2s0=lan, wg0=vpn (optional)", class: "mono" });
|
||||||
const replace = checkbox("replace Housecarl's configuration", false);
|
const replace = checkbox("replace Housecarl's configuration", false);
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
const run = async (dry) => {
|
const run = async (dry) => {
|
||||||
mount(out);
|
mount(out);
|
||||||
|
const zoneNames = {};
|
||||||
|
for (const part of zones.value.split(",").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const [iface, name] = part.split("=").map((s) => (s || "").trim());
|
||||||
|
if (!iface || !name) { mount(out, h("div", { class: "msg err" }, "Zone names: write each as interface=name (enp2s0=lan).")); return; }
|
||||||
|
zoneNames[iface] = name;
|
||||||
|
}
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; }
|
try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; }
|
||||||
try {
|
try {
|
||||||
const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), replace: replace.input.checked },
|
const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), zones: zoneNames, replace: replace.input.checked },
|
||||||
query: dry ? { dry_run: 1 } : undefined });
|
query: dry ? { dry_run: 1 } : undefined });
|
||||||
const st = r.state;
|
const st = r.state;
|
||||||
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
|
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
|
||||||
@@ -34,7 +41,8 @@ export async function render(el) {
|
|||||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"),
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"),
|
||||||
h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))),
|
h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))),
|
||||||
h("div", { class: "panel" }, h("div", { class: "form-grid" },
|
h("div", { class: "panel" }, h("div", { class: "form-grid" },
|
||||||
field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table), h("label"), replace),
|
field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table),
|
||||||
|
field("Zone names", zones, "Name zones by what the network is for; interfaces given one name share a zone. Otherwise a define holding the interface names it, or the interface's own name."), h("label"), replace),
|
||||||
h("div", { class: "actions form-actions" },
|
h("div", { class: "actions form-actions" },
|
||||||
h("button", { onclick: () => run(true) }, "Preview"),
|
h("button", { onclick: () => run(true) }, "Preview"),
|
||||||
h("button", { class: "primary", onclick: () => run(false) }, "Import"))),
|
h("button", { class: "primary", onclick: () => run(false) }, "Import"))),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
+69
-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,21 +116,75 @@ 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"
|
||||||
run "dry run of the import" 0 H import --table "inet main" --defines /w/router.nft -n
|
run "dry run of the import" 0 H import --table "inet main" --defines /w/router.nft -n
|
||||||
contains "5 port forwards, 2 source NAT rules"
|
contains "5 port forwards, 2 source NAT rules"
|
||||||
contains "dry run: nothing was changed"
|
contains "dry run: nothing was changed"
|
||||||
|
contains "dhcp (enp2s0: the define dhcp_int)"
|
||||||
|
run "zone names given at import" 0 H import --table "inet main" --defines /w/router.nft --zone enp2s0=lan --zone wg0=vpn -n
|
||||||
|
contains "lan (enp2s0: 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"
|
||||||
@@ -263,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"
|
||||||
@@ -404,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
|
||||||
@@ -442,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