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