Import: keep what rulesets mean, name zones as asked

Fixes from an audit of the importer, each of which could change what an
imported ruleset does: two base chains on one hook imported their rules
twice (and hoisted the second's built-ins), now in priority order with
the stricter policy; raw rules from a jumped-to chain lost the jump's
condition, now put in front; a jump and a rule matching one field are
combined only when one's addresses lie within the other's; a second
match on a field replaced the first, and != on hop limit and TCP flags
read as ==, now kept raw; a forward accept without ct status dnat no
longer folds into the port forward, nor an interface-limited IPv6
accept into an open pinhole; raw rules' set references are renamed
(NAME_v4/_v6) or written out, so nftables takes them; DNATs with
matches a forward can't hold are left out with a note.

Sets with flags timeout become dynamic sets; a DNAT of several ports one
forward per port. Zone names: --zone IFACE=NAME (and on the Import
page), VLAN defines, more suffixes, and a note on where each name came
from. testdata/audit.nft covers the cases, checked with nft 1.1.3.

make test and make e2e (246 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 08:43:42 +01:00
1 parent c17f5cd9f0
commit 47bae07d42
15 files changed
+823 -117

No files matched your search

+98
View File
@@ -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)
}
}
}
+467 -92
View File
@@ -20,6 +20,7 @@ import (
"strings"
"housecarl/internal/model"
"housecarl/internal/render"
)
// Input is what to import.
@@ -34,6 +35,9 @@ type Input struct {
// Table is the table to import ("inet main"); empty: the only table
// that isn't Housecarl's.
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.
@@ -77,11 +81,13 @@ type nftRule struct {
}
type nftSet struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Elem []json.RawMessage `json:"elem"`
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Flags []string `json:"flags"`
Timeout int `json:"timeout"`
Elem []json.RawMessage `json:"elem"`
}
// nftMap is a named map: its elements are [key, value] pairs.
@@ -121,6 +127,8 @@ type importer struct {
meters map[string]bool // sets used as meters
noted map[string]bool
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) {
@@ -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)
}
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
for _, item := range doc.Nftables {
if raw, ok := item["table"]; ok {
@@ -207,6 +215,7 @@ func Import(in Input) (*Result, error) {
im.importSets()
im.run()
im.nameForwards()
im.noteZones()
im.st.Renumber()
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, ",")
}
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 {
return ifaceRe.MatchString(s) && !strings.ContainsAny(s, ".:") || s == "lo"
return len(s) <= 15 && (ifaceRe.MatchString(s) || vlanRe.MatchString(s))
}
// ---- sets
@@ -360,12 +372,22 @@ func (im *importer) importSets() {
for _, e := range s.Elem {
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...)
continue
}
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: setName(n), Entries: entries})
im.used[n] = true
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: entries})
im.used[name] = true
}
}
@@ -397,7 +419,10 @@ func (im *importer) namedSet(entries []string) string {
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 {
for _, z := range im.st.Zones {
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 {
name = d
for _, suf := range []string{"_interface", "_iface", "_int", "_if", "_dev"} {
name, from = d, "the define "+d
for _, suf := range []string{"_interface", "_ifname", "_iface", "_device", "_int", "_nic", "_if", "_dev"} {
name = strings.TrimSuffix(name, suf)
}
}
name = setName(strings.TrimSuffix(name, "*"))
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.zoneFrom[name] = from
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) {
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
z.External = true
@@ -522,6 +604,11 @@ type parsed struct {
// name: a port forward's name, when the rule's comment won't do
// (one of a map's elements).
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
@@ -550,7 +637,11 @@ func parse(r nftRule) *parsed {
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) {
switch k {
@@ -839,19 +930,24 @@ func (p *parsed) match(m match) {
if meta, ok := left["meta"]; ok {
var x struct{ Key string }
json.Unmarshal(meta, &x)
switch x.Key {
case "iifname":
switch {
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
case "oifname":
case x.Key == "oifname":
p.outIf, p.notOut = right, not
case "nfproto":
if len(right) == 1 && !not {
case x.Key == "nfproto":
if len(right) == 1 && !not && (p.family == "" || p.family == right[0]) {
p.family = right[0]
} else {
p.bad("meta nfproto")
}
case "l4proto":
if len(right) == 1 && !not {
case x.Key == "l4proto":
if len(right) == 1 && !not && (p.proto == "" || p.proto == right[0]) {
p.proto = right[0]
} else {
p.bad("meta l4proto with several protocols")
@@ -864,23 +960,29 @@ func (p *parsed) match(m match) {
if pl, ok := left["payload"]; ok {
var x struct{ Protocol, Field string }
json.Unmarshal(pl, &x)
fam := map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
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"):
if p.family == "" {
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
}
p.family = fam
if x.Field == "saddr" {
p.src, p.notSrc = right, not
} else {
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
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
case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not:
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
default:
p.bad(x.Protocol + " " + x.Field)
@@ -891,9 +993,9 @@ func (p *parsed) match(m match) {
var x struct{ Key string }
json.Unmarshal(ct, &x)
switch {
case x.Key == "state" && !not:
case x.Key == "state" && !not && len(p.ctState) == 0:
p.ctState = right
case x.Key == "status" && !not:
case x.Key == "status" && !not && len(p.ctStatus) == 0:
p.ctStatus = right
default:
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
func (im *importer) run() {
type base struct {
name, kind string
prio int
}
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 {
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"):
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":
bases = append(bases, base{name, "dstnat"})
bases = append(bases, base{name, "dstnat", c.Prio})
case c.Type == "nat" && c.Hook == "postrouting":
bases = append(bases, base{name, "srcnat"})
bases = append(bases, base{name, "srcnat", c.Prio})
case c.Hook != "":
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
}
set := &im.st.Settings
switch {
case c.Hook == "input" && c.Type == "filter":
set.InputPolicy = c.Policy
case c.Hook == "forward" && c.Type == "filter":
set.ForwardPolicy = c.Policy
case c.Hook == "output" && c.Type == "filter":
set.OutputPolicy = c.Policy
}
// 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
for hook, ps := range policies {
policy := model.PolicyAccept
if contains(ps, model.PolicyDrop) {
policy = model.PolicyDrop
}
switch hook {
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
// 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}
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{}
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
@@ -956,8 +1098,12 @@ func (im *importer) run() {
im.srcnat(p)
}
default:
chains = append(chains, b.kind)
prefix := true
if !contains(chains, b.kind) {
chains = append(chains, b.kind)
}
// 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 {
if bi := im.builtinOf(b.kind, p); bi != "" {
if found[bi] == nil {
@@ -1016,6 +1162,7 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
}
jumpMatch := *p
jumpMatch.verdict, jumpMatch.target = "", ""
jumpMatch.jumpText = joinText(p.jumpText, im.condText(p))
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
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)
@@ -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)
}
// merge puts a jump's matches in front of a jumped-to rule's. When both
// match the same field, the rule's own (narrower, usually) is kept and
// noted.
// condText is a jump rule's text without its verdict, counter and
// comment: what it matches ("ip saddr 10.50.50.0/24"); "" when there is
// 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 {
q := *p
q.inJump, q.jumpText = true, prefix.jumpText
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
fill := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
if len(*dst) == 0 && len(src) > 0 {
both := func(what string) { q.bad("both the jump to it and the rule match " + what) }
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
} else if len(*dst) > 0 && len(src) > 0 && key(*dst) != key(src) {
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",
p.handle, what, strings.Join(*dst, ", "), strings.Join(src, ", "))
case *not || srcNot:
if key(*dst) != key(src) || *not != srcNot {
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)
fill("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
fill("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
fill("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
if q.family == "" {
same := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
switch {
case len(src) == 0:
case len(*dst) == 0:
*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
case q.family != prefix.family:
both("an address family")
}
if len(q.ctState) == 0 {
q.ctState = prefix.ctState
}
if q.proto == "" {
switch {
case prefix.proto == "":
case q.proto == "":
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
}
// 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
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
}
// 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.
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
im.checkSets(p)
svcs, ok := im.services(p)
if !ok || len(p.unsupported) > 0 {
return model.Match{}, false
@@ -1441,6 +1716,10 @@ func (im *importer) rule(chain string, p *parsed) {
default:
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)
if r.CountsTraffic() && r.Action == model.ActionAccept {
// 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 {
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
// nftables refuse the whole ruleset.
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
@@ -1480,6 +1767,12 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
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 == "" {
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"
}
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
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 {
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) == 1 && !p.notDst && !p.notSrc:
ports, err := model.NormalizePorts(p.dports[0])
if err != nil {
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) > 0 && !p.notDst && !p.notSrc:
var ports []string
for _, d := range p.dports {
n, err := model.NormalizePorts(d)
if err != nil {
p.bad("the port " + d)
break
}
ports = append(ports, n)
}
if len(p.unsupported) > 0 {
break
}
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, "-"))
// "tcp dport { 80, 443 } dnat to X": a forward for each port.
for _, port := range ports {
im.portForward(p, port, len(ports) > 1)
}
if f.ToPort == ports {
f.ToPort = ""
}
f.PublicAddresses = im.addrRefs(p.dst)
f.Sources = im.addrRefs(p.src)
for _, i := range p.inIf {
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
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
m, ok := im.toMatch(p)
@@ -1534,7 +1872,35 @@ func (im *importer) dstnat(p *parsed) {
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*`)
@@ -1570,6 +1936,7 @@ func (im *importer) nameForwards() {
}
func (im *importer) srcnat(p *parsed) {
im.checkSets(p)
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])
return
@@ -1618,7 +1985,8 @@ func (im *importer) srcnat(p *parsed) {
// absorbForward folds a port forward's own forward-chain accept (and an
// IPv6 pinhole on its port) into the port forward.
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
}
port, err := model.NormalizePorts(p.dports[0])
@@ -1627,6 +1995,12 @@ func (im *importer) absorbForward(p *parsed) bool {
}
dst := p.dst[0]
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 {
inside := f.ToPort
if inside == "" {
@@ -1653,7 +2027,8 @@ func (im *importer) absorbForward(p *parsed) bool {
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
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
return true
+10 -3
View File
@@ -115,9 +115,16 @@ func TestImportRouter(t *testing.T) {
if !wgDNS {
t.Errorf("wg0_input not inlined")
}
// The jump's and the rule's sources both: the rule's are kept, noted.
if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") {
t.Errorf("no note on the overlapping sources: %q", res.Notes)
// The jump's and the rule's sources both: the rule's lie within the
// jump's, so they say it all, without a note.
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.
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
+1
View File
@@ -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
+53
View File
@@ -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"
}
}
+46
View File
@@ -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
}
}