go-firewall/hooks_linux.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

617 lines
22 KiB
Go

package firewall
import (
"fmt"
"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
}
// 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)
}
// 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 (checkICMP/apfCheckICMP), 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 tcp/udp has no portless form.
if r.Source != "" || r.Destination != "" {
return r.Proto == TCP || r.Proto == UDP
}
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 (checkICMP/apfCheckICMP,
// 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.
// A ProtocolAny rule that carries a port has no single iptables form — a --dport/
// --sport match requires a concrete -p tcp/udp — so it fans out into a tcp line and
// a udp line, mirroring the tcp+udp fan-out csf/apf write for a ProtocolAny port
// rule in their native config. A portless ProtocolAny rule is a valid protocol-
// agnostic match (a bare `-j ACCEPT`), so it keeps its own protocol and is not fanned.
func hookRuleProtos(r *Rule) []Protocol {
if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) {
return []Protocol{TCP, UDP}
}
return []Protocol{r.Proto}
}
// hookRuleFamilies lists the address families a rule is written for: 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) is written for both v4 and v6.
func hookRuleFamilies(r *Rule) []Family {
switch r.impliedFamily() {
case IPv4:
return []Family{IPv4}
case IPv6:
return []Family{IPv6}
default:
return []Family{IPv4, IPv6}
}
}
// 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: one iptables
// (or ip6tables) command per underlying iptables line and per 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) rulesToLines(r *Rule) ([]string, error) {
var out []string
for _, proto := range hookRuleProtos(r) {
for _, fam := range hookRuleFamilies(r) {
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) {
desired, err := h.rulesToLines(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
}