393 lines
15 KiB
Go
393 lines
15 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, or a transport
|
|
// protocol their native config does not model (SCTP and the portless IP protocols
|
|
// GRE, ESP and AH).
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|