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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user