go-firewall/csf_linux.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

1918 lines
61 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
}
// 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 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,
}
}
// 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 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 single advanced line carries a single protocol token, so a TCPUDP port rule
// with an address cannot be expressed here: a protocol-less line is defaulted to
// `-p tcp` by csf.pl's linefilter, applying the rule to TCP only while the
// library reads it back as both transports — leaving UDP open on a deny (or
// unallowed on an accept). The address-less form is fanned into a tcp and a udp
// line by the caller (portOnlyDenyLines); one line cannot fan, so reject it here
// rather than under-apply it.
if r.Proto == TCPUDP {
return "", fmt.Errorf("a csf advanced rule cannot carry both transports; expand it to tcp and udp lines: %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 TCPUDP port rule is written as a separate tcp line and udp line, so a
// TCPUDP target matches either concrete-transport line; that coverage, like the
// family and direction coverage the add and remove paths need, is folded into
// EqualForDedup / EqualForRemoval.
func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool {
if remove {
return parsed.EqualForRemoval(target, true)
}
return parsed.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 TCPUDP 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())
var protos []string
for _, sub := range expandProtocols(r) {
protos = append(protos, sub.Proto.String())
}
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 adds or removes a rule in a csf.allow/csf.deny list, rewriting it in
// place. A port-only deny fans out per transport and family placeholder, so a
// TCPUDP rule reads back as the rule that was written rather than being re-added on
// every reconcile and never 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 on
// read, so each family's line reads back as its own rule. The transport is named
// explicitly (a
// protocol-less line defaults to tcp in csf's linefilter), and a TCPUDP 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 (or the IPv6 gate rejects it) 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 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 lists, and a
// port on ProtocolAny — which matches every IP protocol, not just the two csf can
// carry — has no faithful form at all. TCPUDP is allowed: it fans out to both lists
// on an address-less accept, and to a tcp and a udp csf.deny advanced line
// otherwise.
func (f *CSF) checkPortProto(r *Rule) error {
switch r.Proto {
case TCP, UDP, TCPUDP:
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
}
// 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: 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`. 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. Unlike apf — whose trust
// parser derives both transports from one protocol-less line — csf cannot express
// the pair in a single line anywhere.
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(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). ICMP keeps its own handling (checkICMP) and is
// excluded there.
if bareProtoNeedsHook(r) {
_, err := f.hook().edit(r, false)
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
}
// 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
}
// 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
}
// 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).
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
}