Shadow mode: Housecarl beside the firewall in force, changing nothing

shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 18:11:01 +01:00
1 parent 1e6c085ef9
commit de075edd5d
48 files changed
+1345 -93

No files matched your search

+91 -16
View File
@@ -8,6 +8,14 @@
// counter and a comment "hc:<kind><id>", which is how counters are read
// back, and Ruleset.Lines maps each line to its object, for nftables'
// error messages.
//
// In shadow mode (Options.Shadow, docs/shadow.md) the same rules are
// written so that they change nothing: drops and rejects accept instead
// (an accept only ends Housecarl's chain; the packet goes on to other
// tables' chains, which decide), policies accept, NAT only counts, and
// what alters packets (MSS clamping, the flowtable) is left out. What
// would be dropped, rejected or translated carries a marker in its
// comment ("[would drop]"), which the tracer reads.
package render
import (
@@ -42,8 +50,18 @@ type Options struct {
// DeviceSets are the per-device traffic counters' entries
// (stats_up_v4 …), so a reload keeps them.
DeviceSets map[string][]Element
// Shadow renders for shadow mode: nothing is blocked, rejected,
// translated or changed (see the package comment).
Shadow bool
// ShadowBefore runs the filter chains just before other tables' on
// their hooks (priority - 10) instead of just after (+ 10).
ShadowBefore bool
}
// Would marks, in shadow mode, a rule's comment with what it would do:
// "[would drop]".
func Would(action string) string { return "[would " + action + "]" }
// Element is a dynamic set's entry with its remaining lifetime, or a
// per-address quota's entry with what it used.
type Element struct {
@@ -112,6 +130,9 @@ type renderer struct {
func (r *renderer) render() {
w := r.w
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
if r.opt.Shadow {
w.emit("", "# Shadow mode: nothing here drops, rejects or translates; other tables decide.")
}
w.emit("", "table inet %s {}", Table)
w.emit("", "delete table inet %s", Table)
w.emit("", "table inet %s {", Table)
@@ -265,6 +286,10 @@ func addrType(v6 bool) string {
}
func (r *renderer) flowtableDevices() []string {
if r.opt.Shadow {
// Offloaded connections skip every table's forward chain.
return nil
}
var out []string
for _, d := range r.st.Settings.FlowtableDevices {
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
@@ -277,8 +302,19 @@ func (r *renderer) flowtableDevices() []string {
// ---- chains
func (r *renderer) chain(name, hook, prio, policy string) {
nat := prio == "dstnat" || prio == "srcnat"
if r.opt.Shadow {
// NAT chains are asked in turn only until one translates: to
// count anything, Housecarl's must come first.
if nat || r.opt.ShadowBefore {
prio += " - 10"
} else {
prio += " + 10"
}
policy = model.PolicyAccept
}
r.w.emit("", "\tchain %s {", name)
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[prio == "dstnat" || prio == "srcnat"], hook, prio, policy)
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[nat], hook, prio, policy)
}
func (r *renderer) end() { r.w.emit("", "\t}") }
@@ -287,6 +323,16 @@ func (r *renderer) builtin(name, format string, args ...any) {
r.w.emit("built-in: "+name, "\t\t"+format+" comment \"hc:b-%s\"", append(args, name)...)
}
// builtinDrop writes a built-in that drops what it matches, or in shadow
// mode counts it.
func (r *renderer) builtinDrop(name, format string, args ...any) {
if r.opt.Shadow {
r.w.emit("built-in: "+name, "\t\t"+format+" counter accept comment \"hc:b-%s %s\"", append(args, name, Would("drop"))...)
return
}
r.builtin(name, format+" counter drop", args...)
}
func (r *renderer) early() {
st := r.st
r.chain("early", "prerouting", "raw", "accept")
@@ -302,10 +348,10 @@ func (r *renderer) early() {
}
}
if len(v4) > 0 {
r.builtin("antispoof", "iifname %s ip saddr { %s } counter drop", ifaces(ext), strings.Join(v4, ", "))
r.builtinDrop("antispoof", "iifname %s ip saddr { %s }", ifaces(ext), strings.Join(v4, ", "))
}
if len(v6) > 0 {
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
r.builtinDrop("antispoof6", "iifname %s ip6 saddr { %s }", ifaces(ext), strings.Join(v6, ", "))
}
}
r.rules(model.ChainEarly, true)
@@ -325,7 +371,7 @@ func (r *renderer) connBuiltins() {
r.builtin("established", "ct state established,related counter accept")
}
if b.DropInvalid {
r.builtin("invalid", "ct state invalid counter drop")
r.builtinDrop("invalid", "ct state invalid")
}
}
@@ -366,7 +412,8 @@ func (r *renderer) forward(flowtable bool) {
b := st.Settings.Builtins
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
ext := st.ExternalInterfaces()
if b.MSSClamp && len(ext) > 0 {
// Shadow mode changes no packet.
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
}
r.rules(model.ChainForward, true)
@@ -404,15 +451,21 @@ func (r *renderer) output() {
r.end()
}
// policyTail counts (and optionally logs) what reaches the policy.
// policyTail counts (and optionally logs) what reaches the policy. In
// shadow mode, where the policy accepts, that is what it would drop, and
// it is always logged: the log is how shadow mode shows it.
func (r *renderer) policyTail(chain, policy string) {
if policy != model.PolicyDrop {
return
}
obj := "policy: " + chain
if r.st.Settings.LogPolicyDrops && chain != "output" {
if r.opt.Shadow || r.st.Settings.LogPolicyDrops && chain != "output" {
r.w.emit(obj, "\t\tlimit rate 10/second burst 20 packets log prefix %s group %d", quote("hc "+chain+" policy drop: "), LogGroup)
}
if r.opt.Shadow {
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s %s\"", chain, Would("drop"))
return
}
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s\"", chain)
}
@@ -428,7 +481,15 @@ func (r *renderer) rules(chain string, traffic bool) {
obj := rule.Label()
mark := fmt.Sprintf("r%d", rule.ID)
if rule.Raw != "" {
r.w.emit(obj, "\t\t%s", rule.Raw)
raw := rule.Raw
if r.opt.Shadow {
var ok bool
if raw, ok = ShadowRaw(raw); !ok {
r.w.emit(obj, "\t\t# left out in shadow mode: %s", rule.Raw)
continue
}
}
r.w.emit(obj, "\t\t%s", raw)
continue
}
var tail []string
@@ -478,17 +539,22 @@ func (r *renderer) verdict(rule *model.Rule, mark string) string {
}
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
}
switch rule.Action {
case model.ActionAccept, model.ActionDrop:
text := rule.Comment
switch {
case r.opt.Shadow && (rule.Action == model.ActionDrop || rule.Action == model.ActionReject):
// It ends Housecarl's chain as the drop would, and no more.
v = append(v, model.ActionAccept)
text = Would(rule.Action) + " " + text
case rule.Action == model.ActionAccept, rule.Action == model.ActionDrop:
v = append(v, rule.Action)
case model.ActionReject:
case rule.Action == model.ActionReject:
if rule.RejectWith == "tcp-reset" {
v = append(v, "reject with tcp reset")
} else {
v = append(v, "reject")
}
}
v = append(v, "comment "+comment(mark, rule.Comment))
v = append(v, "comment "+comment(mark, text))
return strings.Join(v, " ")
}
@@ -781,7 +847,7 @@ func (r *renderer) dstnat() {
}
}
}
r.w.emit(obj, "\t\t%s counter redirect to :%s comment %s", strings.Join(nonEmpty(line.parts), " "), to, comment(mark, x.Comment))
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat("redirect to :"+to, mark, x.Comment))
}
}
ext := st.ExternalInterfaces()
@@ -811,7 +877,7 @@ func (r *renderer) dstnat() {
for _, wh := range where {
for _, sa := range srcAlts {
for _, p := range f.Protocols {
r.w.emit(obj, "\t\t%s counter dnat ip to %s comment %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), to, comment(mark, f.Name+" "+f.Comment))
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), r.nat("dnat ip to "+to, mark, f.Name+" "+f.Comment))
}
}
}
@@ -885,7 +951,7 @@ func (r *renderer) srcnat() {
}
for _, sa := range srcAlts {
for _, p := range f.Protocols {
r.w.emit(obj, "\t\t%s counter masquerade comment %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), r.nat("masquerade", mark, f.Name))
}
}
}
@@ -917,12 +983,21 @@ func (r *renderer) srcnat() {
action = "snat ip to " + x.To
}
}
r.w.emit(obj, "\t\t%s counter %s comment %s", strings.Join(nonEmpty(line.parts), " "), action, comment(mark, x.Comment))
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat(action, mark, x.Comment))
}
}
r.end()
}
// nat writes a NAT rule's statement and comment; in shadow mode only the
// comment, saying what it would do ("[would dnat]").
func (r *renderer) nat(action, mark, text string) string {
if r.opt.Shadow {
return "comment " + comment(mark, Would(strings.Fields(action)[0])+" "+text)
}
return action + " comment " + comment(mark, text)
}
// ---- helpers
// hours writes a time window. nftables can't take a range past midnight,
+20 -9
View File
@@ -2,6 +2,7 @@ package render
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
@@ -254,15 +255,25 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}})
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
for i, l := range strings.Split(rs.Text, "\n") {
t.Logf("%3d %s", i+1, l)
}
// Enforcing, and in shadow mode after and before other tables.
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
}
}
nftCheck(t, cmd, "shadow fallback", ShadowFallback())
}
func nftCheck(t *testing.T, cmd []string, name, text string) {
t.Helper()
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
for i, l := range strings.Split(text, "\n") {
t.Logf("%3d %s", i+1, l)
}
}
}
+127
View File
@@ -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()
}
+91
View File
@@ -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)
}
}
}