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 ...`). 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) 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 }