go-firewall/hooks_linux.go
2026-08-10 17:17:03 -05:00

1175 lines
42 KiB
Go

package firewall
import (
"bufio"
"fmt"
"os"
"path/filepath"
"slices"
"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. It also parses the
// iptables/ip6tables commands in the hook back into rules.
//
// A rule's comment is carried as a full-line `#` script comment directly above
// its command line(s), the same convention the csf.allow/apf trust files use, so
// the command lines themselves stay free of shell-quoted comment text. An
// iptables `-m comment` embedded in a line — hand-added, or written by an older
// library version — takes precedence on read, and the prefix tag from either
// source marks the rule as HasPrefix (see resolveComment).
type hookScript struct {
// rulePrefix tags each injected rule through the script comment written
// above its lines 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 a newly created hook file is given (0700 for CSF, 0750
// for APF); an existing hook keeps its own mode, except for the execute bit
// (see commit).
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
// ruleLines).
ipv6Enabled bool
// ip4Cmd, ip6Cmd and ipsetCmd are the commands the written lines invoke. The
// backends fill them with absolute paths (newHookScript) so the hook does not
// depend on the PATH it is sourced with; an empty field falls back to the bare
// command name and leaves resolution to the shell.
ip4Cmd string
ip6Cmd string
ipsetCmd string
}
// newHookScript binds a hookScript to a firewall's hook file, resolving the
// commands its lines invoke to absolute paths.
func newHookScript(rulePrefix, hookPath string, hookPerm os.FileMode, ipv6Enabled bool) *hookScript {
return &hookScript{
rulePrefix: rulePrefix,
hookPath: hookPath,
hookPerm: hookPerm,
ipv6Enabled: ipv6Enabled,
ip4Cmd: resolveHookBinary("iptables"),
ip6Cmd: resolveHookBinary("ip6tables"),
ipsetCmd: resolveHookBinary("ipset"),
}
}
// commit installs a staged hook file and makes sure it is left executable.
//
// The execute bit is the hook's activation switch, not a mode the operator
// chose: apf runs hook_pre.sh only when it is executable and ships it 0640, and
// csf's csfpre.sh works the same way. An atomic write preserves an existing
// file's mode, so without this the library's lines land in a file the firewall
// never runs — they read back from the file correctly while never reaching the
// kernel. Only the owner-execute bit is forced; the rest of the mode and the
// ownership stay as they were.
func (h *hookScript) commit(af *atomicFile) error {
if err := af.Commit(); err != nil {
return err
}
fi, err := os.Stat(h.hookPath)
if err != nil {
return err
}
if fi.Mode().Perm()&0100 != 0 {
return nil
}
return os.Chmod(h.hookPath, fi.Mode().Perm()|0100)
}
// resolveHookBinary returns the absolute path of a command the hook invokes, so
// an injected line does not depend on the PATH the firewall happens to source the
// hook with. csf.pl and apf both prepend the standard sbin directories before
// sourcing, but neither guarantees them across versions, and a hook run by hand
// or from a service unit inherits whatever environment its caller had — an
// unresolved `iptables` there fails the line silently at load time. Falls back to
// the bare name when the tool cannot be found, leaving resolution to the shell.
func resolveHookBinary(name string) string {
bin, _ := resolveBinary(name)
return bin
}
// --- hook routing (rule shapes the native configs cannot express) ------------
// 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
}
// 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, "!")
}
// 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 Rule.validate 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()
}
// --- hook file primitives ----------------------------------------------------
// command returns the iptables command a written line invokes for a family: the
// resolved absolute path when the hook has one, otherwise the bare name.
func (h *hookScript) command(fam Family) string {
if fam == IPv6 {
if h.ip6Cmd != "" {
return h.ip6Cmd
}
return "ip6tables"
}
if h.ip4Cmd != "" {
return h.ip4Cmd
}
return "iptables"
}
// ipsetCommand returns the ipset command an address-set line invokes, resolved as
// with command.
func (h *hookScript) ipsetCommand() string {
if h.ipsetCmd != "" {
return h.ipsetCmd
}
return "ipset"
}
// hookCmdIs reports whether a hook line's command token invokes name. Only the
// command's base name is compared, so every spelling of the same tool matches:
// the bare name a hand-written line uses, the resolved path this library writes,
// and any other path an operator wrote by hand (`/sbin/ipset` read by a manager
// that resolved `/usr/sbin/ipset`). Quotes are stripped, since the hook is shell.
func hookCmdIs(tok, name string) bool {
tok = trimQuotes(tok)
if tok == "" {
return false
}
return filepath.Base(tok) == name
}
// hookRuleCommands maps the base name of a rule command to the family it selects.
// Alongside the plain names it covers the update-alternatives variants a
// hand-written line may invoke directly (Debian's iptables-nft/-legacy). The
// save/restore front-ends are deliberately absent: they are not rule commands.
var hookRuleCommands = map[string]Family{
"iptables": IPv4,
"iptables-legacy": IPv4,
"iptables-nft": IPv4,
"ip6tables": IPv6,
"ip6tables-legacy": IPv6,
"ip6tables-nft": IPv6,
}
// hookCmdFamily splits a hook command line into the family its command selects
// and the arguments that follow, reporting whether the line invokes a rule
// command at all. The command is matched on its base name (see hookCmdIs), so a
// line spelled with any path — or none — reads back the same. A command with no
// arguments is not a rule line.
func hookCmdFamily(line string) (fam Family, rest string, ok bool) {
cmd, rest, _ := strings.Cut(strings.TrimSpace(line), " ")
rest = strings.TrimSpace(rest)
cmd = trimQuotes(cmd)
if rest == "" || cmd == "" {
return FamilyAny, "", false
}
if fam, ok := hookRuleCommands[filepath.Base(cmd)]; ok {
return fam, rest, true
}
return FamilyAny, "", false
}
// resolveComment derives a parsed hook rule's user-facing comment and prefix
// flag from the iptables comment embedded in its line and the script comment
// above it. An embedded comment's text wins — foreign lines and lines written
// by an older library version carry one — while the script comment is the form
// this library writes; the prefix tag counts from either source.
func (h *hookScript) resolveComment(embedded, script string) (text string, hasPrefix bool) {
et, eh := prefixedComment(h.rulePrefix, embedded)
st, sh := prefixedComment(h.rulePrefix, script)
if embedded != "" {
return et, eh || sh
}
return st, sh
}
// hookGroup is one physical span of the hook: an iptables/ip6tables command
// line together with the script comment lines attached above it (a LOG line and
// its adjacent action line count as one logical group), or any other line on
// its own. raw preserves the original lines so a rewrite copies user formatting
// through verbatim; rule/nat hold the parsed logical rule when the group
// encodes one, and cmds marks a group whose line is a command even when neither
// parser models it.
type hookGroup struct {
raw []string
cmds []string
rule *Rule
nat *NATRule
}
// scanGroups streams the hook's groups to fn in file order, built on the shared
// comment-group scanner (scanCommentGroups): each full-line script comment
// attaches to the command line below it, and a LOG line pairs with the action
// line under it into the one logged rule they encode. An error from fn stops
// the scan.
func (h *hookScript) scanGroups(fd *os.File, fn func(g hookGroup) error) error {
// held is a parsed LOG-only group waiting to see whether the next command
// line is its action partner. iptables writes a logged rule as two lines (a
// non-terminal LOG line then the action line), so we buffer the LOG line here
// rather than emit it, resolving its comment only once its fate is decided.
type heldGroup struct {
g hookGroup
rule *Rule
embedded string
script string
}
var held *heldGroup
// emitHeld flushes any buffered LOG group, whether it merged with a partner
// or stayed an orphan LOG-only rule. resolveComment folds the embedded
// comment (from a paired action line) over the script comment.
emitHeld := func() error {
if held == nil {
return nil
}
hg := held
held = nil
hg.rule.Comment, hg.rule.HasPrefix = h.resolveComment(hg.embedded, hg.script)
hg.g.rule = hg.rule
return fn(hg.g)
}
isCommand := func(trimmed string) bool {
_, _, ok := hookCmdFamily(trimmed)
return ok
}
err := scanCommentGroups(fd, h.rulePrefix, isCommand, func(cg commentGroup) error {
// A passthrough line sits between a held LOG line and any later action
// line, so it ends the pairing.
if cg.line == "" {
if err := emitHeld(); err != nil {
return err
}
return fn(hookGroup{raw: cg.raw})
}
g := hookGroup{raw: cg.raw, cmds: []string{cg.line}}
if nat, ok := h.parseNATLine(cg.line); ok {
if err := emitHeld(); err != nil {
return err
}
_, sp := prefixedComment(h.rulePrefix, cg.comment)
nat.HasPrefix = nat.HasPrefix || sp
g.nat = nat
return fn(g)
}
rule, ok := h.parseLine(cg.line)
if !ok {
// A command line neither parser models still counts as a command
// (cmds set), so set removal refuses to strand a referencing rule.
if err := emitHeld(); err != nil {
return err
}
return fn(g)
}
// Pair a held LOG line with the action line directly under it. The
// len(cg.raw) == 1 guard enforces physical adjacency: a raw slice longer
// than one line means a comment attached to this action, so the LOG and
// action are not consecutive and must stay separate — pairing them would
// synthesize a rule no removal could locate. On a match, fold the two
// lines into the one logged rule they encode and emit it.
if held != nil && len(cg.raw) == 1 && logPartner(held.rule, rule) {
if rule.Comment != "" {
held.embedded = rule.Comment
}
held.rule = mergeLogPair(held.rule, rule)
held.g.raw = append(held.g.raw, cg.raw[0])
held.g.cmds = append(held.g.cmds, cg.line)
return emitHeld()
}
// This line is not a partner, so any held LOG line is now an orphan;
// flush it before handling this line.
if err := emitHeld(); err != nil {
return err
}
// Buffer a bare LOG line (Log set, no terminal action) to pair against the
// next command line; every other rule is complete on its own and emitted
// at once.
if rule.Action == ActionInvalid && rule.Log {
held = &heldGroup{g: g, rule: rule, embedded: rule.Comment, script: cg.comment}
return nil
}
rule.Comment, rule.HasPrefix = h.resolveComment(rule.Comment, cg.comment)
g.rule = rule
return fn(g)
})
if err != nil {
return err
}
return emitHeld()
}
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
// iptables marshaller quotes free-text fields (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, "'", `'\''`) + "'"
}
// --- filter rules (raw iptables commands in the hook) ------------------------
// linesForRows encodes family-concrete rule rows (an expandFamilies fan-out) as
// the raw command line(s) to inject: one iptables (or ip6tables) command per
// underlying iptables line and per row. A logged rule yields a LOG line followed
// by its action line, as with the iptables backend. The comment is not
// marshalled into the line — it rides as the script comment edit writes above
// the lines instead. 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) linesForRows(rows []*Rule) ([]string, error) {
var out []string
for _, row := range rows {
fam := row.impliedFamily()
cmd := h.command(fam)
// iptables has no both-transports match, so a TCPUDP rule fans out into a tcp
// line and a udp line; a portless ProtocolAny rule is a valid protocol-agnostic
// match (a bare `-j ACCEPT`) and is not fanned.
for _, sub := range expandProtocols(row) {
rc := *sub
rc.Family = fam
rc.Comment = ""
ipt := &IPTables{}
// The hook borrows the iptables encoder, so it also runs its checks:
// the rule reached here through a routing backend's entry point, not
// iptables', and expandProtocols above supplied the concrete cell.
if err := ipt.validateRule(&rc); err != nil {
return nil, err
}
base, err := ipt.marshalRuleLines(&rc)
if err != nil {
return nil, err
}
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
}
// ruleLines encodes a rule as the raw command line(s) an edit acts on. An add
// writes only the family rows the backend enforces (filterFamiliesIPv6): the
// hook runs on every (re)load regardless, but neither csf nor apf flushes
// ip6tables — filter or nat table — 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. 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. A removal sweeps every family row the rule could occupy
// (expandFamilies) regardless of the IPv6 setting, 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) ruleLines(r *Rule, remove bool) ([]string, error) {
if remove {
return h.linesForRows(expandFamilies(r))
}
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
// Resolved live-first, then from the hook's own ipset lines for a set
// just written but not loaded by the firewall yet.
fam, err := ipsetRefFamily(r.Source, r.Destination, h.getAddressSets)
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)
}
rc := *r
rc.Family = fam
return h.linesForRows([]*Rule{&rc})
}
return h.linesForRows(filterFamiliesIPv6(h.ipv6Enabled, r))
}
// 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. Any embedded iptables comment
// is left as its raw text; scanGroups resolves it against the script
// comment above the line (see resolveComment).
func (h *hookScript) parseLine(line string) (*Rule, bool) {
fam, rest, ok := hookCmdFamily(line)
if !ok {
return nil, false
}
r, err := unmarshalIPTablesRule(rest, fam)
if err != nil {
return nil, false
}
return r, true
}
// getRules parses the hook into logical rules, each LOG line coalesced with the
// action line that follows it and each rule carrying the comment resolved from
// its script or embedded comment. Every command line is read, including any a
// user authored by hand, so the library reconciles the hook's real state. Family
// merging is left to the caller, which unions these with the backend's native
// rules.
func (h *hookScript) getRules() ([]*Rule, error) {
fd, err := os.Open(h.hookPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func() { _ = fd.Close() }()
var rules []*Rule
err = h.scanGroups(fd, func(g hookGroup) error {
// An orphan LOG line (its action partner hand-edited away) is not a
// reportable rule; removal still sweeps it (see Rule.OrphanLogMatchesAny).
if g.rule != nil && g.rule.Action != ActionInvalid {
rules = append(rules, g.rule)
}
return nil
})
return rules, err
}
// hookUnit is one logical rule as its hook line(s) — a LOG pair is two lines,
// every other rule one — with the rule those lines encode.
type hookUnit struct {
lines []string
rule *Rule
}
// lineUnits parses marshalled command lines back into the logical units they
// encode, pairing each LOG line with its action line. The round trip normalizes
// field spellings, which edit's matching depends on, so a line that fails to
// parse is an error rather than a silent no-op.
func (h *hookScript) lineUnits(lines []string) ([]hookUnit, error) {
var units []hookUnit
for i := 0; i < len(lines); i++ {
r, ok := h.parseLine(lines[i])
if !ok {
return nil, fmt.Errorf("hook line does not round-trip: %q", lines[i])
}
if i+1 < len(lines) {
if p, pok := h.parseLine(lines[i+1]); pok && logPartner(r, p) {
units = append(units, hookUnit{lines: []string{lines[i], lines[i+1]}, rule: mergeLogPair(r, p)})
i++
continue
}
}
units = append(units, hookUnit{lines: []string{lines[i]}, rule: r})
}
return units, nil
}
// edit adds or removes a rule's command line(s) directly in the hook, rewriting
// the file in a single streamed pass. Both directions match on the underlying
// rule, never the comment, which is not part of rule identity: an add
// is satisfied by an existing line meaning the same rule even when it is spelled
// or commented differently, and a removal drops a copy of the rule a customer
// added under a different comment (or none) too. An added unit is appended with
// its script comment written above it; a dropped one takes its attached comment
// lines with it. 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.
// Every other hook line — user-authored shell and rules alike — streams through
// to the staged rewrite untouched; it 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.ruleLines(r, remove)
if err != nil {
return false, err
}
units, err := h.lineUnits(desired)
if err != nil {
return false, err
}
// A missing hook scans as empty: an add creates it, a removal is a no-op.
fd, err := os.Open(h.hookPath)
if err != nil {
if !os.IsNotExist(err) {
return false, err
}
if remove {
return false, nil
}
} else {
defer func() { _ = fd.Close() }()
}
af, err := newAtomicFile(h.hookPath, h.hookPerm)
if err != nil {
return false, err
}
defer af.Abort()
// A freshly created hook gets a shebang; the firewall sources it as shell.
if fd == nil {
_, _ = fmt.Fprintln(af, "#!/bin/sh")
}
if remove {
targets := make([]*Rule, 0, len(units))
for _, u := range units {
targets = append(targets, u.rule)
}
changed := false
err = h.scanGroups(fd, func(g hookGroup) error {
// An orphan LOG line whose action partner was hand-edited away still
// belongs to the logged rule named by the removal.
if g.rule != nil && (g.rule.MatchesAny(targets) || g.rule.OrphanLogMatchesAny(targets)) {
changed = true
return nil
}
for _, l := range g.raw {
_, _ = fmt.Fprintln(af, l)
}
return nil
})
if err != nil {
return false, err
}
if !changed {
return false, nil
}
return true, h.commit(af)
}
// Copy the hook through, noting which wanted units it already holds.
present := make([]bool, len(units))
err = h.scanGroups(fd, func(g hookGroup) error {
if g.rule != nil {
for i, u := range units {
if !present[i] && g.rule.Equal(u.rule, true) {
present[i] = true
}
}
}
for _, l := range g.raw {
_, _ = fmt.Fprintln(af, l)
}
return nil
})
if err != nil {
return false, err
}
// Append the units the hook does not already hold, each under its script
// comment. A rule that fans out is completed unit by unit, so a subset left
// by a prior single-family add or a manual edit is filled in rather than
// duplicated on every reconcile.
changed := false
comment := combineComment(h.rulePrefix, r.Comment)
for i, u := range units {
if present[i] {
continue
}
if comment != "" {
_, _ = fmt.Fprintln(af, "# "+comment)
}
for _, l := range u.lines {
_, _ = fmt.Fprintln(af, l)
}
changed = true
}
if !changed {
return false, nil
}
return true, h.commit(af)
}
// --- 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 (h *hookScript) ipsetLinesFor(set *AddressSet) []string {
fam := "inet"
if set.Family == IPv6 {
fam = "inet6"
}
cmd := h.ipsetCommand()
lines := []string{
fmt.Sprintf("%s create %s %s family %s -exist", cmd, set.Name, set.Type.String(), fam),
fmt.Sprintf("%s flush %s", cmd, set.Name),
}
for _, e := range set.Entries {
lines = append(lines, fmt.Sprintf("%s add %s %s", cmd, 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> ...`), whether the command is spelled
// bare or as a resolved path.
func hookIPSetName(line string) string {
f := strings.Fields(line)
if len(f) >= 3 && hookCmdIs(f[0], "ipset") {
return f[2]
}
return ""
}
// cmdRefsSet reports whether a command 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 cmdRefsSet(cmd, name string) bool {
f := strings.Fields(cmd)
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. Add lines are applied after the scan so a hand-authored add above
// its create still counts.
func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
fd, err := os.Open(h.hookPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func() { _ = fd.Close() }()
// 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
var adds [][2]string
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
f := strings.Fields(scanner.Text())
if len(f) < 4 || !hookCmdIs(f[0], "ipset") {
continue
}
switch f[1] {
case "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])
case "add":
// 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.
adds = append(adds, [2]string{f[2], f[3]})
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
for _, a := range adds {
if s, ok := sets[a[0]]; ok {
s.Entries = append(s.Entries, a[1])
}
}
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 — streams through to the staged rewrite untouched; 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 drop filter below would strip — every rule and user line in
// the hook.
if set.Name == "" {
return false, fmt.Errorf("an address set requires a name")
}
// A missing hook scans as empty: an add creates it, a removal is a no-op.
fd, err := os.Open(h.hookPath)
if err != nil {
if !os.IsNotExist(err) {
return false, err
}
if remove {
return false, nil
}
} else {
defer func() { _ = fd.Close() }()
}
af, err := newAtomicFile(h.hookPath, h.hookPerm)
if err != nil {
return false, err
}
defer af.Abort()
block := h.ipsetLinesFor(set)
// pos counts the lines written so a no-op re-add can be recognized: the hook
// is unchanged only when the dropped block was contiguous (every drop saw
// the same pos), sat exactly at the reinsertion point, and matches the fresh
// block line for line.
pos := 0
write := func(ls ...string) {
for _, l := range ls {
_, _ = fmt.Fprintln(af, l)
pos++
}
}
// A freshly created hook gets a shebang; the firewall sources it as shell.
if fd == nil {
write("#!/bin/sh")
}
var droppedLines []string
firstDrop, contiguous := 0, true
inserted := false
insertPos := 0
err = h.scanGroups(fd, func(g hookGroup) error {
// Drop any existing lines for this set (idempotent re-add; also the
// removal path). ipset lines are single-line groups, so every rule's
// attached comment stays with its rule.
if len(g.cmds) == 0 && hookIPSetName(g.raw[0]) == set.Name {
if droppedLines == nil {
firstDrop = pos
} else if pos != firstDrop {
contiguous = false
}
droppedLines = append(droppedLines, g.raw[0])
return nil
}
if remove {
for _, cmd := range g.cmds {
if cmdRefsSet(cmd, set.Name) {
return fmt.Errorf("address set %q is in use by a rule", set.Name)
}
}
}
// Insert the set's block ahead of the first command-line group — the
// rule's attached comment included — keeping every ipset line before
// every rule line.
if !remove && !inserted && len(g.cmds) > 0 {
insertPos = pos
write(block...)
inserted = true
}
write(g.raw...)
return nil
})
if err != nil {
return false, err
}
if remove {
if droppedLines == nil {
return false, nil
}
return true, h.commit(af)
}
// Append the block when the hook holds no command line to insert it ahead of.
if !inserted {
insertPos = pos
write(block...)
}
if contiguous && insertPos == firstDrop && slices.Equal(droppedLines, block) {
return false, nil
}
return true, h.commit(af)
}
// 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)
}
// --- 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, the same hazard the filterNATFamiliesIPv6 narrowing guards. APF
// reuses these same NAT methods, binding a hookScript to each of its shell-sourced
// routing files (preroute.rules for destination NAT, postroute.rules for source
// NAT) instead of a single pre-hook — the file differs, the mechanism does not.
// natLine encodes a family-concrete NAT rule row (an expandNATFamilies fan-out)
// as one raw nat-table command line. The prefix tag is not marshalled into the
// line — it rides as the script comment editNAT writes above it. Each
// marshalled line is re-tokenized and re-quoted shell-safely, as with
// linesForRows, because the hook is sourced by /bin/sh.
func (h *hookScript) natLine(r *NATRule) (string, error) {
fam := r.impliedFamily()
rc := *r
rc.Family = fam
ipt := &IPTables{}
// As in linesForRows, the borrowed encoder takes its own check here.
if err := rc.validate(); err != nil {
return "", err
}
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 h.command(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 a comment tag embedded in the line
// (a line written by an older library version); scanGroups additionally
// marks it from the script comment above the line, which is the form this
// library writes. 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) {
fam, args, ok := hookCmdFamily(line)
if !ok {
return nil, false
}
rest, ok := strings.CutPrefix(args, "-t nat ")
if !ok {
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, each with its
// prefix flag resolved from the script comment above it or a tag embedded in
// the line. 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) {
fd, err := os.Open(h.hookPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func() { _ = fd.Close() }()
var rules []*NATRule
err = h.scanGroups(fd, func(g hookGroup) error {
if g.nat != nil {
rules = append(rules, g.nat)
}
return nil
})
return rules, err
}
// 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 is satisfied by an equivalent existing line — 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, taking each dropped line's attached comment with it.
// Every other hook line is preserved; it reports whether the hook changed.
func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) {
// An add writes only the family rows the backend enforces; encode their lines
// up front so a marshalling error stages nothing. A removal marshals nothing:
// it matches scanned lines through EqualForRemoval, whose family-covering
// check already sweeps both families' lines for a family-agnostic rule.
var rows []*NATRule
var famLines []string
if !remove {
rows = filterNATFamiliesIPv6(h.ipv6Enabled, r)
famLines = make([]string, len(rows))
for i, row := range rows {
line, err := h.natLine(row)
if err != nil {
return false, err
}
famLines[i] = line
}
}
// A missing hook scans as empty: an add creates it, a removal is a no-op.
fd, err := os.Open(h.hookPath)
if err != nil {
if !os.IsNotExist(err) {
return false, err
}
if remove {
return false, nil
}
} else {
defer func() { _ = fd.Close() }()
}
af, err := newAtomicFile(h.hookPath, h.hookPerm)
if err != nil {
return false, err
}
defer af.Abort()
// A freshly created hook gets a shebang; the firewall sources it as shell.
if fd == nil {
_, _ = fmt.Fprintln(af, "#!/bin/sh")
}
if remove {
changed := false
err = h.scanGroups(fd, func(g hookGroup) error {
if g.nat != nil && g.nat.EqualForRemoval(r) {
changed = true
return nil
}
for _, l := range g.raw {
_, _ = fmt.Fprintln(af, l)
}
return nil
})
if err != nil {
return false, err
}
if !changed {
return false, nil
}
return true, h.commit(af)
}
// Copy the hook through, noting which family rows it already holds.
present := make([]bool, len(rows))
err = h.scanGroups(fd, func(g hookGroup) error {
if g.nat != nil && g.nat.EqualForRemoval(r) {
for i, row := range rows {
if g.nat.impliedFamily() == row.impliedFamily() {
present[i] = true
}
}
}
for _, l := range g.raw {
_, _ = fmt.Fprintln(af, l)
}
return nil
})
if err != nil {
return false, err
}
// Append the family lines the hook does not already hold, each under the
// prefix-tag script comment (a NAT rule carries no user comment of its own).
changed := false
for i := range rows {
if present[i] {
continue
}
if h.rulePrefix != "" {
_, _ = fmt.Fprintln(af, "# "+h.rulePrefix)
}
_, _ = fmt.Fprintln(af, famLines[i])
changed = true
}
if !changed {
return false, nil
}
return true, h.commit(af)
}