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