Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e6c085ef9
commit
de075edd5d
48 files changed
+1345
-93
No files matched your search
+91
-16
@@ -8,6 +8,14 @@
|
||||
// counter and a comment "hc:<kind><id>", which is how counters are read
|
||||
// back, and Ruleset.Lines maps each line to its object, for nftables'
|
||||
// error messages.
|
||||
//
|
||||
// In shadow mode (Options.Shadow, docs/shadow.md) the same rules are
|
||||
// written so that they change nothing: drops and rejects accept instead
|
||||
// (an accept only ends Housecarl's chain; the packet goes on to other
|
||||
// tables' chains, which decide), policies accept, NAT only counts, and
|
||||
// what alters packets (MSS clamping, the flowtable) is left out. What
|
||||
// would be dropped, rejected or translated carries a marker in its
|
||||
// comment ("[would drop]"), which the tracer reads.
|
||||
package render
|
||||
|
||||
import (
|
||||
@@ -42,8 +50,18 @@ type Options struct {
|
||||
// DeviceSets are the per-device traffic counters' entries
|
||||
// (stats_up_v4 …), so a reload keeps them.
|
||||
DeviceSets map[string][]Element
|
||||
// Shadow renders for shadow mode: nothing is blocked, rejected,
|
||||
// translated or changed (see the package comment).
|
||||
Shadow bool
|
||||
// ShadowBefore runs the filter chains just before other tables' on
|
||||
// their hooks (priority - 10) instead of just after (+ 10).
|
||||
ShadowBefore bool
|
||||
}
|
||||
|
||||
// Would marks, in shadow mode, a rule's comment with what it would do:
|
||||
// "[would drop]".
|
||||
func Would(action string) string { return "[would " + action + "]" }
|
||||
|
||||
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||
// per-address quota's entry with what it used.
|
||||
type Element struct {
|
||||
@@ -112,6 +130,9 @@ type renderer struct {
|
||||
func (r *renderer) render() {
|
||||
w := r.w
|
||||
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
|
||||
if r.opt.Shadow {
|
||||
w.emit("", "# Shadow mode: nothing here drops, rejects or translates; other tables decide.")
|
||||
}
|
||||
w.emit("", "table inet %s {}", Table)
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
@@ -265,6 +286,10 @@ func addrType(v6 bool) string {
|
||||
}
|
||||
|
||||
func (r *renderer) flowtableDevices() []string {
|
||||
if r.opt.Shadow {
|
||||
// Offloaded connections skip every table's forward chain.
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, d := range r.st.Settings.FlowtableDevices {
|
||||
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
||||
@@ -277,8 +302,19 @@ func (r *renderer) flowtableDevices() []string {
|
||||
// ---- chains
|
||||
|
||||
func (r *renderer) chain(name, hook, prio, policy string) {
|
||||
nat := prio == "dstnat" || prio == "srcnat"
|
||||
if r.opt.Shadow {
|
||||
// NAT chains are asked in turn only until one translates: to
|
||||
// count anything, Housecarl's must come first.
|
||||
if nat || r.opt.ShadowBefore {
|
||||
prio += " - 10"
|
||||
} else {
|
||||
prio += " + 10"
|
||||
}
|
||||
policy = model.PolicyAccept
|
||||
}
|
||||
r.w.emit("", "\tchain %s {", name)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[prio == "dstnat" || prio == "srcnat"], hook, prio, policy)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[nat], hook, prio, policy)
|
||||
}
|
||||
|
||||
func (r *renderer) end() { r.w.emit("", "\t}") }
|
||||
@@ -287,6 +323,16 @@ func (r *renderer) builtin(name, format string, args ...any) {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" comment \"hc:b-%s\"", append(args, name)...)
|
||||
}
|
||||
|
||||
// builtinDrop writes a built-in that drops what it matches, or in shadow
|
||||
// mode counts it.
|
||||
func (r *renderer) builtinDrop(name, format string, args ...any) {
|
||||
if r.opt.Shadow {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" counter accept comment \"hc:b-%s %s\"", append(args, name, Would("drop"))...)
|
||||
return
|
||||
}
|
||||
r.builtin(name, format+" counter drop", args...)
|
||||
}
|
||||
|
||||
func (r *renderer) early() {
|
||||
st := r.st
|
||||
r.chain("early", "prerouting", "raw", "accept")
|
||||
@@ -302,10 +348,10 @@ func (r *renderer) early() {
|
||||
}
|
||||
}
|
||||
if len(v4) > 0 {
|
||||
r.builtin("antispoof", "iifname %s ip saddr { %s } counter drop", ifaces(ext), strings.Join(v4, ", "))
|
||||
r.builtinDrop("antispoof", "iifname %s ip saddr { %s }", ifaces(ext), strings.Join(v4, ", "))
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
||||
r.builtinDrop("antispoof6", "iifname %s ip6 saddr { %s }", ifaces(ext), strings.Join(v6, ", "))
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainEarly, true)
|
||||
@@ -325,7 +371,7 @@ func (r *renderer) connBuiltins() {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if b.DropInvalid {
|
||||
r.builtin("invalid", "ct state invalid counter drop")
|
||||
r.builtinDrop("invalid", "ct state invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +412,8 @@ func (r *renderer) forward(flowtable bool) {
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.MSSClamp && len(ext) > 0 {
|
||||
// Shadow mode changes no packet.
|
||||
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
|
||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||
}
|
||||
r.rules(model.ChainForward, true)
|
||||
@@ -404,15 +451,21 @@ func (r *renderer) output() {
|
||||
r.end()
|
||||
}
|
||||
|
||||
// policyTail counts (and optionally logs) what reaches the policy.
|
||||
// policyTail counts (and optionally logs) what reaches the policy. In
|
||||
// shadow mode, where the policy accepts, that is what it would drop, and
|
||||
// it is always logged: the log is how shadow mode shows it.
|
||||
func (r *renderer) policyTail(chain, policy string) {
|
||||
if policy != model.PolicyDrop {
|
||||
return
|
||||
}
|
||||
obj := "policy: " + chain
|
||||
if r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
if r.opt.Shadow || r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
r.w.emit(obj, "\t\tlimit rate 10/second burst 20 packets log prefix %s group %d", quote("hc "+chain+" policy drop: "), LogGroup)
|
||||
}
|
||||
if r.opt.Shadow {
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s %s\"", chain, Would("drop"))
|
||||
return
|
||||
}
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s\"", chain)
|
||||
}
|
||||
|
||||
@@ -428,7 +481,15 @@ func (r *renderer) rules(chain string, traffic bool) {
|
||||
obj := rule.Label()
|
||||
mark := fmt.Sprintf("r%d", rule.ID)
|
||||
if rule.Raw != "" {
|
||||
r.w.emit(obj, "\t\t%s", rule.Raw)
|
||||
raw := rule.Raw
|
||||
if r.opt.Shadow {
|
||||
var ok bool
|
||||
if raw, ok = ShadowRaw(raw); !ok {
|
||||
r.w.emit(obj, "\t\t# left out in shadow mode: %s", rule.Raw)
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s", raw)
|
||||
continue
|
||||
}
|
||||
var tail []string
|
||||
@@ -478,17 +539,22 @@ func (r *renderer) verdict(rule *model.Rule, mark string) string {
|
||||
}
|
||||
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
|
||||
}
|
||||
switch rule.Action {
|
||||
case model.ActionAccept, model.ActionDrop:
|
||||
text := rule.Comment
|
||||
switch {
|
||||
case r.opt.Shadow && (rule.Action == model.ActionDrop || rule.Action == model.ActionReject):
|
||||
// It ends Housecarl's chain as the drop would, and no more.
|
||||
v = append(v, model.ActionAccept)
|
||||
text = Would(rule.Action) + " " + text
|
||||
case rule.Action == model.ActionAccept, rule.Action == model.ActionDrop:
|
||||
v = append(v, rule.Action)
|
||||
case model.ActionReject:
|
||||
case rule.Action == model.ActionReject:
|
||||
if rule.RejectWith == "tcp-reset" {
|
||||
v = append(v, "reject with tcp reset")
|
||||
} else {
|
||||
v = append(v, "reject")
|
||||
}
|
||||
}
|
||||
v = append(v, "comment "+comment(mark, rule.Comment))
|
||||
v = append(v, "comment "+comment(mark, text))
|
||||
return strings.Join(v, " ")
|
||||
}
|
||||
|
||||
@@ -781,7 +847,7 @@ func (r *renderer) dstnat() {
|
||||
}
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter redirect to :%s comment %s", strings.Join(nonEmpty(line.parts), " "), to, comment(mark, x.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat("redirect to :"+to, mark, x.Comment))
|
||||
}
|
||||
}
|
||||
ext := st.ExternalInterfaces()
|
||||
@@ -811,7 +877,7 @@ func (r *renderer) dstnat() {
|
||||
for _, wh := range where {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter dnat ip to %s comment %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), to, comment(mark, f.Name+" "+f.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), r.nat("dnat ip to "+to, mark, f.Name+" "+f.Comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -885,7 +951,7 @@ func (r *renderer) srcnat() {
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter masquerade comment %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), r.nat("masquerade", mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,12 +983,21 @@ func (r *renderer) srcnat() {
|
||||
action = "snat ip to " + x.To
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter %s comment %s", strings.Join(nonEmpty(line.parts), " "), action, comment(mark, x.Comment))
|
||||
r.w.emit(obj, "\t\t%s counter %s", strings.Join(nonEmpty(line.parts), " "), r.nat(action, mark, x.Comment))
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// nat writes a NAT rule's statement and comment; in shadow mode only the
|
||||
// comment, saying what it would do ("[would dnat]").
|
||||
func (r *renderer) nat(action, mark, text string) string {
|
||||
if r.opt.Shadow {
|
||||
return "comment " + comment(mark, Would(strings.Fields(action)[0])+" "+text)
|
||||
}
|
||||
return action + " comment " + comment(mark, text)
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
// hours writes a time window. nftables can't take a range past midnight,
|
||||
|
||||
Reference in new issue
Block a user