- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
1063 lines
39 KiB
Go
1063 lines
39 KiB
Go
package firewall
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/anmitsu/go-shlex"
|
|
)
|
|
|
|
// hookScript injects raw iptables/ip6tables commands into a CSF or APF hook that
|
|
// the firewall sources at (re)load time. It lets those backends express filter
|
|
// rules their native config cannot — connection-state, per-rule interface,
|
|
// logging, rate limiting, ICMPv6, and the transport protocols SCTP, GRE, ESP and
|
|
// AH — by reusing the iptables rule marshaller/parser and writing the resulting
|
|
// commands directly into the firewall's documented hook.
|
|
//
|
|
// The command lines live in the hook file itself (hookPath) rather than in a
|
|
// separate library-owned script: edit touches only the exact command lines it
|
|
// manages, leaving any user-authored hook content in place, and getRules parses
|
|
// every iptables/ip6tables line the hook carries. Reading foreign, user-authored
|
|
// hook rules is intended — the library manages the actual firewall state, and
|
|
// HasPrefix (derived from the rule's comment tag) is the only signal of what it
|
|
// created.
|
|
type hookScript struct {
|
|
// rulePrefix tags each injected rule with an iptables comment so it can be
|
|
// told apart from other rules.
|
|
rulePrefix string
|
|
// hookPath is the firewall hook this library writes its command lines into. It
|
|
// runs before the firewall adds its own rules, so injected rules sit at the top
|
|
// of the INPUT/OUTPUT chains.
|
|
hookPath string
|
|
// hookPerm is the mode the hook file must carry to be executed (0700 for CSF,
|
|
// 0750 for APF).
|
|
hookPerm os.FileMode
|
|
// ipv6Enabled mirrors the backend's own IPv6 handling (csf.conf IPV6, conf.apf
|
|
// USE_IPV6). With it off, a family-agnostic rule is written for IPv4 only (see
|
|
// writeFamilies).
|
|
ipv6Enabled bool
|
|
}
|
|
|
|
// ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot
|
|
// express in their native config and so must be injected as a raw iptables rule
|
|
// through the hook: a forward-chain (routed) rule, connection-state matching,
|
|
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
|
|
// protocol their native config does not model (SCTP and the portless IP protocols
|
|
// GRE, ESP and AH), an address-set reference (@set), or a negated address.
|
|
// csf.allow/apf trust files take literal addresses only, so a `-m set
|
|
// --match-set` match lives in the hook beside the ipset commands that create the
|
|
// set, and a negated address lives there as iptables' native `! -s`/`! -d` —
|
|
// csf.pl passes an advanced line's s=/d= value verbatim to iptables (where a
|
|
// joined "!" is not a reliable negation) and skips a plain "!"-prefixed line as
|
|
// not an address, so neither native form can carry one.
|
|
func ruleNeedsHook(r *Rule) bool {
|
|
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
|
|
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
|
|
isSetRef(r.Source) || isSetRef(r.Destination) ||
|
|
strings.HasPrefix(r.Source, "!") || strings.HasPrefix(r.Destination, "!")
|
|
}
|
|
|
|
// ipv6Unavailable reports whether r resolves to IPv6 while the backend's own IPv6
|
|
// handling is switched off (csf.conf IPV6, conf.apf USE_IPV6). Neither backend can
|
|
// enforce such a rule: its native config silently drops or ignores every IPv6 form,
|
|
// and the raw-iptables hook is no escape hatch — with IPv6 off, neither firewall
|
|
// flushes ip6tables on (re)load (csf.pl guards the whole v6 flush behind IPV6; apf's
|
|
// ipt6() is a no-op unless USE_IPV6=1), so a hook-injected ip6tables line is
|
|
// re-appended on every reload and a line the library removes from the hook lives on
|
|
// in the kernel. Both backends therefore reject a concrete-IPv6 rule outright rather
|
|
// than write one they cannot manage. A FamilyAny rule is not concrete IPv6 and is
|
|
// unaffected: it is written for whichever family the backend enforces. Shared by CSF
|
|
// and APF.
|
|
func ipv6Unavailable(ipv6Enabled bool, r *Rule) bool {
|
|
return !ipv6Enabled && r.impliedFamily() == IPv6
|
|
}
|
|
|
|
// parseAddrFamily parses an address (IP or CIDR) and reports its family, or false
|
|
// when the value is not a valid address. The boolean is what distinguishes it from
|
|
// familyOfAddr, which reports FamilyAny for both an unset and an unparseable address:
|
|
// a config line is classified by whether it is an address at all. Shared by CSF and
|
|
// APF.
|
|
func parseAddrFamily(v string) (Family, bool) {
|
|
cidrIP, _, err := net.ParseCIDR(v)
|
|
ip := net.ParseIP(v)
|
|
if err != nil && ip == nil {
|
|
return FamilyAny, false
|
|
}
|
|
if (cidrIP != nil && cidrIP.To4() == nil) || (ip != nil && ip.To4() == nil) {
|
|
return IPv6, true
|
|
}
|
|
return IPv4, true
|
|
}
|
|
|
|
// listRow is one line a rule materializes into in a csf.allow/csf.deny or apf
|
|
// allow_hosts/deny_hosts file, paired with the rule that line reads back as. A rule
|
|
// spanning a family or transport axis the native line cannot carry has no single
|
|
// form, so it fans out into one row per cell. EditIPList marks the rows the file
|
|
// already holds as it scans and writes only the rest, so a partially present fan-out
|
|
// — one family written by an earlier single-family add, or a line lost to a manual
|
|
// edit — is completed rather than left half open or duplicated wholesale. Shared by
|
|
// CSF and APF.
|
|
type listRow struct {
|
|
// line is the exact text written to the list file.
|
|
line string
|
|
// read is the rule that line parses back to, which is what an existing line in
|
|
// the file is compared against to decide whether the row is already present.
|
|
read *Rule
|
|
}
|
|
|
|
// writeFamilyRows returns the concrete-family rows a rule is written as in a csf/apf
|
|
// list file, narrowed to the families the backend enforces. With its own IPv6 handling
|
|
// off, neither backend installs any IPv6 rule from its config, so a family-agnostic
|
|
// rule is written for IPv4 only: the IPv6 row would sit inert in the file and read back
|
|
// as an IPv6 rule the backend does not enforce and AddRule would reject. It is the
|
|
// list-file analog of hookScript.writeFamilies, and enabling IPv6 later heals the
|
|
// missing row (EditIPList completes a partial fan-out).
|
|
//
|
|
// A row already pinned to a concrete family keeps it, IPv6 included: a fresh
|
|
// concrete-IPv6 add is stopped earlier by the ipv6Unavailable gate, and Restore
|
|
// deliberately bypasses that gate to reproduce a snapshot's entries verbatim. Removal
|
|
// needs no such narrowing — it matches each line against the target directly, so a
|
|
// family-agnostic target already sweeps both rows. Shared by CSF and APF.
|
|
func writeFamilyRows(ipv6Enabled bool, r *Rule) []*Rule {
|
|
rows := expandFamilies(r)
|
|
if ipv6Enabled || len(rows) == 1 {
|
|
return rows
|
|
}
|
|
kept := make([]*Rule, 0, 1)
|
|
for _, row := range rows {
|
|
if row.Family != IPv6 {
|
|
kept = append(kept, row)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// bareHostShape reports whether a rule has the shape a plain csf.allow/apf
|
|
// allow_hosts line expresses: exactly one source or destination address, no ports,
|
|
// and the any-protocol match. Its direction is not considered — a DirAny bare host
|
|
// is the single bidirectional plain line, while a concrete-direction one is one-way
|
|
// (see bareHostOneWay). Shared by CSF and APF.
|
|
func bareHostShape(r *Rule) bool {
|
|
if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny {
|
|
return false
|
|
}
|
|
// A set reference (@set) is not a literal host: it matches through the hook's
|
|
// `-m set` clause (ruleNeedsHook routes it there), never a plain trust-file line.
|
|
if isSetRef(r.Source) || isSetRef(r.Destination) {
|
|
return false
|
|
}
|
|
return (r.Source != "") != (r.Destination != "")
|
|
}
|
|
|
|
// bareHostOneWay reports whether a rule is a ONE-WAY bare-address host allow/deny:
|
|
// the bare host shape with a concrete input or output direction. A plain line matches
|
|
// a host in BOTH directions, and neither backend's advanced-rule format can carry an
|
|
// address without a port, so a one-way bare host rule is expressed through the
|
|
// raw-iptables hook instead.
|
|
func bareHostOneWay(r *Rule) bool {
|
|
return bareHostShape(r) && (r.Direction == DirInput || r.Direction == DirOutput)
|
|
}
|
|
|
|
// dirAnyPlainLine reports whether a DirAny rule maps to a single bidirectional plain
|
|
// csf.allow/apf line: a bare host carrying no feature that would force the
|
|
// raw-iptables hook (connection state, interface, logging, etc.). Every other DirAny
|
|
// rule fans out into a concrete input rule plus its role-swapped output rule on
|
|
// add/remove, since csf/apf have no single native both-directions construct for it.
|
|
func dirAnyPlainLine(r *Rule) bool {
|
|
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
|
|
}
|
|
|
|
// shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF
|
|
// config form and must be injected through the raw-iptables hook. The native forms
|
|
// are narrow — a plain trust-file line holds one address, matches both directions,
|
|
// and is all-protocol; an advanced line holds exactly one address field and one
|
|
// port-flow field and requires both; the conf lists key on a port or an icmp type —
|
|
// while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with
|
|
// `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes are hooked rather than
|
|
// rejected: the alternative is failing on a rule the firewall can enforce.
|
|
//
|
|
// Feature-based routing (state, interface, logging, rate limiting, ICMPv6,
|
|
// hook-only transports, set references, negation) is ruleNeedsHook's job, and the
|
|
// backend-specific connlimit, ICMP and port-list shapes stay in each backend's
|
|
// needsHook; ICMP is excluded from every test here because both backends route it
|
|
// there (CSF's typed advanced rule, APF.isConfRule). Shared by CSF and APF.
|
|
func shapeNeedsHook(r *Rule) bool {
|
|
// A one-way bare host: a plain line is bidirectional and an advanced rule
|
|
// requires a port, so a concrete-direction bare host has no native form.
|
|
if bareHostOneWay(r) {
|
|
return true
|
|
}
|
|
// Portless address shapes no trust file expresses.
|
|
if !r.Proto.IsICMP() && !r.HasPorts() && !r.HasSourcePorts() {
|
|
// A source+destination pair has no single-address advanced/plain form.
|
|
if r.Source != "" && r.Destination != "" {
|
|
return true
|
|
}
|
|
// A single-address host pinned to a transport has no portless form: the plain
|
|
// line is all-protocol and the advanced rule requires a port. TCPUDP counts —
|
|
// it names transports, so it is not the all-protocol plain line either.
|
|
if (r.Source != "" || r.Destination != "") && onProtocolAxis(r.Proto) {
|
|
return true
|
|
}
|
|
}
|
|
// Advanced-line overflows, on the protocols an iptables port or icmp match
|
|
// accepts: a port on ProtocolAny is inexpressible in iptables too, so it stays
|
|
// on the native path and is rejected there by iptablesRuleValid rather than
|
|
// reaching the hook and failing there.
|
|
if onProtocolAxis(r.Proto) || r.Proto.IsICMP() {
|
|
// One port-flow field: a source port and a destination port cannot share it.
|
|
if r.HasPorts() && r.HasSourcePorts() {
|
|
return true
|
|
}
|
|
// An advanced rule requires an address, so a bare source-port match has no
|
|
// advanced form at all; iptables matches --sport on its own.
|
|
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
|
|
return true
|
|
}
|
|
// One address field: a ported or icmp-matching source+destination pair cannot
|
|
// share it (its portless non-ICMP counterpart is routed above).
|
|
if r.Source != "" && r.Destination != "" &&
|
|
(r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()) {
|
|
return true
|
|
}
|
|
}
|
|
// A bare protocol match — a non-ICMP transport with no address and no port — has
|
|
// no native construct (the trust files key on an address, the conf lists on a
|
|
// port or icmp type) but iptables applies it directly.
|
|
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
|
|
}
|
|
|
|
// hookOnlyProto reports whether a protocol has no representation in CSF's or
|
|
// APF's native config and so can only be applied through the raw-iptables hook.
|
|
func hookOnlyProto(p Protocol) bool {
|
|
switch p {
|
|
case SCTP, GRE, ESP, AH:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hookRuleProtos lists the transport protocols a rule is written for in the hook.
|
|
// iptables has no both-transports match, so a TCPUDP rule fans out into a tcp line
|
|
// and a udp line; every other protocol writes one line. A portless ProtocolAny rule
|
|
// is a valid protocol-agnostic match (a bare `-j ACCEPT`) and is not fanned.
|
|
func hookRuleProtos(r *Rule) []Protocol {
|
|
protos := make([]Protocol, 0, 2)
|
|
for _, sub := range expandProtocols(r) {
|
|
protos = append(protos, sub.Proto)
|
|
}
|
|
return protos
|
|
}
|
|
|
|
// hookRuleFamilies lists every address family a rule's hook lines can occupy: a
|
|
// rule pinned to a family (by address or an ICMP protocol) touches only that
|
|
// command, a family-agnostic rule (e.g. a bare state match) spans both v4 and v6.
|
|
// It is the full set, which is what removal must sweep; writeFamilies narrows it to
|
|
// the families the backend actually enforces.
|
|
func hookRuleFamilies(r *Rule) []Family {
|
|
switch r.impliedFamily() {
|
|
case IPv4:
|
|
return []Family{IPv4}
|
|
case IPv6:
|
|
return []Family{IPv6}
|
|
default:
|
|
return []Family{IPv4, IPv6}
|
|
}
|
|
}
|
|
|
|
// writeFamilies lists the address families a rule's hook lines are written for.
|
|
// With the backend's own IPv6 handling off, a family-agnostic rule is written for
|
|
// IPv4 only: the pre-hook runs on every (re)load regardless, but neither csf nor apf
|
|
// flushes ip6tables while IPv6 is disabled (csf.pl guards the v6 flush behind IPV6;
|
|
// apf's ipt6() is a no-op unless USE_IPV6=1), so an injected ip6tables line would be
|
|
// re-appended on each reload and would outlive its own removal from the hook. That is
|
|
// the same hazard ipv6Unavailable rejects a concrete-IPv6 rule for; a family-agnostic
|
|
// rule is not rejected, it is simply written for the family the backend enforces.
|
|
//
|
|
// A rule already pinned to a concrete family keeps it, IPv6 included: a fresh
|
|
// concrete-IPv6 add is stopped earlier by the ipv6Unavailable gate, and Restore
|
|
// deliberately bypasses that gate to reproduce a snapshot's entries verbatim.
|
|
func (h *hookScript) writeFamilies(r *Rule) []Family {
|
|
fams := hookRuleFamilies(r)
|
|
if h.ipv6Enabled || len(fams) == 1 {
|
|
return fams
|
|
}
|
|
return []Family{IPv4}
|
|
}
|
|
|
|
// hookCommand returns the iptables command name for a family.
|
|
func hookCommand(fam Family) string {
|
|
if fam == IPv6 {
|
|
return "ip6tables"
|
|
}
|
|
return "iptables"
|
|
}
|
|
|
|
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
|
|
// iptables marshaller quotes free-text fields (a comment, a log prefix) for an
|
|
// iptables-restore file, where double quotes are literal — but the hook is
|
|
// sourced by /bin/sh, which expands $var, $(...) and backticks inside double
|
|
// quotes. A token made of ordinary argument characters is returned bare for
|
|
// readability; anything else is wrapped in single quotes (with any embedded
|
|
// single quote escaped), which the shell treats as a literal. shlex.Split
|
|
// reverses either form on read-back.
|
|
func shellSafeToken(tok string) string {
|
|
safe := tok != ""
|
|
for _, r := range tok {
|
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
|
strings.ContainsRune("_./:=,+-@%", r) {
|
|
continue
|
|
}
|
|
safe = false
|
|
break
|
|
}
|
|
if safe {
|
|
return tok
|
|
}
|
|
return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// rulesToLines encodes a rule as the raw command line(s) to inject, for the
|
|
// families the backend enforces (see writeFamilies). A family-agnostic rule
|
|
// that references an address set is pinned to the set's family instead: an
|
|
// ipset is single-family, so the opposite-family line would fail every time
|
|
// the firewall sources the hook.
|
|
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
|
|
if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) {
|
|
fam, err := h.setRefFamily(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fam == IPv6 && !h.ipv6Enabled {
|
|
return nil, fmt.Errorf("rule references an IPv6 address set while IPv6 is disabled: %w", ErrUnsupported)
|
|
}
|
|
return h.linesForFamilies(r, []Family{fam})
|
|
}
|
|
return h.linesForFamilies(r, h.writeFamilies(r))
|
|
}
|
|
|
|
// setRefFamily resolves the single family of the address set(s) a rule
|
|
// references from the hook's ipset lines. A set that is not in the hook, or a
|
|
// source/destination pair naming sets of different families, cannot produce a
|
|
// loadable line, so both are errors.
|
|
func (h *hookScript) setRefFamily(r *Rule) (Family, error) {
|
|
sets, err := h.getAddressSets()
|
|
if err != nil {
|
|
return FamilyAny, err
|
|
}
|
|
fam := FamilyAny
|
|
for _, ref := range []string{r.Source, r.Destination} {
|
|
if !isSetRef(ref) {
|
|
continue
|
|
}
|
|
_, bare := splitAddrNeg(strings.TrimSpace(ref))
|
|
name := strings.TrimPrefix(bare, "@")
|
|
var found *AddressSet
|
|
for _, s := range sets {
|
|
if s.Name == name {
|
|
found = s
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return FamilyAny, fmt.Errorf("rule references unknown address set %q", name)
|
|
}
|
|
sf := found.Family
|
|
if sf != IPv6 {
|
|
sf = IPv4
|
|
}
|
|
if fam != FamilyAny && sf != fam {
|
|
return FamilyAny, fmt.Errorf("rule references address sets of different families")
|
|
}
|
|
fam = sf
|
|
}
|
|
if fam == FamilyAny {
|
|
fam = IPv4
|
|
}
|
|
return fam, nil
|
|
}
|
|
|
|
// removalLines encodes every hook line a rule could occupy, across both families
|
|
// regardless of the backend's IPv6 setting. Removal sweeps the wider set so an
|
|
// ip6tables line written while IPv6 was enabled — or added by hand — is still
|
|
// cleared once it is switched off, rather than stranded in the hook.
|
|
func (h *hookScript) removalLines(r *Rule) ([]string, error) {
|
|
return h.linesForFamilies(r, hookRuleFamilies(r))
|
|
}
|
|
|
|
// linesForFamilies encodes a rule as the raw command line(s) to inject: one iptables
|
|
// (or ip6tables) command per underlying iptables line and per requested family. A
|
|
// logged rule yields a LOG line followed by its action line, as with the iptables
|
|
// backend. Each marshalled line is re-tokenized and re-quoted shell-safely,
|
|
// because the hook script is sourced by /bin/sh rather than exec'd argv-style.
|
|
func (h *hookScript) linesForFamilies(r *Rule, fams []Family) ([]string, error) {
|
|
var out []string
|
|
for _, proto := range hookRuleProtos(r) {
|
|
for _, fam := range fams {
|
|
rc := *r
|
|
rc.Proto = proto
|
|
rc.Family = fam
|
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
|
base, err := ipt.marshalRuleLines(&rc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cmd := hookCommand(fam)
|
|
for _, line := range base {
|
|
tokens, terr := shlex.Split(line, true)
|
|
if terr != nil {
|
|
return nil, terr
|
|
}
|
|
for i, t := range tokens {
|
|
tokens[i] = shellSafeToken(t)
|
|
}
|
|
out = append(out, cmd+" "+strings.Join(tokens, " "))
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ruleMatchesAny reports whether e — a rule parsed from a hook line — is the same
|
|
// underlying rule as any of targets, honoring direction (the hook emits explicit
|
|
// -A INPUT/-A OUTPUT lines) but ignoring the comment, which is not part of rule
|
|
// identity. It backs comment-agnostic hook removal.
|
|
func ruleMatchesAny(e *Rule, targets []*Rule) bool {
|
|
for _, t := range targets {
|
|
if e.Equal(t, true) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// orphanLogMatchesAny reports whether e is a LOG line whose action partner is
|
|
// gone from the hook and whose logged rule is named by one of targets, so a
|
|
// removal still clears the stray line.
|
|
func orphanLogMatchesAny(e *Rule, targets []*Rule) bool {
|
|
if e.Action != ActionInvalid || !e.Log {
|
|
return false
|
|
}
|
|
for _, t := range targets {
|
|
if !t.Log {
|
|
continue
|
|
}
|
|
tl := *t
|
|
tl.Action = ActionInvalid
|
|
if e.Equal(&tl, true) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseLine decodes an injected command line back into the rule it represents
|
|
// (one line, so a LOG line yields a rule with Log set and no action), reporting
|
|
// whether the line is one this backend recognizes.
|
|
func (h *hookScript) parseLine(line string) (*Rule, bool) {
|
|
line = strings.TrimSpace(line)
|
|
var fam Family
|
|
var rest string
|
|
switch {
|
|
case strings.HasPrefix(line, "iptables "):
|
|
fam, rest = IPv4, strings.TrimPrefix(line, "iptables ")
|
|
case strings.HasPrefix(line, "ip6tables "):
|
|
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables ")
|
|
default:
|
|
return nil, false
|
|
}
|
|
r, err := unmarshalIPTablesRule(rest, fam)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
// The iptables parser captures the prefixed comment; strip the prefix (a tag)
|
|
// so only the user-facing comment surfaces, and record whether the injected
|
|
// rule carried the prefix so HasPrefix reflects it just like the iptables
|
|
// backend.
|
|
text, hasPrefix := prefixedComment(h.rulePrefix, r.Comment)
|
|
r.Comment = text
|
|
r.HasPrefix = hasPrefix
|
|
return r, true
|
|
}
|
|
|
|
// commandLines returns the iptables/ip6tables command lines currently in the
|
|
// hook (a missing hook contributes none). Every such line is returned, including
|
|
// any a user authored by hand, so the library reconciles the hook's real state.
|
|
func (h *hookScript) commandLines() ([]string, error) {
|
|
lines, _, err := h.readHookLines()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cmds []string
|
|
for _, line := range lines {
|
|
if isHookRuleLine(line) {
|
|
cmds = append(cmds, strings.TrimSpace(line))
|
|
}
|
|
}
|
|
return cmds, nil
|
|
}
|
|
|
|
// getRules parses the hook into logical rules, coalescing each LOG line with the
|
|
// action line that follows it. Family merging is left to the caller, which
|
|
// unions these with the backend's native rules.
|
|
func (h *hookScript) getRules() ([]*Rule, error) {
|
|
cmds, err := h.commandLines()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rules []*Rule
|
|
for _, line := range cmds {
|
|
if r, ok := h.parseLine(line); ok {
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
return coalesceLoggedRules(rules), nil
|
|
}
|
|
|
|
// readHookLines returns the hook's lines and whether the hook file exists. A
|
|
// single trailing newline is trimmed so a rewrite does not accrue a blank line;
|
|
// a missing hook yields no lines.
|
|
func (h *hookScript) readHookLines() ([]string, bool, error) {
|
|
data, err := os.ReadFile(h.hookPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
content := strings.TrimSuffix(string(data), "\n")
|
|
if content == "" {
|
|
return nil, true, nil
|
|
}
|
|
return strings.Split(content, "\n"), true, nil
|
|
}
|
|
|
|
// writeHook atomically replaces the hook with lines. A freshly created hook
|
|
// gets a shebang and the executable hookPerm; an existing hook keeps its own
|
|
// mode and ownership (see writeConfigFile).
|
|
func (h *hookScript) writeHook(lines []string, existed bool) error {
|
|
var b strings.Builder
|
|
if !existed {
|
|
b.WriteString("#!/bin/sh\n")
|
|
}
|
|
for _, l := range lines {
|
|
b.WriteString(l)
|
|
b.WriteByte('\n')
|
|
}
|
|
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
|
|
return fmt.Errorf("failed to move updated hook into place: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// edit adds or removes a rule's command line(s) directly in the hook. Adding
|
|
// matches on the exact deterministic line the marshaller emits, so the library's
|
|
// own tagged line is written once and re-adds are idempotent. Removal instead
|
|
// matches on the underlying rule: a hook rule is dropped when it is the same rule
|
|
// as one r resolves to, ignoring the comment tag, so a copy of the rule a
|
|
// customer added under a different comment (or none) is cleared too — the comment
|
|
// is not part of rule identity. A LOG line and the action line under it are
|
|
// matched as the one logged rule they encode, never independently: a logged rule
|
|
// and its unlogged twin are distinct rules, and removing one must not strip the
|
|
// other's lines. It preserves every other hook line — user-authored shell and
|
|
// rules alike — and reports whether the hook changed. Adding to an absent hook
|
|
// creates it; removing from one is a no-op.
|
|
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
|
// An add writes only the families the backend enforces; a removal sweeps both, so
|
|
// a stale ip6tables line does not outlive an IPv6 switch-off (see writeFamilies).
|
|
linesFor := h.rulesToLines
|
|
if remove {
|
|
linesFor = h.removalLines
|
|
}
|
|
desired, err := linesFor(r)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
lines, existed, err := h.readHookLines()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
changed := false
|
|
if remove {
|
|
// Parse each desired line back into the rule it encodes — the round trip
|
|
// normalizes field spellings — and coalesce each LOG line with its action
|
|
// line so the targets are logical rules. A desired line that fails to
|
|
// parse breaks the marshal/parse round trip the removal match depends on,
|
|
// so it is an error rather than a silent no-op.
|
|
var parsed []*Rule
|
|
for _, l := range desired {
|
|
tr, ok := h.parseLine(l)
|
|
if !ok {
|
|
return false, fmt.Errorf("hook removal target does not round-trip: %q", l)
|
|
}
|
|
parsed = append(parsed, tr)
|
|
}
|
|
targets := coalesceLoggedRules(parsed)
|
|
next := lines[:0:0]
|
|
for i := 0; i < len(lines); i++ {
|
|
er, ok := h.parseLine(lines[i])
|
|
if !ok {
|
|
next = append(next, lines[i])
|
|
continue
|
|
}
|
|
// Pair a LOG line with the action line directly under it, mirroring
|
|
// getRules' coalescing, so the pair is kept or dropped as one rule.
|
|
var partner *Rule
|
|
if i+1 < len(lines) {
|
|
partner, _ = h.parseLine(lines[i+1])
|
|
}
|
|
switch {
|
|
case logPartner(er, partner):
|
|
if ruleMatchesAny(mergeLogPair(er, partner), targets) {
|
|
changed = true
|
|
} else {
|
|
next = append(next, lines[i], lines[i+1])
|
|
}
|
|
i++
|
|
case orphanLogMatchesAny(er, targets):
|
|
// A stray LOG line whose action partner was hand-edited away
|
|
// still belongs to the logged rule named by the removal.
|
|
changed = true
|
|
case ruleMatchesAny(er, targets):
|
|
changed = true
|
|
default:
|
|
next = append(next, lines[i])
|
|
}
|
|
}
|
|
if !changed {
|
|
return false, nil
|
|
}
|
|
lines = next
|
|
} else {
|
|
present := make(map[string]bool, len(lines))
|
|
for _, l := range lines {
|
|
present[strings.TrimSpace(l)] = true
|
|
}
|
|
for _, l := range desired {
|
|
if !present[l] {
|
|
lines = append(lines, l)
|
|
present[l] = true
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, h.writeHook(lines, existed)
|
|
}
|
|
|
|
// --- NAT rules (raw nat-table commands in the hook) --------------------------
|
|
//
|
|
// csf.redirect holds exactly two destination-NAT shapes, so every other NAT rule
|
|
// — source NAT, an interface-bound or source-matched translation, a port
|
|
// range/list — is injected as a raw `iptables -t nat` command through the same
|
|
// hook that carries the filter rules. csf flushes the v4 nat table on every
|
|
// (re)start whenever the kernel provides one (its Config.pm probes `-t nat -L
|
|
// POSTROUTING` and sets NAT=1) and sources the pre-hook afterwards, so the
|
|
// injected lines are applied exactly once per load; the v6 nat flush is guarded
|
|
// by IPV6, which is the same hazard natWriteFamilies narrows writes for. APF
|
|
// expresses the same escape hatch with its own preroute.rules/postroute.rules
|
|
// files instead of the hook.
|
|
|
|
// natRuleFamilies lists every address family a NAT rule's raw nat lines can
|
|
// occupy: a rule pinned to a family (by an address or its Family field) touches
|
|
// only that command; a family-agnostic rule (e.g. a portless masquerade) spans
|
|
// both v4 and v6. It is the full set, which is what removal must sweep;
|
|
// natWriteFamilies narrows it to the families the backend enforces. Shared by
|
|
// CSF's hook NAT lines and APF's routing files.
|
|
func natRuleFamilies(r *NATRule) []Family {
|
|
switch r.impliedFamily() {
|
|
case IPv4:
|
|
return []Family{IPv4}
|
|
case IPv6:
|
|
return []Family{IPv6}
|
|
default:
|
|
return []Family{IPv4, IPv6}
|
|
}
|
|
}
|
|
|
|
// natWriteFamilies lists the families a NAT rule's lines are written for. With
|
|
// the backend's own IPv6 handling off a family-agnostic rule is written for IPv4
|
|
// only: neither csf nor apf flushes the v6 nat table while IPv6 is disabled, so
|
|
// an injected ip6tables line would be re-appended on every reload and outlive
|
|
// its own removal. Removal still sweeps both families (natRuleFamilies) so a
|
|
// stale v6 line does not survive an IPv6 switch-off. It is the NAT analog of
|
|
// hookScript.writeFamilies. Shared by CSF and APF.
|
|
func natWriteFamilies(ipv6Enabled bool, r *NATRule) []Family {
|
|
fams := natRuleFamilies(r)
|
|
if ipv6Enabled || len(fams) == 1 {
|
|
return fams
|
|
}
|
|
return []Family{IPv4}
|
|
}
|
|
|
|
// natLine encodes a NAT rule as one raw nat-table command line for a family.
|
|
// Each marshalled line is re-tokenized and re-quoted shell-safely, as with
|
|
// linesForFamilies, because the hook is sourced by /bin/sh.
|
|
func (h *hookScript) natLine(r *NATRule, fam Family) (string, error) {
|
|
rc := *r
|
|
rc.Family = fam
|
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
|
spec, err := ipt.MarshalNATRule(&rc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tokens, err := shlex.Split(spec, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for i, t := range tokens {
|
|
tokens[i] = shellSafeToken(t)
|
|
}
|
|
return hookCommand(fam) + " -t nat " + strings.Join(tokens, " "), nil
|
|
}
|
|
|
|
// parseNATLine decodes a raw nat command line back into the NATRule it
|
|
// represents, reporting whether the line is one this backend recognizes. The
|
|
// iptables NAT parser derives HasPrefix from the line's comment tag, so a rule
|
|
// this library wrote reports it and a hand-added line does not. A `-t nat` line
|
|
// never doubles as a filter rule: parseLine's chain check (INPUT/OUTPUT/FORWARD)
|
|
// rejects it, so the two line kinds stay disjoint in the same hook.
|
|
func (h *hookScript) parseNATLine(line string) (*NATRule, bool) {
|
|
line = strings.TrimSpace(line)
|
|
var fam Family
|
|
var rest string
|
|
switch {
|
|
case strings.HasPrefix(line, "iptables -t nat "):
|
|
fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ")
|
|
case strings.HasPrefix(line, "ip6tables -t nat "):
|
|
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ")
|
|
default:
|
|
return nil, false
|
|
}
|
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
|
r, err := ipt.UnmarshalNATRule(rest, fam)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return r, true
|
|
}
|
|
|
|
// getNATRules parses the raw nat-table rules the hook carries. Every such line
|
|
// is returned, including any a user authored by hand, so the library reconciles
|
|
// the hook's real state.
|
|
func (h *hookScript) getNATRules() ([]*NATRule, error) {
|
|
lines, _, err := h.readHookLines()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rules []*NATRule
|
|
for _, line := range lines {
|
|
if r, ok := h.parseNATLine(line); ok {
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// editNAT adds or removes a NAT rule's raw nat command line(s) in the hook,
|
|
// mirroring edit for filter rules: an add writes only the families the backend
|
|
// enforces and dedups against an equivalent existing line — the exact text, or
|
|
// the same translation under EqualForRemoval, which stays family-aware so a
|
|
// family-scoped edit leaves an opposite-family twin alone — while a removal
|
|
// sweeps both families. Every other hook line is preserved; it reports whether
|
|
// the hook changed.
|
|
func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) {
|
|
fams := natRuleFamilies(r)
|
|
if !remove {
|
|
fams = natWriteFamilies(h.ipv6Enabled, r)
|
|
}
|
|
want := make([]string, 0, len(fams))
|
|
wantSet := make(map[string]bool, len(fams))
|
|
for _, fam := range fams {
|
|
line, err := h.natLine(r, fam)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
want = append(want, line)
|
|
wantSet[line] = true
|
|
}
|
|
|
|
lines, existed, err := h.readHookLines()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
changed := false
|
|
next := make([]string, 0, len(lines)+len(want))
|
|
present := make(map[string]bool, len(want))
|
|
for _, raw := range lines {
|
|
body := strings.TrimSpace(raw)
|
|
matched := false
|
|
satisfied := ""
|
|
if wantSet[body] {
|
|
matched, satisfied = true, body
|
|
} else if existing, ok := h.parseNATLine(body); ok && existing.EqualForRemoval(r) {
|
|
matched = true
|
|
// The equivalent want-line is the one for this existing line's family;
|
|
// mark it satisfied so its duplicate is not appended below. Recompute it
|
|
// (rather than reuse body) since body is the existing spelling, not ours.
|
|
if line, lerr := h.natLine(r, existing.impliedFamily()); lerr == nil {
|
|
satisfied = line
|
|
}
|
|
}
|
|
if matched {
|
|
if remove {
|
|
changed = true
|
|
continue
|
|
}
|
|
if satisfied != "" {
|
|
present[satisfied] = true
|
|
}
|
|
}
|
|
next = append(next, raw)
|
|
}
|
|
|
|
if remove {
|
|
if !changed {
|
|
return false, nil
|
|
}
|
|
return true, h.writeHook(next, existed)
|
|
}
|
|
for _, line := range want {
|
|
if !present[line] {
|
|
next = append(next, line)
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return false, nil
|
|
}
|
|
return true, h.writeHook(next, existed)
|
|
}
|
|
|
|
// --- address sets (ipset commands in the hook) -----------------------------
|
|
//
|
|
// CSF and APF have no native address-set construct, so the library persists a
|
|
// set as `ipset` commands in the same hook that carries its raw iptables rules.
|
|
// The firewall sources the hook on every (re)start, so the ipset commands
|
|
// recreate the set before the `-m set --match-set` rule lines that follow can
|
|
// reference it — the set survives a reboot exactly as the hook's rules do. Every
|
|
// ipset line is kept ahead of every iptables/ip6tables line to preserve that
|
|
// ordering. Reading foreign, user-authored ipset lines is intended, as with
|
|
// rules: the library manages the actual hook state.
|
|
|
|
// ipsetLinesFor renders the hook lines that (re)create a set and load its
|
|
// entries: an idempotent create (-exist, so a reload does not fail on the
|
|
// existing set), a flush (so a reload drops entries removed since the last
|
|
// write, making the entry list declarative), then one add per entry.
|
|
func ipsetLinesFor(set *AddressSet) []string {
|
|
fam := "inet"
|
|
if set.Family == IPv6 {
|
|
fam = "inet6"
|
|
}
|
|
lines := []string{
|
|
fmt.Sprintf("ipset create %s %s family %s -exist", set.Name, set.Type.String(), fam),
|
|
fmt.Sprintf("ipset flush %s", set.Name),
|
|
}
|
|
for _, e := range set.Entries {
|
|
lines = append(lines, fmt.Sprintf("ipset add %s %s", set.Name, e))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// hookIPSetName returns the set a hook ipset line operates on, or "" when the
|
|
// line is not one of the library's ipset commands. Every such line names the set
|
|
// in its third field (`ipset <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)
|
|
// A hand-authored add may carry trailing options (`timeout 300`, `-exist`);
|
|
// the entry itself is still the fourth field. Options are not modeled, so a
|
|
// rewrite of the set's block re-emits the entry without them.
|
|
if len(f) >= 4 && f[0] == "ipset" && f[1] == "add" {
|
|
if s, ok := sets[f[2]]; ok {
|
|
s.Entries = append(s.Entries, f[3])
|
|
}
|
|
}
|
|
}
|
|
out := make([]*AddressSet, 0, len(order))
|
|
for _, n := range order {
|
|
out = append(out, sets[n])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// editAddressSet writes or removes a set's ipset lines in the hook. Adding drops
|
|
// any prior lines for the set and reinserts its block ahead of the first
|
|
// iptables/ip6tables line, so the set exists before any rule matches it; the
|
|
// write is idempotent. Removing drops the set's lines but refuses when a hook
|
|
// rule still references it. Every other hook line — user shell, rules, other
|
|
// sets — is preserved; it reports whether the hook changed.
|
|
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
|
|
// hookIPSetName reports "" for every non-ipset line, so an unnamed set would
|
|
// match — and the keep-filter below would drop — every rule and user line in
|
|
// the hook.
|
|
if set.Name == "" {
|
|
return false, fmt.Errorf("an address set requires a name")
|
|
}
|
|
lines, existed, err := h.readHookLines()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if remove && setInUse(lines, set.Name) {
|
|
return false, fmt.Errorf("address set %q is in use by a rule", set.Name)
|
|
}
|
|
|
|
// Drop any existing lines for this set (idempotent re-add; also the removal path).
|
|
kept := make([]string, 0, len(lines))
|
|
dropped := false
|
|
for _, l := range lines {
|
|
if hookIPSetName(l) == set.Name {
|
|
dropped = true
|
|
continue
|
|
}
|
|
kept = append(kept, l)
|
|
}
|
|
|
|
if remove {
|
|
if !dropped {
|
|
return false, nil
|
|
}
|
|
return true, h.writeHook(kept, existed)
|
|
}
|
|
|
|
// Insert the set's block ahead of the first rule line (or at the end when the
|
|
// hook has none yet), keeping every ipset line before every rule line.
|
|
block := ipsetLinesFor(set)
|
|
next := make([]string, 0, len(kept)+len(block))
|
|
inserted := false
|
|
for _, l := range kept {
|
|
if !inserted && isHookRuleLine(l) {
|
|
next = append(next, block...)
|
|
inserted = true
|
|
}
|
|
next = append(next, l)
|
|
}
|
|
if !inserted {
|
|
next = append(next, block...)
|
|
}
|
|
|
|
if equalLines(lines, next) {
|
|
return false, nil
|
|
}
|
|
return true, h.writeHook(next, existed)
|
|
}
|
|
|
|
// editAddressSetEntry adds or removes a single entry in an existing set by
|
|
// rewriting the set's block. The set must already exist in the hook.
|
|
func (h *hookScript) editAddressSetEntry(name, entry string, remove bool) (bool, error) {
|
|
sets, err := h.getAddressSets()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var target *AddressSet
|
|
for _, s := range sets {
|
|
if s.Name == name {
|
|
target = s
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return false, fmt.Errorf("address set %q not found", name)
|
|
}
|
|
if remove {
|
|
next := target.Entries[:0]
|
|
found := false
|
|
for _, e := range target.Entries {
|
|
if e == entry {
|
|
found = true
|
|
continue
|
|
}
|
|
next = append(next, e)
|
|
}
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
target.Entries = next
|
|
} else {
|
|
for _, e := range target.Entries {
|
|
if e == entry {
|
|
return false, nil
|
|
}
|
|
}
|
|
target.Entries = append(target.Entries, entry)
|
|
}
|
|
return h.editAddressSet(target, false)
|
|
}
|
|
|
|
// equalLines reports whether two hook line slices are identical.
|
|
func equalLines(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|