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), an address-set reference (@set), or a negated address. // 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, and a negated address lives there as iptables' native `! -s`/`! -d` — // csf.pl passes an advanced line's s=/d= value verbatim to iptables (where a // joined "!" is not a reliable negation) and skips a plain "!"-prefixed line as // not an address, so neither native form can carry one. 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) || strings.HasPrefix(r.Source, "!") || strings.HasPrefix(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) } // shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF // config form and must be injected through the raw-iptables hook. The native forms // are narrow — a plain trust-file line holds one address, matches both directions, // and is all-protocol; an advanced line holds exactly one address field and one // port-flow field and requires both; the conf lists key on a port or an icmp type — // while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with // `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes are hooked rather than // rejected: the alternative is failing on a rule the firewall can enforce. // // Feature-based routing (state, interface, logging, rate limiting, ICMPv6, // hook-only transports, set references, negation) is ruleNeedsHook's job, and the // backend-specific connlimit, ICMP and port-list shapes stay in each backend's // needsHook; ICMP is excluded from every test here because both backends route it // there (CSF's typed advanced rule, APF.isConfRule). Shared by CSF and APF. func shapeNeedsHook(r *Rule) bool { // A one-way bare host: a plain line is bidirectional and an advanced rule // requires a port, so a concrete-direction bare host has no native form. if bareHostOneWay(r) { return true } // Portless address shapes no trust file expresses. if !r.Proto.IsICMP() && !r.HasPorts() && !r.HasSourcePorts() { // 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 != "") && onProtocolAxis(r.Proto) { return true } } // Advanced-line overflows, on the protocols an iptables port or icmp match // accepts: 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. if onProtocolAxis(r.Proto) || r.Proto.IsICMP() { // 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 ported or icmp-matching source+destination pair cannot // share it (its portless non-ICMP counterpart is routed above). if r.Source != "" && r.Destination != "" && (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()) { return true } } // A bare protocol match — a non-ICMP transport with no address and no port — has // no native construct (the trust files key on an address, the conf lists on a // port or icmp type) but iptables applies it directly. 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) for an // iptables-restore file, where double quotes are literal — but the hook is // sourced by /bin/sh, which expands $var, $(...) and backticks inside double // quotes. 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). A family-agnostic rule // that references an address set is pinned to the set's family instead: an // ipset is single-family, so the opposite-family line would fail every time // the firewall sources the hook. func (h *hookScript) rulesToLines(r *Rule) ([]string, error) { if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) { fam, err := h.setRefFamily(r) if err != nil { return nil, err } if fam == IPv6 && !h.ipv6Enabled { return nil, fmt.Errorf("rule references an IPv6 address set while IPv6 is disabled: %w", ErrUnsupported) } return h.linesForFamilies(r, []Family{fam}) } return h.linesForFamilies(r, h.writeFamilies(r)) } // setRefFamily resolves the single family of the address set(s) a rule // references from the hook's ipset lines. A set that is not in the hook, or a // source/destination pair naming sets of different families, cannot produce a // loadable line, so both are errors. func (h *hookScript) setRefFamily(r *Rule) (Family, error) { sets, err := h.getAddressSets() if err != nil { return FamilyAny, err } fam := FamilyAny for _, ref := range []string{r.Source, r.Destination} { if !isSetRef(ref) { continue } _, bare := splitAddrNeg(strings.TrimSpace(ref)) name := strings.TrimPrefix(bare, "@") var found *AddressSet for _, s := range sets { if s.Name == name { found = s break } } if found == nil { return FamilyAny, fmt.Errorf("rule references unknown address set %q", name) } sf := found.Family if sf != IPv6 { sf = IPv4 } if fam != FamilyAny && sf != fam { return FamilyAny, fmt.Errorf("rule references address sets of different families") } fam = sf } if fam == FamilyAny { fam = IPv4 } return fam, nil } // 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 } // orphanLogMatchesAny reports whether e is a LOG line whose action partner is // gone from the hook and whose logged rule is named by one of targets, so a // removal still clears the stray line. func orphanLogMatchesAny(e *Rule, targets []*Rule) bool { if e.Action != ActionInvalid || !e.Log { return false } for _, t := range targets { if !t.Log { continue } tl := *t tl.Action = ActionInvalid if e.Equal(&tl, 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 { if isHookRuleLine(line) { cmds = append(cmds, strings.TrimSpace(line)) } } 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. A freshly created hook // gets a shebang and the executable hookPerm; an existing hook keeps its own // mode and ownership (see writeConfigFile). 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') } 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 hook rule is dropped when it is the same rule // 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 LOG line and the action line under it are // matched as the one logged rule they encode, never independently: a logged rule // and its unlogged twin are distinct rules, and removing one must not strip the // other's lines. 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 — the round trip // normalizes field spellings — and coalesce each LOG line with its action // line so the targets are logical rules. A desired line that fails to // parse breaks the marshal/parse round trip the removal match depends on, // so it is an error rather than a silent no-op. var parsed []*Rule for _, l := range desired { tr, ok := h.parseLine(l) if !ok { return false, fmt.Errorf("hook removal target does not round-trip: %q", l) } parsed = append(parsed, tr) } targets := coalesceLoggedRules(parsed) next := lines[:0:0] for i := 0; i < len(lines); i++ { er, ok := h.parseLine(lines[i]) if !ok { next = append(next, lines[i]) continue } // Pair a LOG line with the action line directly under it, mirroring // getRules' coalescing, so the pair is kept or dropped as one rule. var partner *Rule if i+1 < len(lines) { partner, _ = h.parseLine(lines[i+1]) } switch { case logPartner(er, partner): if ruleMatchesAny(mergeLogPair(er, partner), targets) { changed = true } else { next = append(next, lines[i], lines[i+1]) } i++ case orphanLogMatchesAny(er, targets): // A stray LOG line whose action partner was hand-edited away // still belongs to the logged rule named by the removal. changed = true case ruleMatchesAny(er, targets): changed = true default: next = append(next, lines[i]) } } 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) } // --- NAT rules (raw nat-table commands in the hook) -------------------------- // // csf.redirect holds exactly two destination-NAT shapes, so every other NAT rule // — source NAT, an interface-bound or source-matched translation, a port // range/list — is injected as a raw `iptables -t nat` command through the same // hook that carries the filter rules. csf flushes the v4 nat table on every // (re)start whenever the kernel provides one (its Config.pm probes `-t nat -L // POSTROUTING` and sets NAT=1) and sources the pre-hook afterwards, so the // injected lines are applied exactly once per load; the v6 nat flush is guarded // by IPV6, which is the same hazard natWriteFamilies narrows writes for. APF // expresses the same escape hatch with its own preroute.rules/postroute.rules // files instead of the hook. // natRuleFamilies lists every address family a NAT rule's raw nat lines can // occupy: a rule pinned to a family (by an address or its Family field) touches // only that command; a family-agnostic rule (e.g. a portless masquerade) spans // both v4 and v6. It is the full set, which is what removal must sweep; // natWriteFamilies narrows it to the families the backend enforces. Shared by // CSF's hook NAT lines and APF's routing files. func natRuleFamilies(r *NATRule) []Family { switch r.impliedFamily() { case IPv4: return []Family{IPv4} case IPv6: return []Family{IPv6} default: return []Family{IPv4, IPv6} } } // natWriteFamilies lists the families a NAT rule's lines are written for. With // the backend's own IPv6 handling off a family-agnostic rule is written for IPv4 // only: neither csf nor apf flushes the v6 nat table while IPv6 is disabled, so // an injected ip6tables line would be re-appended on every reload and outlive // its own removal. Removal still sweeps both families (natRuleFamilies) so a // stale v6 line does not survive an IPv6 switch-off. It is the NAT analog of // hookScript.writeFamilies. Shared by CSF and APF. func natWriteFamilies(ipv6Enabled bool, r *NATRule) []Family { fams := natRuleFamilies(r) if ipv6Enabled || len(fams) == 1 { return fams } return []Family{IPv4} } // natLine encodes a NAT rule as one raw nat-table command line for a family. // Each marshalled line is re-tokenized and re-quoted shell-safely, as with // linesForFamilies, because the hook is sourced by /bin/sh. func (h *hookScript) natLine(r *NATRule, fam Family) (string, error) { rc := *r rc.Family = fam ipt := &IPTables{rulePrefix: h.rulePrefix} spec, err := ipt.MarshalNATRule(&rc) if err != nil { return "", err } tokens, err := shlex.Split(spec, true) if err != nil { return "", err } for i, t := range tokens { tokens[i] = shellSafeToken(t) } return hookCommand(fam) + " -t nat " + strings.Join(tokens, " "), nil } // parseNATLine decodes a raw nat command line back into the NATRule it // represents, reporting whether the line is one this backend recognizes. The // iptables NAT parser derives HasPrefix from the line's comment tag, so a rule // this library wrote reports it and a hand-added line does not. A `-t nat` line // never doubles as a filter rule: parseLine's chain check (INPUT/OUTPUT/FORWARD) // rejects it, so the two line kinds stay disjoint in the same hook. func (h *hookScript) parseNATLine(line string) (*NATRule, bool) { line = strings.TrimSpace(line) var fam Family var rest string switch { case strings.HasPrefix(line, "iptables -t nat "): fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ") case strings.HasPrefix(line, "ip6tables -t nat "): fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ") default: return nil, false } ipt := &IPTables{rulePrefix: h.rulePrefix} r, err := ipt.UnmarshalNATRule(rest, fam) if err != nil { return nil, false } return r, true } // getNATRules parses the raw nat-table rules the hook carries. Every such line // is returned, including any a user authored by hand, so the library reconciles // the hook's real state. func (h *hookScript) getNATRules() ([]*NATRule, error) { lines, _, err := h.readHookLines() if err != nil { return nil, err } var rules []*NATRule for _, line := range lines { if r, ok := h.parseNATLine(line); ok { rules = append(rules, r) } } return rules, nil } // editNAT adds or removes a NAT rule's raw nat command line(s) in the hook, // mirroring edit for filter rules: an add writes only the families the backend // enforces and dedups against an equivalent existing line — the exact text, or // the same translation under EqualForRemoval, which stays family-aware so a // family-scoped edit leaves an opposite-family twin alone — while a removal // sweeps both families. Every other hook line is preserved; it reports whether // the hook changed. func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) { fams := natRuleFamilies(r) if !remove { fams = natWriteFamilies(h.ipv6Enabled, r) } want := make([]string, 0, len(fams)) wantSet := make(map[string]bool, len(fams)) for _, fam := range fams { line, err := h.natLine(r, fam) if err != nil { return false, err } want = append(want, line) wantSet[line] = true } lines, existed, err := h.readHookLines() if err != nil { return false, err } changed := false next := make([]string, 0, len(lines)+len(want)) present := make(map[string]bool, len(want)) for _, raw := range lines { body := strings.TrimSpace(raw) matched := false satisfied := "" if wantSet[body] { matched, satisfied = true, body } else if existing, ok := h.parseNATLine(body); ok && existing.EqualForRemoval(r) { matched = true // The equivalent want-line is the one for this existing line's family; // mark it satisfied so its duplicate is not appended below. Recompute it // (rather than reuse body) since body is the existing spelling, not ours. if line, lerr := h.natLine(r, existing.impliedFamily()); lerr == nil { satisfied = line } } if matched { if remove { changed = true continue } if satisfied != "" { present[satisfied] = true } } next = append(next, raw) } if remove { if !changed { return false, nil } return true, h.writeHook(next, existed) } for _, line := range want { if !present[line] { next = append(next, line) changed = true } } if !changed { return false, nil } return true, h.writeHook(next, 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 ...`). 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 ` 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 family ...` 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) // A hand-authored add may carry trailing options (`timeout 300`, `-exist`); // the entry itself is still the fourth field. Options are not modeled, so a // rewrite of the set's block re-emits the entry without them. 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) { // hookIPSetName reports "" for every non-ipset line, so an unnamed set would // match — and the keep-filter below would drop — every rule and user line in // the hook. if set.Name == "" { return false, fmt.Errorf("an address set requires a name") } 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 }