Files
housecarl/internal/render/shadow.go
T
abrown38andClaude Opus 5.5 de075edd5d 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>
2026-10-20 18:11:01 +01:00

128 lines
3.7 KiB
Go

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()
}