package firewall import ( "bufio" "context" "fmt" "net" "os" "strconv" "strings" "time" dbus "github.com/coreos/go-systemd/dbus" ) const ( CSFType = "csf" CSFConf = "/etc/csf/csf.conf" CSFAllow = "/etc/csf/csf.allow" CSFDeny = "/etc/csf/csf.deny" // CSFRedirect holds csf's port-forwarding rules, one per line in the // pipe-delimited form "IPx|portA|IPy|portB|proto". A destination IP (IPy) of // "*" is a local port redirect; a concrete IPy is a forward to another host. CSFRedirect = "/etc/csf/csf.redirect" // CSFHook is the csf pre-hook, run after csf flushes and before it loads its // own rules, so injected rules land at the top of the chains and are re-added // on every reload. This library writes the iptables rules for features csf's // native config cannot express directly into this hook. (csf sources both // /usr/local/csf/bin/csfpre.sh and /etc/csf/csfpre.sh when present, so this // /etc/csf hook always runs.) CSFHook = "/etc/csf/csfpre.sh" ) // CSF manages a ConfigServer Security & Firewall (csf) installation, mapping // rules onto its config files (csf.conf, csf.allow, csf.deny, csf.redirect) and // a managed pre-hook for features csf's native config cannot express. type CSF struct { // rulePrefix is prepended to a rule's comment when it is written as a // full-line comment above a rule in csf.allow/csf.deny, so rules this // library creates can be told apart. csf.conf port-list rules carry no // per-rule comment and so ignore it. rulePrefix string // ipv6Enabled mirrors csf.conf's IPV6. csf.pl's linefilter silently drops a // csf.allow/csf.deny line (plain or advanced) that resolves to an IPv6 // address whenever IPV6 is not "1" (the shipped default), so AddRule must // reject that shape rather than write a line csf will never enforce. // ICMPv6 is unaffected: it always routes through the raw-iptables hook, // which runs outside csf's own IPV6-gated logic. ipv6Enabled bool } // NewCSF constructs a CSF manager, verifying the csf service is enabled and its // config files are present, and reading whether csf's own IPv6 handling is on. func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) { csf := new(CSF) csf.rulePrefix = rulePrefix // Connect to systemd dbus interface. conn, err := dbus.NewWithContext(ctx) if err != nil { return nil, fmt.Errorf("failed to connect to systemd: %s", err) } defer conn.Close() // Find the systemd service for csf and confirm it is set to start. CSF's // installer detects a running systemd at install time and drops in a real // static unit file, giving "enabled"/"enabled-runtime" — but when systemd // isn't PID 1 at install time (e.g. a container image built without an init // process) the installer falls back to its SysV /etc/init.d/csf script, and // systemd's sysv-generator synthesizes a wrapper unit at boot, which reports // as "generated" (confirmed against a real csf install: FragmentPath under // /run/systemd/generator*, SourcePath /etc/init.d/csf). Both are a // legitimately enabled csf.service, so both are accepted here. prop, err := conn.GetUnitPropertyContext(ctx, "csf.service", "UnitFileState") if err != nil { return nil, fmt.Errorf("error getting csf service property: %s", err) } switch prop.Value.Value() { case "enabled", "enabled-runtime", "generated": // csf.service is present and set to start (native unit or SysV-backed). default: return nil, fmt.Errorf("the csf service is not enabled on this server") } // Confirm config files exist. files := []string{CSFConf, CSFAllow, CSFDeny} for _, f := range files { if _, err := os.Stat(f); err != nil { return nil, fmt.Errorf("the config file %s is missing", f) } } // Confirm its not disabled. if _, err := os.Stat("/etc/csf/csf.disable"); err == nil { return nil, fmt.Errorf("csf is currently disabled") } // Read whether csf's own IPv6 handling is turned on. useIPv6, err := readConfValue(CSFConf, "IPV6") if err != nil { return nil, fmt.Errorf("error reading %s: %s", CSFConf, err) } csf.ipv6Enabled = useIPv6 == "1" // Return the new csf object. return csf, nil } // Type reports the backend identifier, "csf". func (f *CSF) Type() string { return CSFType } // 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, } } // 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 } // 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) } // 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, ",") } // 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 } // 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 } // csfAddrFamily parses an address (IP or CIDR) and reports its family, or false // when the value is not a valid address. func csfAddrFamily(v string) (Family, bool) { cidrIP, _, err := net.ParseCIDR(v) ip := net.ParseIP(v) if err != nil && ip == nil { return FamilyAny, false } if (cidrIP != nil && cidrIP.To4() == nil) || (ip != nil && ip.To4() == nil) { return IPv6, true } return IPv4, true } // parseAddr classifies a csf advanced-rule address field. It returns the // address, its family, and whether the value is an address at all (a non-address // value is a port list or ICMP type). A zero "any" network (0.0.0.0/0 or ::/0) // is normalized to an empty address so a port-only rule written with the "any" // placeholder round-trips against a rule that carries no address. func (f *CSF) parseAddr(v string) (addr string, fam Family, ok bool) { family, ok := csfAddrFamily(v) if !ok { return "", FamilyAny, false } if _, network, err := net.ParseCIDR(v); err == nil { if ones, _ := network.Mask.Size(); ones == 0 && network.IP.IsUnspecified() { return "", family, true } } return v, family, true } // 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 } // 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 } // 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 } // MarshalAdvRule encodes a rule as a csf advanced allow/deny line. A csf // advanced rule must carry a source or destination address. func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { if r.Proto == ICMPv6 { return "", fmt.Errorf("csf advanced rules do not support icmpv6") } if r.Source == "" && r.Destination == "" { return "", fmt.Errorf("a csf advanced rule requires a source or destination address") } // A csf advanced rule holds a single address field, so a rule matching both a // source and a destination cannot be expressed; reject it rather than silently // dropping the destination (installing a broader rule and leaving it // unremovable). Mirrors the dual-port guard below. if r.Source != "" && r.Destination != "" { return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address") } // A csf advanced rule carries a single port-flow field, so a rule cannot match // both a source and a destination port at once. if r.HasPorts() && r.HasSourcePorts() { return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port") } // An ICMP advanced rule needs a concrete type for the port-flow field: without // one the address would land there and csf would parse it as `--icmp-type ` // and drop the rule (see checkICMP). Refuse to emit such a line. if r.Proto == ICMP && r.ICMPType == nil { return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type") } // A protocol-bearing port rule needs a concrete tcp/udp: an address+port rule // with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter // defaults that to `-p tcp`, so the rule would silently apply to TCP only while // the library reads it back as ProtocolAny — leaving UDP open on a deny (or // unallowed on an accept). The port-only form has no address here (it takes the // placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot // fan within a single advanced line, so reject it rather than under-apply it. if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported) } // csf.pl's linefilter reads an advanced line by fixed field position, not by // tag: it always looks for the port-flow field (d=/s=) before the address // field. A tcp/udp rule with an address but no port shifts the address into // the port-flow slot instead, where it is parsed as a garbage --sport/--dport // value and the address field is left empty — linefilter then requires both // an address and a port to install anything, so the rule is silently dropped. if r.Proto != ICMP && !r.HasPorts() && !r.HasSourcePorts() { return "", fmt.Errorf("a csf tcp/udp advanced rule with an address requires a port: %w", ErrUnsupported) } var parts []string switch r.Proto { case TCP: parts = append(parts, "tcp") case UDP: parts = append(parts, "udp") case ICMP: parts = append(parts, "icmp") } if r.IsOutput() { parts = append(parts, "out") } else { parts = append(parts, "in") } // The port-flow field: an ICMP type, a source port, or a destination port. switch { case r.Proto == ICMP: if r.ICMPType != nil { parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType)) } case r.HasSourcePorts(): parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs())) case r.HasPorts(): parts = append(parts, "d="+f.advPortValue(r.PortSpecs())) } // Address. if r.Source != "" { parts = append(parts, "s="+r.Source) } else if r.Destination != "" { parts = append(parts, "d="+r.Destination) } return strings.Join(parts, "|"), nil } // ParseIPList reads a csf.allow/csf.deny file into rules, stamping each with the // given action and any full-line comment that precedes it. func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) { // Read the allow/deny IP rule list. fd, err := os.Open(filePath) if err != nil { return nil, err } scanner := bufio.NewScanner(fd) // A full-line comment immediately above a rule is that rule's comment. // Consecutive comment lines accumulate (joined by a space); a blank line // detaches a comment from any rule that follows. var pendingComment string flushComment := func() (string, bool) { text, hasPrefix := prefixedComment(f.rulePrefix, pendingComment) pendingComment = "" return text, hasPrefix } for scanner.Scan() { raw := scanner.Text() trimmed := strings.TrimSpace(raw) // A full-line comment is held as a candidate rule comment. if trimmed != "" && strings.HasPrefix(trimmed, "#") { text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) // A prefix tag starts a fresh comment block for the rule that // follows, so header/section comments above it are not absorbed into // the rule's comment and prefix detection stays reliable. if f.rulePrefix != "" && (text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ")) { pendingComment = text } else if pendingComment != "" { pendingComment += " " + text } else { pendingComment = text } continue } // Strip an inline trailing comment (not a rule comment). if ci := strings.IndexByte(trimmed, '#'); ci >= 0 { trimmed = trimmed[:ci] } trimmed = strings.TrimSpace(trimmed) // A blank line detaches a pending comment from any later rule. if len(trimmed) == 0 { pendingComment = "" continue } comment, hasPrefix := flushComment() if strings.Contains(trimmed, "|") { rule := f.ParseAdvRule(trimmed, action) if rule == nil { continue } rule.Comment = comment rule.HasPrefix = hasPrefix rules = append(rules, rule) } else { // Try to parse IP. family, ok := csfAddrFamily(trimmed) if !ok { continue } // A plain IP line matches the host in both directions, so it is one // bidirectional DirAny rule, authored in the inbound frame (Source=X). rules = append(rules, &Rule{ Direction: DirAny, Family: family, Source: trimmed, Action: action, Comment: comment, HasPrefix: hasPrefix, }) } } _ = fd.Close() if serr := scanner.Err(); serr != nil { return nil, serr } return } // GetRules reads all filter rules from csf's config files and the managed // pre-hook, merging family and protocol fan-outs back to their written form. func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { // Read the standard configuration. fd, err := os.Open(CSFConf) if err != nil { return nil, err } // Scan each line. scanner := bufio.NewScanner(fd) for scanner.Scan() { // Get the line. line := scanner.Text() // Remove comments. ci := strings.IndexByte(line, '#') if ci >= 0 { line = line[:ci] } // Trim spaces. line = strings.TrimSpace(line) // Ignore zero lines. if len(line) == 0 { continue } // Parse key/value. key, val, found := strings.Cut(line, "=") if !found { continue } key = strings.TrimSpace(key) val = trimQuotes(strings.TrimSpace(val)) // Parse rules. switch key { case "TCP_IN": rules = append(rules, f.ParsePorts(val, IPv4, TCP, false)...) case "TCP_OUT": rules = append(rules, f.ParsePorts(val, IPv4, TCP, true)...) case "UDP_IN": rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...) case "UDP_OUT": rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...) case "TCP6_IN": rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...) case "TCP6_OUT": rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...) case "UDP6_IN": rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...) case "UDP6_OUT": rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...) case "CONNLIMIT": rules = append(rules, f.ParseConnLimit(val)...) } } _ = fd.Close() if err := scanner.Err(); err != nil { return nil, err } // Read the allowed IP rule list. ipRules, err := f.ParseIPList(CSFAllow, Accept) if err != nil { return nil, err } rules = append(rules, ipRules...) // Read the denied IP rule list. A csf.deny entry takes effect as the DROP // (inbound) or DROP_OUT (outbound) action from csf.conf, so stamp each rule // with the action its direction actually gets rather than a fixed Reject — // otherwise a Drop rule the caller manages reads back as Reject, never // compares equal to the desired rule, and churns on every Sync. dropIn, dropOut := f.dropActions() ipRules, err = f.ParseIPList(CSFDeny, dropIn) if err != nil { return nil, err } for _, r := range ipRules { if r.IsOutput() { r.Action = dropOut } } rules = append(rules, ipRules...) // Read the iptables rules injected through the csf pre-hook (state, // interface, logging, rate-limit, icmpv6). hookRules, err := f.hook().getRules() if err != nil { return nil, err } rules = append(rules, hookRules...) // Merge rules across families, then across protocol: a ProtocolAny port rule is // stored as a tcp line plus a udp line (see the fan-out in EditIPList), so // collapse such a pair back to one ProtocolAny rule or it never reads back as // what was written and churns on every reconcile. rules = mergeFamilies(rules) rules = f.mergeProtocols(rules) // Collapse each input/output twin into one DirAny rule — in particular a plain // csf.allow/csf.deny IP, read as an inbound (source) rule plus an outbound // (destination) rule, reads back as the single bidirectional line it was written // as. Runs after the family/protocol merges so the twin is already canonicalized. rules = mergeDirections(rules) return } // mergeProtocols collapses a TCP rule and a UDP rule that are otherwise // identical into one ProtocolAny rule — the inverse of the tcp+udp fan-out // EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule // never reads back as the rule that was written, so it is re-added on every // reconcile and can never be matched for removal. func (f *CSF) mergeProtocols(rules []*Rule) []*Rule { for i := 0; i < len(rules); i++ { if rules[i].Proto != TCP && rules[i].Proto != UDP { continue } for b := i + 1; b < len(rules); b++ { if rules[b].Proto != TCP && rules[b].Proto != UDP { continue } if rules[i].Proto == rules[b].Proto { continue } // Only collapse a same-family pair: CSF expresses IPv4 and IPv6 through // separate config keys (TCP_IN vs TCP6_IN), so a tcp/v4 and udp/v6 pair // cover different families and merging them would drop one family's // coverage. mergeFamilies has already collapsed genuine v4/v6 twins, so // the only pairs left to merge here are same-family. if rules[i].impliedFamily() != rules[b].impliedFamily() { continue } // Compare ignoring protocol: a tcp/udp pair equal in every other field is // the fanned-out form of one ProtocolAny rule. a, c := *rules[i], *rules[b] a.Proto, c.Proto = ProtocolAny, ProtocolAny if a.EqualBase(&c, true) { rules[i].Proto = ProtocolAny rules = append(rules[:b], rules[b+1:]...) b-- } } } return rules } // advMatch reports whether a parsed advanced-rule line (the existing row) // matches target. A ProtocolAny port rule is written as a separate tcp line and // udp line, so a ProtocolAny target matches either concrete-protocol line: the // parsed line's protocol is normalized to match before comparison. The family // coverage the add and remove paths need is folded into EqualForDedup / // EqualForRemoval. func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool { p := parsed if target.Proto == ProtocolAny && (parsed.Proto == TCP || parsed.Proto == UDP) { q := *parsed q.Proto = ProtocolAny p = &q } if remove { return p.EqualForRemoval(target, true) } return p.EqualForDedup(target, true) } // portOnlyDenyLines returns the advanced csf.deny lines a port-only deny (no // address) fans out to: one per transport (tcp and udp for a ProtocolAny rule) and // per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a // family-neutral rule). It mirrors the emit path so the add logic can compare // against the lines already in the file and write only the missing ones — a single // "exists" flag would skip the whole fan-out when just one family/protocol line was // already present, leaving the other family/protocol open. func (f *CSF) portOnlyDenyLines(r *Rule) []string { dir := "in" if r.IsOutput() { dir = "out" } port := f.advPortValue(r.PortSpecs()) protos := []string{r.Proto.String()} if r.Proto == ProtocolAny { protos = []string{"tcp", "udp"} } var placeholders []string switch r.impliedFamily() { case IPv6: placeholders = []string{"::/0"} case IPv4: placeholders = []string{"0.0.0.0/0"} default: placeholders = []string{"0.0.0.0/0", "::/0"} } var lines []string for _, ph := range placeholders { for _, p := range protos { tokens := []string{p, dir, "d=" + port} if r.IsOutput() { tokens = append(tokens, "d="+ph) } else { tokens = append(tokens, "s="+ph) } lines = append(lines, strings.Join(tokens, "|")) } } return lines } // 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() } // EditIPList rewrites a csf.allow/csf.deny file to add or remove a rule, // carrying its full-line comment with it. func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error { // Read the allow/deny IP rule list. fd, err := os.Open(filePath) if err != nil { return err } // Stage the rewrite, preserving the list file's mode and ownership. af, err := newAtomicFile(filePath, 0644) if err != nil { _ = fd.Close() return err } defer af.Abort() scanner := bufio.NewScanner(fd) exists := false // A port-only deny (no address) fans out into several csf.deny lines across // family and protocol. Track which of those lines are already in the file so the // add path can write only the missing ones: the single "exists" flag below marks // the whole rule present as soon as any one fan-out line matches, which would // otherwise leave the other family/protocol open. wantDeny is empty for every // other rule shape, so this tracking is inert unless the fan-out applies. wantDeny := map[string]bool{} if !remove && action != Accept && r.HasPorts() && r.Source == "" && r.Destination == "" { for _, l := range f.portOnlyDenyLines(r) { wantDeny[l] = true } } presentDeny := map[string]bool{} // csf.allow/csf.deny encode no action of their own — the file decides it // (csf.allow is accept, csf.deny is a deny). A rule read from a file is stamped // with that file's action, so match an incoming rule with its action coerced // the same way: otherwise a rule added as Drop (written to csf.deny, read back // as the deny action) could never be found and removed. match := *r match.Action = action // Full-line comments immediately above a rule are held back so they can be // dropped together with a removed rule (they are its comment) and written // ahead of a kept one. A blank line detaches them. var pending []string flush := func() { for _, c := range pending { _, _ = fmt.Fprintln(af, c) } pending = nil } drop := func() { pending = nil } for scanner.Scan() { orig := scanner.Text() trimmed := strings.TrimSpace(orig) // A full-line comment is held as a candidate rule comment. if trimmed != "" && strings.HasPrefix(trimmed, "#") { // Mirror ParseIPList: a prefix tag starts a fresh comment block, so // any header/section comments above it are not part of the rule's comment // and must survive its removal. Flush them now and begin the rule's block // at the tag, so drop() only discards the tag and the rule's own comment. if f.rulePrefix != "" { if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") { flush() } } pending = append(pending, orig) continue } // Strip an inline trailing comment for matching, but preserve the // original line (with its inline note) when copying it through. line := trimmed if ci := strings.IndexByte(line, '#'); ci >= 0 { line = line[:ci] } line = strings.TrimSpace(line) // A blank line detaches a pending comment; write it and the blank. if len(line) == 0 { flush() _, _ = fmt.Fprintln(af, orig) continue } // Note a fan-out line that is already present so the add path below skips it // and writes only the missing family/protocol lines. Every such line is // preserved (written back) by the pass-through branches below. if wantDeny[line] { presentDeny[line] = true } if strings.Contains(line, "|") { rule := f.ParseAdvRule(line, action) if rule == nil { flush() _, _ = fmt.Fprintln(af, orig) continue } if f.advMatch(rule, &match, remove) { exists = true if !remove { flush() _, _ = fmt.Fprintln(af, orig) } else { drop() } } else { flush() _, _ = fmt.Fprintln(af, orig) } } else { // Try to parse IP. family, ok := csfAddrFamily(line) if !ok { flush() _, _ = fmt.Fprintln(af, orig) continue } // A plain IP line is one bidirectional DirAny rule; match the target // against it in the inbound frame (canonicalMatch), so a DirAny or a // concrete-direction input/output target that names this host lines up. plainRule := &Rule{ Direction: DirAny, Family: family, Source: line, Action: action, } if plainRule.EqualBase(match.canonicalMatch(), false) { exists = true if !remove { flush() _, _ = fmt.Fprintln(af, orig) } else { drop() } } else { flush() _, _ = fmt.Fprintln(af, orig) } } } // Write any trailing comments that followed the last rule. flush() // If not exists and not remove, try adding the rule. if !exists && !remove { writeComment := func() { if c := combineComment(f.rulePrefix, r.Comment); c != "" { _, _ = fmt.Fprintln(af, "# "+c) } } hasIP := r.Source != "" || r.Destination != "" switch { case hasIP && (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()): // A port/ICMP rule with an address is an advanced rule. line, err := f.MarshalAdvRule(r) if err != nil { _ = fd.Close() return err } writeComment() _, _ = fmt.Fprintln(af, line) case hasIP: // A bare all-protocol host allow/deny: a single address matching every // protocol. csf.allow/csf.deny hold no other portless address shape — a // concrete-protocol host or a source+destination pair — so AddRule diverts // those to the raw-iptables hook (hostNeedsHook) and never reaches here // with one. A direct caller of this exported writer that supplies such a // shape gets a best-effort single-address write, not a guard. writeComment() if r.Source != "" { _, _ = fmt.Fprintln(af, r.Source) } else { _, _ = fmt.Fprintln(af, r.Destination) } } } // A port-only deny (no address) fans out into a csf.deny line per family and // protocol. Unlike the single-line cases above it must NOT be gated on the // whole-rule "exists" flag: when only a subset of the fan-out lines is already // present (e.g. the IPv4 line but not the IPv6 one, from a prior single-family // add or a manual edit), the missing lines must still be written or that // family/protocol stays open while the library reports the port blocked. Emit // only the lines not already present (presentDeny, noted during the scan). // // csf's advanced-rule handler only emits an iptables rule when the line carries // an address, so each fan-out line uses the "any" network placeholder (0.0.0.0/0 // or ::/0) as the address; parseAddr normalizes it back to an empty address // and mergeFamilies collapses the v4/v6 pair to FamilyAny on read, keeping the // rule readable and removable. The transport is named explicitly (a // protocol-less line defaults to tcp in csf's linefilter), and a ProtocolAny // deny fans to both tcp and udp. if len(wantDeny) > 0 { writeComment := func() { if c := combineComment(f.rulePrefix, r.Comment); c != "" { _, _ = fmt.Fprintln(af, "# "+c) } } for _, line := range f.portOnlyDenyLines(r) { if presentDeny[line] { continue } writeComment() _, _ = fmt.Fprintln(af, line) } } _ = fd.Close() // A read error means the rewritten file is truncated; discard it. if serr := scanner.Err(); serr != nil { return serr } // Move new file into place, preserving mode and ownership. return af.Commit() } // 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 } // checkConnLimit rejects a connection-limit request csf's CONNLIMIT cannot // express, so an inexpressible one is reported rather than silently dropped. func (f *CSF) checkConnLimit(r *Rule) error { if r.ConnLimit == nil || f.isConnLimitRule(r) { return nil } return fmt.Errorf("csf connection limiting (CONNLIMIT) applies only to a single inbound tcp port with no address, per source, rejecting the excess with a tcp reset: %w", ErrUnsupportedConnLimit) } // 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 } // 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, ",")) } // checkSourcePort rejects a source-port match csf cannot express. csf source // ports live in an advanced rule, which requires an address, so a source-port // rule without one has nowhere to go. func (f *CSF) checkSourcePort(r *Rule) error { if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { return fmt.Errorf("a csf source-port rule requires a source or destination address: %w", ErrUnsupportedSourcePort) } return nil } // checkPortProto rejects a port match on a concrete protocol csf cannot // express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a // concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written // into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less // accept maps to both lists (the faithful "any" expansion) and a port-only // reject to a protocol-less csf.deny advanced rule. func (f *CSF) checkPortProto(r *Rule) error { switch r.Proto { case TCP, UDP, ProtocolAny: return nil } if r.HasPorts() || r.HasSourcePorts() { return fmt.Errorf("csf requires a tcp or udp protocol for a port match: %w", ErrUnsupported) } return nil } // checkICMP rejects ICMP rules csf cannot express: csf advanced rules are // built on iptables ICMP (IPv4) and require an address, and must also carry a // concrete type: csf's linefilter treats the single port-flow field as the // icmp-type, so an address with no type would put the address in that field // (`--icmp-type `), which csf then fails to parse and drops silently (csf.pl // linefilter). There is no csf advanced-rule encoding for "any icmp type from an // address" — a bare host rule already covers all protocols — so reject it rather // than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes // it to the pre-hook before addRule/RemoveRule call checkICMP. func (f *CSF) checkICMP(r *Rule) error { if r.Proto == ICMP { if r.Source == "" && r.Destination == "" { return fmt.Errorf("a csf icmp rule requires a source or destination address: %w", ErrUnsupported) } if r.ICMPType == nil { return fmt.Errorf("a csf icmp rule with an address requires a concrete icmp type: %w", ErrUnsupported) } } return nil } // ipv6Unavailable reports whether adding r would silently write a // csf.allow/csf.deny line (plain or advanced) that csf.pl's linefilter drops: // any line resolving to an IPv6 address is dropped whenever csf.conf's IPV6 is // not "1". ICMPv6 is unaffected — it always routes through the raw-iptables // hook (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule // that reaches the native csf path (past the hook branch in addRule) with an // implied IPv6 family is written as a v6-resolving line — whether the family // comes from a v6 address or from a port-only deny/allow whose "any" address is // synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied // family alone. func (f *CSF) ipv6Unavailable(r *Rule) bool { return !f.ipv6Enabled && r.Proto != ICMPv6 && r.impliedFamily() == IPv6 } // 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) } // addRule is AddRule's implementation, with the IPv6 gate optional so Restore // can reproduce a prior snapshot's inert entries rather than be rejected by a // gate meant to catch fresh no-op writes. func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { // A DirAny rule maps to a single native construct only as a bare-host plain line; // every other DirAny shape fans out into a concrete input rule plus its swapped // output rule, each routed independently (a half may itself need the hook). if r.Direction == DirAny && !dirAnyPlainLine(r) { for _, sub := range expandDirections(r) { if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { return err } } return nil } // Features csf's native config cannot express (connection-state, per-rule // interface, logging, rate limiting, icmpv6) are injected as iptables rules // through the csf pre-hook. if ruleNeedsHook(r) { _, err := f.hook().edit(r, false) return err } // A one-way bare host allow/deny cannot be a plain csf line (bidirectional) nor // an advanced rule (needs a port), so it is written to the hook. A DirAny bare // host takes the plain-line path below. if bareHostOneWay(r) { _, err := f.hook().edit(r, false) return err } // A concrete-protocol host or a source+destination pair likewise has no csf.allow/ // csf.deny form (see hostNeedsHook), so it too is injected as a raw iptables rule. if hostNeedsHook(r) { _, err := f.hook().edit(r, false) return err } // A bare protocol match with no address and no port has no native csf construct — // csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny // on an address — but iptables expresses it directly, so it is injected through the // pre-hook (see bareProtoNeedsHook). It is checked ahead of the IPv6 gate because // the hook runs ip6tables directly, outside csf.conf's IPV6-gated logic. ICMP keeps // its own handling (checkICMP) and is excluded there. if bareProtoNeedsHook(r) { _, err := f.hook().edit(r, false) return err } if enforceIPv6Gate && f.ipv6Unavailable(r) { return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported) } if err := f.checkSourcePort(r); err != nil { return err } if err := f.checkConnLimit(r); err != nil { return err } if err := f.checkICMP(r); err != nil { return err } if err := f.checkPortProto(r); err != nil { return err } // A connection-limit rule maps onto the csf.conf CONNLIMIT list. if r.ConnLimit != nil { return f.EditConf(ctx, r, false) } // A port-only accept maps to a csf.conf port list rather than csf.allow. if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { err := f.EditConf(ctx, r, false) if err != nil { return err } } // Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny // entry carries no action of its own — csf applies csf.conf's action by direction // (DROP inbound, DROP_OUT outbound) — so a deny whose action matches is written // natively, while one that differs has no native form and is injected through the // pre-hook instead, whose iptables rule carries the exact action. A DirAny bare-host // deny is expanded to its two concrete directions first, since each hook line is // one-way. if r.Action == Accept { err := f.EditIPList(ctx, CSFAllow, Accept, r, false) if err != nil { return err } } else { denyAction := f.denyAction(r.IsOutput()) if r.Action != denyAction { for _, sub := range expandDirections(r) { if _, err := f.hook().edit(sub, false); err != nil { return err } } return nil } err := f.EditIPList(ctx, CSFDeny, denyAction, r, false) if err != nil { return err } } return nil } // RemoveRule removes a filter rule from whichever csf construct holds it. func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { // A non-plain-line DirAny target fans out into its two concrete-direction rules, // mirroring addRule, so each half is removed from wherever it was written. if r.Direction == DirAny && !dirAnyPlainLine(r) { for _, sub := range expandDirections(r) { if err := f.RemoveRule(ctx, zoneName, sub); err != nil { return err } } return nil } // Hook-injected rules (see AddRule) are removed from the managed script. if ruleNeedsHook(r) { _, err := f.hook().edit(r, true) return err } // A one-way bare host rule is stored either as its own hook rule or as one // direction of a bidirectional plain line; removing it may need to split the // plain line (see removeBareHostOneWay). if bareHostOneWay(r) { return f.removeBareHostOneWay(ctx, zoneName, r) } // A concrete-protocol host or source+destination pair is stored only as its own // hook rule (it has no plain-line form to split), so remove it directly. if hostNeedsHook(r) { _, err := f.hook().edit(r, true) return err } // A bare protocol match is stored only as its own hook rule (see AddRule), so // remove it from the managed script directly. ICMP keeps its own handling below. if bareProtoNeedsHook(r) { _, err := f.hook().edit(r, true) return err } if err := f.checkSourcePort(r); err != nil { return err } if err := f.checkConnLimit(r); err != nil { return err } if err := f.checkICMP(r); err != nil { return err } if err := f.checkPortProto(r); err != nil { return err } // This rule is natively expressible, but a copy of it may nonetheless live in the // hook — the library's own (a deny whose action differs from csf.conf's is stored // there, see AddRule) or one a customer added by hand for a shape csf can also // express natively. Clear any hook copy before removing the native entry, so a rule // present in both csf's config and the hook is removed from both and a hook-only // deny is fully removed here. DirAny is expanded so both one-way hook lines are // matched; a rule with no hook copy makes this a harmless no-op. for _, sub := range expandDirections(r) { if _, err := f.hook().edit(sub, true); err != nil { return err } } // A connection-limit rule maps onto the csf.conf CONNLIMIT list. if r.ConnLimit != nil { return f.EditConf(ctx, r, true) } // A port-only accept maps to a csf.conf port list rather than csf.allow. if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { err := f.EditConf(ctx, r, true) if err != nil { return err } } // Edit csf.allow if accept is the action, otherwise edit csf.deny. A deny whose // action differs from csf.conf's action for its direction was only ever in the hook // (cleared above), so return rather than match it against csf.deny — where it could // coincide with a genuine matching-action entry. A matching-action deny lives in // csf.deny. if r.Action == Accept { err := f.EditIPList(ctx, CSFAllow, Accept, r, true) if err != nil { return err } } else { denyAction := f.denyAction(r.IsOutput()) if r.Action != denyAction { return nil } err := f.EditIPList(ctx, CSFDeny, denyAction, r, true) if err != nil { return err } } return nil } // 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 } // 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) } // 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 } // 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()) } // 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()) } // 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()) } // 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; Restore removes the // current rules and re-adds these, so every rule read is preserved. return &Backup{Rules: rules, NATRules: natRules}, 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 } } // 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 } // 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) } // 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 } // 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) } // UnmarshalNATRule decodes a csf.redirect line into a NATRule. func (f *CSF) UnmarshalNATRule(line string) *NATRule { fields := strings.Split(line, "|") if len(fields) != 5 { return nil } ipx, porta, ipy, portb, proto := fields[0], fields[1], fields[2], fields[3], fields[4] parsePort := func(s string) (uint16, bool) { if s == "*" || s == "" { return 0, true } n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16) if err != nil { return 0, false } return uint16(n), true } r := &NATRule{Proto: GetProtocol(proto)} if r.Proto != TCP && r.Proto != UDP { return nil } if ipx != "*" && ipx != "" { if _, ok := csfAddrFamily(ipx); !ok { return nil } r.Destination = ipx } pa, ok := parsePort(porta) if !ok { return nil } r.Port = pa pb, ok := parsePort(portb) if !ok { return nil } r.ToPort = pb if ipy == "*" || ipy == "" { r.Kind = Redirect if r.ToPort == 0 || r.Port == 0 { return nil } } else { fam, ok := csfAddrFamily(ipy) if !ok { return nil } r.Kind = DNAT r.ToAddress = ipy r.Family = fam } if r.Family == FamilyAny { r.Family = r.impliedFamily() } return r } // GetNATRules reads the NAT rules from csf.redirect. func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { fd, err := os.Open(CSFRedirect) if err != nil { // csf.redirect is optional; a missing file simply has no rules. if os.IsNotExist(err) { return nil, nil } return nil, err } defer func() { _ = fd.Close() }() var rules []*NATRule scanner := bufio.NewScanner(fd) for scanner.Scan() { line := scanner.Text() if ci := strings.IndexByte(line, '#'); ci >= 0 { line = line[:ci] } line = strings.TrimSpace(line) if line == "" { continue } if r := f.UnmarshalNATRule(line); r != nil { rules = append(rules, r) } } if err := scanner.Err(); err != nil { return nil, err } // csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no // rule in it carries the configured prefix; HasPrefix stays false (mirroring // firewalld's zones). merged := mergeNATFamilies(rules) return merged, nil } // 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) } // 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) } // Capabilities reports the firewall features csf supports. func (f *CSF) Capabilities() Capabilities { return Capabilities{ Output: true, Forward: true, ICMPv6: true, // A csf.conf port list (TCP_IN="80,443,...") stores each port independently // and reads back as one rule per port, so a discrete multi-port rule does // not round-trip as a single rule (a range, kept as one token, does). Report // PortList as unsupported to reflect that; callers open several ports by // adding a rule per port, which is how csf stores them anyway. PortList: false, ConnState: true, InterfaceMatch: true, Logging: true, RateLimit: true, ConnLimit: true, NAT: true, RuleOrdering: false, DefaultPolicy: false, RuleCounters: false, AddressSets: false, Comments: 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 is unsupported: csf has no address-set construct. func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { return nil, unsupportedSet(f.Type()) } // GetAddressSet is unsupported: csf has no address-set construct. func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { return nil, unsupportedSet(f.Type()) } // AddAddressSet is unsupported: csf has no address-set construct. func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error { return unsupportedSet(f.Type()) } // RemoveAddressSet is unsupported: csf has no address-set construct. func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { return unsupportedSet(f.Type()) } // AddAddressSetEntry is unsupported: csf has no address-set construct. func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { return unsupportedSet(f.Type()) } // RemoveAddressSetEntry is unsupported: csf has no address-set construct. func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { return unsupportedSet(f.Type()) }