1947 lines
63 KiB
Go
1947 lines
63 KiB
Go
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=<n>` to `--icmp-type <n>` for an icmp rule), so mirror the d= branch.
|
|
v := strings.TrimPrefix(fld, "s=")
|
|
if addr, fam, ok := f.parseAddr(v); ok {
|
|
r.Family = fam
|
|
r.Source = addr
|
|
continue
|
|
}
|
|
if r.Proto == ICMP {
|
|
n, ok := parseICMPType(v)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
continue
|
|
}
|
|
specs, err := f.parseAdvPorts(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sourcePortSpecsToRule(r, specs)
|
|
case strings.HasPrefix(fld, "d="):
|
|
v := strings.TrimPrefix(fld, "d=")
|
|
// A destination value is either an address, or (when it is not) an
|
|
// ICMP type for icmp rules or a port list/range otherwise.
|
|
if addr, fam, ok := f.parseAddr(v); ok {
|
|
r.Family = fam
|
|
r.Destination = addr
|
|
continue
|
|
}
|
|
if r.Proto == ICMP {
|
|
n, ok := parseICMPType(v)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
continue
|
|
}
|
|
specs, err := f.parseAdvPorts(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
portSpecsToRule(r, specs)
|
|
case strings.HasPrefix(fld, "u=") || strings.HasPrefix(fld, "g="):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// MarshalAdvRule encodes a rule as a csf advanced allow/deny line. A csf
|
|
// advanced rule must carry a source or destination address.
|
|
func (f *CSF) MarshalAdvRule(r *Rule) (string, error) {
|
|
if r.Proto == ICMPv6 {
|
|
return "", fmt.Errorf("csf advanced rules do not support icmpv6")
|
|
}
|
|
if r.Source == "" && r.Destination == "" {
|
|
return "", fmt.Errorf("a csf advanced rule requires a source or destination address")
|
|
}
|
|
// A csf advanced rule holds a single address field, so a rule matching both a
|
|
// source and a destination cannot be expressed; reject it rather than silently
|
|
// dropping the destination (installing a broader rule and leaving it
|
|
// unremovable). Mirrors the dual-port guard below.
|
|
if r.Source != "" && r.Destination != "" {
|
|
return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address")
|
|
}
|
|
// A csf advanced rule carries a single port-flow field, so a rule cannot match
|
|
// both a source and a destination port at once.
|
|
if r.HasPorts() && r.HasSourcePorts() {
|
|
return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port")
|
|
}
|
|
// An ICMP advanced rule needs a concrete type for the port-flow field: without
|
|
// one the address would land there and csf would parse it as `--icmp-type <ip>`
|
|
// and drop the rule (see checkICMP). Refuse to emit such a line.
|
|
if r.Proto == ICMP && r.ICMPType == nil {
|
|
return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type")
|
|
}
|
|
// A 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 <ip>`), which csf then fails to parse and drops silently (csf.pl
|
|
// linefilter). There is no csf advanced-rule encoding for "any icmp type from an
|
|
// address" — a bare host rule already covers all protocols — so reject it rather
|
|
// than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes
|
|
// it to the pre-hook 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())
|
|
}
|