go-firewall/csf_linux.go
James Coleman 060d667e93 Route inexpressible csf/apf shapes to the hook instead of rejecting them
csf and apf both back a native config with a raw-iptables pre-hook, but a rule
their config could not express was reported as unsupported even when iptables
applies it directly. Route those shapes to the hook, and share the routing
predicates between the two backends.

Shared hook layer (hooks_linux.go): extract parseAddrFamily, listRow/listRows,
bareHostShape, hostNeedsHook, advRuleNeedsHook and bareProtoNeedsHook so both
backends route on the same predicates. MarshalAdvRule no longer returns an error
for shapes its caller has already routed away.

csf: replace checkConnLimit, checkICMP and checkPortProto with a single needsHook
gate mirroring apf's. A connection limit CONNLIMIT cannot express, an ICMP rule
the advanced-line format cannot carry (see nativeICMP), and a port on a protocol
its TCP_IN/UDP_IN lists cannot hold now reach the hook. A port on ProtocolAny has
no form in iptables either, so it stays on the native path and iptablesRuleValid
rejects it with ErrUnsupported.

csf: remove a deny from csf.deny whatever action the caller names, as apf does.
The file encodes no action of its own, so the deny of an address is a single
entry; the old early return reported success while csf kept enforcing it.

IPv6: ipv6Unavailable only rejects concrete-IPv6 rules, so a FamilyAny rule
passed the gate and then fanned out into an IPv6 row. With csf.conf's IPV6 or
conf.apf's USE_IPV6 off that row is never enforced, and in the hook it is worse
than inert: the pre-hook runs on every reload while neither backend flushes
ip6tables, so an injected ip6tables line is re-appended each time and outlives
its removal from the hook. Narrow both write-side fan-outs to the families the
backend enforces (hookScript.writeFamilies, writeFamilyRows). Removals stay wide
so a v6 line written while IPv6 was on is still swept once it is off.

Integration: the shared probes take the first variant a backend accepts, so
widening csf's accepted shapes silently moved icmp/icmptype/connlimit onto its
hook path. Add csf-gated subtests to keep the native csf.conf CONNLIMIT and
csf.allow advanced-rule paths covered, alongside one for action-agnostic
csf.deny removal.
2026-07-09 21:30:27 -05:00

1774 lines
58 KiB
Go

package firewall
import (
"bufio"
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
CSFConf = "/etc/csf/csf.conf"
CSFAllow = "/etc/csf/csf.allow"
CSFDeny = "/etc/csf/csf.deny"
// CSFRedirect holds csf's port-forwarding rules, one per line in the
// pipe-delimited form "IPx|portA|IPy|portB|proto". A destination IP (IPy) of
// "*" is a local port redirect; a concrete IPy is a forward to another host.
CSFRedirect = "/etc/csf/csf.redirect"
// CSFHook is the csf pre-hook, run after csf flushes and before it loads its
// own rules, so injected rules land at the top of the chains and are re-added
// on every reload. This library writes the iptables rules for features csf's
// native config cannot express directly into this hook. (csf sources both
// /usr/local/csf/bin/csfpre.sh and /etc/csf/csfpre.sh when present, so this
// /etc/csf hook always runs.)
CSFHook = "/etc/csf/csfpre.sh"
)
// CSF manages a ConfigServer Security & Firewall (csf) installation, mapping
// rules onto its config files (csf.conf, csf.allow, csf.deny, csf.redirect) and
// a managed pre-hook for features csf's native config cannot express.
type CSF struct {
// rulePrefix tags rules this library creates so they can be told apart
// from foreign rules. In csf.allow/csf.deny it is prepended to the
// comment written on the line above each rule; csf.conf port-list rules
// carry no per-rule comment and so cannot carry the tag.
rulePrefix string
// ipv6Enabled mirrors csf.conf's IPV6. With it off (the shipped default) csf
// enforces no IPv6 at all: csf.pl's linefilter silently drops a csf.allow/
// csf.deny line resolving to an IPv6 address, the TCP6_IN/UDP6_IN port lists
// go unread, and ip6tables is never flushed on (re)load. AddRule therefore
// rejects every concrete-IPv6 rule (see ipv6Unavailable) rather than write one
// csf will never enforce.
ipv6Enabled bool
}
// NewCSF constructs a CSF manager, verifying the csf service is enabled and its
// config files are present, and reading whether csf's own IPv6 handling is on.
func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) {
csf := new(CSF)
csf.rulePrefix = rulePrefix
// Confirm csf is enabled under whatever init system the host uses
// (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local).
if !serviceEnabled(ctx, "csf") {
return nil, fmt.Errorf("the csf service is not enabled on this server")
}
// Confirm config files exist.
files := []string{CSFConf, CSFAllow, CSFDeny}
for _, f := range files {
if _, err := os.Stat(f); err != nil {
return nil, fmt.Errorf("the config file %s is missing", f)
}
}
// Confirm its not disabled.
if _, err := os.Stat("/etc/csf/csf.disable"); err == nil {
return nil, fmt.Errorf("csf is currently disabled")
}
// Read whether csf's own IPv6 handling is turned on.
useIPv6, err := readConfValue(CSFConf, "IPV6")
if err != nil {
return nil, fmt.Errorf("error reading %s: %s", CSFConf, err)
}
csf.ipv6Enabled = useIPv6 == "1"
// Return the new csf object.
return csf, nil
}
// Type reports the backend identifier, "csf".
func (f *CSF) Type() string {
return CSFType
}
// Capabilities reports the firewall features csf supports.
func (f *CSF) Capabilities() Capabilities {
return Capabilities{
Output: true,
Forward: true,
// ICMPv6 mirrors ipv6Enabled: with csf.conf's IPV6 off, csf never touches
// ip6tables, so neither its native config nor the raw-iptables hook yields a
// rule csf will keep in sync across a reload (see ipv6Unavailable).
ICMPv6: f.ipv6Enabled,
// A csf.conf port list (TCP_IN="80,443,...") stores each port independently
// and reads back as one rule per port, so a discrete multi-port rule does
// not round-trip as a single rule (a range, kept as one token, does). Report
// PortList as unsupported to reflect that; callers open several ports by
// adding a rule per port, which is how csf stores them anyway.
PortList: false,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: true,
Comments: true,
}
}
// GetZone reports no zone; csf has no concept of zones.
func (f *CSF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// ParseConnLimit decodes a csf.conf CONNLIMIT value ("port;limit,...") into
// connection-limit rules: csf caps concurrent new TCP connections per source and
// rejects the excess with a TCP reset, so each entry becomes an inbound tcp
// reject rule carrying a per-source ConnLimit.
func (f *CSF) ParseConnLimit(val string) (rules []*Rule) {
for _, entry := range strings.Split(val, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
portTok, limitTok, ok := strings.Cut(entry, ";")
if !ok {
continue
}
port, err := strconv.ParseUint(strings.TrimSpace(portTok), 10, 16)
if err != nil {
continue
}
limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32)
if err != nil {
continue
}
// CONNLIMIT is a single config key, but csf.pl only installs its IPv6
// CONNLIMIT rule (ip6tables) when csf.conf's IPV6 is enabled (ConfigServer/
// Config.pm, csf.pl); on the shipped default (IPV6="0") CONNLIMIT is IPv4
// only. Report FamilyAny when IPv6 handling is on — so a FamilyAny desired
// connlimit rule reconciles with its dual-stack read-back rather than
// churning every Sync — and IPv4 otherwise, matching what csf actually
// enforces.
fam := IPv4
if f.ipv6Enabled {
fam = FamilyAny
}
rules = append(rules, &Rule{
Family: fam,
Proto: TCP,
Port: uint16(port),
Action: Reject,
ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true},
})
}
return
}
// parseAddr classifies a csf advanced-rule address field. It returns the
// address, its family, and whether the value is an address at all (a non-address
// value is a port list or ICMP type). A zero "any" network (0.0.0.0/0 or ::/0)
// is normalized to an empty address so a port-only rule written with the "any"
// placeholder round-trips against a rule that carries no address.
func (f *CSF) parseAddr(v string) (addr string, fam Family, ok bool) {
family, ok := parseAddrFamily(v)
if !ok {
return "", FamilyAny, false
}
if _, network, err := net.ParseCIDR(v); err == nil {
if ones, _ := network.Mask.Size(); ones == 0 && network.IP.IsUnspecified() {
return "", family, true
}
}
return v, family, true
}
// parseAdvPorts parses a csf advanced-rule port value: a comma list whose
// entries are single ports or underscore ranges (e.g. "22,80,2000_3000").
func (f *CSF) parseAdvPorts(val string) ([]PortRange, error) {
var specs []PortRange
for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
lo, hi, isRange := strings.Cut(tok, "_")
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q", lo)
}
pr := PortRange{Start: uint16(start), End: uint16(start)}
if isRange {
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q", hi)
}
pr.End = uint16(end)
if pr.End < pr.Start {
return nil, fmt.Errorf("port range end below start")
}
}
specs = append(specs, pr)
}
if len(specs) == 0 {
return nil, fmt.Errorf("no ports")
}
return specs, nil
}
// ParseAdvRule decodes a csf advanced allow/deny rule of the form
// tcp/udp/icmp|in/out|s/d=port(s)|s/d=ip. The port field accepts a comma
// multiport list and underscore ranges; for icmp it holds the ICMP type.
func (f *CSF) ParseAdvRule(val string, action Action) (r *Rule) {
r = &Rule{
Action: action,
}
fields := strings.Split(val, "|")
for _, fld := range fields {
switch {
case strings.EqualFold(fld, "tcp"):
r.Proto = TCP
case strings.EqualFold(fld, "udp"):
r.Proto = UDP
case strings.EqualFold(fld, "icmp"):
r.Proto = ICMP
case strings.EqualFold(fld, "in"):
r.Direction = DirInput
case strings.EqualFold(fld, "out"):
r.Direction = DirOutput
case strings.HasPrefix(fld, "s="):
// The source field is either an address or, when it is not, an ICMP type
// for icmp rules or a source port list/range otherwise. csf reuses the
// port position for the ICMP type in both s= and d= (csf.pl maps
// `s=<n>` to `--icmp-type <n>` for an icmp rule), so mirror the d= branch.
v := strings.TrimPrefix(fld, "s=")
if addr, fam, ok := f.parseAddr(v); ok {
r.Family = fam
r.Source = addr
continue
}
if r.Proto == ICMP {
n, ok := parseICMPType(v)
if !ok {
return nil
}
r.ICMPType = Ptr(n)
continue
}
specs, err := f.parseAdvPorts(v)
if err != nil {
return nil
}
sourcePortSpecsToRule(r, specs)
case strings.HasPrefix(fld, "d="):
v := strings.TrimPrefix(fld, "d=")
// A destination value is either an address, or (when it is not) an
// ICMP type for icmp rules or a port list/range otherwise.
if addr, fam, ok := f.parseAddr(v); ok {
r.Family = fam
r.Destination = addr
continue
}
if r.Proto == ICMP {
n, ok := parseICMPType(v)
if !ok {
return nil
}
r.ICMPType = Ptr(n)
continue
}
specs, err := f.parseAdvPorts(v)
if err != nil {
return nil
}
portSpecsToRule(r, specs)
case strings.HasPrefix(fld, "u=") || strings.HasPrefix(fld, "g="):
return nil
}
}
return
}
// ParseIPList reads a csf.allow/csf.deny file into rules, stamping each with the
// given action and any full-line comment that precedes it.
func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) {
// Read the allow/deny IP rule list.
fd, err := os.Open(filePath)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(fd)
// A full-line comment immediately above a rule is that rule's comment.
// Consecutive comment lines accumulate (joined by a space); a blank line
// detaches a comment from any rule that follows.
var pendingComment string
flushComment := func() (string, bool) {
text, hasPrefix := prefixedComment(f.rulePrefix, pendingComment)
pendingComment = ""
return text, hasPrefix
}
for scanner.Scan() {
raw := scanner.Text()
trimmed := strings.TrimSpace(raw)
// A full-line comment is held as a candidate rule comment.
if trimmed != "" && strings.HasPrefix(trimmed, "#") {
text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
// A prefix tag starts a fresh comment block for the rule that
// follows, so header/section comments above it are not absorbed into
// the rule's comment and prefix detection stays reliable.
if f.rulePrefix != "" && (text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ")) {
pendingComment = text
} else if pendingComment != "" {
pendingComment += " " + text
} else {
pendingComment = text
}
continue
}
// Strip an inline trailing comment (not a rule comment).
if ci := strings.IndexByte(trimmed, '#'); ci >= 0 {
trimmed = trimmed[:ci]
}
trimmed = strings.TrimSpace(trimmed)
// A blank line detaches a pending comment from any later rule.
if len(trimmed) == 0 {
pendingComment = ""
continue
}
comment, hasPrefix := flushComment()
if strings.Contains(trimmed, "|") {
rule := f.ParseAdvRule(trimmed, action)
if rule == nil {
continue
}
rule.Comment = comment
rule.HasPrefix = hasPrefix
rules = append(rules, rule)
} else {
// Try to parse IP.
family, ok := parseAddrFamily(trimmed)
if !ok {
continue
}
// A plain IP line matches the host in both directions, so it is one
// bidirectional DirAny rule, authored in the inbound frame (Source=X).
rules = append(rules, &Rule{
Direction: DirAny,
Family: family,
Source: trimmed,
Action: action,
Comment: comment,
HasPrefix: hasPrefix,
})
}
}
_ = fd.Close()
if serr := scanner.Err(); serr != nil {
return nil, serr
}
return
}
// ParsePorts decodes a csf.conf port-list value into one accept rule per port
// token for the given family, protocol, and direction.
func (f *CSF) ParsePorts(val string, family Family, proto Protocol, out bool) (rules []*Rule) {
ports := strings.Split(val, ",")
for _, port := range ports {
port = strings.TrimSpace(port)
if port == "" {
continue
}
// A csf.conf port token is a single port or a colon range.
pr, err := ParsePortRange(port)
if err != nil {
continue
}
rule := &Rule{
Family: family,
Proto: proto,
Direction: directionFromOutput(out),
Action: Accept,
}
portSpecsToRule(rule, []PortRange{pr})
rules = append(rules, rule)
}
return
}
// dropActions reads csf.conf's DROP (inbound) and DROP_OUT (outbound)
// settings, which decide whether a csf.deny entry is dropped or rejected: csf
// builds its DENYIN chain with `-j $DROP` and its DENYOUT chain with
// `-j $DROP_OUT`, so a deny rule's effective action follows its direction.
// Only "DROP" and "REJECT" are valid values; anything else (or an unreadable
// file) falls back to stock csf defaults — DROP drops inbound, DROP_OUT rejects
// outbound.
func (f *CSF) dropActions() (dropIn, dropOut Action) {
dropIn, dropOut = Drop, Reject
fd, err := os.Open(CSFConf)
if err != nil {
return
}
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
key, val, found := strings.Cut(strings.TrimSpace(line), "=")
if !found {
continue
}
key = strings.TrimSpace(key)
val = strings.ToUpper(trimQuotes(strings.TrimSpace(val)))
switch key {
case "DROP":
if val == "REJECT" {
dropIn = Reject
} else {
dropIn = Drop
}
case "DROP_OUT":
if val == "DROP" {
dropOut = Drop
} else {
dropOut = Reject
}
}
}
return
}
// hook returns the managed pre-hook script used to inject iptables rules for
// features csf's native config cannot express.
func (f *CSF) hook() *hookScript {
return &hookScript{
rulePrefix: f.rulePrefix,
hookPath: CSFHook,
hookPerm: 0700,
ipv6Enabled: f.ipv6Enabled,
}
}
// GetRules reads all filter rules from csf's config files and the managed
// pre-hook, merging family and protocol fan-outs back to their written form.
func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Read the standard configuration.
fd, err := os.Open(CSFConf)
if err != nil {
return nil, err
}
// Scan each line.
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
line := scanner.Text()
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
continue
}
// Parse key/value.
key, val, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
val = trimQuotes(strings.TrimSpace(val))
// Parse rules.
switch key {
case "TCP_IN":
rules = append(rules, f.ParsePorts(val, IPv4, TCP, false)...)
case "TCP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, TCP, true)...)
case "UDP_IN":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...)
case "UDP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
case "TCP6_IN":
rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...)
case "TCP6_OUT":
rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...)
case "UDP6_IN":
rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...)
case "UDP6_OUT":
rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...)
case "CONNLIMIT":
rules = append(rules, f.ParseConnLimit(val)...)
}
}
_ = fd.Close()
if err := scanner.Err(); err != nil {
return nil, err
}
// Read the allowed IP rule list.
ipRules, err := f.ParseIPList(CSFAllow, Accept)
if err != nil {
return nil, err
}
rules = append(rules, ipRules...)
// Read the denied IP rule list. A csf.deny entry takes effect as the DROP
// (inbound) or DROP_OUT (outbound) action from csf.conf, so stamp each rule
// with the action its direction actually gets rather than a fixed Reject —
// otherwise a Drop rule the caller manages reads back as Reject, never
// compares equal to the desired rule, and churns on every Sync.
dropIn, dropOut := f.dropActions()
ipRules, err = f.ParseIPList(CSFDeny, dropIn)
if err != nil {
return nil, err
}
for _, r := range ipRules {
if r.IsOutput() {
r.Action = dropOut
}
}
rules = append(rules, ipRules...)
// Read the iptables rules injected through the csf pre-hook (state,
// interface, logging, rate-limit, icmpv6).
hookRules, err := f.hook().getRules()
if err != nil {
return nil, err
}
rules = append(rules, hookRules...)
return
}
// confPortToken renders a port spec for a csf.conf port list, where a range
// is written with a colon (e.g. "30000:35000").
func (f *CSF) confPortToken(pr PortRange) string {
pr = pr.normalized()
if pr.Start == pr.End {
return strconv.FormatUint(uint64(pr.Start), 10)
}
return fmt.Sprintf("%d:%d", pr.Start, pr.End)
}
// editConnLimit renders the csf.conf CONNLIMIT line with a port's per-source
// limit added or removed, preserving the other entries.
func (f *CSF) editConnLimit(val string, port uint16, limit uint, remove bool) string {
portStr := strconv.Itoa(int(port))
var kept []string
present := false
for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
p, _, ok := strings.Cut(tok, ";")
if ok && strings.TrimSpace(p) == portStr {
present = true
if remove {
continue
}
kept = append(kept, fmt.Sprintf("%d;%d", port, limit))
continue
}
kept = append(kept, tok)
}
if !remove && !present {
kept = append(kept, fmt.Sprintf("%d;%d", port, limit))
}
return fmt.Sprintf(`CONNLIMIT = "%s"`, strings.Join(kept, ","))
}
// isConnLimitRule reports whether a rule maps onto csf.conf's CONNLIMIT: a
// per-source cap on concurrent new connections to a single inbound TCP port with
// no address. csf's CONNLIMIT chain rejects the excess with a TCP reset
// (`-j REJECT --reject-with tcp-reset`), so the excess action is Reject, not Drop.
func (f *CSF) isConnLimitRule(r *Rule) bool {
return r.ConnLimit != nil && r.ConnLimit.PerSource &&
!r.IsOutput() && r.Proto == TCP && r.Source == "" && r.Destination == "" &&
r.HasPorts() && !r.HasPortSet() && r.Action == Reject
}
// EditRulePort returns the config line for key with the rule's port added or
// removed, leaving lines the rule does not apply to unchanged.
func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string {
// A connection-limit rule is expressed solely through the CONNLIMIT config;
// it must never also add or remove its port from an accept port list, or
// RemoveRule would close a port the caller never opened and a round-trip
// would report a spurious accept rule alongside the connlimit.
if r.ConnLimit != nil && key != "CONNLIMIT" {
return orig
}
// Determine if this key needs edits.
switch key {
case "TCP_IN":
if r.IsOutput() || r.Family == IPv6 || r.Proto == UDP {
return orig
}
case "TCP_OUT":
if !r.IsOutput() || r.Family == IPv6 || r.Proto == UDP {
return orig
}
case "UDP_IN":
if r.IsOutput() || r.Family == IPv6 || r.Proto == TCP {
return orig
}
case "UDP_OUT":
if !r.IsOutput() || r.Family == IPv6 || r.Proto == TCP {
return orig
}
case "TCP6_IN":
if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
return orig
}
case "TCP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
return orig
}
case "UDP6_IN":
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig
}
case "UDP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig
}
case "CONNLIMIT":
// CONNLIMIT tokens are "port;limit", edited independently of the port
// lists above.
if !f.isConnLimitRule(r) {
return orig
}
// isConnLimitRule guarantees a single discrete port, which may be
// carried in either Port or a one-element Ports; read it via PortSpecs so
// a rule expressing its port through Ports is not written as port 0.
return f.editConnLimit(val, r.PortSpecs()[0].Start, r.ConnLimit.Count, remove)
default:
return orig
}
// The rule may carry one or more ports (a single port, a range, or a list).
// Add or remove each of the rule's port tokens from the config list,
// preserving any existing tokens the rule does not touch.
specs := r.PortSpecs()
present := make(map[string]bool)
var kept []string
for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
// Preserve tokens we cannot parse untouched.
pr, err := ParsePortRange(tok)
if err != nil {
kept = append(kept, tok)
continue
}
if remove && portRangeInSpecs(pr, specs) {
continue
}
kept = append(kept, tok)
present[f.confPortToken(pr)] = true
}
if !remove {
for _, sp := range specs {
tok := f.confPortToken(sp)
if !present[tok] {
kept = append(kept, tok)
present[tok] = true
}
}
}
// Re-create the configuration with new port list.
return fmt.Sprintf(`%s = "%s"`, key, strings.Join(kept, ","))
}
// EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule.
func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error {
// For port only rules, open the standard config file.
fd, err := os.Open(CSFConf)
if err != nil {
return err
}
// Stage the rewrite, preserving csf.conf's mode and ownership.
af, err := newAtomicFile(CSFConf, 0644)
if err != nil {
_ = fd.Close()
return err
}
defer af.Abort()
// Parse config one line at a time, adding the port rule.
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
orig := scanner.Text()
line := orig[:]
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
_, _ = fmt.Fprintln(af, orig)
continue
}
// Parse key/value.
key, val, found := strings.Cut(line, "=")
if !found {
_, _ = fmt.Fprintln(af, orig)
continue
}
key = strings.TrimSpace(key)
val = trimQuotes(strings.TrimSpace(val))
// Parse rules.
orig = f.EditRulePort(orig, key, val, r, remove)
_, _ = fmt.Fprintln(af, orig)
}
_ = fd.Close()
// A read error means the rewritten file is truncated; discard it.
if serr := scanner.Err(); serr != nil {
return serr
}
// Move new file into place, preserving mode and ownership.
return af.Commit()
}
// advPortValue renders port specs for a csf advanced rule, which uses a comma
// list and an underscore range (e.g. "22,80,2000_3000").
func (f *CSF) advPortValue(specs []PortRange) string {
parts := make([]string, len(specs))
for i, pr := range specs {
pr = pr.normalized()
if pr.Start == pr.End {
parts[i] = strconv.FormatUint(uint64(pr.Start), 10)
} else {
parts[i] = fmt.Sprintf("%d_%d", pr.Start, pr.End)
}
}
return strings.Join(parts, ",")
}
// MarshalAdvRule encodes a rule as a csf advanced allow/deny line: a protocol
// token, a direction, one port-flow field (an icmp type, a source port or a
// destination port) and one address field, joined by "|". It validates nothing;
// addRule/RemoveRule route every shape the line cannot carry elsewhere first.
func (f *CSF) MarshalAdvRule(r *Rule) string {
var parts []string
switch r.Proto {
case TCP:
parts = append(parts, "tcp")
case UDP:
parts = append(parts, "udp")
case ICMP:
parts = append(parts, "icmp")
}
if r.IsOutput() {
parts = append(parts, "out")
} else {
parts = append(parts, "in")
}
// The port-flow field: an ICMP type, a source port, or a destination port.
switch {
case r.Proto == ICMP:
if r.ICMPType != nil {
parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType))
}
case r.HasSourcePorts():
parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs()))
case r.HasPorts():
parts = append(parts, "d="+f.advPortValue(r.PortSpecs()))
}
// Address.
if r.Source != "" {
parts = append(parts, "s="+r.Source)
} else if r.Destination != "" {
parts = append(parts, "d="+r.Destination)
}
return strings.Join(parts, "|")
}
// parseListLine parses one csf.allow/csf.deny rule line into the rule it holds: an
// advanced rule, or a plain address line, which is a single bidirectional DirAny
// rule matching every protocol. It returns nil for a line that is neither, which the
// caller passes through untouched. The action comes from the file (csf.allow is
// accept, csf.deny is a deny); the line encodes none of its own.
func (f *CSF) parseListLine(line string, action Action) *Rule {
if strings.Contains(line, "|") {
return f.ParseAdvRule(line, action)
}
family, ok := parseAddrFamily(line)
if !ok {
return nil
}
return &Rule{Direction: DirAny, Family: family, Source: line, Action: action}
}
// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write
// order, or none for a shape the lists cannot hold. The rule must already carry the
// action its file implies (see EditIPList's match), since each row's read-back form
// is compared against lines stamped with it.
//
// csf has no both-transports line anywhere — csf.pl's linefilter silently reads a
// protocol-less advanced line as `-p tcp` — so a TCPUDP rule fans out into a tcp row
// and a udp row. A port-only deny additionally carries no address of its own, and
// csf's advanced-rule handler only emits an iptables rule for a line that has one, so
// each of its rows takes the "any" network as a placeholder address. That literal is
// family-specific, so a family-neutral rule fans out per family too rather than
// silently becoming IPv4-only — across the families csf actually enforces (see
// writeFamilyRows; with csf.conf's IPV6 off that is IPv4 alone). parseAddr normalizes
// the placeholder back to an empty address, so each row reads back as the address-less
// rule it stands for.
func (f *CSF) listRows(action Action, match *Rule) []listRow {
hasIP := match.Source != "" || match.Destination != ""
var rows []listRow
switch {
case hasIP && (match.HasPorts() || match.HasSourcePorts() || match.Proto.IsICMP()):
// A port/ICMP rule with an address is an advanced rule.
for _, sub := range expandProtocols(match) {
rows = append(rows, listRow{line: f.MarshalAdvRule(sub), read: sub})
}
case hasIP:
// A bare all-protocol host allow/deny: a single address matching every
// protocol. csf.allow/csf.deny hold no other portless address shape — a
// concrete-protocol host or a source+destination pair — so AddRule diverts
// those to the raw-iptables hook (hostNeedsHook) and never reaches here with
// one. A direct caller of this exported writer that supplies such a shape gets
// a best-effort single-address write, not a guard.
addr := match.Source
if addr == "" {
addr = match.Destination
}
// The plain line is bidirectional and names its address as the source, which is
// the frame the scan reads it back in.
read := *match
read.Direction = DirAny
read.Source, read.Destination = addr, ""
rows = append(rows, listRow{line: addr, read: &read})
case action != Accept && match.HasPorts():
for _, fam := range writeFamilyRows(f.ipv6Enabled, match) {
placeholder := "0.0.0.0/0"
if fam.impliedFamily() == IPv6 {
placeholder = "::/0"
}
for _, sub := range expandProtocols(fam) {
// The row reads back address-less; only the line carries the placeholder.
// advRuleNeedsHook has already routed an address-less source-port match to
// the hook, so the address field being filled in here is free.
row := *sub
if row.IsOutput() {
row.Destination = placeholder
} else {
row.Source = placeholder
}
rows = append(rows, listRow{line: f.MarshalAdvRule(&row), read: sub})
}
}
}
return rows
}
// EditIPList adds or removes a rule in a csf.allow/csf.deny list, rewriting it in
// place. An add expands the rule into the rows it materializes into (see listRows),
// notes which of them the file already holds, and appends only the rest, so a rule
// that fans out across families or transports is completed rather than duplicated on
// every reconcile. A removal drops every line the target covers.
func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error {
// Read the allow/deny IP rule list.
fd, err := os.Open(filePath)
if err != nil {
return err
}
// Stage the rewrite, preserving the list file's mode and ownership.
af, err := newAtomicFile(filePath, 0644)
if err != nil {
_ = fd.Close()
return err
}
defer af.Abort()
scanner := bufio.NewScanner(fd)
// csf.allow/csf.deny encode no action of their own — the file decides it
// (csf.allow is accept, csf.deny is a deny). A rule read from a file is stamped
// with that file's action, so match an incoming rule with its action coerced
// the same way: otherwise a rule added as Drop (written to csf.deny, read back
// as the deny action) could never be found and removed.
match := *r
match.Action = action
// The rows an add must end up with, and which of them the scan finds already in
// the file. A removal wants no rows: it matches the target against each line
// directly, since a line it must drop need not be one this library would write.
var rows []listRow
if !remove {
rows = f.listRows(action, &match)
}
present := make([]bool, len(rows))
// Full-line comments immediately above a rule are held back so they can be
// dropped together with a removed rule (they are its comment) and written
// ahead of a kept one. A blank line detaches them.
var pending []string
flush := func() {
for _, c := range pending {
_, _ = fmt.Fprintln(af, c)
}
pending = nil
}
drop := func() { pending = nil }
for scanner.Scan() {
orig := scanner.Text()
trimmed := strings.TrimSpace(orig)
// A full-line comment is held as a candidate rule comment.
if trimmed != "" && strings.HasPrefix(trimmed, "#") {
// Mirror ParseIPList: a prefix tag starts a fresh comment block, so
// any header/section comments above it are not part of the rule's comment
// and must survive its removal. Flush them now and begin the rule's block
// at the tag, so drop() only discards the tag and the rule's own comment.
if f.rulePrefix != "" {
if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") {
flush()
}
}
pending = append(pending, orig)
continue
}
// Strip an inline trailing comment for matching, but preserve the
// original line (with its inline note) when copying it through.
line := trimmed
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
// A blank line detaches a pending comment; write it and the blank.
if len(line) == 0 {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// A line neither form parses is not a rule; pass it through untouched.
cur := f.parseListLine(line, action)
if cur == nil {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// A removal drops every line the target covers, along with its comment. A
// TCPUDP or family-neutral target touches each of the concrete lines it was
// written as; that coverage is folded into EqualForRemoval.
if remove {
if cur.EqualForRemoval(&match, true) {
drop()
continue
}
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// An add keeps every line and only notes which wanted rows the file already
// covers, so the tail writes the rest. Coverage rather than a text compare, so
// a row is satisfied by an existing line that spans it (a TCPUDP line absorbing
// a tcp row) and by one spelled differently but meaning the same.
for i := range rows {
if !present[i] && cur.EqualForDedup(rows[i].read, true) {
present[i] = true
}
}
flush()
_, _ = fmt.Fprintln(af, orig)
}
// Write any trailing comments that followed the last rule.
flush()
// Append the wanted rows the file does not already hold. A rule that fans out is
// completed row by row: when only a subset is present (the IPv4 line but not its
// IPv6 twin, from a prior single-family add or a manual edit) the missing rows
// must still be written, or that family stays open while the library reports the
// rule in force.
writeComment := func() {
if c := combineComment(f.rulePrefix, r.Comment); c != "" {
_, _ = fmt.Fprintln(af, "# "+c)
}
}
for i, row := range rows {
if present[i] {
continue
}
writeComment()
_, _ = fmt.Fprintln(af, row.line)
}
_ = fd.Close()
// A read error means the rewritten file is truncated; discard it.
if serr := scanner.Err(); serr != nil {
return serr
}
// Move new file into place, preserving mode and ownership.
return af.Commit()
}
// nativeICMP reports whether an ICMPv4 rule maps onto a csf advanced rule and so
// belongs in csf.allow/csf.deny rather than the raw-iptables hook. An advanced line
// requires an address, and its single port-flow field carries the icmp type, so only
// a rule with exactly one address and a concrete type has a faithful form: csf.pl's
// linefilter reads that field by position, and an address with no type would land the
// address there (`--icmp-type <ip>`), which csf fails to parse and drops silently. A
// source+destination pair overflows the single address field and is routed by
// advRuleNeedsHook. ICMPv6 never reaches this test — ruleNeedsHook sends it to the
// hook, or the IPv6 gate rejects it.
func (f *CSF) nativeICMP(r *Rule) bool {
return r.Proto == ICMP && r.ICMPType != nil && (r.Source != "") != (r.Destination != "")
}
// needsHook reports whether a rule must be injected through the csf pre-hook as a
// raw iptables rule because csf's native config cannot express it. It is the single
// gate between the hook path and csf's config files: everything it rejects (returns
// true) is written to the hook, everything it accepts (returns false) maps onto
// csf.conf or the csf.allow/csf.deny lists. The shared shapes (ruleNeedsHook,
// bareHostOneWay, hostNeedsHook, advRuleNeedsHook, bareProtoNeedsHook) keep their own
// predicates, since RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
func (f *CSF) needsHook(r *Rule) bool {
// Features csf's native config cannot express (connection state, per-rule
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
// csf does not carry, an address set) go to the hook.
if ruleNeedsHook(r) {
return true
}
// A one-way bare host has no csf.allow/csf.deny form (a plain line is
// bidirectional, an advanced rule needs a port), and a concrete-protocol host or a
// source+destination pair likewise has none (see hostNeedsHook); all go to the hook.
if bareHostOneWay(r) || hostNeedsHook(r) {
return true
}
// A ported source+destination pair, a source port matched with a destination port,
// and an address-less source-port match all overflow the advanced line's single
// address and single port-flow field (see advRuleNeedsHook); iptables matches each
// directly, so they go to the hook.
if advRuleNeedsHook(r) {
return true
}
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a
// per-source cap on a single address-less inbound tcp port rejecting the excess
// (isConnLimitRule) — goes to the hook's `-m connlimit` match.
if r.ConnLimit != nil && !f.isConnLimitRule(r) {
return true
}
// An ICMPv4 rule csf's advanced-rule format cannot carry (see nativeICMP) goes to
// the hook's `iptables -p icmp` match, which needs neither an address nor a type.
if r.Proto == ICMP && !f.nativeICMP(r) {
return true
}
// A bare protocol match with no address and no port has no native csf construct —
// csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny
// on an address — but iptables expresses it directly, so it goes to the hook (see
// bareProtoNeedsHook). ICMP is excluded there and routed above.
return bareProtoNeedsHook(r)
}
// denyAction returns the action a csf.deny entry takes in the given direction,
// following csf.conf's DROP (inbound) / DROP_OUT (outbound) settings. A deny
// rule this library writes must carry exactly this action: csf.deny encodes no
// action of its own, so a rule read back is stamped with what csf would apply,
// and a caller asking for the opposite action could never reconcile against it.
func (f *CSF) denyAction(output bool) Action {
dropIn, dropOut := f.dropActions()
if output {
return dropOut
}
return dropIn
}
// addRule is AddRule's implementation, with the IPv6 gate optional so Restore
// can reproduce a prior snapshot's inert entries rather than be rejected by a
// gate meant to catch fresh no-op writes.
func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error {
// Reject a concrete-IPv6 rule when csf's own IPv6 handling is off, ahead of every
// routing decision below: neither csf's config nor the pre-hook can carry one that
// csf will keep in sync (see ipv6Unavailable). Checking here rather than past the
// hook branches also keeps a DirAny rule from writing its input half before its
// output half is rejected.
if enforceIPv6Gate && ipv6Unavailable(f.ipv6Enabled, r) {
return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported)
}
// A DirAny rule maps to a single native construct only as a bare-host plain line;
// every other DirAny shape fans out into a concrete input rule plus its swapped
// output rule, each routed independently (a half may itself need the hook).
if r.Direction == DirAny && !dirAnyPlainLine(r) {
for _, sub := range expandDirections(r) {
if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil {
return err
}
}
return nil
}
// csf has no both-transports construct anywhere: its port lists are a TCP list and
// a UDP list, and csf.pl's linefilter silently reads a protocol-less advanced line
// as `-p tcp` rather than as both transports. So a TCPUDP rule fans out into a tcp
// rule and a udp rule, each routed independently, and each reads back as its own
// rule.
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(r) {
if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil {
return err
}
}
return nil
}
// Verify the rule is valid with iptables.
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// Any shape csf's native config cannot express (a stateful/interface/logged/
// rate-limited rule, a one-way or concrete-protocol host, a source+destination
// pair, a source-and-destination port match, an address-less source-port match, a
// non-native connection limit, a non-native ICMPv4 rule, or a bare protocol match)
// is injected as a raw iptables rule through the csf pre-hook. See needsHook for
// each clause; everything past this gate maps onto csf's own config files.
if f.needsHook(r) {
_, err := f.hook().edit(r, false)
return err
}
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list (a
// non-native one was diverted to the hook above by needsHook).
if r.ConnLimit != nil {
return f.EditConf(ctx, r, false)
}
// A port-only accept maps to a csf.conf port list rather than csf.allow.
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
err := f.EditConf(ctx, r, false)
if err != nil {
return err
}
}
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny
// entry carries no action of its own — csf applies csf.conf's action by direction
// (DROP inbound, DROP_OUT outbound) — so a deny whose action matches is written
// natively, while one that differs has no native form and is injected through the
// pre-hook instead, whose iptables rule carries the exact action. A DirAny bare-host
// deny is expanded to its two concrete directions first, since each hook line is
// one-way.
if r.Action == Accept {
err := f.EditIPList(ctx, CSFAllow, Accept, r, false)
if err != nil {
return err
}
} else {
denyAction := f.denyAction(r.IsOutput())
if r.Action != denyAction {
for _, sub := range expandDirections(r) {
if _, err := f.hook().edit(sub, false); err != nil {
return err
}
}
return nil
}
err := f.EditIPList(ctx, CSFDeny, denyAction, r, false)
if err != nil {
return err
}
}
return nil
}
// AddRule adds a filter rule to the appropriate csf construct: a csf.conf port
// list, an advanced rule, a bare address list, CONNLIMIT, or the pre-hook.
func (f *CSF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return f.addRule(ctx, zoneName, r, true)
}
// InsertRule is unsupported: CSF organizes rules in config files, not an ordered list.
func (f *CSF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
func (f *CSF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// removePlainHost drops the bidirectional plain csf.allow/csf.deny line backing the
// DirAny rule e, choosing the list by the rule's action.
func (f *CSF) removePlainHost(ctx context.Context, e *Rule) error {
if e.Action == Accept {
return f.EditIPList(ctx, CSFAllow, Accept, e, true)
}
return f.EditIPList(ctx, CSFDeny, f.denyAction(false), e, true)
}
// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is
// stored either as its own hook rule or as one direction of a bidirectional plain
// csf.allow/csf.deny line (a DirAny rule). When a matching plain line exists, split
// it: drop the line and re-add the surviving opposite direction as a hook rule so
// the untargeted direction keeps its coverage.
func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error {
existing, err := f.GetRules(ctx, zoneName)
if err != nil {
return err
}
for _, e := range existing {
if !e.IsAny() || !e.EqualForRemoval(r, true) {
continue
}
// The host is stored as a bidirectional plain line; drop it, then re-add the
// surviving direction as a hook rule.
if err := f.removePlainHost(ctx, e); err != nil {
return err
}
if s := splitDualRowDirection(e, r); s != nil {
_, err := f.hook().edit(s, false)
return err
}
return nil
}
// Not stored as a plain line; remove the one-way hook rule.
_, err = f.hook().edit(r, true)
return err
}
// RemoveRule removes a filter rule from whichever csf construct holds it.
func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A non-plain-line DirAny target fans out into its two concrete-direction rules,
// mirroring addRule, so each half is removed from wherever it was written.
if r.Direction == DirAny && !dirAnyPlainLine(r) {
for _, sub := range expandDirections(r) {
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
// A TCPUDP target fans out into its two concrete-transport rules, mirroring
// addRule, so each is removed from whichever list or line it was written to. A
// caller removing one transport targets that transport directly and leaves the
// other in place.
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(r) {
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
// carries only in the hook (see needsHook) lives nowhere else, so this is its
// entire removal; a natively-expressible rule may still have a stray hook copy —
// the library's own (a deny whose action differs from csf.conf's is stored there,
// see AddRule) or one a customer added by hand for a shape csf can also express
// natively — that must be cleared before the native entry below. DirAny is expanded
// so both one-way hook lines are matched; a rule with no hook copy makes this a
// harmless no-op.
var err error
for _, sub := range expandDirections(r) {
if _, e := f.hook().edit(sub, true); e != nil {
err = e
break
}
}
// A rule csf carries only in the hook has no native entry to fall through to, so
// return once its hook copy is cleared (or on any hook error). Returning here also
// keeps such a rule out of the plain-line split scan below, which could wrongly
// split an unrelated coexisting native entry.
if ruleNeedsHook(r) || err != nil {
return err
}
// A one-way bare host rule is stored either as its own hook rule (cleared above) or
// as one direction of a bidirectional plain line; removing it may need to split the
// plain line (see removeBareHostOneWay).
if bareHostOneWay(r) {
return f.removeBareHostOneWay(ctx, zoneName, r)
}
// Every other shape csf's native config cannot express (see needsHook) has already
// had its hook copy cleared above and has no native entry to split, so it is done.
if f.needsHook(r) {
return nil
}
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
if r.ConnLimit != nil {
return f.EditConf(ctx, r, true)
}
// A port-only accept maps to a csf.conf port list rather than csf.allow.
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
err := f.EditConf(ctx, r, true)
if err != nil {
return err
}
}
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny entry
// carries no action of its own — csf applies csf.conf's action by direction — so the
// deny of an address is a single entry there, and it is removed whatever action the
// caller named: asking to stop denying something means the entry goes, or RemoveRule
// would report success while csf kept enforcing it. EditIPList coerces the target's
// action to the file's, so a differing-action deny still matches the line. The hook
// copy such a deny was added as (see AddRule) was already cleared above, exactly as
// the hook copy of a matching-action deny is, so both backings are swept either way.
if r.Action == Accept {
err := f.EditIPList(ctx, CSFAllow, Accept, r, true)
if err != nil {
return err
}
} else {
err := f.EditIPList(ctx, CSFDeny, f.denyAction(r.IsOutput()), r, true)
if err != nil {
return err
}
}
return nil
}
// UnmarshalNATRule decodes a csf.redirect line into a NATRule.
func (f *CSF) UnmarshalNATRule(line string) *NATRule {
fields := strings.Split(line, "|")
if len(fields) != 5 {
return nil
}
ipx, porta, ipy, portb, proto := fields[0], fields[1], fields[2], fields[3], fields[4]
parsePort := func(s string) (uint16, bool) {
if s == "*" || s == "" {
return 0, true
}
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16)
if err != nil {
return 0, false
}
return uint16(n), true
}
r := &NATRule{Proto: GetProtocol(proto)}
if r.Proto != TCP && r.Proto != UDP {
return nil
}
if ipx != "*" && ipx != "" {
if _, ok := parseAddrFamily(ipx); !ok {
return nil
}
r.Destination = ipx
}
pa, ok := parsePort(porta)
if !ok {
return nil
}
r.Port = pa
pb, ok := parsePort(portb)
if !ok {
return nil
}
r.ToPort = pb
if ipy == "*" || ipy == "" {
r.Kind = Redirect
if r.ToPort == 0 || r.Port == 0 {
return nil
}
} else {
fam, ok := parseAddrFamily(ipy)
if !ok {
return nil
}
r.Kind = DNAT
r.ToAddress = ipy
r.Family = fam
}
if r.Family == FamilyAny {
r.Family = r.impliedFamily()
}
return r
}
// GetNATRules reads the NAT rules from csf.redirect.
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
fd, err := os.Open(CSFRedirect)
if err != nil {
// csf.redirect is optional; a missing file simply has no rules.
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func() { _ = fd.Close() }()
var rules []*NATRule
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if r := f.UnmarshalNATRule(line); r != nil {
rules = append(rules, r)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no
// rule in it carries the configured prefix; HasPrefix stays false (mirroring
// firewalld's zones).
return rules, nil
}
// redirectAddr renders an address for a csf.redirect field, using "*" for an
// empty (any) address.
func (f *CSF) redirectAddr(a string) string {
if a == "" {
return "*"
}
return a
}
// redirectPort renders a single port for a csf.redirect field, using "*" for
// an unset (0) port, which csf reads as "any/unchanged".
func (f *CSF) redirectPort(p uint16) string {
if p == 0 {
return "*"
}
return strconv.FormatUint(uint64(p), 10)
}
// MarshalNATRule encodes a NAT rule as a csf.redirect line
// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a
// Redirect to a local port (IPy = "*") or a DNAT forward to another host
// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols
// are not representable.
func (f *CSF) MarshalNATRule(r *NATRule) (string, error) {
if err := r.validate(); err != nil {
return "", err
}
if r.Kind.isSource() {
return "", fmt.Errorf("csf.redirect cannot express source NAT: %w", ErrUnsupportedNAT)
}
if r.Proto != TCP && r.Proto != UDP {
return "", fmt.Errorf("csf.redirect requires a tcp or udp protocol")
}
if r.HasPortSet() {
return "", fmt.Errorf("csf.redirect matches a single port, not a range or list")
}
if r.Source != "" {
return "", fmt.Errorf("csf.redirect cannot match a source address")
}
ipx := f.redirectAddr(r.Destination)
porta := f.redirectPort(r.Port)
switch r.Kind {
case Redirect:
// A local port redirect: IPy is "*", portB is the target local port.
if r.Port == 0 {
return "", fmt.Errorf("a csf redirect requires a matched port")
}
return strings.Join([]string{ipx, porta, "*", f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil
case DNAT:
// A forward to another host: IPy is the translation address. csf.redirect
// accepts a DNAT only in two shapes (csf.pl "Invalid csf.redirect format"
// otherwise): a full-IP forward (concrete IPx, both ports "*") or a port
// forward (concrete IPx and both ports concrete). Reject the shapes csf
// would refuse rather than emit a line that aborts the whole redirect load —
// the line still parses back here, so a round-trip check alone misses it.
if r.Destination == "" {
return "", fmt.Errorf("csf.redirect requires a destination address for a forward: %w", ErrUnsupportedNAT)
}
if (r.Port == 0) != (r.ToPort == 0) {
return "", fmt.Errorf("csf.redirect forward requires both a matched and a target port, or neither: %w", ErrUnsupportedNAT)
}
return strings.Join([]string{ipx, porta, r.ToAddress, f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil
}
return "", fmt.Errorf("csf.redirect cannot express this nat kind: %w", ErrUnsupportedNAT)
}
// editRedirect adds or removes a csf.redirect line, returning without change when
// an add is a duplicate or a remove finds no match.
func (f *CSF) editRedirect(r *NATRule, remove bool) error {
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
data, err := os.ReadFile(CSFRedirect)
if err != nil {
if os.IsNotExist(err) {
if remove {
return nil
}
data = nil
} else {
return err
}
}
lines := strings.Split(string(data), "\n")
// Drop the trailing empty element left by a final newline so repeated adds do
// not accumulate blank lines.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
out := make([]string, 0, len(lines)+1)
found := false
for _, raw := range lines {
body := raw
if ci := strings.IndexByte(body, '#'); ci >= 0 {
body = body[:ci]
}
body = strings.TrimSpace(body)
if body != "" {
// Keep the match family-aware (EqualForRemoval): a family-scoped removal
// must not drop an opposite-family twin sharing this file (mirrors the
// filter-rule and pf/nft NAT family gates).
if existing := f.UnmarshalNATRule(body); existing != nil && existing.EqualForRemoval(r) {
found = true
if remove {
continue
}
}
}
out = append(out, raw)
}
if remove {
if !found {
return nil
}
} else {
if found {
return nil
}
out = append(out, line)
}
// Ensure the file ends with a single trailing newline.
content := strings.Join(out, "\n")
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return writeConfigFile(CSFRedirect, []byte(content), 0600)
}
// AddNATRule adds a NAT rule to csf.redirect.
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return f.editRedirect(r, false)
}
// InsertNATRule is unsupported: CSF stores redirects in a config file it applies
// as a whole, with no explicit ordering.
func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedOrdering(f.Type())
}
// RemoveNATRule removes a NAT rule from csf.redirect.
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return f.editRedirect(r, true)
}
// GetDefaultPolicy is unsupported: csf exposes no chain default policy.
func (f *CSF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type())
}
// SetDefaultPolicy is unsupported: csf exposes no chain default policy.
func (f *CSF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
return unsupportedPolicy(f.Type())
}
// GetAddressSets returns the address sets carried by the csf pre-hook.
func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
return f.hook().getAddressSets()
}
// GetAddressSet returns a single address set by name, or an error if absent.
func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
sets, err := f.hook().getAddressSets()
if err != nil {
return nil, err
}
for _, s := range sets {
if s.Name == name {
return s, nil
}
}
return nil, fmt.Errorf("address set %q not found", name)
}
// AddAddressSet writes a set as ipset commands in the pre-hook; csf -r (Reload)
// sources the hook to create the set. Re-adding a set is idempotent.
func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error {
if set == nil || set.Name == "" {
return fmt.Errorf("an address set requires a name")
}
_, err := f.hook().editAddressSet(set, false)
return err
}
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error {
_, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
return err
}
// AddAddressSetEntry adds an entry to an existing set in the pre-hook.
func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
_, err := f.hook().editAddressSetEntry(name, entry, false)
return err
}
// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook.
func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
_, err := f.hook().editAddressSetEntry(name, entry, true)
return err
}
// Backup captures the current filter and NAT rules managed by this backend.
func (f *CSF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
if err != nil {
return nil, err
}
natRules, err := f.GetNATRules(ctx, zoneName)
if err != nil {
return nil, err
}
// Backup captures the full filter and NAT rule state plus the hook's address
// sets; Restore removes the current rules and re-adds these, so every rule read
// is preserved.
backup := &Backup{Rules: rules, NATRules: natRules}
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
return nil, err
}
return backup, nil
}
// Restore replaces the managed rules with the contents of a Backup.
func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Remove existing rules.
existing, err := f.GetRules(ctx, zoneName)
if err != nil {
return err
}
for _, r := range existing {
if err := f.RemoveRule(ctx, zoneName, r); err != nil {
return err
}
}
existingNAT, err := f.GetNATRules(ctx, zoneName)
if err != nil {
return err
}
for _, r := range existingNAT {
if err := f.RemoveNATRule(ctx, zoneName, r); err != nil {
return err
}
}
// Recreate the address sets before the rules so a set-referencing rule resolves
// when csf sources the hook. The old rules are already gone, and editAddressSet
// rewrites each set's block idempotently, so cleanFirst is unnecessary.
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
return err
}
// Re-add rules from backup.
for _, r := range backup.Rules {
if err := f.addRule(ctx, zoneName, r, false); err != nil {
return err
}
}
for _, r := range backup.NATRules {
if err := f.AddNATRule(ctx, zoneName, r); err != nil {
return err
}
}
return nil
}
// Reload restarts csf to apply config changes, retrying past csf's transient
// restart lock.
func (f *CSF) Reload(ctx context.Context) error {
// csf serializes restarts behind a lock, so a reload issued while a previous
// restart is still finishing fails transiently with "csf is being restarted, try
// again in a moment" (Resource temporarily unavailable). Wait and retry rather
// than surfacing that transient condition — the caller asked for a reload, not to
// race csf's own in-flight restart.
var err error
for attempt := 0; attempt < 20; attempt++ {
if _, err = runCommand(ctx, "csf", "-r"); err == nil {
return nil
}
if !strings.Contains(err.Error(), "being restarted") && !strings.Contains(err.Error(), "temporarily unavailable") {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
return err
}
// Close releases any resources held by the manager; csf holds none.
func (f *CSF) Close(ctx context.Context) error {
return nil
}