go-firewall/csf_linux.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- New Capabilities: PortPair, Negation, RejectAction,
  FamilyWithoutAddress, DenyActionFromConfig, advertised per backend.
- coversDirection isolates DirForward even when output is unowned;
  add splitNATDualRow so a concrete-family removal re-adds the opposite
  family's NAT translation.
- Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the
  "invalid" sentinel as input while JSON round-trips it.
- Sync counts additions on mid-batch failure and uses RuleBatcher.
- NewManager runs a probe loop joining each backend's reason for
  diagnosability; services.go drops "generated" from enabled, handles it
  on enable, clears start-limit-hit on restart, and matches rc.local by
  token.
- nftables: per-source connection limits (meter set), quoted-token
  parsing preserving log-prefix spacing, digit-led prefix sanitizing.
- apf/csf: deny-action-from-config with cached STOP settings, port lists
  and inexpressible shapes routed through the pre-hook, confKeyApplies
  guard against a missing config line.
- atomic config writes fsync before rename and resolve symlinks;
  readConfValue is last-assignment-wins; runCommand preserves the exit
  code through the wrapped error.
- Move coreos/go-systemd to the maintained v22 module directly.
2026-07-13 17:50:43 -05:00

1907 lines
65 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 it is 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 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,
// PortList is honored through the pre-hook: a csf.conf port list
// (TCP_IN="80,443,...") stores each port independently and reads back as
// one rule per port, so an address-less multi-port accept is injected as a
// single `-m multiport` iptables line instead (see multiPortConfAccept).
// An addressed multi-port rule is native: an advanced line's port field
// is a comma list.
PortList: true,
PortPair: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: true,
Comments: true,
Negation: true,
RejectAction: true,
FamilyWithoutAddress: true,
// A csf.deny entry stores no action; csf applies csf.conf's configured
// deny action, so removal matches an entry whatever action is named.
DenyActionFromConfig: true,
}
}
// GetZone reports no zone; csf has no concept of zones.
func (f *CSF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// ParseConnLimit decodes a csf.conf CONNLIMIT value ("port;limit,...") into
// connection-limit rules: csf caps concurrent new TCP connections per source and
// rejects the excess with a TCP reset, so each entry becomes an inbound tcp
// reject rule carrying a per-source ConnLimit.
func (f *CSF) ParseConnLimit(val string) (rules []*Rule) {
for _, entry := range strings.Split(val, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
portTok, limitTok, ok := strings.Cut(entry, ";")
if !ok {
continue
}
port, err := strconv.ParseUint(strings.TrimSpace(portTok), 10, 16)
if err != nil {
continue
}
limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32)
if err != nil {
continue
}
// CONNLIMIT is a single config key, but csf.pl only installs its IPv6
// CONNLIMIT rule (ip6tables) when csf.conf's IPV6 is enabled (ConfigServer/
// Config.pm, csf.pl); on the shipped default (IPV6="0") CONNLIMIT is IPv4
// only. Report FamilyAny when IPv6 handling is on — so a FamilyAny desired
// connlimit rule reconciles with its dual-stack read-back rather than
// churning every Sync — and IPv4 otherwise, matching what csf actually
// enforces.
fam := IPv4
if f.ipv6Enabled {
fam = FamilyAny
}
rules = append(rules, &Rule{
Family: fam,
Proto: TCP,
Port: uint16(port),
Action: Reject,
ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true},
})
}
return
}
// parseAddr classifies a csf advanced-rule address field. It returns the
// address, its family, and whether the value is an address at all (a non-address
// value is a port list or ICMP type). A zero "any" network (0.0.0.0/0 or ::/0)
// is normalized to an empty address so a port-only rule written with the "any"
// placeholder round-trips against a rule that carries no address.
func (f *CSF) parseAddr(v string) (addr string, fam Family, ok bool) {
family, ok := parseAddrFamily(v)
if !ok {
return "", FamilyAny, false
}
if _, network, err := net.ParseCIDR(v); err == nil {
if ones, _ := network.Mask.Size(); ones == 0 && network.IP.IsUnspecified() {
return "", family, true
}
}
return v, family, true
}
// parseAdvPorts parses a csf advanced-rule port value: a comma list whose
// entries are single ports or underscore ranges (e.g. "22,80,2000_3000").
func (f *CSF) parseAdvPorts(val string) ([]PortRange, error) {
var specs []PortRange
for _, tok := range strings.Split(val, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
lo, hi, isRange := strings.Cut(tok, "_")
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q", lo)
}
pr := PortRange{Start: uint16(start), End: uint16(start)}
if isRange {
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q", hi)
}
pr.End = uint16(end)
if pr.End < pr.Start {
return nil, fmt.Errorf("port range end below start")
}
}
specs = append(specs, pr)
}
if len(specs) == 0 {
return nil, fmt.Errorf("no ports")
}
return specs, nil
}
// ParseAdvRule decodes a csf advanced allow/deny rule of the form
// tcp/udp/icmp|in/out|s/d=port(s)|s/d=ip. The port field accepts a comma
// multiport list and underscore ranges; for icmp it holds the ICMP type.
func (f *CSF) ParseAdvRule(val string, action Action) (r *Rule) {
r = &Rule{
Action: action,
}
fields := strings.Split(val, "|")
for _, fld := range fields {
switch {
case strings.EqualFold(fld, "tcp"):
r.Proto = TCP
case strings.EqualFold(fld, "udp"):
r.Proto = UDP
case strings.EqualFold(fld, "icmp"):
r.Proto = ICMP
case strings.EqualFold(fld, "in"):
r.Direction = DirInput
case strings.EqualFold(fld, "out"):
r.Direction = DirOutput
case strings.HasPrefix(fld, "s="):
// The source field is either an address or, when it is not, an ICMP type
// for icmp rules or a source port list/range otherwise. csf reuses the
// port position for the ICMP type in both s= and d= (csf.pl maps
// `s=<n>` to `--icmp-type <n>` for an icmp rule), so mirror the d= branch.
v := strings.TrimPrefix(fld, "s=")
if addr, fam, ok := f.parseAddr(v); ok {
r.Family = fam
r.Source = addr
continue
}
if r.Proto == ICMP {
n, ok := parseICMPType(v)
if !ok {
return nil
}
r.ICMPType = Ptr(n)
continue
}
specs, err := f.parseAdvPorts(v)
if err != nil {
return nil
}
sourcePortSpecsToRule(r, specs)
case strings.HasPrefix(fld, "d="):
v := strings.TrimPrefix(fld, "d=")
// A destination value is either an address, or (when it is not) an
// ICMP type for icmp rules or a port list/range otherwise.
if addr, fam, ok := f.parseAddr(v); ok {
r.Family = fam
r.Destination = addr
continue
}
if r.Proto == ICMP {
n, ok := parseICMPType(v)
if !ok {
return nil
}
r.ICMPType = Ptr(n)
continue
}
specs, err := f.parseAdvPorts(v)
if err != nil {
return nil
}
portSpecsToRule(r, specs)
case strings.HasPrefix(fld, "u=") || strings.HasPrefix(fld, "g="):
return nil
}
}
// csf.pl defaults a protocol-less advanced line to `-p tcp` (its $protocol
// starts as "-p tcp"), so mirror it: reading the line back as ProtocolAny
// would report an all-protocol rule csf does not enforce, and one whose
// removal the iptables validity check rejects.
if r.Proto == ProtocolAny {
r.Proto = TCP
}
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()
// parseListLine classifies an advanced line (pipe or colon delimited) or a
// plain address line — one bidirectional DirAny rule authored in the
// inbound frame (Source=X) — and passes anything else through untouched.
rule := f.parseListLine(trimmed, action)
if rule == nil {
continue
}
rule.Comment = comment
rule.HasPrefix = hasPrefix
rules = append(rules, rule)
}
_ = fd.Close()
if serr := scanner.Err(); serr != nil {
return nil, serr
}
return
}
// ParsePorts decodes a csf.conf port-list value into one accept rule per port
// token for the given family, protocol, and direction.
func (f *CSF) ParsePorts(val string, family Family, proto Protocol, out bool) (rules []*Rule) {
ports := strings.Split(val, ",")
for _, port := range ports {
port = strings.TrimSpace(port)
if port == "" {
continue
}
// A csf.conf port token is a single port or a colon range.
pr, err := ParsePortRange(port)
if err != nil {
continue
}
rule := &Rule{
Family: family,
Proto: proto,
Direction: directionFromOutput(out),
Action: Accept,
}
portSpecsToRule(rule, []PortRange{pr})
rules = append(rules, rule)
}
return
}
// dropActions reads csf.conf's DROP (inbound) and DROP_OUT (outbound)
// settings, which decide whether a csf.deny entry is dropped or rejected: csf
// builds its DENYIN chain with `-j $DROP` and its DENYOUT chain with
// `-j $DROP_OUT`, so a deny rule's effective action follows its direction.
// Only "DROP" and "REJECT" are valid values; anything else (or an unreadable
// file) falls back to stock csf defaults — DROP drops inbound, DROP_OUT rejects
// outbound.
func (f *CSF) dropActions() (dropIn, dropOut Action) {
dropIn, dropOut = Drop, Reject
fd, err := os.Open(CSFConf)
if err != nil {
return
}
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
key, val, found := strings.Cut(strings.TrimSpace(line), "=")
if !found {
continue
}
key = strings.TrimSpace(key)
val = strings.ToUpper(trimQuotes(strings.TrimSpace(val)))
switch key {
case "DROP":
if val == "REJECT" {
dropIn = Reject
} else {
dropIn = Drop
}
case "DROP_OUT":
if val == "DROP" {
dropOut = Drop
} else {
dropOut = Reject
}
}
}
return
}
// hook returns the managed pre-hook script used to inject iptables rules for
// features csf's native config cannot express.
func (f *CSF) hook() *hookScript {
return &hookScript{
rulePrefix: f.rulePrefix,
hookPath: CSFHook,
hookPerm: 0700,
ipv6Enabled: f.ipv6Enabled,
}
}
// GetRules reads all filter rules from csf's config files and the managed
// pre-hook, merging family and protocol fan-outs back to their written form.
func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Read the standard configuration.
fd, err := os.Open(CSFConf)
if err != nil {
return nil, err
}
// Scan each line.
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
line := scanner.Text()
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
continue
}
// Parse key/value.
key, val, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
val = trimQuotes(strings.TrimSpace(val))
// Parse rules.
switch key {
case "TCP_IN":
rules = append(rules, f.ParsePorts(val, IPv4, TCP, false)...)
case "TCP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, TCP, true)...)
case "UDP_IN":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...)
case "UDP_OUT":
rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...)
case "TCP6_IN", "TCP6_OUT", "UDP6_IN", "UDP6_OUT":
// With IPV6 off csf never applies the *6_* lists, so their entries
// are inert; reporting them would claim IPv6 coverage the firewall
// does not enforce.
if !f.ipv6Enabled {
continue
}
proto := TCP
if strings.HasPrefix(key, "UDP") {
proto = UDP
}
out := strings.HasSuffix(key, "_OUT")
rules = append(rules, f.ParsePorts(val, IPv6, proto, out)...)
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.
// LF_BLOCKINONLY (off in stock csf.conf) is not modeled: with it set, csf
// skips a deny line's outbound rule while the line still reads back DirAny.
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
}
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "TCP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP {
return orig
}
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "UDP6_IN":
if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig
}
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
return orig
}
case "UDP6_OUT":
if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP {
return orig
}
if !remove && !f.ipv6Enabled && r.Family != IPv6 {
// With IPV6 off a family-agnostic add is written for IPv4 only (the
// v6 entry would sit inert and read back as unenforced coverage);
// removals still sweep, and a concrete-IPv6 row written by Restore
// keeps its family. The list-file analog is writeFamilyRows.
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 {
// Open the standard config file; EditRulePort rewrites the port-list and
// CONNLIMIT lines the rule applies to.
fd, err := os.Open(CSFConf)
if err != nil {
return err
}
// Stage the rewrite, preserving csf.conf's mode and ownership.
af, err := newAtomicFile(CSFConf, 0644)
if err != nil {
_ = fd.Close()
return err
}
defer af.Abort()
// Parse config one line at a time, adding the port rule.
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
orig := scanner.Text()
line := orig
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
_, _ = fmt.Fprintln(af, orig)
continue
}
// Parse key/value.
key, val, found := strings.Cut(line, "=")
if !found {
_, _ = fmt.Fprintln(af, orig)
continue
}
key = strings.TrimSpace(key)
val = trimQuotes(strings.TrimSpace(val))
// Parse rules.
orig = f.EditRulePort(orig, key, val, r, remove)
_, _ = fmt.Fprintln(af, orig)
}
_ = fd.Close()
// A read error means the rewritten file is truncated; discard it.
if serr := scanner.Err(); serr != nil {
return serr
}
// Move new file into place, preserving mode and ownership.
return af.Commit()
}
// advPortValue renders port specs for a csf advanced rule, which uses a comma
// list and an underscore range (e.g. "22,80,2000_3000").
func (f *CSF) advPortValue(specs []PortRange) string {
parts := make([]string, len(specs))
for i, pr := range specs {
pr = pr.normalized()
if pr.Start == pr.End {
parts[i] = strconv.FormatUint(uint64(pr.Start), 10)
} else {
parts[i] = fmt.Sprintf("%d_%d", pr.Start, pr.End)
}
}
return strings.Join(parts, ",")
}
// MarshalAdvRule encodes a rule as a csf advanced allow/deny line: a protocol
// token, a direction, one port-flow field (an icmp type, a source port or a
// destination port) and one address field, joined by "|". It validates nothing;
// addRule/RemoveRule route every shape the line cannot carry elsewhere first.
func (f *CSF) MarshalAdvRule(r *Rule) string {
var parts []string
switch r.Proto {
case TCP:
parts = append(parts, "tcp")
case UDP:
parts = append(parts, "udp")
case ICMP:
parts = append(parts, "icmp")
}
if r.IsOutput() {
parts = append(parts, "out")
} else {
parts = append(parts, "in")
}
// The port-flow field: an ICMP type, a source port, or a destination port.
switch {
case r.Proto == ICMP:
if r.ICMPType != nil {
parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType))
}
case r.HasSourcePorts():
parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs()))
case r.HasPorts():
parts = append(parts, "d="+f.advPortValue(r.PortSpecs()))
}
// Address.
if r.Source != "" {
parts = append(parts, "s="+r.Source)
} else if r.Destination != "" {
parts = append(parts, "d="+r.Destination)
}
return strings.Join(parts, "|")
}
// parseListLine parses one csf.allow/csf.deny rule line into the rule it holds: an
// advanced rule, or a plain address line, which is a single bidirectional DirAny
// rule matching every protocol. It returns nil for a line that is neither, which the
// caller passes through untouched. The action comes from the file (csf.allow is
// accept, csf.deny is a deny); the line encodes none of its own.
func (f *CSF) parseListLine(line string, action Action) *Rule {
if strings.Contains(line, "|") {
return f.ParseAdvRule(line, action)
}
if family, ok := parseAddrFamily(line); ok {
return &Rule{Direction: DirAny, Family: family, Source: line, Action: action}
}
// csf.pl accepts a colon-delimited advanced line, converting `:` to `|` when
// the line carries no pipe; an address (IPv6 included) was classified above,
// so the conversion never touches one. An advanced line always carries an s=
// or d= field, which keeps other unparseable colon text opaque.
if strings.Contains(line, ":") && strings.Contains(line, "=") {
return f.ParseAdvRule(strings.ReplaceAll(line, ":", "|"), action)
}
return nil
}
// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write
// order, or none for a shape the lists cannot hold. The rule must already carry the
// action its file implies (see EditIPList's match), since each row's read-back form
// is compared against lines stamped with it.
//
// csf has no both-transports line anywhere — csf.pl's linefilter silently reads a
// protocol-less advanced line as `-p tcp` — so a TCPUDP rule fans out into a tcp row
// and a udp row. A port-only deny additionally carries no address of its own, and
// csf's advanced-rule handler only emits an iptables rule for a line that has one, so
// each of its rows takes the "any" network as a placeholder address. That literal is
// family-specific, so a family-neutral rule fans out per family too rather than
// silently becoming IPv4-only — across the families csf actually enforces (see
// writeFamilyRows; with csf.conf's IPV6 off that is IPv4 alone). parseAddr normalizes
// the placeholder back to an empty address, so each row reads back as the address-less
// rule it stands for.
func (f *CSF) listRows(action Action, match *Rule) []listRow {
hasIP := match.Source != "" || match.Destination != ""
var rows []listRow
switch {
case hasIP && (match.HasPorts() || match.HasSourcePorts() || match.Proto.IsICMP()):
// A port/ICMP rule with an address is an advanced rule.
for _, sub := range expandProtocols(match) {
rows = append(rows, listRow{line: f.MarshalAdvRule(sub), read: sub})
}
case hasIP:
// A bare all-protocol host allow/deny: a single address matching every
// protocol. csf.allow/csf.deny hold no other portless address shape — a
// concrete-protocol host or a source+destination pair — so AddRule diverts
// those to the raw-iptables hook (shapeNeedsHook) and never reaches here with
// one. A direct caller of this exported writer that supplies such a shape gets
// a best-effort single-address write, not a guard.
addr := match.Source
if addr == "" {
addr = match.Destination
}
// The plain line is bidirectional and names its address as the source, which is
// the frame the scan reads it back in.
read := *match
read.Direction = DirAny
read.Source, read.Destination = addr, ""
rows = append(rows, listRow{line: addr, read: &read})
case action != Accept && match.HasPorts():
for _, fam := range writeFamilyRows(f.ipv6Enabled, match) {
placeholder := "0.0.0.0/0"
if fam.impliedFamily() == IPv6 {
placeholder = "::/0"
}
for _, sub := range expandProtocols(fam) {
// The row reads back address-less; only the line carries the placeholder.
// shapeNeedsHook has already routed an address-less source-port match to
// the hook, so the address field being filled in here is free.
row := *sub
if row.IsOutput() {
row.Destination = placeholder
} else {
row.Source = placeholder
}
rows = append(rows, listRow{line: f.MarshalAdvRule(&row), read: sub})
}
}
}
return rows
}
// EditIPList adds or removes a rule in a csf.allow/csf.deny list, rewriting it in
// place. An add expands the rule into the rows it materializes into (see listRows),
// notes which of them the file already holds, and appends only the rest, so a rule
// that fans out across families or transports is completed rather than duplicated on
// every reconcile. A removal drops every line the target covers.
func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error {
// Read the allow/deny IP rule list.
fd, err := os.Open(filePath)
if err != nil {
return err
}
// Stage the rewrite, preserving the list file's mode and ownership.
af, err := newAtomicFile(filePath, 0644)
if err != nil {
_ = fd.Close()
return err
}
defer af.Abort()
scanner := bufio.NewScanner(fd)
// csf.allow/csf.deny encode no action of their own — the file decides it
// (csf.allow is accept, csf.deny is a deny). A rule read from a file is stamped
// with that file's action, so match an incoming rule with its action coerced
// the same way: otherwise a rule added as Drop (written to csf.deny, read back
// as the deny action) could never be found and removed.
match := *r
match.Action = action
// The rows an add must end up with, and which of them the scan finds already in
// the file. A removal wants no rows: it matches the target against each line
// directly, since a line it must drop need not be one this library would write.
var rows []listRow
if !remove {
rows = f.listRows(action, &match)
}
present := make([]bool, len(rows))
// Full-line comments immediately above a rule are held back so they can be
// dropped together with a removed rule (they are its comment) and written
// ahead of a kept one. A blank line detaches them.
var pending []string
flush := func() {
for _, c := range pending {
_, _ = fmt.Fprintln(af, c)
}
pending = nil
}
drop := func() { pending = nil }
for scanner.Scan() {
orig := scanner.Text()
trimmed := strings.TrimSpace(orig)
// A full-line comment is held as a candidate rule comment.
if trimmed != "" && strings.HasPrefix(trimmed, "#") {
// Mirror ParseIPList: a prefix tag starts a fresh comment block, so
// any header/section comments above it are not part of the rule's comment
// and must survive its removal. Flush them now and begin the rule's block
// at the tag, so drop() only discards the tag and the rule's own comment.
if f.rulePrefix != "" {
if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") {
flush()
}
}
pending = append(pending, orig)
continue
}
// Strip an inline trailing comment for matching, but preserve the
// original line (with its inline note) when copying it through.
line := trimmed
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
line = strings.TrimSpace(line)
// A blank line detaches a pending comment; write it and the blank.
if len(line) == 0 {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// A line neither form parses is not a rule; pass it through untouched.
cur := f.parseListLine(line, action)
if cur == nil {
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// A removal drops every line the target covers, along with its comment. A
// TCPUDP or family-neutral target touches each of the concrete lines it was
// written as; that coverage is folded into EqualForRemoval.
if remove {
if cur.EqualForRemoval(&match, true) {
drop()
continue
}
flush()
_, _ = fmt.Fprintln(af, orig)
continue
}
// An add keeps every line and only notes which wanted rows the file already
// covers, so the tail writes the rest. Coverage rather than a text compare, so
// a row is satisfied by an existing line that spans it (a TCPUDP line absorbing
// a tcp row) and by one spelled differently but meaning the same.
for i := range rows {
if !present[i] && cur.EqualForDedup(rows[i].read, true) {
present[i] = true
}
}
flush()
_, _ = fmt.Fprintln(af, orig)
}
// Write any trailing comments that followed the last rule.
flush()
// Append the wanted rows the file does not already hold. A rule that fans out is
// completed row by row: when only a subset is present (the IPv4 line but not its
// IPv6 twin, from a prior single-family add or a manual edit) the missing rows
// must still be written, or that family stays open while the library reports the
// rule in force.
writeComment := func() {
if c := combineComment(f.rulePrefix, r.Comment); c != "" {
_, _ = fmt.Fprintln(af, "# "+c)
}
}
for i, row := range rows {
if present[i] {
continue
}
writeComment()
_, _ = fmt.Fprintln(af, row.line)
}
_ = fd.Close()
// A read error means the rewritten file is truncated; discard it.
if serr := scanner.Err(); serr != nil {
return serr
}
// Move new file into place, preserving mode and ownership.
return af.Commit()
}
// needsHook reports whether a rule must be injected through the csf pre-hook as a
// raw iptables rule because csf's native config cannot express it. It is the single
// gate between the hook path and csf's config files: everything it rejects (returns
// true) is written to the hook, everything it accepts (returns false) maps onto
// csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook,
// shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and
// RemoveRule routes on ruleNeedsHook and bareHostOneWay directly.
func (f *CSF) needsHook(r *Rule) bool {
// Features csf's native config cannot express (connection state, per-rule
// interface, logging, rate limiting, forward-chain routing, icmpv6, a transport
// csf does not carry, an address set) go to the hook.
if ruleNeedsHook(r) {
return true
}
// A shape no native csf form holds — a one-way bare host, a source+destination
// pair, a concrete-protocol portless host, an advanced-line address/port-flow
// overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook.
if shapeNeedsHook(r) {
return true
}
// An address-less multi-port accept goes to the hook's `-m multiport` match:
// a csf.conf port list stores each port as an independent token that reads
// back as its own rule, so the list shape has no single native form (see
// multiPortConfAccept). An addressed multi-port rule stays native — an
// advanced line's port field is a comma list.
if f.multiPortConfAccept(r) {
return true
}
// A connection limit csf.conf's CONNLIMIT cannot express — anything but a
// per-source cap on a single address-less inbound tcp port rejecting the excess
// (isConnLimitRule) — goes to the hook's `-m connlimit` match.
if r.ConnLimit != nil && !f.isConnLimitRule(r) {
return true
}
// An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's
// `iptables -p icmp` match, which needs neither an address nor a type. The only
// native form is exactly one address with a concrete type: an advanced line
// requires an address, its single port-flow field carries the icmp type, and
// csf.pl's linefilter reads that field by position — an address with no type
// would land the address there (`--icmp-type <ip>`), which csf fails to parse
// and drops silently. A source+destination pair overflows the single address
// field and is routed by shapeNeedsHook above; ICMPv6 never reaches this test —
// ruleNeedsHook sends it to the hook, or the IPv6 gate rejects it.
return r.Proto == ICMP && !(r.ICMPType != nil && (r.Source != "") != (r.Destination != ""))
}
// multiPortConfAccept reports whether a rule is an address-less tcp/udp accept
// carrying a discrete multi-port list — the one port shape a csf.conf port list
// cannot hold as a single rule: TCP_IN="80,443" stores independent tokens that
// read back one rule per port, so the rule would never round-trip whole. AddRule
// injects it through the hook's `-m multiport` match, which keeps the list on
// one line; a single port or one range stays a native token. A connection-limited
// rule is excluded (CONNLIMIT routing owns it), as is a source-port match, which
// is not the port-list shape and is routed by shapeNeedsHook — the exclusions let
// RemoveRule sweep the csf.conf port lists for this shape without touching tokens
// other rules own.
func (f *CSF) multiPortConfAccept(r *Rule) bool {
return onProtocolAxis(r.Proto) && r.Action == Accept && r.ConnLimit == nil &&
r.Source == "" && r.Destination == "" && !r.HasSourcePorts() &&
len(r.PortSpecs()) > 1
}
// denyAction returns the action a csf.deny entry takes in the given direction,
// following csf.conf's DROP (inbound) / DROP_OUT (outbound) settings. A deny
// rule this library writes must carry exactly this action: csf.deny encodes no
// action of its own, so a rule read back is stamped with what csf would apply,
// and a caller asking for the opposite action could never reconcile against it.
func (f *CSF) denyAction(output bool) Action {
dropIn, dropOut := f.dropActions()
if output {
return dropOut
}
return dropIn
}
// addRule is AddRule's implementation, with the IPv6 gate optional so Restore
// can reproduce a prior snapshot's inert entries rather than be rejected by a
// gate meant to catch fresh no-op writes.
func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error {
// Reject a concrete-IPv6 rule when csf's own IPv6 handling is off, ahead of every
// routing decision below: neither csf's config nor the pre-hook can carry one that
// csf will keep in sync (see ipv6Unavailable). Checking here rather than past the
// hook branches also keeps a DirAny rule from writing its input half before its
// output half is rejected.
if enforceIPv6Gate && ipv6Unavailable(f.ipv6Enabled, r) {
return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported)
}
// A DirAny rule maps to a single native construct only as a bare-host plain line;
// every other DirAny shape fans out into a concrete input rule plus its swapped
// output rule, each routed independently (a half may itself need the hook).
if r.Direction == DirAny && !dirAnyPlainLine(r) {
for _, sub := range expandDirections(r) {
if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil {
return err
}
}
return nil
}
// csf has no both-transports construct anywhere: its port lists are a TCP list and
// a UDP list, and csf.pl's linefilter silently reads a protocol-less advanced line
// as `-p tcp` rather than as both transports. So a TCPUDP rule fans out into a tcp
// rule and a udp rule, each routed independently, and each reads back as its own
// rule.
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(r) {
if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil {
return err
}
}
return nil
}
// Verify the rule is valid with iptables.
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// Any shape csf's native config cannot express (a stateful/interface/logged/
// rate-limited rule, a one-way or concrete-protocol host, a source+destination
// pair, a source-and-destination port match, an address-less source-port match,
// an address-less multi-port accept, a non-native connection limit, a non-native
// ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule
// through the csf pre-hook. See needsHook for each clause; everything past this
// gate maps onto csf's own config files.
if f.needsHook(r) {
_, err := f.hook().edit(r, false)
return err
}
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list (a
// non-native one was diverted to the hook above by needsHook).
if r.ConnLimit != nil {
return f.EditConf(ctx, r, false)
}
// A port-only accept maps to a csf.conf port list rather than csf.allow;
// listRows has no row for it, so falling through to the csf.allow edit would
// only rewrite that file byte-identically. Only a single port or one range
// reaches here — needsHook diverted a multi-port list to the hook above.
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
return f.EditConf(ctx, r, false)
}
// 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 {
// A deny plain line's outbound half was enforced with csf.conf's
// DROP_OUT action, while the DirAny row reads back with the inbound
// action; re-express the survivor with the action csf actually applied
// so the split does not silently change wire behavior.
if s.Action != Accept && s.IsOutput() {
_, s.Action = f.dropActions()
}
_, 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
}
// Validate the shape before the hook sweep below: an iptables-inexpressible
// rule (a port on ProtocolAny) exists nowhere csf can hold it, and letting it
// fail inside the hook marshal would return the bare error without the
// sentinel AddRule attaches to the same shape.
if err := iptablesRuleValid(r); err != nil {
return fmt.Errorf("%v: %w", err, ErrUnsupported)
}
// Clear any hook copy of the rule first, no matter how csf stores it. A rule csf
// carries only in the hook (see needsHook) lives nowhere else, so this is its
// entire removal; a natively-expressible rule may still have a stray hook copy —
// the library's own (a deny whose action differs from csf.conf's is stored there,
// see AddRule) or one a customer added by hand for a shape csf can also express
// natively — that must be cleared before the native entry below. DirAny is expanded
// so both one-way hook lines are matched; a rule with no hook copy makes this a
// harmless no-op.
var err error
for _, sub := range expandDirections(r) {
if _, e := f.hook().edit(sub, true); e != nil {
err = e
break
}
}
// A rule csf carries only in the hook has no native entry to fall through to, so
// return once its hook copy is cleared (or on any hook error). Returning here also
// keeps such a rule out of the plain-line split scan below, which could wrongly
// split an unrelated coexisting native entry.
if ruleNeedsHook(r) || err != nil {
return err
}
// A one-way bare host rule is stored either as its own hook rule (cleared above) or
// as one direction of a bidirectional plain line; removing it may need to split the
// plain line (see removeBareHostOneWay).
if bareHostOneWay(r) {
return f.removeBareHostOneWay(ctx, zoneName, r)
}
// Every other shape csf's native config cannot express (see needsHook) has already
// had its hook copy cleared above and has no native entry to split, so it is done.
// An address-less multi-port accept is the exception: it lives in the hook now,
// but an earlier per-port add (or a manual edit) may also hold its ports as
// csf.conf tokens, so it falls through to the port-list sweep below.
if f.needsHook(r) && !f.multiPortConfAccept(r) {
return nil
}
// A native connection-limit rule maps onto the csf.conf CONNLIMIT list.
if r.ConnLimit != nil {
return f.EditConf(ctx, r, true)
}
// A port-only accept maps to a csf.conf port list rather than csf.allow.
if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept {
err := f.EditConf(ctx, r, true)
if err != nil {
return err
}
}
// Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny entry
// carries no action of its own — csf applies csf.conf's action by direction — so the
// deny of an address is a single entry there, and it is removed whatever action the
// caller named: asking to stop denying something means the entry goes, or RemoveRule
// would report success while csf kept enforcing it. EditIPList coerces the target's
// action to the file's, so a differing-action deny still matches the line. The hook
// copy such a deny was added as (see AddRule) was already cleared above, exactly as
// the hook copy of a matching-action deny is, so both backings are swept either way.
if r.Action == Accept {
err := f.EditIPList(ctx, CSFAllow, Accept, r, true)
if err != nil {
return err
}
} else {
err := f.EditIPList(ctx, CSFDeny, f.denyAction(r.IsOutput()), r, true)
if err != nil {
return err
}
}
return nil
}
// UnmarshalNATRule decodes a csf.redirect line into a NATRule.
func (f *CSF) UnmarshalNATRule(line string) *NATRule {
fields := strings.Split(line, "|")
if len(fields) != 5 {
return nil
}
ipx, porta, ipy, portb, proto := fields[0], fields[1], fields[2], fields[3], fields[4]
parsePort := func(s string) (uint16, bool) {
if s == "*" || s == "" {
return 0, true
}
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16)
if err != nil {
return 0, false
}
return uint16(n), true
}
r := &NATRule{Proto: GetProtocol(proto)}
if r.Proto != TCP && r.Proto != UDP {
return nil
}
if ipx != "*" && ipx != "" {
if _, ok := parseAddrFamily(ipx); !ok {
return nil
}
r.Destination = ipx
}
pa, ok := parsePort(porta)
if !ok {
return nil
}
r.Port = pa
pb, ok := parsePort(portb)
if !ok {
return nil
}
r.ToPort = pb
if ipy == "*" || ipy == "" {
r.Kind = Redirect
if r.ToPort == 0 || r.Port == 0 {
return nil
}
} else {
fam, ok := parseAddrFamily(ipy)
if !ok {
return nil
}
r.Kind = DNAT
r.ToAddress = ipy
r.Family = fam
}
if r.Family == FamilyAny {
r.Family = r.impliedFamily()
}
return r
}
// natNeedsHook reports whether a NAT rule has no csf.redirect form and must be
// injected as a raw nat-table command through the pre-hook instead. csf.redirect
// encodes destination NAT only, in exactly two DNAT shapes plus the local
// redirect, always tcp/udp on a single concrete port, with no source or
// interface match (see MarshalNATRule). iptables expresses all of the excess —
// source NAT, an interface binding, a port range — directly, so those shapes are
// hooked rather than rejected.
func (f *CSF) natNeedsHook(r *NATRule) bool {
if r.Kind.isSource() {
return true
}
if r.Proto != TCP && r.Proto != UDP {
return true
}
if r.HasPortSet() {
return true
}
if r.Source != "" || r.Interface != "" {
return true
}
// A csf.redirect line carries family only through its addresses, so a family
// pinned by the Family field alone (an address-less redirect) has no native
// form: it would read back family-agnostic and never reconcile.
if r.Family != FamilyAny && familyOfAddr(r.Destination) == FamilyAny && familyOfAddr(r.ToAddress) == FamilyAny {
return true
}
switch r.Kind {
case Redirect:
return r.Port == 0
case DNAT:
// csf.pl accepts a DNAT only as a full-IP forward (both ports "*") or a
// port forward (both ports concrete); any other pairing aborts the load.
return r.Destination == "" || (r.Port == 0) != (r.ToPort == 0)
}
return true
}
// GetNATRules reads the NAT rules from csf.redirect and the raw nat-table
// commands the pre-hook carries.
func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
var rules []*NATRule
fd, err := os.Open(CSFRedirect)
switch {
case err == nil:
defer func() { _ = fd.Close() }()
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
}
case os.IsNotExist(err):
// csf.redirect is optional; a missing file simply has no native rules.
default:
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). Hook NAT lines are raw iptables commands, so a rule this
// library added there carries the prefix in its -m comment tag instead.
hookNAT, err := f.hook().getNATRules()
if err != nil {
return nil, err
}
return append(rules, hookNAT...), 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
// have no line here — AddNATRule routes those shapes through the pre-hook
// (natNeedsHook), so the rejections below guard only direct marshal callers.
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, or — for the shapes csf.redirect
// cannot hold (natNeedsHook) — as a raw nat-table command in the pre-hook.
func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
return err
}
// A concrete-IPv6 translation cannot be kept in sync with IPV6 off: csf
// sources the pre-hook on every (re)load but only flushes the v6 nat table
// when IPV6 is on, so an injected ip6tables line would re-append each reload
// and outlive its own removal, and a csf.redirect v6 entry is never applied
// at all — the NAT analog of ipv6Unavailable.
if !f.ipv6Enabled && r.impliedFamily() == IPv6 {
return fmt.Errorf("csf cannot manage an IPv6 nat rule with IPV6 disabled: %w", ErrUnsupportedNAT)
}
if f.natNeedsHook(r) {
_, err := f.hook().editNAT(r, false)
return err
}
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 and from the pre-hook.
// The hook is swept first whatever the rule's shape: a hook-only shape lives
// nowhere else, and a natively-expressible rule may still have a stray hook
// copy a customer added by hand, mirroring the filter-side RemoveRule.
func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
return err
}
if _, err := f.hook().editNAT(r, true); err != nil {
return err
}
if f.natNeedsHook(r) {
return nil
}
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
// hook rule still references the set; removing an absent set is a no-op.
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
}