go-firewall/hooks_linux.go
James Coleman 060d667e93 Route inexpressible csf/apf shapes to the hook instead of rejecting them
csf and apf both back a native config with a raw-iptables pre-hook, but a rule
their config could not express was reported as unsupported even when iptables
applies it directly. Route those shapes to the hook, and share the routing
predicates between the two backends.

Shared hook layer (hooks_linux.go): extract parseAddrFamily, listRow/listRows,
bareHostShape, hostNeedsHook, advRuleNeedsHook and bareProtoNeedsHook so both
backends route on the same predicates. MarshalAdvRule no longer returns an error
for shapes its caller has already routed away.

csf: replace checkConnLimit, checkICMP and checkPortProto with a single needsHook
gate mirroring apf's. A connection limit CONNLIMIT cannot express, an ICMP rule
the advanced-line format cannot carry (see nativeICMP), and a port on a protocol
its TCP_IN/UDP_IN lists cannot hold now reach the hook. A port on ProtocolAny has
no form in iptables either, so it stays on the native path and iptablesRuleValid
rejects it with ErrUnsupported.

csf: remove a deny from csf.deny whatever action the caller names, as apf does.
The file encodes no action of its own, so the deny of an address is a single
entry; the old early return reported success while csf kept enforcing it.

IPv6: ipv6Unavailable only rejects concrete-IPv6 rules, so a FamilyAny rule
passed the gate and then fanned out into an IPv6 row. With csf.conf's IPV6 or
conf.apf's USE_IPV6 off that row is never enforced, and in the hook it is worse
than inert: the pre-hook runs on every reload while neither backend flushes
ip6tables, so an injected ip6tables line is re-appended each time and outlives
its removal from the hook. Narrow both write-side fan-outs to the families the
backend enforces (hookScript.writeFamilies, writeFamilyRows). Removals stay wide
so a v6 line written while IPv6 was on is still swept once it is off.

Integration: the shared probes take the first variant a backend accepts, so
widening csf's accepted shapes silently moved icmp/icmptype/connlimit onto its
hook path. Add csf-gated subtests to keep the native csf.conf CONNLIMIT and
csf.allow advanced-rule paths covered, alongside one for action-agnostic
csf.deny removal.
2026-07-09 21:30:27 -05:00

779 lines
29 KiB
Go

package firewall
import (
"fmt"
"net"
"os"
"strings"
"github.com/anmitsu/go-shlex"
)
// hookScript injects raw iptables/ip6tables commands into a CSF or APF hook that
// the firewall sources at (re)load time. It lets those backends express filter
// rules their native config cannot — connection-state, per-rule interface,
// logging, rate limiting, ICMPv6, and the transport protocols SCTP, GRE, ESP and
// AH — by reusing the iptables rule marshaller/parser and writing the resulting
// commands directly into the firewall's documented hook.
//
// The command lines live in the hook file itself (hookPath) rather than in a
// separate library-owned script: edit touches only the exact command lines it
// manages, leaving any user-authored hook content in place, and getRules parses
// every iptables/ip6tables line the hook carries. Reading foreign, user-authored
// hook rules is intended — the library manages the actual firewall state, and
// HasPrefix (derived from the rule's comment tag) is the only signal of what it
// created.
type hookScript struct {
// rulePrefix tags each injected rule with an iptables comment so it can be
// told apart from other rules.
rulePrefix string
// hookPath is the firewall hook this library writes its command lines into. It
// runs before the firewall adds its own rules, so injected rules sit at the top
// of the INPUT/OUTPUT chains.
hookPath string
// hookPerm is the mode the hook file must carry to be executed (0700 for CSF,
// 0750 for APF).
hookPerm os.FileMode
// ipv6Enabled mirrors the backend's own IPv6 handling (csf.conf IPV6, conf.apf
// USE_IPV6). With it off, a family-agnostic rule is written for IPv4 only (see
// writeFamilies).
ipv6Enabled bool
}
// ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot
// express in their native config and so must be injected as a raw iptables rule
// through the hook: a forward-chain (routed) rule, connection-state matching,
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
// protocol their native config does not model (SCTP and the portless IP protocols
// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files
// take literal addresses only, so a `-m set --match-set` match lives in the hook
// beside the ipset commands that create the set.
func ruleNeedsHook(r *Rule) bool {
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
isSetRef(r.Source) || isSetRef(r.Destination)
}
// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6
// handling is switched off (csf.conf IPV6, conf.apf USE_IPV6). Neither backend can
// enforce such a rule: its native config silently drops or ignores every IPv6 form,
// and the raw-iptables hook is no escape hatch — with IPv6 off, neither firewall
// flushes ip6tables on (re)load (csf.pl guards the whole v6 flush behind IPV6; apf's
// ipt6() is a no-op unless USE_IPV6=1), so a hook-injected ip6tables line is
// re-appended on every reload and a line the library removes from the hook lives on
// in the kernel. Both backends therefore reject a concrete-IPv6 rule outright rather
// than write one they cannot manage. A FamilyAny rule is not concrete IPv6 and is
// unaffected: it is written for whichever family the backend enforces. Shared by CSF
// and APF.
func ipv6Unavailable(ipv6Enabled bool, r *Rule) bool {
return !ipv6Enabled && r.impliedFamily() == IPv6
}
// parseAddrFamily parses an address (IP or CIDR) and reports its family, or false
// when the value is not a valid address. The boolean is what distinguishes it from
// familyOfAddr, which reports FamilyAny for both an unset and an unparseable address:
// a config line is classified by whether it is an address at all. Shared by CSF and
// APF.
func parseAddrFamily(v string) (Family, bool) {
cidrIP, _, err := net.ParseCIDR(v)
ip := net.ParseIP(v)
if err != nil && ip == nil {
return FamilyAny, false
}
if (cidrIP != nil && cidrIP.To4() == nil) || (ip != nil && ip.To4() == nil) {
return IPv6, true
}
return IPv4, true
}
// listRow is one line a rule materializes into in a csf.allow/csf.deny or apf
// allow_hosts/deny_hosts file, paired with the rule that line reads back as. A rule
// spanning a family or transport axis the native line cannot carry has no single
// form, so it fans out into one row per cell. EditIPList marks the rows the file
// already holds as it scans and writes only the rest, so a partially present fan-out
// — one family written by an earlier single-family add, or a line lost to a manual
// edit — is completed rather than left half open or duplicated wholesale. Shared by
// CSF and APF.
type listRow struct {
// line is the exact text written to the list file.
line string
// read is the rule that line parses back to, which is what an existing line in
// the file is compared against to decide whether the row is already present.
read *Rule
}
// writeFamilyRows returns the concrete-family rows a rule is written as in a csf/apf
// list file, narrowed to the families the backend enforces. With its own IPv6 handling
// off, neither backend installs any IPv6 rule from its config, so a family-agnostic
// rule is written for IPv4 only: the IPv6 row would sit inert in the file and read back
// as an IPv6 rule the backend does not enforce and AddRule would reject. It is the
// list-file analog of hookScript.writeFamilies, and enabling IPv6 later heals the
// missing row (EditIPList completes a partial fan-out).
//
// A row already pinned to a concrete family keeps it, IPv6 included: a fresh
// concrete-IPv6 add is stopped earlier by the ipv6Unavailable gate, and Restore
// deliberately bypasses that gate to reproduce a snapshot's entries verbatim. Removal
// needs no such narrowing — it matches each line against the target directly, so a
// family-agnostic target already sweeps both rows. Shared by CSF and APF.
func writeFamilyRows(ipv6Enabled bool, r *Rule) []*Rule {
rows := expandFamilies(r)
if ipv6Enabled || len(rows) == 1 {
return rows
}
kept := make([]*Rule, 0, 1)
for _, row := range rows {
if row.Family != IPv6 {
kept = append(kept, row)
}
}
return kept
}
// bareHostShape reports whether a rule has the shape a plain csf.allow/apf
// allow_hosts line expresses: exactly one source or destination address, no ports,
// and the any-protocol match. Its direction is not considered — a DirAny bare host
// is the single bidirectional plain line, while a concrete-direction one is one-way
// (see bareHostOneWay). Shared by CSF and APF.
func bareHostShape(r *Rule) bool {
if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny {
return false
}
// A set reference (@set) is not a literal host: it matches through the hook's
// `-m set` clause (ruleNeedsHook routes it there), never a plain trust-file line.
if isSetRef(r.Source) || isSetRef(r.Destination) {
return false
}
return (r.Source != "") != (r.Destination != "")
}
// bareHostOneWay reports whether a rule is a ONE-WAY bare-address host allow/deny:
// the bare host shape with a concrete input or output direction. A plain line matches
// a host in BOTH directions, and neither backend's advanced-rule format can carry an
// address without a port, so a one-way bare host rule is expressed through the
// raw-iptables hook instead.
func bareHostOneWay(r *Rule) bool {
return bareHostShape(r) && (r.Direction == DirInput || r.Direction == DirOutput)
}
// dirAnyPlainLine reports whether a DirAny rule maps to a single bidirectional plain
// csf.allow/apf line: a bare host carrying no feature that would force the
// raw-iptables hook (connection state, interface, logging, etc.). Every other DirAny
// rule fans out into a concrete input rule plus its role-swapped output rule on
// add/remove, since csf/apf have no single native both-directions construct for it.
func dirAnyPlainLine(r *Rule) bool {
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
}
// hostNeedsHook reports whether an address-bearing rule with no port must be
// injected through the raw-iptables hook because a csf/apf trust file has no form
// for it: a source+destination pair (both store a single address) or a host
// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the
// advanced rule requires a port). Both are expressed natively as an iptables
// rule. A single-address all-protocol host is not covered here — a one-way one is
// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP
// keeps its own handling (CSF.nativeICMP, APF.isConfRule), so it is excluded. Shared
// by CSF and APF.
func hostNeedsHook(r *Rule) bool {
if r.Proto.IsICMP() || r.HasPorts() || r.HasSourcePorts() {
return false
}
// A source+destination pair has no single-address advanced/plain form.
if r.Source != "" && r.Destination != "" {
return true
}
// A single-address host pinned to a transport has no portless form: the plain
// line is all-protocol and the advanced rule requires a port. TCPUDP counts —
// it names transports, so it is not the all-protocol plain line either.
if r.Source != "" || r.Destination != "" {
return onProtocolAxis(r.Proto)
}
return false
}
// advRuleNeedsHook reports whether a rule that would otherwise be written as a
// csf/apf advanced rule must instead be injected through the raw-iptables hook,
// because the advanced-line format cannot carry its shape. An advanced line holds
// exactly one address field and exactly one port-flow field, and requires an
// address, so three shapes overflow it: a source+destination pair (the second
// address has nowhere to go), a source port matched together with a destination
// port, and an address-less source-port match. iptables expresses all three directly
// (`-s`/`-d`, `--sport`/`--dport`), so they are hooked rather than rejected — the
// alternative is failing on a rule the firewall can enforce.
//
// hostNeedsHook covers the portless address shapes and bows out once a rule carries
// a port, so this predicate is what routes their ported counterparts. Only the
// protocols an iptables port or icmp match accepts are routed here: a port on
// ProtocolAny is inexpressible in iptables too, so it stays on the native path and is
// rejected there by iptablesRuleValid rather than reaching the hook and failing there.
// Shared by CSF and APF.
func advRuleNeedsHook(r *Rule) bool {
if !onProtocolAxis(r.Proto) && !r.Proto.IsICMP() {
return false
}
// One port-flow field: a source port and a destination port cannot share it.
if r.HasPorts() && r.HasSourcePorts() {
return true
}
// An advanced rule requires an address, so a bare source-port match has no
// advanced form at all; iptables matches --sport on its own.
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
return true
}
// One address field: a source+destination pair cannot share it. Only the advanced
// form is at stake, so a rule with neither a port nor an icmp match — which never
// reaches MarshalAdvRule — is left to hostNeedsHook.
if r.Source != "" && r.Destination != "" {
return r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()
}
return false
}
// bareProtoNeedsHook reports whether a rule is a bare protocol match — a non-ICMP
// transport with no address and no port — that CSF/APF cannot express in their
// native config (the trust files key on an address and the conf lists key on a
// port or icmp type) but iptables applies directly (`-p tcp -j ACCEPT`, or a bare
// `-j ACCEPT`). Such a rule is injected through the raw-iptables hook rather than
// rejected. ICMP/ICMPv6 keep their own native/hook handling (CSF.nativeICMP,
// APF.isConfRule, APF.nativeICMPv6, ruleNeedsHook), so they are excluded here. Shared
// by CSF and APF.
func bareProtoNeedsHook(r *Rule) bool {
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
}
// hookOnlyProto reports whether a protocol has no representation in CSF's or
// APF's native config and so can only be applied through the raw-iptables hook.
func hookOnlyProto(p Protocol) bool {
switch p {
case SCTP, GRE, ESP, AH:
return true
}
return false
}
// hookRuleProtos lists the transport protocols a rule is written for in the hook.
// iptables has no both-transports match, so a TCPUDP rule fans out into a tcp line
// and a udp line; every other protocol writes one line. A portless ProtocolAny rule
// is a valid protocol-agnostic match (a bare `-j ACCEPT`) and is not fanned.
func hookRuleProtos(r *Rule) []Protocol {
protos := make([]Protocol, 0, 2)
for _, sub := range expandProtocols(r) {
protos = append(protos, sub.Proto)
}
return protos
}
// hookRuleFamilies lists every address family a rule's hook lines can occupy: a
// rule pinned to a family (by address or an ICMP protocol) touches only that
// command, a family-agnostic rule (e.g. a bare state match) spans both v4 and v6.
// It is the full set, which is what removal must sweep; writeFamilies narrows it to
// the families the backend actually enforces.
func hookRuleFamilies(r *Rule) []Family {
switch r.impliedFamily() {
case IPv4:
return []Family{IPv4}
case IPv6:
return []Family{IPv6}
default:
return []Family{IPv4, IPv6}
}
}
// writeFamilies lists the address families a rule's hook lines are written for.
// With the backend's own IPv6 handling off, a family-agnostic rule is written for
// IPv4 only: the pre-hook runs on every (re)load regardless, but neither csf nor apf
// flushes ip6tables while IPv6 is disabled (csf.pl guards the v6 flush behind IPV6;
// apf's ipt6() is a no-op unless USE_IPV6=1), so an injected ip6tables line would be
// re-appended on each reload and would outlive its own removal from the hook. That is
// the same hazard ipv6Unavailable rejects a concrete-IPv6 rule for; a family-agnostic
// rule is not rejected, it is simply written for the family the backend enforces.
//
// A rule already pinned to a concrete family keeps it, IPv6 included: a fresh
// concrete-IPv6 add is stopped earlier by the ipv6Unavailable gate, and Restore
// deliberately bypasses that gate to reproduce a snapshot's entries verbatim.
func (h *hookScript) writeFamilies(r *Rule) []Family {
fams := hookRuleFamilies(r)
if h.ipv6Enabled || len(fams) == 1 {
return fams
}
return []Family{IPv4}
}
// hookCommand returns the iptables command name for a family.
func hookCommand(fam Family) string {
if fam == IPv6 {
return "ip6tables"
}
return "iptables"
}
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
// iptables marshaller quotes free-text fields (a comment, a log prefix) with
// strconv.Quote — Go double-quoting, which is right for an iptables-restore file
// but NOT for the hook script, which /bin/sh sources: inside double quotes the
// shell still expands $var, $(...) and backticks. A token made of ordinary
// argument characters is returned bare for readability; anything else is wrapped
// in single quotes (with any embedded single quote escaped), which the shell
// treats as a literal. shlex.Split reverses either form on read-back.
func shellSafeToken(tok string) string {
safe := tok != ""
for _, r := range tok {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
strings.ContainsRune("_./:=,+-@%", r) {
continue
}
safe = false
break
}
if safe {
return tok
}
return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'"
}
// rulesToLines encodes a rule as the raw command line(s) to inject, for the
// families the backend enforces (see writeFamilies).
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
return h.linesForFamilies(r, h.writeFamilies(r))
}
// removalLines encodes every hook line a rule could occupy, across both families
// regardless of the backend's IPv6 setting. Removal sweeps the wider set so an
// ip6tables line written while IPv6 was enabled — or added by hand — is still
// cleared once it is switched off, rather than stranded in the hook.
func (h *hookScript) removalLines(r *Rule) ([]string, error) {
return h.linesForFamilies(r, hookRuleFamilies(r))
}
// linesForFamilies encodes a rule as the raw command line(s) to inject: one iptables
// (or ip6tables) command per underlying iptables line and per requested family. A
// logged rule yields a LOG line followed by its action line, as with the iptables
// backend. Each marshalled line is re-tokenized and re-quoted shell-safely,
// because the hook script is sourced by /bin/sh rather than exec'd argv-style.
func (h *hookScript) linesForFamilies(r *Rule, fams []Family) ([]string, error) {
var out []string
for _, proto := range hookRuleProtos(r) {
for _, fam := range fams {
rc := *r
rc.Proto = proto
rc.Family = fam
ipt := &IPTables{rulePrefix: h.rulePrefix}
base, err := ipt.marshalRuleLines(&rc)
if err != nil {
return nil, err
}
cmd := hookCommand(fam)
for _, line := range base {
tokens, terr := shlex.Split(line, true)
if terr != nil {
return nil, terr
}
for i, t := range tokens {
tokens[i] = shellSafeToken(t)
}
out = append(out, cmd+" "+strings.Join(tokens, " "))
}
}
}
return out, nil
}
// ruleMatchesAny reports whether e — a rule parsed from a hook line — is the same
// underlying rule as any of targets, honoring direction (the hook emits explicit
// -A INPUT/-A OUTPUT lines) but ignoring the comment, which is not part of rule
// identity. It backs comment-agnostic hook removal.
func ruleMatchesAny(e *Rule, targets []*Rule) bool {
for _, t := range targets {
if e.Equal(t, true) {
return true
}
}
return false
}
// parseLine decodes an injected command line back into the rule it represents
// (one line, so a LOG line yields a rule with Log set and no action), reporting
// whether the line is one this backend recognizes.
func (h *hookScript) parseLine(line string) (*Rule, bool) {
line = strings.TrimSpace(line)
var fam Family
var rest string
switch {
case strings.HasPrefix(line, "iptables "):
fam, rest = IPv4, strings.TrimPrefix(line, "iptables ")
case strings.HasPrefix(line, "ip6tables "):
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables ")
default:
return nil, false
}
r, err := unmarshalIPTablesRule(rest, fam)
if err != nil {
return nil, false
}
// The iptables parser captures the prefixed comment; strip the prefix (a tag)
// so only the user-facing comment surfaces, and record whether the injected
// rule carried the prefix so HasPrefix reflects it just like the iptables
// backend.
text, hasPrefix := prefixedComment(h.rulePrefix, r.Comment)
r.Comment = text
r.HasPrefix = hasPrefix
return r, true
}
// commandLines returns the iptables/ip6tables command lines currently in the
// hook (a missing hook contributes none). Every such line is returned, including
// any a user authored by hand, so the library reconciles the hook's real state.
func (h *hookScript) commandLines() ([]string, error) {
lines, _, err := h.readHookLines()
if err != nil {
return nil, err
}
var cmds []string
for _, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") {
cmds = append(cmds, t)
}
}
return cmds, nil
}
// getRules parses the hook into logical rules, coalescing each LOG line with the
// action line that follows it. Family merging is left to the caller, which
// unions these with the backend's native rules.
func (h *hookScript) getRules() ([]*Rule, error) {
cmds, err := h.commandLines()
if err != nil {
return nil, err
}
var rules []*Rule
for _, line := range cmds {
if r, ok := h.parseLine(line); ok {
rules = append(rules, r)
}
}
return coalesceLoggedRules(rules), nil
}
// readHookLines returns the hook's lines and whether the hook file exists. A
// single trailing newline is trimmed so a rewrite does not accrue a blank line;
// a missing hook yields no lines.
func (h *hookScript) readHookLines() ([]string, bool, error) {
data, err := os.ReadFile(h.hookPath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
content := strings.TrimSuffix(string(data), "\n")
if content == "" {
return nil, true, nil
}
return strings.Split(content, "\n"), true, nil
}
// writeHook atomically replaces the hook with lines, giving a freshly created
// hook a shebang, and keeps it executable so the firewall can source it.
func (h *hookScript) writeHook(lines []string, existed bool) error {
var b strings.Builder
if !existed {
b.WriteString("#!/bin/sh\n")
}
for _, l := range lines {
b.WriteString(l)
b.WriteByte('\n')
}
// Preserve an existing hook's mode and ownership; a freshly created hook gets
// the executable hookPerm so the firewall can source it.
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
return fmt.Errorf("failed to move updated hook into place: %s", err)
}
return nil
}
// edit adds or removes a rule's command line(s) directly in the hook. Adding
// matches on the exact deterministic line the marshaller emits, so the library's
// own tagged line is written once and re-adds are idempotent. Removal instead
// matches on the underlying rule: a line is dropped when the rule it encodes is
// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a
// customer added under a different comment (or none) is cleared too — the comment
// is not part of rule identity. A logged rule's LOG and action lines are matched
// independently, exactly as they were written. It preserves every other hook
// line — user-authored shell and rules alike — and reports whether the hook
// changed. Adding to an absent hook creates it; removing from one is a no-op.
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
// An add writes only the families the backend enforces; a removal sweeps both, so
// a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies).
linesFor := h.rulesToLines
if remove {
linesFor = h.removalLines
}
desired, err := linesFor(r)
if err != nil {
return false, err
}
lines, existed, err := h.readHookLines()
if err != nil {
return false, err
}
changed := false
if remove {
// Parse each desired line back into the rule it encodes; a hook line whose own
// rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line
// that is not a rule the hook recognizes (foreign shell, a comment) never parses
// and is preserved.
var targets []*Rule
for _, l := range desired {
if tr, ok := h.parseLine(l); ok {
targets = append(targets, tr)
}
}
next := lines[:0:0]
for _, l := range lines {
if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) {
changed = true
continue
}
next = append(next, l)
}
if !changed {
return false, nil
}
lines = next
} else {
present := make(map[string]bool, len(lines))
for _, l := range lines {
present[strings.TrimSpace(l)] = true
}
for _, l := range desired {
if !present[l] {
lines = append(lines, l)
present[l] = true
changed = true
}
}
if !changed {
return false, nil
}
}
return true, h.writeHook(lines, existed)
}
// --- address sets (ipset commands in the hook) -----------------------------
//
// CSF and APF have no native address-set construct, so the library persists a
// set as `ipset` commands in the same hook that carries its raw iptables rules.
// The firewall sources the hook on every (re)start, so the ipset commands
// recreate the set before the `-m set --match-set` rule lines that follow can
// reference it — the set survives a reboot exactly as the hook's rules do. Every
// ipset line is kept ahead of every iptables/ip6tables line to preserve that
// ordering. Reading foreign, user-authored ipset lines is intended, as with
// rules: the library manages the actual hook state.
// ipsetLinesFor renders the hook lines that (re)create a set and load its
// entries: an idempotent create (-exist, so a reload does not fail on the
// existing set), a flush (so a reload drops entries removed since the last
// write, making the entry list declarative), then one add per entry.
func ipsetLinesFor(set *AddressSet) []string {
fam := "inet"
if set.Family == IPv6 {
fam = "inet6"
}
lines := []string{
fmt.Sprintf("ipset create %s %s family %s -exist", set.Name, set.Type.String(), fam),
fmt.Sprintf("ipset flush %s", set.Name),
}
for _, e := range set.Entries {
lines = append(lines, fmt.Sprintf("ipset add %s %s", set.Name, e))
}
return lines
}
// hookIPSetName returns the set a hook ipset line operates on, or "" when the
// line is not one of the library's ipset commands. Every such line names the set
// in its third field (`ipset <verb> <name> ...`).
func hookIPSetName(line string) string {
f := strings.Fields(line)
if len(f) >= 3 && f[0] == "ipset" {
return f[2]
}
return ""
}
// isHookRuleLine reports whether a hook line is an iptables/ip6tables command, as
// opposed to an ipset command or user-authored shell.
func isHookRuleLine(line string) bool {
t := strings.TrimSpace(line)
return strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ")
}
// setInUse reports whether any hook rule line references name through an
// `-m set --match-set <name>` match, so a set is not removed out from under a
// rule that still uses it (the kernel enforces the same on a live destroy).
func setInUse(lines []string, name string) bool {
for _, l := range lines {
if !isHookRuleLine(l) {
continue
}
f := strings.Fields(l)
for i := 0; i+1 < len(f); i++ {
if f[i] == "--match-set" && f[i+1] == name {
return true
}
}
}
return false
}
// getAddressSets parses the sets the hook carries, in the order their create
// lines appear. An ipset is pinned to a single family, so each create yields one
// set and its add lines supply the entries; flush lines carry no state and are
// ignored.
func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
lines, _, err := h.readHookLines()
if err != nil {
return nil, err
}
// ipsetParseType is an IPTables method that ignores its receiver; a zero value
// reuses the same create-line parser the iptables backend uses.
ipt := &IPTables{}
sets := map[string]*AddressSet{}
var order []string
for _, line := range lines {
f := strings.Fields(line)
if len(f) >= 4 && f[0] == "ipset" && f[1] == "create" {
// ipsetParseType scans a `create NAME <type> family <fam> ...` slice from
// its third element, so drop the leading `ipset` word to line it up.
fam, typ := ipt.ipsetParseType(f[1:])
sets[f[2]] = &AddressSet{Name: f[2], Family: fam, Type: typ}
order = append(order, f[2])
}
}
for _, line := range lines {
f := strings.Fields(line)
if len(f) == 4 && f[0] == "ipset" && f[1] == "add" {
if s, ok := sets[f[2]]; ok {
s.Entries = append(s.Entries, f[3])
}
}
}
out := make([]*AddressSet, 0, len(order))
for _, n := range order {
out = append(out, sets[n])
}
return out, nil
}
// editAddressSet writes or removes a set's ipset lines in the hook. Adding drops
// any prior lines for the set and reinserts its block ahead of the first
// iptables/ip6tables line, so the set exists before any rule matches it; the
// write is idempotent. Removing drops the set's lines but refuses when a hook
// rule still references it. Every other hook line — user shell, rules, other
// sets — is preserved; it reports whether the hook changed.
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
lines, existed, err := h.readHookLines()
if err != nil {
return false, err
}
if remove && setInUse(lines, set.Name) {
return false, fmt.Errorf("address set %q is in use by a rule", set.Name)
}
// Drop any existing lines for this set (idempotent re-add; also the removal path).
kept := make([]string, 0, len(lines))
dropped := false
for _, l := range lines {
if hookIPSetName(l) == set.Name {
dropped = true
continue
}
kept = append(kept, l)
}
if remove {
if !dropped {
return false, nil
}
return true, h.writeHook(kept, existed)
}
// Insert the set's block ahead of the first rule line (or at the end when the
// hook has none yet), keeping every ipset line before every rule line.
block := ipsetLinesFor(set)
next := make([]string, 0, len(kept)+len(block))
inserted := false
for _, l := range kept {
if !inserted && isHookRuleLine(l) {
next = append(next, block...)
inserted = true
}
next = append(next, l)
}
if !inserted {
next = append(next, block...)
}
if equalLines(lines, next) {
return false, nil
}
return true, h.writeHook(next, existed)
}
// editAddressSetEntry adds or removes a single entry in an existing set by
// rewriting the set's block. The set must already exist in the hook.
func (h *hookScript) editAddressSetEntry(name, entry string, remove bool) (bool, error) {
sets, err := h.getAddressSets()
if err != nil {
return false, err
}
var target *AddressSet
for _, s := range sets {
if s.Name == name {
target = s
break
}
}
if target == nil {
return false, fmt.Errorf("address set %q not found", name)
}
if remove {
next := target.Entries[:0]
found := false
for _, e := range target.Entries {
if e == entry {
found = true
continue
}
next = append(next, e)
}
if !found {
return false, nil
}
target.Entries = next
} else {
for _, e := range target.Entries {
if e == entry {
return false, nil
}
}
target.Entries = append(target.Entries, entry)
}
return h.editAddressSet(target, false)
}
// equalLines reports whether two hook line slices are identical.
func equalLines(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}