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

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

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

1973 lines
64 KiB
Go

package firewall
import (
"bufio"
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
dbus "github.com/coreos/go-systemd/dbus"
)
const (
CSFType = "csf"
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 is prepended to a rule's comment when it is written as a
// full-line comment above a rule in csf.allow/csf.deny, so rules this
// library creates can be told apart. csf.conf port-list rules carry no
// per-rule comment and so ignore it.
rulePrefix string
// ipv6Enabled mirrors csf.conf's IPV6. csf.pl's linefilter silently drops a
// csf.allow/csf.deny line (plain or advanced) that resolves to an IPv6
// address whenever IPV6 is not "1" (the shipped default), so AddRule must
// reject that shape rather than write a line csf will never enforce.
// ICMPv6 is unaffected: it always routes through the raw-iptables hook,
// which runs outside csf's own IPV6-gated logic.
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
// Connect to systemd dbus interface.
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to systemd: %s", err)
}
defer conn.Close()
// Find the systemd service for csf and confirm it is set to start. CSF's
// installer detects a running systemd at install time and drops in a real
// static unit file, giving "enabled"/"enabled-runtime" — but when systemd
// isn't PID 1 at install time (e.g. a container image built without an init
// process) the installer falls back to its SysV /etc/init.d/csf script, and
// systemd's sysv-generator synthesizes a wrapper unit at boot, which reports
// as "generated" (confirmed against a real csf install: FragmentPath under
// /run/systemd/generator*, SourcePath /etc/init.d/csf). Both are a
// legitimately enabled csf.service, so both are accepted here.
prop, err := conn.GetUnitPropertyContext(ctx, "csf.service", "UnitFileState")
if err != nil {
return nil, fmt.Errorf("error getting csf service property: %s", err)
}
switch prop.Value.Value() {
case "enabled", "enabled-runtime", "generated":
// csf.service is present and set to start (native unit or SysV-backed).
default:
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: true,
// 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
}
// csfAddrFamily parses an address (IP or CIDR) and reports its family, or false
// when the value is not a valid address.
func csfAddrFamily(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
}
// 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 := csfAddrFamily(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 := csfAddrFamily(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 (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule
// that reaches the native csf path (past the hook branch in addRule) with an
// implied IPv6 family is written as a v6-resolving line — whether the family
// comes from a v6 address or from a port-only deny/allow whose "any" address is
// synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied
// family alone.
func (f *CSF) hook() *hookScript {
return &hookScript{
rulePrefix: f.rulePrefix,
hookPath: CSFHook,
hookPerm: 0700,
}
}
// mergeProtocols collapses a TCP rule and a UDP rule that are otherwise
// identical into one ProtocolAny rule — the inverse of the tcp+udp fan-out
func (f *CSF) mergeProtocols(rules []*Rule) []*Rule {
for i := 0; i < len(rules); i++ {
if rules[i].Proto != TCP && rules[i].Proto != UDP {
continue
}
for b := i + 1; b < len(rules); b++ {
if rules[b].Proto != TCP && rules[b].Proto != UDP {
continue
}
if rules[i].Proto == rules[b].Proto {
continue
}
// Only collapse a same-family pair: CSF expresses IPv4 and IPv6 through
// separate config keys (TCP_IN vs TCP6_IN), so a tcp/v4 and udp/v6 pair
// cover different families and merging them would drop one family's
// coverage. mergeFamilies has already collapsed genuine v4/v6 twins, so
// the only pairs left to merge here are same-family.
if rules[i].impliedFamily() != rules[b].impliedFamily() {
continue
}
// Compare ignoring protocol: a tcp/udp pair equal in every other field is
// the fanned-out form of one ProtocolAny rule.
a, c := *rules[i], *rules[b]
a.Proto, c.Proto = ProtocolAny, ProtocolAny
if a.EqualBase(&c, true) {
rules[i].Proto = ProtocolAny
rules = append(rules[:b], rules[b+1:]...)
b--
}
}
}
return rules
}
// 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...)
// Merge rules across families, then across protocol: a ProtocolAny port rule is
// stored as a tcp line plus a udp line (see the fan-out in EditIPList), so
// collapse such a pair back to one ProtocolAny rule or it never reads back as
// what was written and churns on every reconcile.
rules = mergeFamilies(rules)
rules = f.mergeProtocols(rules)
// Collapse each input/output twin into one DirAny rule — in particular a plain
// csf.allow/csf.deny IP, read as an inbound (source) rule plus an outbound
// (destination) rule, reads back as the single bidirectional line it was written
// as. Runs after the family/protocol merges so the twin is already canonicalized.
rules = mergeDirections(rules)
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 csf
// advanced rule must carry a source or destination address.
func (f *CSF) MarshalAdvRule(r *Rule) (string, error) {
if r.Proto == ICMPv6 {
return "", fmt.Errorf("csf advanced rules do not support icmpv6")
}
if r.Source == "" && r.Destination == "" {
return "", fmt.Errorf("a csf advanced rule requires a source or destination address")
}
// A csf advanced rule holds a single address field, so a rule matching both a
// source and a destination cannot be expressed; reject it rather than silently
// dropping the destination (installing a broader rule and leaving it
// unremovable). Mirrors the dual-port guard below.
if r.Source != "" && r.Destination != "" {
return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address")
}
// A csf advanced rule carries a single port-flow field, so a rule cannot match
// both a source and a destination port at once.
if r.HasPorts() && r.HasSourcePorts() {
return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port")
}
// An ICMP advanced rule needs a concrete type for the port-flow field: without
// one the address would land there and csf would parse it as `--icmp-type <ip>`
// and drop the rule (see checkICMP). Refuse to emit such a line.
if r.Proto == ICMP && r.ICMPType == nil {
return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type")
}
// A protocol-bearing port rule needs a concrete tcp/udp: an address+port rule
// with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter
// defaults that to `-p tcp`, so the rule would silently apply to TCP only while
// the library reads it back as ProtocolAny — leaving UDP open on a deny (or
// unallowed on an accept). The port-only form has no address here (it takes the
// placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot
// fan within a single advanced line, so reject it rather than under-apply it.
if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) {
return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported)
}
// csf.pl's linefilter reads an advanced line by fixed field position, not by
// tag: it always looks for the port-flow field (d=/s=) before the address
// field. A tcp/udp rule with an address but no port shifts the address into
// the port-flow slot instead, where it is parsed as a garbage --sport/--dport
// value and the address field is left empty — linefilter then requires both
// an address and a port to install anything, so the rule is silently dropped.
if r.Proto != ICMP && !r.HasPorts() && !r.HasSourcePorts() {
return "", fmt.Errorf("a csf tcp/udp advanced rule with an address requires a port: %w", ErrUnsupported)
}
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, "|"), nil
}
// advMatch reports whether a parsed advanced-rule line (the existing row)
// matches target. A ProtocolAny port rule is written as a separate tcp line and
// udp line, so a ProtocolAny target matches either concrete-protocol line: the
// parsed line's protocol is normalized to match before comparison. The family
// coverage the add and remove paths need is folded into EqualForDedup /
// EqualForRemoval.
func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool {
p := parsed
if target.Proto == ProtocolAny && (parsed.Proto == TCP || parsed.Proto == UDP) {
q := *parsed
q.Proto = ProtocolAny
p = &q
}
if remove {
return p.EqualForRemoval(target, true)
}
return p.EqualForDedup(target, true)
}
// portOnlyDenyLines returns the advanced csf.deny lines a port-only deny (no
// address) fans out to: one per transport (tcp and udp for a ProtocolAny rule) and
// per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a
// family-neutral rule). It mirrors the emit path so the add logic can compare
// against the lines already in the file and write only the missing ones — a single
// "exists" flag would skip the whole fan-out when just one family/protocol line was
// already present, leaving the other family/protocol open.
func (f *CSF) portOnlyDenyLines(r *Rule) []string {
dir := "in"
if r.IsOutput() {
dir = "out"
}
port := f.advPortValue(r.PortSpecs())
protos := []string{r.Proto.String()}
if r.Proto == ProtocolAny {
protos = []string{"tcp", "udp"}
}
var placeholders []string
switch r.impliedFamily() {
case IPv6:
placeholders = []string{"::/0"}
case IPv4:
placeholders = []string{"0.0.0.0/0"}
default:
placeholders = []string{"0.0.0.0/0", "::/0"}
}
var lines []string
for _, ph := range placeholders {
for _, p := range protos {
tokens := []string{p, dir, "d=" + port}
if r.IsOutput() {
tokens = append(tokens, "d="+ph)
} else {
tokens = append(tokens, "s="+ph)
}
lines = append(lines, strings.Join(tokens, "|"))
}
}
return lines
}
// EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule
// never reads back as the rule that was written, so it is re-added on every
// reconcile and can never be matched for removal.
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)
exists := false
// A port-only deny (no address) fans out into several csf.deny lines across
// family and protocol. Track which of those lines are already in the file so the
// add path can write only the missing ones: the single "exists" flag below marks
// the whole rule present as soon as any one fan-out line matches, which would
// otherwise leave the other family/protocol open. wantDeny is empty for every
// other rule shape, so this tracking is inert unless the fan-out applies.
wantDeny := map[string]bool{}
if !remove && action != Accept && r.HasPorts() && r.Source == "" && r.Destination == "" {
for _, l := range f.portOnlyDenyLines(r) {
wantDeny[l] = true
}
}
presentDeny := map[string]bool{}
// 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
// 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
}
// Note a fan-out line that is already present so the add path below skips it
// and writes only the missing family/protocol lines. Every such line is
// preserved (written back) by the pass-through branches below.
if wantDeny[line] {
presentDeny[line] = true
}
if strings.Contains(line, "|") {
rule := f.ParseAdvRule(line, action)
if rule == nil {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
if f.advMatch(rule, &match, remove) {
exists = true
if !remove {
flush()
_, _ = fmt.Fprintln(af, orig)
} else {
drop()
}
} else {
flush()
_, _ = fmt.Fprintln(af, orig)
}
} else {
// Try to parse IP.
family, ok := csfAddrFamily(line)
if !ok {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// A plain IP line is one bidirectional DirAny rule; match the target
// against it in the inbound frame (canonicalMatch), so a DirAny or a
// concrete-direction input/output target that names this host lines up.
plainRule := &Rule{
Direction: DirAny,
Family: family,
Source: line,
Action: action,
}
if plainRule.EqualBase(match.canonicalMatch(), false) {
exists = true
if !remove {
flush()
_, _ = fmt.Fprintln(af, orig)
} else {
drop()
}
} else {
flush()
_, _ = fmt.Fprintln(af, orig)
}
}
}
// Write any trailing comments that followed the last rule.
flush()
// If not exists and not remove, try adding the rule.
if !exists && !remove {
writeComment := func() {
if c := combineComment(f.rulePrefix, r.Comment); c != "" {
_, _ = fmt.Fprintln(af, "# "+c)
}
}
hasIP := r.Source != "" || r.Destination != ""
switch {
case hasIP && (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()):
// A port/ICMP rule with an address is an advanced rule.
line, err := f.MarshalAdvRule(r)
if err != nil {
_ = fd.Close()
return err
}
writeComment()
_, _ = fmt.Fprintln(af, line)
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.
writeComment()
if r.Source != "" {
_, _ = fmt.Fprintln(af, r.Source)
} else {
_, _ = fmt.Fprintln(af, r.Destination)
}
}
}
// A port-only deny (no address) fans out into a csf.deny line per family and
// protocol. Unlike the single-line cases above it must NOT be gated on the
// whole-rule "exists" flag: when only a subset of the fan-out lines is already
// present (e.g. the IPv4 line but not the IPv6 one, from a prior single-family
// add or a manual edit), the missing lines must still be written or that
// family/protocol stays open while the library reports the port blocked. Emit
// only the lines not already present (presentDeny, noted during the scan).
//
// csf's advanced-rule handler only emits an iptables rule when the line carries
// an address, so each fan-out line uses the "any" network placeholder (0.0.0.0/0
// or ::/0) as the address; parseAddr normalizes it back to an empty address
// and mergeFamilies collapses the v4/v6 pair to FamilyAny on read, keeping the
// rule readable and removable. The transport is named explicitly (a
// protocol-less line defaults to tcp in csf's linefilter), and a ProtocolAny
// deny fans to both tcp and udp.
if len(wantDeny) > 0 {
writeComment := func() {
if c := combineComment(f.rulePrefix, r.Comment); c != "" {
_, _ = fmt.Fprintln(af, "# "+c)
}
}
for _, line := range f.portOnlyDenyLines(r) {
if presentDeny[line] {
continue
}
writeComment()
_, _ = fmt.Fprintln(af, 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()
}
// checkConnLimit rejects a connection-limit request csf's CONNLIMIT cannot
// express, so an inexpressible one is reported rather than silently dropped.
func (f *CSF) checkConnLimit(r *Rule) error {
if r.ConnLimit == nil || f.isConnLimitRule(r) {
return nil
}
return fmt.Errorf("csf connection limiting (CONNLIMIT) applies only to a single inbound tcp port with no address, per source, rejecting the excess with a tcp reset: %w", ErrUnsupportedConnLimit)
}
// checkICMP rejects ICMP rules csf cannot express: csf advanced rules are
// built on iptables ICMP (IPv4) and require an address, and must also carry a
// concrete type: csf's linefilter treats the single port-flow field as the
// icmp-type, so an address with no type would put the address in that field
// (`--icmp-type <ip>`), which csf then fails to parse and drops silently (csf.pl
// linefilter). There is no csf advanced-rule encoding for "any icmp type from an
// address" — a bare host rule already covers all protocols — so reject it rather
// than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes
// it to the pre-hook before addRule/RemoveRule call checkICMP.
func (f *CSF) checkICMP(r *Rule) error {
if r.Proto == ICMP {
if r.Source == "" && r.Destination == "" {
return fmt.Errorf("a csf icmp rule requires a source or destination address: %w", ErrUnsupported)
}
if r.ICMPType == nil {
return fmt.Errorf("a csf icmp rule with an address requires a concrete icmp type: %w", ErrUnsupported)
}
}
return nil
}
// checkPortProto rejects a port match on a concrete protocol csf cannot
// express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a
// concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written
// into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less
// accept maps to both lists (the faithful "any" expansion) and a port-only
// reject to a protocol-less csf.deny advanced rule.
func (f *CSF) checkPortProto(r *Rule) error {
switch r.Proto {
case TCP, UDP, ProtocolAny:
return nil
}
if r.HasPorts() || r.HasSourcePorts() {
return fmt.Errorf("csf requires a tcp or udp protocol for a port match: %w", ErrUnsupported)
}
return nil
}
// checkSourcePort rejects a source-port match csf cannot express. csf source
// ports live in an advanced rule, which requires an address, so a source-port
// rule without one has nowhere to go.
func (f *CSF) checkSourcePort(r *Rule) error {
if r.HasSourcePorts() && r.Source == "" && r.Destination == "" {
return fmt.Errorf("a csf source-port rule requires a source or destination address: %w", ErrUnsupportedSourcePort)
}
return nil
}
// 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
}
// ipv6Unavailable reports whether adding r would silently write a
// csf.allow/csf.deny line (plain or advanced) that csf.pl's linefilter drops:
// any line resolving to an IPv6 address is dropped whenever csf.conf's IPV6 is
// not "1". ICMPv6 is unaffected — it always routes through the raw-iptables
func (f *CSF) ipv6Unavailable(r *Rule) bool {
return !f.ipv6Enabled && r.Proto != ICMPv6 && r.impliedFamily() == IPv6
}
// 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 {
// 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
}
// Features csf's native config cannot express (connection-state, per-rule
// interface, logging, rate limiting, icmpv6) are injected as iptables rules
// through the csf pre-hook.
if ruleNeedsHook(r) {
_, err := f.hook().edit(r, false)
return err
}
// A one-way bare host allow/deny cannot be a plain csf line (bidirectional) nor
// an advanced rule (needs a port), so it is written to the hook. A DirAny bare
// host takes the plain-line path below.
if bareHostOneWay(r) {
_, err := f.hook().edit(r, false)
return err
}
// A concrete-protocol host or a source+destination pair likewise has no csf.allow/
// csf.deny form (see hostNeedsHook), so it too is injected as a raw iptables rule.
if hostNeedsHook(r) {
_, err := f.hook().edit(r, false)
return err
}
// 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 is injected through the
// pre-hook (see bareProtoNeedsHook). It is checked ahead of the IPv6 gate because
// the hook runs ip6tables directly, outside csf.conf's IPV6-gated logic. ICMP keeps
// its own handling (checkICMP) and is excluded there.
if bareProtoNeedsHook(r) {
_, err := f.hook().edit(r, false)
return err
}
if enforceIPv6Gate && f.ipv6Unavailable(r) {
return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported)
}
if err := f.checkSourcePort(r); err != nil {
return err
}
if err := f.checkConnLimit(r); err != nil {
return err
}
if err := f.checkICMP(r); err != nil {
return err
}
if err := f.checkPortProto(r); err != nil {
return err
}
// A connection-limit rule maps onto the csf.conf CONNLIMIT list.
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
}
// Hook-injected rules (see AddRule) are removed from the managed script.
if ruleNeedsHook(r) {
_, err := f.hook().edit(r, true)
return err
}
// A one-way bare host rule is stored either as its own hook rule 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)
}
// A concrete-protocol host or source+destination pair is stored only as its own
// hook rule (it has no plain-line form to split), so remove it directly.
if hostNeedsHook(r) {
_, err := f.hook().edit(r, true)
return err
}
// A bare protocol match is stored only as its own hook rule (see AddRule), so
// remove it from the managed script directly. ICMP keeps its own handling below.
if bareProtoNeedsHook(r) {
_, err := f.hook().edit(r, true)
return err
}
if err := f.checkSourcePort(r); err != nil {
return err
}
if err := f.checkConnLimit(r); err != nil {
return err
}
if err := f.checkICMP(r); err != nil {
return err
}
if err := f.checkPortProto(r); err != nil {
return err
}
// This rule is natively expressible, but a copy of it may nonetheless live in the
// hook — 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. Clear any hook copy before removing the native entry, so a rule
// present in both csf's config and the hook is removed from both and a hook-only
// deny is fully removed here. DirAny is expanded so both one-way hook lines are
// matched; a rule with no hook copy makes this a harmless no-op.
for _, sub := range expandDirections(r) {
if _, err := f.hook().edit(sub, true); err != nil {
return err
}
}
// A 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 deny whose
// action differs from csf.conf's action for its direction was only ever in the hook
// (cleared above), so return rather than match it against csf.deny — where it could
// coincide with a genuine matching-action entry. A matching-action deny lives in
// csf.deny.
if r.Action == Accept {
err := f.EditIPList(ctx, CSFAllow, Accept, r, true)
if err != nil {
return err
}
} else {
denyAction := f.denyAction(r.IsOutput())
if r.Action != denyAction {
return nil
}
err := f.EditIPList(ctx, CSFDeny, denyAction, 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 := csfAddrFamily(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 := csfAddrFamily(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).
merged := mergeNATFamilies(rules)
return merged, 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)
}
// 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
}
// 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
// hook rule still references the set.
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
}
// 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
}