Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads ruleset-fallback.nft (render.Fallback), saved beside it from the same configuration: nothing new in from external zones, established, the ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and masqueraded, and the unit marked failed with a critical message. housecarld replaces it when it applies; status, the dashboard and /healthz say while it is in force. no_external_zone now also says the fallback closes everything without an external zone. The boot ruleset has no flowtable: nftables on Debian 13's kernel refuses one naming a device that doesn't exist yet (ppp0 before PPPoE), and with it the whole file. internal/linkwatch listens to rtnetlink link messages; housecarld applies again when a flowtable device appears or comes back as a new interface (by ifindex), under the engine's lock. docs/flowtable.md: what it does, when to use it and when not, how. GET /healthz reports firewall (applied, unconfigured, fallback, missing), changes on probation and the machine's warnings, with 503 unless in force, read at most every five seconds. Renaming a zone, set or service changes what names it in the same change (one history entry, one undo); dynamic sets keep their names. The importer keeps a chain's head in order: once a would-be built-in stays a rule, the ones after it do too, so the maintainer's ICMPv6 accepts stay behind the invalid drop as written. hc settings set --flowtable none empties the list. make test (nft 1.1.3) and make e2e (242 checks, with VLAN sub-interfaces, the fallback and the flowtable) pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
835a9ba2cc
commit
9d766b9823
38 files changed
+1335
-84
No files matched your search
@@ -940,8 +940,10 @@ func (im *importer) run() {
|
||||
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] })
|
||||
im.st.Settings.Builtins = model.Builtins{}
|
||||
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
|
||||
var deferred []func()
|
||||
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
|
||||
var chains []string
|
||||
rest := map[string][]*parsed{} // chain → the rules after its head
|
||||
for _, b := range bases {
|
||||
rules := im.flatten(b.name, nil, 0)
|
||||
switch b.kind {
|
||||
@@ -954,9 +956,9 @@ func (im *importer) run() {
|
||||
im.srcnat(p)
|
||||
}
|
||||
default:
|
||||
chains = append(chains, b.kind)
|
||||
prefix := true
|
||||
for _, p := range rules {
|
||||
p := p
|
||||
if bi := im.builtinOf(b.kind, p); bi != "" {
|
||||
if found[bi] == nil {
|
||||
found[bi] = map[string]string{}
|
||||
@@ -967,18 +969,25 @@ func (im *importer) run() {
|
||||
}
|
||||
found[bi][b.kind] = where
|
||||
if prefix {
|
||||
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
|
||||
continue
|
||||
}
|
||||
}
|
||||
prefix = false
|
||||
kind := b.kind
|
||||
deferred = append(deferred, func() { im.rule(kind, p) })
|
||||
rest[b.kind] = append(rest[b.kind], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
im.settleBuiltins(found)
|
||||
for _, f := range deferred {
|
||||
f()
|
||||
im.settleBuiltins(found, heads)
|
||||
for _, chain := range chains {
|
||||
for _, h := range heads[chain] {
|
||||
if found[h.builtin][chain] == "demoted" {
|
||||
im.rule(chain, h.p)
|
||||
}
|
||||
}
|
||||
for _, p := range rest[chain] {
|
||||
im.rule(chain, p)
|
||||
}
|
||||
}
|
||||
if len(im.flow) > 0 {
|
||||
im.st.Settings.FlowtableDevices = im.flow[0].Dev
|
||||
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// headRule is a rule at the head of a chain that is a built-in.
|
||||
type headRule struct {
|
||||
builtin string
|
||||
p *parsed
|
||||
}
|
||||
|
||||
// settleBuiltins turns on the built-ins found where they go, in every
|
||||
// chain they apply to; the others stay off, and the rules that looked
|
||||
// like them are kept as rules.
|
||||
func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
||||
// like them are kept as rules. Built-ins come before every rule, so once
|
||||
// one at a chain's head stays a rule, the ones after it there stay rules
|
||||
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
|
||||
// ahead of dropping invalid packets) would let through what the drop
|
||||
// stopped.
|
||||
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
|
||||
b := &im.st.Settings.Builtins
|
||||
on := func(name string, chains ...string) bool {
|
||||
for _, c := range chains {
|
||||
@@ -1252,14 +1271,36 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
b.Loopback = on("loopback", model.ChainInput)
|
||||
b.Established = on("established", model.ChainInput, model.ChainForward)
|
||||
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
||||
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
|
||||
b.PingInternal = on("ping", model.ChainInput)
|
||||
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
||||
b.MSSClamp = on("mss-clamp", model.ChainForward)
|
||||
b.Antispoof = on("antispoof", model.ChainEarly)
|
||||
for {
|
||||
b.Loopback = on("loopback", model.ChainInput)
|
||||
b.Established = on("established", model.ChainInput, model.ChainForward)
|
||||
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
||||
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
|
||||
b.PingInternal = on("ping", model.ChainInput)
|
||||
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
||||
b.MSSClamp = on("mss-clamp", model.ChainForward)
|
||||
b.Antispoof = on("antispoof", model.ChainEarly)
|
||||
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
|
||||
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}
|
||||
changed := false
|
||||
for chain, hs := range heads {
|
||||
cut := false
|
||||
for _, h := range hs {
|
||||
// mss-clamp and flowtable decide nothing: their place
|
||||
// doesn't matter.
|
||||
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
|
||||
cut = true
|
||||
}
|
||||
if cut && found[h.builtin][chain] == "prefix" {
|
||||
found[h.builtin][chain] = "demoted"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !b.Antispoof {
|
||||
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
|
||||
} else if im.st.Settings.AntispoofV6 == nil {
|
||||
@@ -1268,40 +1309,16 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
||||
if b.ICMPv6 {
|
||||
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
|
||||
}
|
||||
// The ones found in one chain's head but not another's: explicit rules
|
||||
// in the chains where they were found.
|
||||
for name, chains := range found {
|
||||
for chain, where := range chains {
|
||||
if where != "prefix" {
|
||||
continue
|
||||
}
|
||||
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
|
||||
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
|
||||
if !kept {
|
||||
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
|
||||
im.builtinRule(name, chain)
|
||||
// The ones that stay rules, in their place: say which.
|
||||
for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
|
||||
for _, h := range heads[chain] {
|
||||
if found[h.builtin][chain] == "demoted" {
|
||||
im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// builtinRule writes a built-in that stays off as a rule of its chain.
|
||||
func (im *importer) builtinRule(name, chain string) {
|
||||
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
|
||||
switch name {
|
||||
case "established":
|
||||
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
|
||||
case "invalid":
|
||||
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
|
||||
case "loopback":
|
||||
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
|
||||
default:
|
||||
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
|
||||
return
|
||||
}
|
||||
im.st.Rules = append(im.st.Rules, r)
|
||||
}
|
||||
|
||||
func (im *importer) nextPos(chain string) float64 {
|
||||
im.positions[chain]++
|
||||
return im.positions[chain]
|
||||
|
||||
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
|
||||
if s := st.Set("masq_to_internet"); s == nil {
|
||||
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
|
||||
}
|
||||
// Built-ins: the ones at the head of their chains.
|
||||
// Built-ins: the ones at the head of their chains, up to the invalid
|
||||
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
|
||||
// accepts after it stay rules too, in their order, so nothing the
|
||||
// drop stopped gets through ahead of it.
|
||||
b := st.Settings.Builtins
|
||||
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
|
||||
if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
|
||||
t.Errorf("built-ins %+v", b)
|
||||
}
|
||||
var input []string
|
||||
for _, r := range st.Rules {
|
||||
if r.Chain == model.ChainInput {
|
||||
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
|
||||
}
|
||||
}
|
||||
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
|
||||
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
|
||||
t.Errorf("input rules start %q", input)
|
||||
}
|
||||
// The forward chain's asymmetric-routing rule sits between the
|
||||
// established and invalid rules: invalid stays rules, in order.
|
||||
if b.DropInvalid {
|
||||
|
||||
Reference in new issue
Block a user