iptables: persist ipsets across reboot to match rule persistence. - Detect the ipset save-file + restore unit (RHEL ipset.service / /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets), non-fatally. - After each set mutation, `ipset save` into the layout's save-file and auto-enable a present-but-disabled restore unit; warn when no mechanism exists (sets stay live-only). - Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230; CentOS 7 ships 219). APF/CSF: gain address sets by persisting ipset commands in the pre-hook. - The hook carries an `ipset create/flush/add` block ordered ahead of the `-m set --match-set` rule lines, so the firewall recreates the set on every (re)start before any rule references it. - Route set-referencing rules (Source/Destination names an ipset) through the hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape). - Implement the six address-set methods, advertise AddressSets, and wire sets into Backup/Restore via captureBackupState/restoreBackupSets. Validated live: reboot simulation for iptables; generated-hook source for APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and set-ref routing; the capability-gated integration subtest now covers APF/CSF.
3279 lines
100 KiB
Go
3279 lines
100 KiB
Go
package firewall
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/anmitsu/go-shlex"
|
|
dbus "github.com/coreos/go-systemd/dbus"
|
|
)
|
|
|
|
const (
|
|
// IPTablesType is the manager type string for the iptables backend.
|
|
IPTablesType = "iptables"
|
|
// IPTablesNoSave is the error text returned when no iptables save path is found.
|
|
IPTablesNoSave = "unable to find iptables save path"
|
|
// IPTablesNoService is the error text returned when no iptables service is found.
|
|
IPTablesNoService = "unable to find iptables service"
|
|
)
|
|
|
|
// IPTables manages filter and NAT rules through iptables save files and their systemd units.
|
|
type IPTables struct {
|
|
Conn *dbus.Conn
|
|
IP4Service string
|
|
IP4Path string
|
|
IP6Path string
|
|
IP6Service string
|
|
// IPSetPath and IPSetService describe the optional ipset persistence
|
|
// mechanism detected for this host. IPSetPath is the save file the sets are
|
|
// written to so a reboot restores them; IPSetService is the unit that restores
|
|
// it before the rules load. Both are empty when no mechanism is installed, in
|
|
// which case address sets are created live but not persisted across a reboot.
|
|
IPSetPath string
|
|
IPSetService string
|
|
// rulePrefix, when set, is written as an iptables comment on rules this
|
|
// library creates so they can be told apart from pre-existing rules.
|
|
rulePrefix string
|
|
}
|
|
|
|
// iptLayout names the save-file paths and systemd units a supported iptables
|
|
// packaging uses. The Debian layout carries the same unit for both families
|
|
// (netfilter-persistent.service restores both rules.v4 and rules.v6), so
|
|
// ip4Service and ip6Service are equal there.
|
|
type iptLayout struct {
|
|
ip4Path, ip6Path string
|
|
ip4Service, ip6Service string
|
|
// ipsetPath is the save file the ipset restore unit reads on boot, and
|
|
// ipsetService is the unit that restores it before the rules unit loads the
|
|
// -m set rules that reference the sets. Persisting sets is optional (a missing
|
|
// mechanism is not fatal, unlike a missing rules save file), so these describe
|
|
// the packaging's convention; NewIPTables confirms the mechanism is installed.
|
|
ipsetPath, ipsetService string
|
|
// ipsetPlugin, when set, is a glob whose presence proves the restore mechanism
|
|
// is installed. The Debian layout persists sets through a netfilter-persistent
|
|
// plugin rather than a dedicated unit, so its absence means the saved file
|
|
// would never be restored and the sets are left live-only.
|
|
ipsetPlugin string
|
|
}
|
|
|
|
// probeDebianLayout reports the Debian/Ubuntu iptables-persistent layout
|
|
// (/etc/iptables/rules.v4 and rules.v6, both restored by the single
|
|
// netfilter-persistent.service) if its save files are both present under
|
|
// root. root is prepended to both paths so tests can point the probe at a
|
|
// temp dir; production callers pass "".
|
|
func probeDebianLayout(root string) (l iptLayout, ok bool) {
|
|
l = iptLayout{
|
|
ip4Path: "/etc/iptables/rules.v4", ip6Path: "/etc/iptables/rules.v6",
|
|
ip4Service: "netfilter-persistent.service", ip6Service: "netfilter-persistent.service",
|
|
ipsetPath: "/etc/iptables/ipsets", ipsetService: "netfilter-persistent.service",
|
|
ipsetPlugin: "/usr/share/netfilter-persistent/plugins.d/*ipset*",
|
|
}
|
|
if _, err := os.Stat(root + l.ip4Path); err != nil {
|
|
return iptLayout{}, false
|
|
}
|
|
if _, err := os.Stat(root + l.ip6Path); err != nil {
|
|
return iptLayout{}, false
|
|
}
|
|
return l, true
|
|
}
|
|
|
|
// probeRHELLayout reports the RHEL/iptables-services layout
|
|
// (/etc/sysconfig/iptables, iptables.service/ip6tables.service) if its save
|
|
// files are both present under root. It does not check root itself, so a
|
|
// caller must have already confirmed the RHEL v4 path exists before treating
|
|
// an incomplete pair (ok=false) as fatal rather than falling through to
|
|
// another layout. root is prepended to both paths so tests can point the
|
|
// probe at a temp dir; production callers pass "".
|
|
func probeRHELLayout(root string) (l iptLayout, ok bool) {
|
|
l = iptLayout{
|
|
ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables",
|
|
ip4Service: "iptables.service", ip6Service: "ip6tables.service",
|
|
ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset.service",
|
|
}
|
|
if _, err := os.Stat(root + l.ip4Path); err != nil {
|
|
return iptLayout{}, false
|
|
}
|
|
if _, err := os.Stat(root + l.ip6Path); err != nil {
|
|
return iptLayout{}, false
|
|
}
|
|
return l, true
|
|
}
|
|
|
|
// NewIPTables creates an iptables manager, detecting the save-file layout and confirming its systemd units are enabled.
|
|
func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
|
|
ipt := new(IPTables)
|
|
ipt.rulePrefix = rulePrefix
|
|
|
|
// Prefer the RHEL layout when its v4 save file is present. A v4 file with
|
|
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
|
|
// Debian layout) — we do not know what may be used for the firewall in that
|
|
// case. Only when the RHEL v4 path is entirely absent do we probe the
|
|
// Debian iptables-persistent layout instead.
|
|
var layout iptLayout
|
|
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
|
|
l, ok := probeRHELLayout("")
|
|
if !ok {
|
|
return nil, errors.New(IPTablesNoSave)
|
|
}
|
|
layout = l
|
|
} else {
|
|
l, ok := probeDebianLayout("")
|
|
if !ok {
|
|
return nil, errors.New(IPTablesNoSave)
|
|
}
|
|
layout = l
|
|
}
|
|
ipt.IP4Path, ipt.IP6Path = layout.ip4Path, layout.ip6Path
|
|
|
|
// Connect to systemd dbus interface.
|
|
conn, err := dbus.NewWithContext(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to systemd: %s", err)
|
|
}
|
|
ipt.Conn = conn
|
|
|
|
// Find the systemd service for loading iptables rules and confirm it was loaded.
|
|
ipt.IP4Service = layout.ip4Service
|
|
if err := ipt.requireUnitEnabled(ctx, ipt.IP4Service); err != nil {
|
|
ipt.Conn.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// If ip6tables service is missing, we do not want to modify iptables
|
|
// as we do not know what may be used for the firewall. Skip the redundant
|
|
// check when it is the same unit already confirmed enabled above (the
|
|
// Debian layout uses one unit for both families).
|
|
ipt.IP6Service = layout.ip6Service
|
|
if ipt.IP6Service != ipt.IP4Service {
|
|
if err := ipt.requireUnitEnabled(ctx, ipt.IP6Service); err != nil {
|
|
ipt.Conn.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Detect the optional ipset persistence mechanism. Unlike the rules save file,
|
|
// a missing mechanism is not fatal: address sets still work live, they just do
|
|
// not survive a reboot (persistIPSets warns when a set is added in that case).
|
|
ipt.IPSetPath, ipt.IPSetService = ipt.detectIPSetLayout(ctx, layout)
|
|
|
|
return ipt, nil
|
|
}
|
|
|
|
// detectIPSetLayout reports the ipset save file and restore unit to persist sets
|
|
// with, or empty strings when the packaging's persistence mechanism is not
|
|
// installed. The Debian layout restores sets through a netfilter-persistent
|
|
// plugin (proven by ipsetPlugin's presence); the RHEL layout uses a dedicated
|
|
// ipset.service unit (proven by its unit file existing).
|
|
func (f *IPTables) detectIPSetLayout(ctx context.Context, layout iptLayout) (path, service string) {
|
|
if layout.ipsetPath == "" {
|
|
return "", ""
|
|
}
|
|
if layout.ipsetPlugin != "" {
|
|
if matches, _ := filepath.Glob(layout.ipsetPlugin); len(matches) == 0 {
|
|
return "", ""
|
|
}
|
|
return layout.ipsetPath, layout.ipsetService
|
|
}
|
|
if _, present, err := f.unitFileState(ctx, layout.ipsetService); err != nil || !present {
|
|
return "", ""
|
|
}
|
|
return layout.ipsetPath, layout.ipsetService
|
|
}
|
|
|
|
// unitFileState returns service's enablement state (e.g. "enabled", "disabled",
|
|
// "static") and whether its unit file is installed. It reads the full unit-file
|
|
// list and filters by name rather than calling ListUnitFilesByPatterns, which
|
|
// older systemd (CentOS 7's v219) does not export over D-Bus.
|
|
func (f *IPTables) unitFileState(ctx context.Context, service string) (state string, present bool, err error) {
|
|
files, err := f.Conn.ListUnitFilesContext(ctx)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
for _, uf := range files {
|
|
if filepath.Base(uf.Path) == service {
|
|
return uf.Type, true, nil
|
|
}
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
// Type returns the manager type.
|
|
func (f *IPTables) Type() string {
|
|
return IPTablesType
|
|
}
|
|
|
|
// Capabilities returns the set of features this backend can express.
|
|
func (f *IPTables) Capabilities() Capabilities {
|
|
return Capabilities{
|
|
Output: true,
|
|
Forward: true,
|
|
ICMPv6: true,
|
|
PortList: true,
|
|
ConnState: true,
|
|
InterfaceMatch: true,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: true,
|
|
NAT: true,
|
|
RuleOrdering: true,
|
|
DefaultPolicy: true,
|
|
RuleCounters: true,
|
|
AddressSets: true,
|
|
Comments: true,
|
|
}
|
|
}
|
|
|
|
// GetZone reports no zone: iptables has only policy groups, and rules are
|
|
// inserted at the top of the INPUT/OUTPUT policies.
|
|
func (f *IPTables) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
|
return "", nil
|
|
}
|
|
|
|
// IgnoreLine reports whether an iptables-save line is a blank, comment, table, chain or COMMIT line to be skipped.
|
|
func (*IPTables) IgnoreLine(line string) bool {
|
|
if len(line) == 0 {
|
|
return true
|
|
}
|
|
if line[0] == '#' || line[0] == '*' || line[0] == ':' {
|
|
return true
|
|
}
|
|
if line == "COMMIT" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// prefixedComment splits a stored comment into its user-facing text and whether
|
|
// the comment carried the configured prefix (marking a rule tagged with this
|
|
// library's namespace). A comment equal to the prefix (a prefix-only tag) has the
|
|
// prefix with empty text; a comment carrying the prefix followed by a space has
|
|
// the prefix with the remainder as text; any other comment lacks the prefix and
|
|
// is returned unchanged. An empty prefix gives the library no namespace of its
|
|
// own, so the prefix cannot be derived from the comment — hasPrefix is reported
|
|
// false and the caller decides (backends treat an empty prefix as covering
|
|
// everything, see GetRules).
|
|
func prefixedComment(prefix, comment string) (text string, hasPrefix bool) {
|
|
if prefix == "" {
|
|
return comment, false
|
|
}
|
|
if comment == prefix {
|
|
return "", true
|
|
}
|
|
if rest, ok := strings.CutPrefix(comment, prefix+" "); ok {
|
|
return rest, true
|
|
}
|
|
return comment, false
|
|
}
|
|
|
|
// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi")
|
|
// into PortRange values.
|
|
func iptParsePorts(val string) ([]PortRange, error) {
|
|
return ParsePortRanges(val, ",")
|
|
}
|
|
|
|
// unmarshalIPTablesRule decodes an iptables rulespec (e.g. an `-A CHAIN ...`
|
|
// line) into a rule. It is shared by the iptables backend and the ufw backend,
|
|
// whose before/after iptables rules files are in this format.
|
|
func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error) {
|
|
r = &Rule{
|
|
Family: family,
|
|
}
|
|
not := false
|
|
tokens, err := shlex.Split(ruleSpec, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// An iptables-save line may carry a leading [pkts:bytes] counter prefix.
|
|
// Capture the counters onto the rule and strip the prefix before parsing.
|
|
if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") {
|
|
inner := strings.TrimSuffix(strings.TrimPrefix(tokens[0], "["), "]")
|
|
if pk, bs, ok := strings.Cut(inner, ":"); ok {
|
|
if n, e := strconv.ParseUint(pk, 10, 64); e == nil {
|
|
r.Packets = n
|
|
}
|
|
if n, e := strconv.ParseUint(bs, 10, 64); e == nil {
|
|
r.Bytes = n
|
|
}
|
|
}
|
|
tokens = tokens[1:]
|
|
}
|
|
|
|
// Start at 2, the command and the chain.
|
|
i := 2
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("unexpected token length")
|
|
}
|
|
|
|
// Check the chain.
|
|
switch tokens[1] {
|
|
case "INPUT":
|
|
r.Direction = DirInput
|
|
case "OUTPUT":
|
|
r.Direction = DirOutput
|
|
case "FORWARD":
|
|
r.Direction = DirForward
|
|
default:
|
|
return nil, fmt.Errorf("the chain is not INPUT, OUTPUT or FORWARD")
|
|
}
|
|
|
|
// Check the command.
|
|
switch tokens[0] {
|
|
case "-A", "--append":
|
|
case "-I", "--insert":
|
|
// If insert rule has an integer rule number, increment i.
|
|
if i < len(tokens) {
|
|
_, err := strconv.Atoi(tokens[i])
|
|
if err == nil {
|
|
i++
|
|
}
|
|
}
|
|
case "-R", "--replace":
|
|
_, err := strconv.Atoi(tokens[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the replace command requires an integer rule number")
|
|
}
|
|
i++
|
|
default:
|
|
return nil, fmt.Errorf("unsupported command provided")
|
|
}
|
|
|
|
// Process the rule.
|
|
for ; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
// A leading "!" negates the match that follows it.
|
|
case "!":
|
|
not = true
|
|
// Continue so the negation is not cleared before the match token is read.
|
|
continue
|
|
case "-p", "--protocol":
|
|
// We do not support negation on this parameter.
|
|
if not {
|
|
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
|
|
}
|
|
|
|
// Verify the protocol is specified.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid protocol parameter")
|
|
}
|
|
|
|
// Verify the protocol value is valid.
|
|
r.Proto = GetProtocol(tokens[i])
|
|
if r.Proto == ProtocolAny && !strings.EqualFold(tokens[i], "all") {
|
|
return nil, fmt.Errorf("invalid protocol parameter")
|
|
}
|
|
case "-s", "--source":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid source parameter")
|
|
}
|
|
|
|
// Confirm we can parse the address.
|
|
_, _, err := net.ParseCIDR(tokens[i])
|
|
ip := net.ParseIP(tokens[i])
|
|
if err != nil && ip == nil {
|
|
return nil, fmt.Errorf("invalid source parameter")
|
|
}
|
|
|
|
// Set the source address.
|
|
if not {
|
|
r.Source = "!" + tokens[i]
|
|
} else {
|
|
r.Source = tokens[i]
|
|
}
|
|
case "-d", "--destination":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid destination parameter")
|
|
}
|
|
|
|
// Confirm we can parse the address.
|
|
_, _, err := net.ParseCIDR(tokens[i])
|
|
ip := net.ParseIP(tokens[i])
|
|
if err != nil && ip == nil {
|
|
return nil, fmt.Errorf("invalid destination parameter")
|
|
}
|
|
|
|
// Set the destination address.
|
|
if not {
|
|
r.Destination = "!" + tokens[i]
|
|
} else {
|
|
r.Destination = tokens[i]
|
|
}
|
|
case "--icmp-type", "--icmpv6-type":
|
|
// A bare icmp-type match (as in `-p icmp --icmp-type echo-request`,
|
|
// without an explicit `-m icmp`), common in ufw's iptables rules files.
|
|
if not {
|
|
return nil, fmt.Errorf("a negated icmp type is not supported")
|
|
}
|
|
// The flag names the family: --icmpv6-type resolves names through the
|
|
// ICMPv6 table, where reused names (e.g. echo-request) map to different
|
|
// numbers than in ICMPv4.
|
|
v6 := tokens[i] == "--icmpv6-type"
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid icmp-type parameter")
|
|
}
|
|
n, ok := parseICMPTypeFamily(tokens[i], v6)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
case "--sport", "--source-port":
|
|
// A bare source-port match (as in `-p udp --sport 5353`).
|
|
if not {
|
|
return nil, fmt.Errorf("a negated source port is not supported")
|
|
}
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid sport parameter")
|
|
}
|
|
pr, perr := ParsePortRange(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.SourcePort = pr.Start
|
|
} else {
|
|
r.SourcePorts = []PortRange{pr}
|
|
}
|
|
case "--dport", "--destination-port":
|
|
// A bare destination-port match (as in `-p udp --dport 5353`).
|
|
if not {
|
|
return nil, fmt.Errorf("a negated port is not supported")
|
|
}
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid dport parameter")
|
|
}
|
|
pr, perr := ParsePortRange(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
case "-i", "--in-interface":
|
|
if not {
|
|
return nil, fmt.Errorf("a negated interface match is not supported")
|
|
}
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid in-interface parameter")
|
|
}
|
|
r.InInterface = tokens[i]
|
|
case "-o", "--out-interface":
|
|
if not {
|
|
return nil, fmt.Errorf("a negated interface match is not supported")
|
|
}
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid out-interface parameter")
|
|
}
|
|
r.OutInterface = tokens[i]
|
|
case "-j", "--jump":
|
|
// We do not support negation on this parameter.
|
|
if not {
|
|
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
|
|
}
|
|
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid jump parameter")
|
|
}
|
|
|
|
// Parse the valid options.
|
|
switch tokens[i] {
|
|
case "DROP":
|
|
r.Action = Drop
|
|
case "REJECT":
|
|
r.Action = Reject
|
|
if i+2 < len(tokens) {
|
|
if tokens[i+1] == "--reject-with" {
|
|
i += 2
|
|
}
|
|
}
|
|
case "ACCEPT":
|
|
r.Action = Accept
|
|
case "LOG":
|
|
// A LOG target is non-terminal: a logged rule is written as a
|
|
// LOG line followed by the action line, coalesced on read. This
|
|
// line contributes only the Log flag and prefix.
|
|
r.Log = true
|
|
for i+1 < len(tokens) {
|
|
if tokens[i+1] == "--log-prefix" && i+2 < len(tokens) {
|
|
r.LogPrefix = tokens[i+2]
|
|
i += 2
|
|
} else if tokens[i+1] == "--log-level" && i+2 < len(tokens) {
|
|
i += 2
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
|
|
}
|
|
case "-m", "--match":
|
|
// We do not support negation on this parameter (the set match negates
|
|
// internally, after `-m set`, so it is handled inside its case).
|
|
if not {
|
|
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
|
|
}
|
|
|
|
// Verify options are set.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse the valid options.
|
|
switch tokens[i] {
|
|
case "set":
|
|
// -m set [!] --match-set <name> src|dst names an ipset in place of an
|
|
// address; the optional `!` negates it. A combined `src,dst` flag
|
|
// matches on both, which the Rule model (one set per direction) cannot
|
|
// represent, so it is rejected.
|
|
i++
|
|
setNot := false
|
|
if i < len(tokens) && tokens[i] == "!" {
|
|
setNot = true
|
|
i++
|
|
}
|
|
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
|
|
return nil, fmt.Errorf("unsupported set match")
|
|
}
|
|
name := tokens[i+1]
|
|
dir := tokens[i+2]
|
|
i += 2
|
|
if setNot {
|
|
name = "!" + name
|
|
}
|
|
switch dir {
|
|
case "src":
|
|
r.Source = name
|
|
case "dst":
|
|
r.Destination = name
|
|
default:
|
|
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
|
|
}
|
|
case "comment":
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
if tokens[i+1] != "--comment" {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
i += 2
|
|
// Capture the comment text. The caller strips it when it is the
|
|
// configured prefix rather than a user-supplied label.
|
|
r.Comment = tokens[i]
|
|
case "conntrack":
|
|
// Only the conntrack state option is modeled.
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--ctstate" {
|
|
return nil, fmt.Errorf("unsupported conntrack match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(tokens[i])
|
|
if serr != nil {
|
|
return nil, serr
|
|
}
|
|
r.State = state
|
|
case "state":
|
|
// Legacy state match: -m state --state NEW,ESTABLISHED.
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--state" {
|
|
return nil, fmt.Errorf("unsupported state match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(tokens[i])
|
|
if serr != nil {
|
|
return nil, serr
|
|
}
|
|
r.State = state
|
|
case "limit":
|
|
// -m limit --limit N/unit [--limit-burst B]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--limit" {
|
|
return nil, fmt.Errorf("unsupported limit match")
|
|
}
|
|
i += 2
|
|
rate, unit, rerr := parseRateToken(tokens[i])
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
rl := &RateLimit{Rate: rate, Unit: unit}
|
|
if i+2 < len(tokens) && tokens[i+1] == "--limit-burst" {
|
|
b, berr := strconv.ParseUint(tokens[i+2], 10, 32)
|
|
if berr != nil {
|
|
return nil, fmt.Errorf("invalid limit burst %q", tokens[i+2])
|
|
}
|
|
rl.Burst = uint(b)
|
|
i += 2
|
|
}
|
|
// Legacy (xtables) iptables prints a default --limit-burst of 5 on
|
|
// every -m limit match; treat it as unset so a Burst-0 rule still
|
|
// matches on a legacy host (an explicit 5 collapses to 0 too).
|
|
if rl.Burst == 5 {
|
|
rl.Burst = 0
|
|
}
|
|
r.RateLimit = rl
|
|
case "connlimit":
|
|
// -m connlimit --connlimit-above N [--connlimit-mask M]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--connlimit-above" {
|
|
return nil, fmt.Errorf("unsupported connlimit match")
|
|
}
|
|
i += 2
|
|
n, nerr := strconv.ParseUint(tokens[i], 10, 32)
|
|
if nerr != nil {
|
|
return nil, fmt.Errorf("invalid connlimit %q", tokens[i])
|
|
}
|
|
// The default mask (32/128) counts per source; an explicit mask
|
|
// of 0 counts globally.
|
|
cl := &ConnLimit{Count: uint(n), PerSource: true}
|
|
if i+2 < len(tokens) && tokens[i+1] == "--connlimit-mask" {
|
|
if tokens[i+2] == "0" {
|
|
cl.PerSource = false
|
|
}
|
|
i += 2
|
|
}
|
|
// iptables-save always appends the counting key (--connlimit-saddr
|
|
// by default, or --connlimit-daddr) after the match; consume it so
|
|
// the trailing flag does not fail the parse and drop the whole rule.
|
|
if i+1 < len(tokens) && (tokens[i+1] == "--connlimit-saddr" || tokens[i+1] == "--connlimit-daddr") {
|
|
i++
|
|
}
|
|
r.ConnLimit = cl
|
|
case "icmp", "icmp6":
|
|
// -m icmp --icmp-type N / -m icmp6 --icmpv6-type N. The type
|
|
// qualifier is optional; a bare match just selects the module.
|
|
v6 := tokens[i] == "icmp6"
|
|
typeFlag := "--icmp-type"
|
|
if v6 {
|
|
typeFlag = "--icmpv6-type"
|
|
}
|
|
if i+2 < len(tokens) && tokens[i+1] == typeFlag {
|
|
i += 2
|
|
// iptables-save spells a type-with-code as `type/code` (e.g.
|
|
// `3/1`); the Rule model carries only the type, so drop a trailing
|
|
// `/code` before resolving rather than failing the whole rule.
|
|
typeTok := tokens[i]
|
|
if slash := strings.IndexByte(typeTok, '/'); slash >= 0 {
|
|
typeTok = typeTok[:slash]
|
|
}
|
|
n, ok := parseICMPTypeFamily(typeTok, v6)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
}
|
|
case "multiport":
|
|
// -m multiport --dports/--sports 80,443,1000:2000 (also --ports).
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid multiport match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port":
|
|
default:
|
|
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
|
}
|
|
src := strings.HasPrefix(tokens[i+1], "--s")
|
|
i += 2
|
|
specs, perr := iptParsePorts(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if src {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.SourcePort = specs[0].Start
|
|
} else {
|
|
r.SourcePorts = specs
|
|
}
|
|
} else {
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
}
|
|
case "tcp":
|
|
// Invalid protocol define.
|
|
if r.Proto == UDP {
|
|
return nil, fmt.Errorf("specifying TCP options for UDP")
|
|
}
|
|
|
|
// Verify options are set.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse options.
|
|
tcpTokenLoop:
|
|
for ; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "!", "--syn", "--tcp-option":
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
case "--source-port", "--sport":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse port (iptables-save renders a contiguous range as lo:hi).
|
|
pr, err := ParsePortRange(tokens[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.SourcePort = pr.Start
|
|
} else {
|
|
r.SourcePorts = []PortRange{pr}
|
|
}
|
|
case "--destination-port", "--dport":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse port (iptables-save renders a contiguous range as lo:hi).
|
|
pr, err := ParsePortRange(tokens[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
default:
|
|
i--
|
|
break tcpTokenLoop
|
|
}
|
|
}
|
|
case "udp", "sctp":
|
|
// SCTP carries ports like UDP and iptables-save spells its port
|
|
// match module `-m sctp`, so it shares this branch.
|
|
// Invalid protocol define.
|
|
if r.Proto == TCP {
|
|
return nil, fmt.Errorf("specifying UDP options for TCP")
|
|
}
|
|
|
|
// Verify options are set.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse options.
|
|
udpTokenLoop:
|
|
for ; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "!":
|
|
// A negated match cannot be represented.
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
case "--source-port", "--sport":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse port (iptables-save renders a contiguous range as lo:hi).
|
|
pr, err := ParsePortRange(tokens[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.SourcePort = pr.Start
|
|
} else {
|
|
r.SourcePorts = []PortRange{pr}
|
|
}
|
|
case "--destination-port", "--dport":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
|
|
// Parse port (iptables-save renders a contiguous range as lo:hi).
|
|
pr, err := ParsePortRange(tokens[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
default:
|
|
i--
|
|
break udpTokenLoop
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported match option: %s", tokens[i])
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
|
|
}
|
|
|
|
// As rule has been parsed, we may now mark not as false.
|
|
not = false
|
|
}
|
|
|
|
// If no action provided, error — unless this is a LOG-only line (the log
|
|
// half of a logged rule), which carries the Log flag but no terminal action.
|
|
if r.Action == ActionInvalid && !r.Log {
|
|
return nil, fmt.Errorf("no valid action was provided")
|
|
}
|
|
return
|
|
}
|
|
|
|
// UnmarshalRule decodes an iptables rulespec into a firewall rule.
|
|
func (f *IPTables) UnmarshalRule(ruleSpec string, family Family) (*Rule, error) {
|
|
r, err := unmarshalIPTablesRule(ruleSpec, family)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The shared parser is prefix-agnostic; strip this backend's configured
|
|
// prefix so only the user-facing comment surfaces, and record whether the
|
|
// prefix was present so callers can tell our rules from foreign ones. The
|
|
// comment is not part of rule identity, so this does not affect dedup or
|
|
// removal comparisons. An empty prefix gives us no namespace, so no rule
|
|
// reports HasPrefix.
|
|
text, hasPrefix := prefixedComment(f.rulePrefix, r.Comment)
|
|
r.Comment = text
|
|
r.HasPrefix = hasPrefix
|
|
return r, nil
|
|
}
|
|
|
|
// iptSameMatch reports whether two rules have identical match fields ignoring
|
|
// their action and logging flags. It is used to pair a LOG line with the action
|
|
// line that follows it.
|
|
func iptSameMatch(a, b *Rule) bool {
|
|
ac, bc := *a, *b
|
|
ac.Log, bc.Log = false, false
|
|
ac.LogPrefix, bc.LogPrefix = "", ""
|
|
ac.Action, bc.Action = Accept, Accept
|
|
return ac.EqualBase(&bc, true)
|
|
}
|
|
|
|
// logPartner reports whether cur is a standalone LOG line (no terminal action)
|
|
// and next is its action partner — the pairing GetRules coalesces into one
|
|
// logged rule (see iptSameMatch). next may be nil when cur is the last rule in
|
|
// the sequence. It is the shared predicate behind coalesceLoggedRules,
|
|
func logPartner(cur, next *Rule) bool {
|
|
return cur != nil && cur.Action == ActionInvalid && cur.Log &&
|
|
next != nil && next.Action != ActionInvalid && iptSameMatch(cur, next)
|
|
}
|
|
|
|
// mergeLogPair folds a standalone LOG line's prefix into its action partner,
|
|
// producing the single logical rule GetRules reports for a logged rule.
|
|
func mergeLogPair(logLine, action *Rule) *Rule {
|
|
merged := *action
|
|
merged.Log = true
|
|
merged.LogPrefix = logLine.LogPrefix
|
|
return &merged
|
|
}
|
|
|
|
// coalesceLoggedRules merges each LOG-only rule that is immediately followed by
|
|
// a matching action rule into a single logical rule with Log set. An orphan LOG
|
|
// rule (no matching action after it) is dropped.
|
|
func coalesceLoggedRules(rules []*Rule) []*Rule {
|
|
out := make([]*Rule, 0, len(rules))
|
|
for i := 0; i < len(rules); i++ {
|
|
cur := rules[i]
|
|
if cur.Action == ActionInvalid && cur.Log {
|
|
// A LOG-only line: fold it into the next line if that line is its
|
|
// action partner, else drop this orphan LOG line.
|
|
var next *Rule
|
|
if i+1 < len(rules) {
|
|
next = rules[i+1]
|
|
}
|
|
if logPartner(cur, next) {
|
|
out = append(out, mergeLogPair(cur, next))
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
out = append(out, cur)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseFilterFile reads a family's iptables-save file and returns its filter
|
|
// rules as logical rules, coalescing each LOG line with the action line that
|
|
// follows it. Lines that do not parse (nat rules, custom chains) are skipped.
|
|
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
|
|
fd, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
|
|
var perLine []*Rule
|
|
scanner := bufio.NewScanner(fd)
|
|
// The save file is a full iptables-save dump, so *nat and *mangle also carry
|
|
// INPUT/OUTPUT chains whose plain ACCEPT/DROP/LOG rules would parse as filter
|
|
// rules. Track table scope and only read the *filter table so a foreign nat or
|
|
// mangle rule does not bleed into GetRules (and is never relocated or removed as
|
|
// if it were a filter rule).
|
|
inFilter := false
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(line, "*") {
|
|
inFilter = line == "*filter"
|
|
continue
|
|
}
|
|
if line == "COMMIT" {
|
|
inFilter = false
|
|
continue
|
|
}
|
|
if !inFilter {
|
|
continue
|
|
}
|
|
if f.IgnoreLine(line) {
|
|
continue
|
|
}
|
|
r, err := f.UnmarshalRule(line, family)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// UnmarshalRule already stripped the prefix from the comment and
|
|
// set HasPrefix; a second strip here would remove a prefix word a second
|
|
// time and truncate a user comment that itself begins with the prefix.
|
|
perLine = append(perLine, r)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Return the coalesced rules; GetRules assigns Number after merging families
|
|
// across the two save files (the other callers use the result only for
|
|
// EqualBase dedup and never read Number).
|
|
return coalesceLoggedRules(perLine), nil
|
|
}
|
|
|
|
// GetRules returns the existing filter rules from the zone.
|
|
func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
|
v4, err := f.parseFilterFile(f.IP4Path, IPv4)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
|
|
}
|
|
rules = append(rules, v4...)
|
|
|
|
v6, err := f.parseFilterFile(f.IP6Path, IPv6)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
|
|
}
|
|
rules = append(rules, v6...)
|
|
|
|
// Merge rules across families, number per direction (before the direction
|
|
// merge, so surviving output rows keep the physical position of their still-
|
|
// present twin), then collapse each input/output twin into one DirAny rule.
|
|
rules = mergeFamilies(rules)
|
|
numberByDirection(rules)
|
|
rules = mergeDirections(rules)
|
|
|
|
return
|
|
}
|
|
|
|
// applyRuleFiles runs prepare against each family file the rule applies to,
|
|
// staging all temp files first and committing them only once every file has
|
|
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
|
|
// files, so staging up front avoids leaving the rule half-applied if the second
|
|
// file fails to prepare.
|
|
func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicFile, error)) error {
|
|
// A DirAny rule fans out into an input row plus its role-swapped output row,
|
|
// each marshalled into its own chain within the same family file(s). Recurse per
|
|
// concrete-direction half; expandDirections returns a single element for a
|
|
// concrete rule, so this never recurses more than once.
|
|
if subs := expandDirections(r); len(subs) > 1 {
|
|
for _, sub := range subs {
|
|
if err := f.applyRuleFiles(sub, prepare); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp`
|
|
// belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file.
|
|
family := r.impliedFamily()
|
|
var paths []string
|
|
if family == IPv4 || family == FamilyAny {
|
|
paths = append(paths, f.IP4Path)
|
|
}
|
|
if family == IPv6 || family == FamilyAny {
|
|
paths = append(paths, f.IP6Path)
|
|
}
|
|
|
|
// Stage every file first.
|
|
var staged []*atomicFile
|
|
for _, path := range paths {
|
|
af, err := prepare(path, r)
|
|
if err != nil {
|
|
// Discard any temp files already staged.
|
|
for _, s := range staged {
|
|
s.Abort()
|
|
}
|
|
return err
|
|
}
|
|
// A nil handle means no change was needed for this file.
|
|
if af != nil {
|
|
staged = append(staged, af)
|
|
}
|
|
}
|
|
|
|
// Commit each staged file into place, preserving its mode and ownership.
|
|
for _, s := range staged {
|
|
if err := s.Commit(); err != nil {
|
|
return fmt.Errorf("failed to move new firewall rules into place: %s", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddRule adds a rule to the zone.
|
|
func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
return f.applyRuleFiles(r, f.prepareAddRuleFile)
|
|
}
|
|
|
|
// chainOf returns the chain named by an iptables-save rule body such as
|
|
// "-A FORWARD -j ACCEPT" (the token after the -A/-I/-R command), or "" when the
|
|
// body has no chain token. It lets the file-rewrite paths tell an INPUT/OUTPUT
|
|
// rule the library manages from a rule in a chain it does not model.
|
|
func (f *IPTables) chainOf(body string) string {
|
|
fields := strings.Fields(body)
|
|
if len(fields) >= 2 {
|
|
return fields[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ruleLineBody strips an optional leading [pkts:bytes] counter token from a
|
|
// trimmed iptables-save line and returns the remaining rule body. iptables-save
|
|
// -c annotates each rule with counters; the library never emits them, but the
|
|
// file-rewrite paths must still recognise a counter-prefixed line as a rule when
|
|
// operating on a pre-existing save file (matching the read parser, which strips
|
|
// the same prefix). A line without a counter is returned unchanged.
|
|
func (f *IPTables) ruleLineBody(line string) string {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "[") {
|
|
if i := strings.IndexByte(line, ']'); i >= 0 {
|
|
return strings.TrimSpace(line[i+1:])
|
|
}
|
|
}
|
|
return line
|
|
}
|
|
|
|
// chainRules parses the `-A` lines of chain in the *filter table of lines, in
|
|
// file order, returning one entry per line (nil for a line the parser rejects,
|
|
// which counts as an ordinary foreign rule). It scopes to the *filter table so a
|
|
// *nat/*mangle INPUT/OUTPUT chain is never counted, and feeds logicalStarts.
|
|
func (f *IPTables) chainRules(lines []string, chain string) []*Rule {
|
|
var rules []*Rule
|
|
filterFound := false
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if !filterFound {
|
|
if line == "*filter" {
|
|
filterFound = true
|
|
}
|
|
continue
|
|
}
|
|
// Leave the filter table at its COMMIT or the next table header so a
|
|
// *nat/*mangle INPUT/OUTPUT chain is not counted.
|
|
if strings.HasPrefix(line, "*") || line == "COMMIT" {
|
|
break
|
|
}
|
|
if f.chainOf(f.ruleLineBody(line)) != chain {
|
|
continue
|
|
}
|
|
rule, _ := f.UnmarshalRule(line, FamilyAny)
|
|
rules = append(rules, rule)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// iptChainForDirection returns the filter chain name (INPUT, OUTPUT or FORWARD)
|
|
// a rule of the given direction lives in.
|
|
func iptChainForDirection(d Direction) string {
|
|
switch d {
|
|
case DirOutput:
|
|
return "OUTPUT"
|
|
case DirForward:
|
|
return "FORWARD"
|
|
}
|
|
return "INPUT"
|
|
}
|
|
|
|
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
|
|
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
|
|
// not a logical-rule start — an action line coalesced into a preceding LOG line,
|
|
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an
|
|
// insert/move position aligns with the per-chain numbering GetRules reports: a
|
|
// LOG line paired with its action is one logical rule beginning at the LOG line,
|
|
// while an orphan LOG line (no matching action after it) begins none. The
|
|
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
|
|
func (f *IPTables) logicalStarts(rules []*Rule) []int {
|
|
starts := make([]int, len(rules))
|
|
pos := 0
|
|
for i := 0; i < len(rules); i++ {
|
|
cur := rules[i]
|
|
if cur != nil && cur.Action == ActionInvalid && cur.Log {
|
|
var next *Rule
|
|
if i+1 < len(rules) {
|
|
next = rules[i+1]
|
|
}
|
|
if logPartner(cur, next) {
|
|
// A LOG line paired with its action is one logical rule that begins
|
|
// at the LOG line; the action partner (starts[i+1]) stays 0.
|
|
pos++
|
|
starts[i] = pos
|
|
i++
|
|
continue
|
|
}
|
|
// An orphan LOG line is dropped by GetRules, so it begins no logical rule.
|
|
continue
|
|
}
|
|
pos++
|
|
starts[i] = pos
|
|
}
|
|
return starts
|
|
}
|
|
|
|
// addrArgs encodes a source or destination match. dir is "src" or "dst". An
|
|
// IP/CIDR uses `-s`/`-d`; a non-address token names an ipset, matched with
|
|
// `-m set --match-set <name> <dir>`. A leading "!" negation is emitted before the
|
|
// match in both cases.
|
|
func (f *IPTables) addrArgs(addr, dir string) []string {
|
|
neg, bare := splitAddrNeg(addr)
|
|
if isSetRef(addr) {
|
|
// The set match negates internally: `-m set ! --match-set name dir`.
|
|
out := []string{"-m", "set"}
|
|
if neg {
|
|
out = append(out, "!")
|
|
}
|
|
return append(out, "--match-set", bare, dir)
|
|
}
|
|
// An address negates with a leading `!`: `! -s addr`.
|
|
var out []string
|
|
if neg {
|
|
out = append(out, "!")
|
|
}
|
|
flag := "-s"
|
|
if dir == "dst" {
|
|
flag = "-d"
|
|
}
|
|
return append(out, flag, bare)
|
|
}
|
|
|
|
// combineComment joins the configured prefix and an optional user comment into the
|
|
// single comment string stored on a rule. The prefix is always carried so rules
|
|
// this library creates stay identifiable: when both are present the prefix is
|
|
// followed by a space and the user text; when only one is present it is used
|
|
// alone; when neither is present the result is empty.
|
|
func combineComment(prefix, comment string) string {
|
|
if prefix == "" {
|
|
return comment
|
|
}
|
|
if comment == "" {
|
|
return prefix
|
|
}
|
|
return prefix + " " + comment
|
|
}
|
|
|
|
// iptMultiportValue renders port specs for `-m multiport --dports`, using a
|
|
// colon for ranges (e.g. "80,443,1000:2000"). The specs are canonicalized
|
|
// (sorted, with contiguous/overlapping ranges merged) so that two rules the model
|
|
// considers Equal — port-set order and coalescing are not part of rule identity —
|
|
// always render to the same string. Backends that match on the exact marshalled
|
|
// line (the CSF/APF hook script) rely on this to stay idempotent.
|
|
func iptMultiportValue(specs []PortRange) string {
|
|
specs = coalescePortRanges(specs)
|
|
parts := make([]string, len(specs))
|
|
for i, pr := range specs {
|
|
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, ",")
|
|
}
|
|
|
|
// iptablesRuleValid reports whether a filter rule can be expressed directly in
|
|
// iptables. A port match requires a concrete port-carrying protocol (TCP/UDP/SCTP),
|
|
// and an ICMP type is only meaningful on an ICMP protocol, so both are rejected
|
|
// rather than silently emitting an invalid iptables-save line.
|
|
func iptablesRuleValid(r *Rule) error {
|
|
if r.PortNeedsConcreteProtocol() {
|
|
return fmt.Errorf("a port requires a tcp, udp, or sctp protocol")
|
|
}
|
|
if err := r.checkICMPType(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// quoteCommentToken double-quotes v for a comment/log-prefix token in an
|
|
// iptables-save-format rule line, escaping only backslash and double-quote so it
|
|
// round-trips through the shlex.Split reader (strconv.Quote is unusable: its
|
|
// \t/\n/\uXXXX escapes are not un-escaped by shlex). A literal newline or
|
|
// carriage return is rejected outright, since it would split the one-line rule.
|
|
func (f *IPTables) quoteCommentToken(v string) (string, error) {
|
|
if strings.ContainsAny(v, "\n\r") {
|
|
return "", fmt.Errorf("a comment cannot contain a newline")
|
|
}
|
|
var b strings.Builder
|
|
b.WriteByte('"')
|
|
for _, r := range v {
|
|
if r == '\\' || r == '"' {
|
|
b.WriteByte('\\')
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
b.WriteByte('"')
|
|
return b.String(), nil
|
|
}
|
|
|
|
// stateValue renders a conntrack state set as an upper-case comma list (e.g.
|
|
// "NEW,ESTABLISHED").
|
|
func (f *IPTables) stateValue(s ConnState) string {
|
|
names := s.Strings()
|
|
for i, n := range names {
|
|
names[i] = strings.ToUpper(n)
|
|
}
|
|
return strings.Join(names, ",")
|
|
}
|
|
|
|
// marshalMatches builds the iptables-save match tokens for a rule (everything
|
|
// up to but not including the `-j <target>`), including any rate/connection
|
|
// limit and the identifying comment. MarshalRule and the LOG-line encoder share
|
|
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
|
|
if err := iptablesRuleValid(r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Start with the APPEND command and the chain (INPUT, OUTPUT or FORWARD).
|
|
parts := []string{}
|
|
switch r.Direction {
|
|
case DirOutput:
|
|
parts = append(parts, "-A", "OUTPUT")
|
|
case DirForward:
|
|
parts = append(parts, "-A", "FORWARD")
|
|
default:
|
|
parts = append(parts, "-A", "INPUT")
|
|
}
|
|
|
|
// Add source and destination. A non-address token names an ipset, matched with
|
|
// `-m set --match-set` rather than `-s`/`-d`.
|
|
if r.Source != "" {
|
|
parts = append(parts, f.addrArgs(r.Source, "src")...)
|
|
}
|
|
if r.Destination != "" {
|
|
parts = append(parts, f.addrArgs(r.Destination, "dst")...)
|
|
}
|
|
|
|
// Interface match. `-i` is only valid on INPUT and `-o` only on OUTPUT; the
|
|
// FORWARD chain sees both an ingress and an egress interface, so it accepts
|
|
// either. Reject only the pairings the chain cannot express.
|
|
if r.IsOutput() && r.InInterface != "" {
|
|
return nil, fmt.Errorf("an input interface cannot be matched on an output rule")
|
|
}
|
|
if r.IsInput() && r.OutInterface != "" {
|
|
return nil, fmt.Errorf("an output interface cannot be matched on an input rule")
|
|
}
|
|
if r.InInterface != "" {
|
|
parts = append(parts, "-i", r.InInterface)
|
|
}
|
|
if r.OutInterface != "" {
|
|
parts = append(parts, "-o", r.OutInterface)
|
|
}
|
|
|
|
// Append protocol.
|
|
if r.Proto != ProtocolAny {
|
|
parts = append(parts, "-p", r.Proto.String())
|
|
}
|
|
|
|
// An ICMP type match uses the icmp/icmp6 match module.
|
|
if r.Proto.IsICMP() && r.ICMPType != nil {
|
|
if r.Proto == ICMPv6 {
|
|
parts = append(parts, "-m", "icmp6", "--icmpv6-type", strconv.Itoa(int(*r.ICMPType)))
|
|
} else {
|
|
parts = append(parts, "-m", "icmp", "--icmp-type", strconv.Itoa(int(*r.ICMPType)))
|
|
}
|
|
}
|
|
|
|
srcSpecs := r.SourcePortSpecs()
|
|
dstSpecs := r.PortSpecs()
|
|
|
|
// If source port(s) defined, add them. A concrete protocol is guaranteed above.
|
|
if len(srcSpecs) == 1 && srcSpecs[0].Start == srcSpecs[0].End {
|
|
parts = append(parts, "-m", r.Proto.String(), "--sport", strconv.FormatUint(uint64(srcSpecs[0].Start), 10))
|
|
} else if len(srcSpecs) > 0 {
|
|
parts = append(parts, "-m", "multiport", "--sports", iptMultiportValue(srcSpecs))
|
|
}
|
|
|
|
// If destination port(s) defined, add them.
|
|
if len(dstSpecs) == 1 && dstSpecs[0].Start == dstSpecs[0].End {
|
|
parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(dstSpecs[0].Start), 10))
|
|
} else if len(dstSpecs) > 0 {
|
|
parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(dstSpecs))
|
|
}
|
|
|
|
// Connection-tracking state match.
|
|
if r.State != 0 {
|
|
parts = append(parts, "-m", "conntrack", "--ctstate", f.stateValue(r.State))
|
|
}
|
|
|
|
// Rate limit: `-m limit` matches only while under the configured rate.
|
|
if r.RateLimit != nil {
|
|
parts = append(parts, "-m", "limit", "--limit", r.RateLimit.String())
|
|
if r.RateLimit.Burst > 0 {
|
|
parts = append(parts, "--limit-burst", strconv.FormatUint(uint64(r.RateLimit.Burst), 10))
|
|
}
|
|
}
|
|
|
|
// Connection limit: `-m connlimit` matches while the tracked count is over
|
|
// the limit. The default mask counts per source; a mask of 0 counts globally.
|
|
if r.ConnLimit != nil {
|
|
parts = append(parts, "-m", "connlimit", "--connlimit-above", strconv.FormatUint(uint64(r.ConnLimit.Count), 10))
|
|
if !r.ConnLimit.PerSource {
|
|
parts = append(parts, "--connlimit-mask", "0")
|
|
}
|
|
}
|
|
|
|
// Attach a comment. A user-supplied Comment is carried alongside the
|
|
// configured prefix (prefix + " " + comment) so rules this library creates
|
|
// stay identifiable; with no user comment the prefix alone tags the rule.
|
|
// The comment is not part of the rule identity, so it is ignored when
|
|
// comparing rules.
|
|
comment := combineComment(f.rulePrefix, r.Comment)
|
|
if comment != "" {
|
|
quoted, err := f.quoteCommentToken(comment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, "-m", "comment", "--comment", quoted)
|
|
}
|
|
|
|
return parts, nil
|
|
}
|
|
|
|
// MarshalRule encodes a rule as a single iptables-save rulespec ending in its
|
|
// action target.
|
|
func (f *IPTables) MarshalRule(r *Rule) (string, error) {
|
|
parts, err := f.marshalMatches(r)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts = append(parts, "-j", strings.ToUpper(r.Action.String()))
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// marshalLogLine encodes the LOG half of a logged rule: the same matches ending
|
|
// in a non-terminal LOG target carrying the optional prefix.
|
|
func (f *IPTables) marshalLogLine(r *Rule) (string, error) {
|
|
parts, err := f.marshalMatches(r)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts = append(parts, "-j", "LOG")
|
|
if r.LogPrefix != "" {
|
|
quoted, err := f.quoteCommentToken(r.LogPrefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts = append(parts, "--log-prefix", quoted)
|
|
}
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// marshalRuleLines returns the save-file lines representing r: a LOG line
|
|
// followed by the action line when r.Log is set (iptables cannot both log and
|
|
// take a terminal action in one rule), otherwise just the action line.
|
|
func (f *IPTables) marshalRuleLines(r *Rule) ([]string, error) {
|
|
action, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !r.Log {
|
|
return []string{action}, nil
|
|
}
|
|
logLine, err := f.marshalLogLine(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{logLine, action}, nil
|
|
}
|
|
|
|
// readAllLines reads every line of an iptables-save file.
|
|
func (f *IPTables) readAllLines(path string) ([]string, error) {
|
|
fd, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
var lines []string
|
|
scanner := bufio.NewScanner(fd)
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
return lines, scanner.Err()
|
|
}
|
|
|
|
// prepareInsertRuleFile is like prepareAddRuleFile but inserts the rule at the
|
|
// given 1-based position within its chain.
|
|
func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
existing, err := f.parseFilterFile(filePath, FamilyAny)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range existing {
|
|
if e.EqualBase(r, true) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
ruleLines, err := f.marshalRuleLines(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fd, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
|
|
af, err := newAtomicFile(filePath, 0644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Match the target chain by an exact chain-name compare, not a prefix: a
|
|
// foreign chain whose name merely starts with INPUT/OUTPUT (e.g. a firewalld
|
|
// "INPUT_direct" chain) must not be counted, or the 1-based position would
|
|
// diverge from the per-direction numbering GetRules reports.
|
|
expectedChain := iptChainForDirection(r.Direction)
|
|
|
|
// Precompute the 1-based logical position each in-chain line begins, mirroring
|
|
// GetRules' numbering (a LOG+action pair is one logical rule, an orphan LOG
|
|
// line is none). Indexing this as the write pass scans keeps the insert aligned
|
|
// with the position GetRules reports and never splits a logged rule's two lines.
|
|
allLines, err := f.readAllLines(filePath)
|
|
if err != nil {
|
|
af.Abort()
|
|
return nil, err
|
|
}
|
|
chainStarts := f.logicalStarts(f.chainRules(allLines, expectedChain))
|
|
chainIdx := 0
|
|
|
|
scanner := bufio.NewScanner(fd)
|
|
filterFound := false
|
|
writtenRule := false
|
|
writeRule := func() {
|
|
for _, l := range ruleLines {
|
|
_, _ = fmt.Fprintln(af, l)
|
|
}
|
|
writtenRule = true
|
|
}
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
if !filterFound {
|
|
if line == "*filter" {
|
|
filterFound = true
|
|
}
|
|
_, _ = fmt.Fprintln(af, line)
|
|
continue
|
|
}
|
|
|
|
if !writtenRule && f.chainOf(f.ruleLineBody(line)) == expectedChain {
|
|
pos := 0
|
|
if chainIdx < len(chainStarts) {
|
|
pos = chainStarts[chainIdx]
|
|
}
|
|
chainIdx++
|
|
if pos == position {
|
|
writeRule()
|
|
}
|
|
}
|
|
|
|
if !writtenRule && line == "COMMIT" {
|
|
writeRule()
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(af, line)
|
|
}
|
|
|
|
// A read error means the staged file is truncated; discard it rather than
|
|
// installing a partial ruleset.
|
|
if err := scanner.Err(); err != nil {
|
|
af.Abort()
|
|
return nil, err
|
|
}
|
|
|
|
if !writtenRule {
|
|
af.Abort()
|
|
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
|
}
|
|
|
|
return af, nil
|
|
}
|
|
|
|
// InsertRule inserts rule before the given 1-based position in the iptables save
|
|
// file. A non-positive position is treated as 1; a position larger than the
|
|
// current rule count appends the rule.
|
|
func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) {
|
|
return f.prepareInsertRuleFile(path, r, position)
|
|
})
|
|
}
|
|
|
|
// extractRuleLines returns the raw save-file lines belonging to the first rule
|
|
// equal to r, the index where they start, or a negative index when the rule is
|
|
// not present. It coalesces LOG+action lines for logged rules.
|
|
func (f *IPTables) extractRuleLines(lines []string, r *Rule) ([]string, int, error) {
|
|
var result []string
|
|
var resultIdx int
|
|
filterFound := false
|
|
var pendingLog string
|
|
var pendingIdx int
|
|
var pendingRule *Rule
|
|
flushPending := func() {
|
|
pendingRule = nil
|
|
}
|
|
|
|
for i, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
|
|
if !filterFound {
|
|
if line == "*filter" {
|
|
filterFound = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Leave filter scope at the table's COMMIT or the next table header. The save
|
|
// file is a full iptables-save dump, so *nat/*mangle also carry INPUT/OUTPUT
|
|
// chains; without this a foreign nat/mangle rule that parses would be extracted
|
|
// (and later removed from its real table and spliced into *filter) as if it
|
|
// were a filter rule. Mirrors prepareRemoveRuleFile's scoping.
|
|
if strings.HasPrefix(line, "*") || line == "COMMIT" {
|
|
flushPending()
|
|
if line != "*filter" {
|
|
filterFound = false
|
|
}
|
|
continue
|
|
}
|
|
|
|
if f.IgnoreLine(line) {
|
|
flushPending()
|
|
continue
|
|
}
|
|
|
|
rule, err := f.UnmarshalRule(line, FamilyAny)
|
|
if err != nil {
|
|
flushPending()
|
|
continue
|
|
}
|
|
|
|
if rule.Action == ActionInvalid && rule.Log {
|
|
flushPending()
|
|
pendingLog = raw
|
|
pendingIdx = i
|
|
pendingRule = rule
|
|
continue
|
|
}
|
|
|
|
logical := rule
|
|
coalesced := logPartner(pendingRule, rule)
|
|
if coalesced {
|
|
logical = mergeLogPair(pendingRule, rule)
|
|
}
|
|
|
|
if logical.EqualBase(r, true) {
|
|
// Only bundle the held LOG line when it is actually this rule's LOG
|
|
// partner (it coalesced). A standalone LOG line that did not coalesce is
|
|
// unrelated and must stay where it is, not be dragged with the rule.
|
|
if coalesced {
|
|
result = []string{pendingLog, raw}
|
|
resultIdx = pendingIdx
|
|
} else {
|
|
result = []string{raw}
|
|
resultIdx = i
|
|
}
|
|
return result, resultIdx, nil
|
|
}
|
|
flushPending()
|
|
}
|
|
flushPending()
|
|
return nil, -1, nil
|
|
}
|
|
|
|
// stageLines writes lines to a fresh atomicFile for path and returns it
|
|
// uncommitted, so a caller staging several family files can commit them together
|
|
// once all have been prepared.
|
|
func (f *IPTables) stageLines(path string, lines []string) (*atomicFile, error) {
|
|
af, err := newAtomicFile(path, 0644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w := bufio.NewWriter(af)
|
|
for _, l := range lines {
|
|
_, _ = fmt.Fprintln(w, l)
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
af.Abort()
|
|
return nil, err
|
|
}
|
|
return af, nil
|
|
}
|
|
|
|
// prepareMoveRuleFile removes the first matching rule and re-inserts it at the
|
|
// given 1-based position within its chain.
|
|
func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
lines, err := f.readAllLines(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Exact chain-name compare so a foreign chain whose name starts with
|
|
// INPUT/OUTPUT is not counted (see prepareInsertRuleFile).
|
|
expectedChain := iptChainForDirection(r.Direction)
|
|
|
|
extracted, removedIdx, err := f.extractRuleLines(lines, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if removedIdx < 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
without := make([]string, 0, len(lines)-len(extracted))
|
|
for i, l := range lines {
|
|
if i >= removedIdx && i < removedIdx+len(extracted) {
|
|
continue
|
|
}
|
|
without = append(without, l)
|
|
}
|
|
|
|
// Re-insert at the requested 1-based position. A position past the last rule in
|
|
// the chain falls through to the COMMIT branch below, which appends after the
|
|
// chain's last rule — so no explicit rule count or clamp is needed here (see
|
|
// prepareInsertRuleFile, which relies on the same COMMIT fallback).
|
|
// Precompute the 1-based logical position each in-chain line begins over the
|
|
// post-removal lines, mirroring GetRules' numbering so the re-inserted rule
|
|
// lands at the requested position and never between a LOG line and its action.
|
|
chainStarts := f.logicalStarts(f.chainRules(without, expectedChain))
|
|
chainIdx := 0
|
|
|
|
out := make([]string, 0, len(without)+len(extracted))
|
|
filterFound := false
|
|
inserted := false
|
|
for _, raw := range without {
|
|
line := strings.TrimSpace(raw)
|
|
|
|
if !filterFound {
|
|
if line == "*filter" {
|
|
filterFound = true
|
|
}
|
|
out = append(out, raw)
|
|
continue
|
|
}
|
|
|
|
if !inserted && f.chainOf(f.ruleLineBody(line)) == expectedChain {
|
|
pos := 0
|
|
if chainIdx < len(chainStarts) {
|
|
pos = chainStarts[chainIdx]
|
|
}
|
|
chainIdx++
|
|
if pos == position {
|
|
out = append(out, extracted...)
|
|
inserted = true
|
|
}
|
|
}
|
|
if !inserted && line == "COMMIT" {
|
|
out = append(out, extracted...)
|
|
inserted = true
|
|
}
|
|
out = append(out, raw)
|
|
}
|
|
|
|
if !inserted {
|
|
return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file")
|
|
}
|
|
|
|
return f.stageLines(filePath, out)
|
|
}
|
|
|
|
// MoveRule moves an existing rule to the given 1-based position within its chain.
|
|
func (f *IPTables) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
|
return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) {
|
|
return f.prepareMoveRuleFile(path, r, position)
|
|
})
|
|
}
|
|
|
|
// RemoveRule removes a rule from the zone.
|
|
func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
return f.applyRuleFiles(r, f.prepareRemoveRuleFile)
|
|
}
|
|
|
|
// parseNATTarget parses an iptables NAT target ("addr", "addr:port" or
|
|
// "[v6]:port") into its address and port.
|
|
func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
|
|
if strings.HasPrefix(tok, "[") {
|
|
if end := strings.Index(tok, "]"); end >= 0 {
|
|
addr = tok[1:end]
|
|
rest := tok[end+1:]
|
|
if strings.HasPrefix(rest, ":") {
|
|
if p, err := strconv.ParseUint(rest[1:], 10, 16); err == nil {
|
|
port = uint16(p)
|
|
}
|
|
}
|
|
return addr, port
|
|
}
|
|
}
|
|
if strings.Count(tok, ":") == 1 {
|
|
host, ps, _ := strings.Cut(tok, ":")
|
|
if p, err := strconv.ParseUint(ps, 10, 16); err == nil {
|
|
return host, uint16(p)
|
|
}
|
|
}
|
|
return tok, 0
|
|
}
|
|
|
|
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
|
|
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
|
|
tokens, err := shlex.Split(spec, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// An iptables-save line may carry a leading [pkts:bytes] counter prefix
|
|
// (iptables-save -c). NATRule has no counter fields, so just strip it before
|
|
// parsing — mirroring the filter parser so a counter-annotated save file's
|
|
// NAT rules are not silently dropped.
|
|
if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") {
|
|
tokens = tokens[1:]
|
|
}
|
|
if len(tokens) < 2 {
|
|
return nil, fmt.Errorf("unexpected token length")
|
|
}
|
|
|
|
r := &NATRule{Family: family}
|
|
switch tokens[1] {
|
|
case "PREROUTING", "POSTROUTING":
|
|
default:
|
|
// The NATRule model derives its chain from Kind (DNAT/Redirect => PREROUTING,
|
|
// SNAT/Masquerade => POSTROUTING) and has no direction field, so an OUTPUT-chain
|
|
// nat rule (locally-generated DNAT) cannot be represented distinctly — surfacing
|
|
// it would make MarshalNATRule relocate it to PREROUTING on Restore. Treat the
|
|
// OUTPUT chain (and any other) as foreign: skip it on read so it is left in place
|
|
// verbatim rather than moved (see managedNATChain).
|
|
return nil, fmt.Errorf("not a managed nat chain: %s", tokens[1])
|
|
}
|
|
|
|
i := 2
|
|
switch tokens[0] {
|
|
case "-A", "--append":
|
|
case "-I", "--insert":
|
|
if i < len(tokens) {
|
|
if _, err := strconv.Atoi(tokens[i]); err == nil {
|
|
i++
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported command provided")
|
|
}
|
|
|
|
not := false
|
|
for ; i < len(tokens); i++ {
|
|
switch tokens[i] {
|
|
case "!":
|
|
not = true
|
|
continue
|
|
case "-s", "--source":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid source parameter")
|
|
}
|
|
if not {
|
|
r.Source = "!" + tokens[i]
|
|
} else {
|
|
r.Source = tokens[i]
|
|
}
|
|
case "-d", "--destination":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid destination parameter")
|
|
}
|
|
if not {
|
|
r.Destination = "!" + tokens[i]
|
|
} else {
|
|
r.Destination = tokens[i]
|
|
}
|
|
case "-i", "--in-interface", "-o", "--out-interface":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid interface parameter")
|
|
}
|
|
r.Interface = tokens[i]
|
|
case "-p", "--protocol":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid protocol parameter")
|
|
}
|
|
r.Proto = GetProtocol(tokens[i])
|
|
case "--dport", "--destination-port":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid dport parameter")
|
|
}
|
|
pr, perr := ParsePortRange(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
case "-m", "--match":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
switch tokens[i] {
|
|
case "set":
|
|
// -m set [!] --match-set <name> src|dst names an ipset in place of an
|
|
// address (see the filter parser).
|
|
i++
|
|
setNot := false
|
|
if i < len(tokens) && tokens[i] == "!" {
|
|
setNot = true
|
|
i++
|
|
}
|
|
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
|
|
return nil, fmt.Errorf("unsupported set match")
|
|
}
|
|
name := tokens[i+1]
|
|
dir := tokens[i+2]
|
|
i += 2
|
|
if setNot {
|
|
name = "!" + name
|
|
}
|
|
switch dir {
|
|
case "src":
|
|
r.Source = name
|
|
case "dst":
|
|
r.Destination = name
|
|
default:
|
|
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
|
|
}
|
|
case "comment":
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--comment" {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
i += 2
|
|
// A NAT rule carries no user comment, only the prefix tag; its
|
|
// presence marks the rule as one this library tagged.
|
|
if _, hasPrefix := prefixedComment(f.rulePrefix, tokens[i]); hasPrefix {
|
|
r.HasPrefix = true
|
|
}
|
|
case "tcp", "udp", "sctp":
|
|
if i+2 < len(tokens) && (tokens[i+1] == "--dport" || tokens[i+1] == "--destination-port") {
|
|
i += 2
|
|
pr, perr := ParsePortRange(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
}
|
|
case "multiport":
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid multiport match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "--dports", "--dport", "--ports", "--port":
|
|
default:
|
|
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
|
|
}
|
|
i += 2
|
|
specs, perr := iptParsePorts(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
r.Port = specs[0].Start
|
|
} else {
|
|
r.Ports = specs
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported match option: %s", tokens[i])
|
|
}
|
|
case "-j", "--jump":
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid jump parameter")
|
|
}
|
|
switch tokens[i] {
|
|
case "DNAT":
|
|
r.Kind = DNAT
|
|
if i+2 < len(tokens) && tokens[i+1] == "--to-destination" {
|
|
i += 2
|
|
r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i])
|
|
}
|
|
case "REDIRECT":
|
|
r.Kind = Redirect
|
|
if i+2 < len(tokens) && tokens[i+1] == "--to-ports" {
|
|
i += 2
|
|
p, perr := strconv.ParseUint(tokens[i], 10, 16)
|
|
if perr != nil {
|
|
return nil, fmt.Errorf("invalid redirect port %q", tokens[i])
|
|
}
|
|
r.ToPort = uint16(p)
|
|
}
|
|
case "SNAT":
|
|
r.Kind = SNAT
|
|
if i+2 < len(tokens) && tokens[i+1] == "--to-source" {
|
|
i += 2
|
|
r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i])
|
|
}
|
|
case "MASQUERADE":
|
|
r.Kind = Masquerade
|
|
default:
|
|
return nil, fmt.Errorf("unsupported nat target: %s", tokens[i])
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
|
|
}
|
|
not = false
|
|
}
|
|
|
|
if r.Kind == NATInvalid {
|
|
return nil, fmt.Errorf("no nat action was provided")
|
|
}
|
|
if r.Family == FamilyAny {
|
|
r.Family = r.impliedFamily()
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// fileFamily returns the IP family of one of this backend's save files.
|
|
func (f *IPTables) fileFamily(path string) Family {
|
|
if path == f.IP6Path {
|
|
return IPv6
|
|
}
|
|
return IPv4
|
|
}
|
|
|
|
// natRulesInFile parses the nat-table rules from a save file.
|
|
func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
family := f.fileFamily(path)
|
|
var rules []*NATRule
|
|
inNat := false
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "*nat" {
|
|
inNat = true
|
|
continue
|
|
}
|
|
if !inNat {
|
|
continue
|
|
}
|
|
if line == "COMMIT" {
|
|
inNat = false
|
|
continue
|
|
}
|
|
if f.IgnoreLine(line) {
|
|
continue
|
|
}
|
|
nr, err := f.UnmarshalNATRule(line, family)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rules = append(rules, nr)
|
|
}
|
|
// Return the rules; GetNATRules assigns Number after merging families across the
|
|
// two save files (the other callers use the result only for EqualBase dedup).
|
|
return rules, nil
|
|
}
|
|
|
|
// GetNATRules returns the existing NAT rules from the zone.
|
|
func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
|
|
v4, err := f.natRulesInFile(f.IP4Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
|
|
}
|
|
rules = append(rules, v4...)
|
|
v6, err := f.natRulesInFile(f.IP6Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
|
|
}
|
|
rules = append(rules, v6...)
|
|
// Merge families, then renumber per nat chain so a collapsed v4/v6 pair leaves
|
|
// no gap in the derived Number sequence.
|
|
merged := mergeNATFamilies(rules)
|
|
numberNATByChain(merged)
|
|
return merged, nil
|
|
}
|
|
|
|
// natChain returns the nat-table chain a NAT rule belongs in.
|
|
func (f *IPTables) natChain(r *NATRule) string {
|
|
if r.Kind.isSource() {
|
|
return "POSTROUTING"
|
|
}
|
|
return "PREROUTING"
|
|
}
|
|
|
|
// natTarget renders an iptables NAT translation target "addr" or "addr:port",
|
|
// bracketing an IPv6 address when a port is present.
|
|
func natTarget(fam Family, addr string, port uint16) string {
|
|
if port == 0 {
|
|
return addr
|
|
}
|
|
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
|
|
return fmt.Sprintf("[%s]:%d", addr, port)
|
|
}
|
|
return fmt.Sprintf("%s:%d", addr, port)
|
|
}
|
|
|
|
// MarshalNATRule encodes a NAT rule as an iptables-save rulespec for the nat
|
|
// table (e.g. `-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination ...`).
|
|
func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) {
|
|
if err := r.validate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
fam := r.impliedFamily()
|
|
parts := []string{"-A", f.natChain(r)}
|
|
|
|
if r.Source != "" {
|
|
parts = append(parts, f.addrArgs(r.Source, "src")...)
|
|
}
|
|
if r.Destination != "" {
|
|
parts = append(parts, f.addrArgs(r.Destination, "dst")...)
|
|
}
|
|
|
|
// Interface, bound to the translation direction.
|
|
if r.Interface != "" {
|
|
if r.Kind.isSource() {
|
|
parts = append(parts, "-o", r.Interface)
|
|
} else {
|
|
parts = append(parts, "-i", r.Interface)
|
|
}
|
|
}
|
|
|
|
if r.Proto != ProtocolAny {
|
|
parts = append(parts, "-p", r.Proto.String())
|
|
}
|
|
|
|
specs := r.PortSpecs()
|
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
|
parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(specs[0].Start), 10))
|
|
} else if len(specs) > 0 {
|
|
parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(specs))
|
|
}
|
|
|
|
if f.rulePrefix != "" {
|
|
quoted, err := f.quoteCommentToken(f.rulePrefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts = append(parts, "-m", "comment", "--comment", quoted)
|
|
}
|
|
|
|
switch r.Kind {
|
|
case DNAT:
|
|
parts = append(parts, "-j", "DNAT", "--to-destination", natTarget(fam, r.ToAddress, r.ToPort))
|
|
case Redirect:
|
|
parts = append(parts, "-j", "REDIRECT", "--to-ports", strconv.FormatUint(uint64(r.ToPort), 10))
|
|
case SNAT:
|
|
parts = append(parts, "-j", "SNAT", "--to-source", natTarget(fam, r.ToAddress, r.ToPort))
|
|
case Masquerade:
|
|
parts = append(parts, "-j", "MASQUERADE")
|
|
default:
|
|
return "", fmt.Errorf("invalid nat kind")
|
|
}
|
|
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// writeAllLines atomically replaces path with the provided lines, preserving
|
|
// the existing file's mode and ownership.
|
|
func (f *IPTables) writeAllLines(path string, lines []string) error {
|
|
af, err := newAtomicFile(path, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer af.Abort()
|
|
w := bufio.NewWriter(af)
|
|
for _, l := range lines {
|
|
_, _ = fmt.Fprintln(w, l)
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
return af.Commit()
|
|
}
|
|
|
|
// editNATFile inserts or removes a NAT rule line within a save file's nat table,
|
|
// creating the table section when adding to a file that lacks one.
|
|
func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) error {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Locate the nat section and its COMMIT.
|
|
natStart, commitIdx := -1, -1
|
|
inNat := false
|
|
for i, raw := range lines {
|
|
t := strings.TrimSpace(raw)
|
|
if t == "*nat" {
|
|
natStart = i
|
|
inNat = true
|
|
continue
|
|
}
|
|
if inNat && t == "COMMIT" {
|
|
commitIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if add {
|
|
// Skip if an equivalent rule already exists.
|
|
existing, err := f.natRulesInFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range existing {
|
|
if e.EqualBase(r) {
|
|
return nil
|
|
}
|
|
}
|
|
if natStart == -1 || commitIdx == -1 {
|
|
// No nat table yet; append a fresh one carrying the rule.
|
|
section := make([]string, 0, len(defaultNATSection)+1)
|
|
section = append(section, defaultNATSection[:len(defaultNATSection)-1]...)
|
|
section = append(section, line, "COMMIT")
|
|
lines = append(lines, section...)
|
|
return f.writeAllLines(path, lines)
|
|
}
|
|
// Insert before COMMIT.
|
|
updated := make([]string, 0, len(lines)+1)
|
|
updated = append(updated, lines[:commitIdx]...)
|
|
updated = append(updated, line)
|
|
updated = append(updated, lines[commitIdx:]...)
|
|
return f.writeAllLines(path, updated)
|
|
}
|
|
|
|
// Removal: drop the matching nat rule line.
|
|
if natStart == -1 || commitIdx == -1 {
|
|
return nil
|
|
}
|
|
updated := make([]string, 0, len(lines))
|
|
removed := false
|
|
inNat = false
|
|
for _, raw := range lines {
|
|
t := strings.TrimSpace(raw)
|
|
if t == "*nat" {
|
|
inNat = true
|
|
updated = append(updated, raw)
|
|
continue
|
|
}
|
|
if inNat && t == "COMMIT" {
|
|
inNat = false
|
|
updated = append(updated, raw)
|
|
continue
|
|
}
|
|
if inNat && !removed && !f.IgnoreLine(t) {
|
|
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
|
|
removed = true
|
|
continue
|
|
}
|
|
}
|
|
updated = append(updated, raw)
|
|
}
|
|
if !removed {
|
|
return nil
|
|
}
|
|
return f.writeAllLines(path, updated)
|
|
}
|
|
|
|
// natPaths returns the save files a NAT rule applies to, per its family.
|
|
func (f *IPTables) natPaths(r *NATRule) []string {
|
|
fam := r.impliedFamily()
|
|
var paths []string
|
|
if fam == IPv4 || fam == FamilyAny {
|
|
paths = append(paths, f.IP4Path)
|
|
}
|
|
if fam == IPv6 || fam == FamilyAny {
|
|
paths = append(paths, f.IP6Path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// defaultNATSection is the nat table scaffold written when a save file has none.
|
|
var defaultNATSection = []string{
|
|
"*nat",
|
|
":PREROUTING ACCEPT [0:0]",
|
|
":INPUT ACCEPT [0:0]",
|
|
":OUTPUT ACCEPT [0:0]",
|
|
":POSTROUTING ACCEPT [0:0]",
|
|
"COMMIT",
|
|
}
|
|
|
|
// AddNATRule adds a NAT rule to the zone.
|
|
func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
line, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, path := range f.natPaths(r) {
|
|
if err := f.editNATFile(path, r, line, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// insertNATFile inserts a NAT rule line at the given 1-based position within its
|
|
// nat chain, creating the table section when the file lacks one. Position counts
|
|
// only lines in the rule's own chain (PREROUTING or POSTROUTING); a non-positive
|
|
// position is treated as 1 and a position past the chain's end appends after the
|
|
// chain's last rule.
|
|
func (f *IPTables) insertNATFile(path string, r *NATRule, line string, position int) error {
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
// Skip if an equivalent rule already exists.
|
|
existing, err := f.natRulesInFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range existing {
|
|
if e.EqualBase(r) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Locate the nat section and its COMMIT.
|
|
natStart, commitIdx := -1, -1
|
|
inNat := false
|
|
for i, raw := range lines {
|
|
t := strings.TrimSpace(raw)
|
|
if t == "*nat" {
|
|
natStart = i
|
|
inNat = true
|
|
continue
|
|
}
|
|
if inNat && t == "COMMIT" {
|
|
commitIdx = i
|
|
break
|
|
}
|
|
}
|
|
if natStart == -1 || commitIdx == -1 {
|
|
// No nat table yet; append a fresh one carrying the rule.
|
|
section := make([]string, 0, len(defaultNATSection)+1)
|
|
section = append(section, defaultNATSection[:len(defaultNATSection)-1]...)
|
|
section = append(section, line, "COMMIT")
|
|
lines = append(lines, section...)
|
|
return f.writeAllLines(path, lines)
|
|
}
|
|
|
|
// Find the insertion index: before the position-th rule in the rule's chain,
|
|
// or after the chain's last rule when position runs past the end. Default to
|
|
// COMMIT so a chain with no existing rules still lands inside the nat table.
|
|
// Exact chain-name compare so a foreign chain whose name starts with the
|
|
// target chain (e.g. "PREROUTING_direct") is not counted (see
|
|
// prepareInsertRuleFile).
|
|
chainName := f.natChain(r)
|
|
insertAt := commitIdx
|
|
lastChainLine := -1
|
|
pos := 0
|
|
for i := natStart + 1; i < commitIdx; i++ {
|
|
t := strings.TrimSpace(lines[i])
|
|
if f.chainOf(f.ruleLineBody(t)) == chainName {
|
|
lastChainLine = i
|
|
pos++
|
|
if pos == position {
|
|
insertAt = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if pos < position && lastChainLine >= 0 {
|
|
insertAt = lastChainLine + 1
|
|
}
|
|
|
|
updated := make([]string, 0, len(lines)+1)
|
|
updated = append(updated, lines[:insertAt]...)
|
|
updated = append(updated, line)
|
|
updated = append(updated, lines[insertAt:]...)
|
|
return f.writeAllLines(path, updated)
|
|
}
|
|
|
|
// InsertNATRule inserts a NAT rule at the given 1-based position within its nat
|
|
// chain. A non-positive position is treated as 1; a position larger than the
|
|
// chain's current rule count appends the rule.
|
|
func (f *IPTables) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
|
line, err := f.MarshalNATRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, path := range f.natPaths(r) {
|
|
if err := f.insertNATFile(path, r, line, position); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveNATRule removes a NAT rule from the zone.
|
|
func (f *IPTables) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
for _, path := range f.natPaths(r) {
|
|
if err := f.editNATFile(path, r, "", false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Backup captures the current filter and NAT rules managed by this backend.
|
|
func (f *IPTables) 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 INPUT/OUTPUT/FORWARD filter rules, the nat rules, the
|
|
// filter chain default policies and the managed ipsets; Restore replaces exactly
|
|
// those on replay, leaving user-defined chains and other tables (which Backup
|
|
// does not capture) intact.
|
|
backup := &Backup{Rules: rules, NATRules: natRules}
|
|
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// modeledFilterChain reports whether a *filter chain name is one the library
|
|
// models as a Rule direction (INPUT, OUTPUT or FORWARD). The file-rewrite paths
|
|
// use it to tell a managed rule from a rule in a chain the library does not model
|
|
// (a user-defined chain), which must be preserved verbatim.
|
|
func (f *IPTables) modeledFilterChain(ch string) bool {
|
|
switch ch {
|
|
case "INPUT", "OUTPUT", "FORWARD":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// preservedFilterLines returns the indices of modeled-chain (INPUT/OUTPUT/
|
|
// FORWARD) -A lines a rewrite must keep verbatim because GetRules cannot represent
|
|
// them as a modeled Rule, so they never appear in the desired set and would
|
|
// otherwise be dropped. Two kinds qualify, on the same principle the library
|
|
// already applies to a foreign chain (a user-defined chain): a rule the library
|
|
// does not model, so it must not be deleted just because it is invisible.
|
|
// - A line the rule parser rejects outright — a foreign rule using a match this
|
|
// library does not model (e.g. -m recent, -m owner, --tcp-flags).
|
|
// - A standalone LOG rule — a non-terminal `-j LOG` line with no action partner
|
|
// immediately after it. GetRules coalesces a LOG line with its following
|
|
// action line into one logged rule and drops an unpaired one, so it too is
|
|
// unmodeled. The pairing mirrors coalesceLoggedRules over the same INPUT/OUTPUT
|
|
// sequence parseFilterFile builds.
|
|
//
|
|
// Rules in other chains are excluded here; the caller preserves those verbatim by
|
|
// its own chain check.
|
|
func (f *IPTables) preservedFilterLines(lines []string) map[int]bool {
|
|
type parsed struct {
|
|
idx int
|
|
rule *Rule // nil when the line does not parse as a modeled rule.
|
|
}
|
|
var seq []parsed
|
|
inFilter := false
|
|
for i, line := range lines {
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(t, "*") {
|
|
inFilter = t == "*filter"
|
|
continue
|
|
}
|
|
body := f.ruleLineBody(t)
|
|
if !inFilter || !strings.HasPrefix(body, "-A ") {
|
|
continue
|
|
}
|
|
// Only modeled-chain lines are decided here; the caller keeps every other
|
|
// chain verbatim, so recording them would double-preserve.
|
|
if !f.modeledFilterChain(f.chainOf(body)) {
|
|
continue
|
|
}
|
|
r, err := f.UnmarshalRule(t, FamilyAny)
|
|
if err != nil {
|
|
r = nil // Unmodeled foreign rule: preserve it.
|
|
}
|
|
seq = append(seq, parsed{i, r})
|
|
}
|
|
preserved := map[int]bool{}
|
|
for k, p := range seq {
|
|
// A line the parser rejects is a foreign rule the desired set cannot
|
|
// reproduce; keep it.
|
|
if p.rule == nil {
|
|
preserved[p.idx] = true
|
|
continue
|
|
}
|
|
if p.rule.Action != ActionInvalid || !p.rule.Log {
|
|
continue
|
|
}
|
|
// A LOG line paired with the action line that follows it is a coalesced
|
|
// logged rule the desired set reproduces, so it is not preserved here.
|
|
var next *Rule
|
|
if k+1 < len(seq) {
|
|
next = seq[k+1].rule
|
|
}
|
|
if logPartner(p.rule, next) {
|
|
continue
|
|
}
|
|
preserved[p.idx] = true
|
|
}
|
|
return preserved
|
|
}
|
|
|
|
// rewriteFilterRules atomically rewrites path so that the *filter table's rule
|
|
// (-A) lines are exactly ruleLines, leaving the chain-policy lines, any *nat
|
|
// table and all other content untouched. A file with no *filter table gains one.
|
|
func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
preserved := f.preservedFilterLines(lines)
|
|
out := make([]string, 0, len(lines)+len(ruleLines))
|
|
inFilter := false
|
|
inserted := false
|
|
for idx, line := range lines {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "*"):
|
|
inFilter = t == "*filter"
|
|
out = append(out, line)
|
|
case inFilter && t == "COMMIT":
|
|
if !inserted {
|
|
out = append(out, ruleLines...)
|
|
inserted = true
|
|
}
|
|
out = append(out, line)
|
|
inFilter = false
|
|
case inFilter && strings.HasPrefix(f.ruleLineBody(t), "-A "):
|
|
// The library models the INPUT, OUTPUT and FORWARD chains, so the desired
|
|
// set can only ever contain those. Drop an existing modeled rule line
|
|
// (counter-annotated or not) — the desired set replaces it — but preserve
|
|
// a rule in any other chain (a user-defined chain) verbatim, since
|
|
// parseFilterFile never captures those chains and dropping them would
|
|
// silently delete rules the library does not manage. A modeled-chain line
|
|
// the library cannot model (an unmodeled match or a standalone LOG rule)
|
|
// is likewise invisible to GetRules and preserved.
|
|
if !f.modeledFilterChain(f.chainOf(f.ruleLineBody(t))) {
|
|
out = append(out, line)
|
|
} else if preserved[idx] {
|
|
out = append(out, line)
|
|
}
|
|
default:
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
if !inserted {
|
|
out = append(out, "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]")
|
|
out = append(out, ruleLines...)
|
|
out = append(out, "COMMIT")
|
|
}
|
|
return f.writeAllLines(path, out)
|
|
}
|
|
|
|
// managedNATChain reports whether a nat-table chain is one this backend reads
|
|
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
|
|
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
|
|
// locally-generated DNAT the NATRule model cannot represent distinctly (see
|
|
func (f *IPTables) managedNATChain(chain string) bool {
|
|
switch chain {
|
|
case "PREROUTING", "POSTROUTING":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
|
|
// the managed chains are exactly natLines, leaving the chain-policy lines, any
|
|
// user-defined nat chain, the *filter table and all other content untouched. A
|
|
// file with no *nat table gains one. It is the nat counterpart of
|
|
func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := make([]string, 0, len(lines)+len(natLines))
|
|
inNat := false
|
|
inserted := false
|
|
for _, line := range lines {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "*"):
|
|
inNat = t == "*nat"
|
|
out = append(out, line)
|
|
case inNat && t == "COMMIT":
|
|
if !inserted {
|
|
out = append(out, natLines...)
|
|
inserted = true
|
|
}
|
|
out = append(out, line)
|
|
inNat = false
|
|
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
|
|
// Drop an existing rule in a managed chain — the desired set replaces it —
|
|
// but preserve a rule in a user-defined nat chain verbatim, mirroring how
|
|
// rewriteFilterRules preserves FORWARD/custom-chain rules.
|
|
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) {
|
|
out = append(out, line)
|
|
}
|
|
default:
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
if !inserted {
|
|
out = append(out, defaultNATSection[:len(defaultNATSection)-1]...)
|
|
out = append(out, natLines...)
|
|
out = append(out, "COMMIT")
|
|
}
|
|
return f.writeAllLines(path, out)
|
|
}
|
|
|
|
// Restore replaces the managed INPUT/OUTPUT/FORWARD filter rules and the nat rules
|
|
// with the contents of a Backup, splicing them into each family's existing save
|
|
// file, and re-asserts the captured filter chain policies and ipsets. User-defined
|
|
// chains and the *mangle/*raw tables — none of which Backup captures — are left
|
|
// untouched.
|
|
func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
|
if backup == nil {
|
|
return fmt.Errorf("backup cannot be nil")
|
|
}
|
|
|
|
// Recreate the ipsets first so a set-referencing rule (@set) resolves when the
|
|
// save files are loaded below. The old rules are still loaded at this point, so
|
|
// the sets cannot be removed out from under them; AddAddressSet (ipset -exist)
|
|
// creates or repopulates each set idempotently.
|
|
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Group rules by family.
|
|
groupRules := func() map[Family][]*Rule {
|
|
m := map[Family][]*Rule{}
|
|
for _, r := range backup.Rules {
|
|
fam := r.impliedFamily()
|
|
if fam == FamilyAny {
|
|
m[IPv4] = append(m[IPv4], r)
|
|
m[IPv6] = append(m[IPv6], r)
|
|
} else {
|
|
m[fam] = append(m[fam], r)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
groupNAT := func() map[Family][]*NATRule {
|
|
m := map[Family][]*NATRule{}
|
|
for _, r := range backup.NATRules {
|
|
fam := r.impliedFamily()
|
|
if fam == FamilyAny {
|
|
m[IPv4] = append(m[IPv4], r)
|
|
m[IPv6] = append(m[IPv6], r)
|
|
} else {
|
|
m[fam] = append(m[fam], r)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
for _, fam := range []Family{IPv4, IPv6} {
|
|
path := f.IP4Path
|
|
if fam == IPv6 {
|
|
path = f.IP6Path
|
|
}
|
|
|
|
// Marshal only the rule (-A) lines; rewriteFilterRules/rewriteNATRules splice
|
|
// them into the existing save file, replacing the managed chains' rules while
|
|
// preserving chain-policy lines, user-defined chains, and the *mangle/*raw
|
|
// tables that Backup never captures. A from-scratch scaffold would silently
|
|
// reset a DROP policy to ACCEPT and delete every unmanaged rule.
|
|
var ruleLines []string
|
|
for _, r := range groupRules()[fam] {
|
|
c := *r
|
|
if c.Family == FamilyAny {
|
|
c.Family = fam
|
|
}
|
|
rl, err := f.marshalRuleLines(&c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ruleLines = append(ruleLines, rl...)
|
|
}
|
|
|
|
var natLines []string
|
|
for _, r := range groupNAT()[fam] {
|
|
c := *r
|
|
if c.Family == FamilyAny {
|
|
c.Family = fam
|
|
}
|
|
rl, err := f.MarshalNATRule(&c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
natLines = append(natLines, rl)
|
|
}
|
|
|
|
if err := f.rewriteFilterRules(path, ruleLines); err != nil {
|
|
return err
|
|
}
|
|
if err := f.rewriteNATRules(path, natLines); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Re-assert the captured filter chain policies last, so a restore onto a host
|
|
// whose default policy differs (e.g. a fresh ACCEPT host) reproduces the backed-
|
|
// up policy rather than silently inheriting the current one.
|
|
return applyBackupPolicy(ctx, f, zoneName, backup)
|
|
}
|
|
|
|
// parsePolicyLine decodes a `:CHAIN POLICY [counters]` chain declaration.
|
|
func (f *IPTables) parsePolicyLine(line string) (chain string, action Action, ok bool) {
|
|
t := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(t, ":") {
|
|
return "", 0, false
|
|
}
|
|
fields := strings.Fields(t)
|
|
if len(fields) < 2 {
|
|
return "", 0, false
|
|
}
|
|
switch fields[1] {
|
|
case "ACCEPT":
|
|
action = Accept
|
|
case "DROP":
|
|
action = Drop
|
|
default:
|
|
return "", 0, false
|
|
}
|
|
return strings.TrimPrefix(fields[0], ":"), action, true
|
|
}
|
|
|
|
// policyFromFile reads the INPUT/OUTPUT/FORWARD chain policies from an
|
|
// iptables-save file. A direction whose chain line is absent is reported as
|
|
// ActionInvalid.
|
|
func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := &DefaultPolicy{}
|
|
// Only the *filter table carries the input/output/forward policy. The other
|
|
// tables (*nat, *mangle, *raw, ...) declare their own :INPUT/:OUTPUT built-in
|
|
// chains — *nat's is always ACCEPT (iptables rejects any other policy there),
|
|
// while *mangle/*raw can carry any policy but are not filtering tables — and
|
|
// iptables-save emits them after *filter, so scanning table-agnostically would
|
|
// let one of those chains shadow a hardened filter policy (e.g. report
|
|
// Input=Accept when filter INPUT is DROP). Track the table.
|
|
inFilter := false
|
|
for _, raw := range lines {
|
|
if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") {
|
|
inFilter = t == "*filter"
|
|
continue
|
|
}
|
|
if !inFilter {
|
|
continue
|
|
}
|
|
chain, action, ok := f.parsePolicyLine(raw)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch chain {
|
|
case "INPUT":
|
|
p.Input = action
|
|
case "OUTPUT":
|
|
p.Output = action
|
|
case "FORWARD":
|
|
p.Forward = action
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// GetDefaultPolicy returns the default action applied to packets that match no rule.
|
|
func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
|
v4, err := f.policyFromFile(f.IP4Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v6, err := f.policyFromFile(f.IP6Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// SetDefaultPolicy writes both families identically, so on a host this library
|
|
// manages they always agree. A divergence means the IPv4 and IPv6 chain
|
|
// policies were set out of band and there is no single policy to report.
|
|
if *v4 != *v6 {
|
|
return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6)
|
|
}
|
|
return v4, nil
|
|
}
|
|
|
|
// setPolicyFile rewrites the chain declaration lines in an iptables-save
|
|
// file for the directions named in policy, preserving the counter slots.
|
|
func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error {
|
|
lines, err := f.readAllLines(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated := make([]string, len(lines))
|
|
// Only rewrite policy lines inside the *filter table; the other tables
|
|
// (nat/mangle/raw/...) declare their own built-in chains — nat's must stay
|
|
// ACCEPT (iptables rejects any other policy there), and mangle/raw are not
|
|
// filtering tables regardless — so leave them all untouched.
|
|
inFilter := false
|
|
for i, raw := range lines {
|
|
if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") {
|
|
inFilter = t == "*filter"
|
|
updated[i] = raw
|
|
continue
|
|
}
|
|
chain, _, ok := f.parsePolicyLine(raw)
|
|
if !ok || !inFilter {
|
|
updated[i] = raw
|
|
continue
|
|
}
|
|
var action Action
|
|
switch chain {
|
|
case "INPUT":
|
|
action = policy.Input
|
|
case "OUTPUT":
|
|
action = policy.Output
|
|
case "FORWARD":
|
|
action = policy.Forward
|
|
default:
|
|
updated[i] = raw
|
|
continue
|
|
}
|
|
if action == ActionInvalid {
|
|
updated[i] = raw
|
|
continue
|
|
}
|
|
fields := strings.Fields(raw)
|
|
counters := "[0:0]"
|
|
if len(fields) >= 3 {
|
|
counters = fields[2]
|
|
}
|
|
updated[i] = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters)
|
|
}
|
|
return f.writeAllLines(path, updated)
|
|
}
|
|
|
|
// SetDefaultPolicy sets the default action for the directions named in policy.
|
|
func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
|
if policy == nil {
|
|
return fmt.Errorf("policy cannot be nil")
|
|
}
|
|
for _, action := range []Action{policy.Input, policy.Output, policy.Forward} {
|
|
if action == Reject {
|
|
return fmt.Errorf("iptables chain policy may only be accept or drop")
|
|
}
|
|
}
|
|
for _, path := range []string{f.IP4Path, f.IP6Path} {
|
|
if err := f.setPolicyFile(path, policy); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- address sets (ipset) ---------------------------------------------------
|
|
|
|
// ipsetParseType reads the family and type out of an ipset `create` line's
|
|
// trailing options.
|
|
func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
|
|
family := IPv4
|
|
t := SetHashIP
|
|
for i := 2; i < len(fields); i++ {
|
|
switch fields[i] {
|
|
case "hash:net":
|
|
t = SetHashNet
|
|
case "hash:ip":
|
|
t = SetHashIP
|
|
case "family":
|
|
if i+1 < len(fields) && fields[i+1] == "inet6" {
|
|
family = IPv6
|
|
}
|
|
}
|
|
}
|
|
return family, t
|
|
}
|
|
|
|
// GetAddressSets returns the address sets managed by this backend.
|
|
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|
out, err := runCommand(ctx, "ipset", "save")
|
|
if err != nil {
|
|
// ipset not installed, or no sets: nothing to report.
|
|
return nil, nil
|
|
}
|
|
sets := map[string]*AddressSet{}
|
|
var names []string
|
|
for _, line := range out {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 3 && fields[0] == "create" {
|
|
family, t := f.ipsetParseType(fields)
|
|
sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t}
|
|
names = append(names, fields[1])
|
|
}
|
|
}
|
|
for _, line := range out {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 3 && fields[0] == "add" {
|
|
if set, ok := sets[fields[1]]; ok {
|
|
set.Entries = append(set.Entries, fields[2])
|
|
}
|
|
}
|
|
}
|
|
result := make([]*AddressSet, 0, len(names))
|
|
for _, n := range names {
|
|
result = append(result, sets[n])
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetAddressSet returns a single address set by name, or an error if it does not exist.
|
|
func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
sets, err := f.GetAddressSets(ctx)
|
|
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)
|
|
}
|
|
|
|
// ipsetTypeSpec renders the ipset type keyword and family option for a set.
|
|
func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string {
|
|
spec := t.String()
|
|
fam := "inet"
|
|
if family == IPv6 {
|
|
fam = "inet6"
|
|
}
|
|
return spec + " family " + fam
|
|
}
|
|
|
|
// AddAddressSet creates an address set; adding a set that already exists by name is a no-op.
|
|
func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|
if set == nil || set.Name == "" {
|
|
return fmt.Errorf("an address set requires a name")
|
|
}
|
|
// -exist makes create idempotent (re-create over an existing set).
|
|
family := set.Family
|
|
if family == FamilyAny {
|
|
family = IPv4
|
|
}
|
|
args := []string{"create", set.Name}
|
|
args = append(args, strings.Fields(f.ipsetTypeSpec(family, set.Type))...)
|
|
args = append(args, "-exist")
|
|
if _, err := runCommand(ctx, "ipset", args...); err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range set.Entries {
|
|
if _, err := runCommand(ctx, "ipset", "add", set.Name, entry, "-exist"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return f.persistIPSets(ctx)
|
|
}
|
|
|
|
// RemoveAddressSet removes an address set by name.
|
|
func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error {
|
|
if _, err := runCommand(ctx, "ipset", "flush", name); err != nil {
|
|
// A missing set is a no-op; any other flush failure (permission denied,
|
|
// set busy) is real and must be surfaced rather than silently proceeding
|
|
// to destroy.
|
|
if !strings.Contains(err.Error(), "does not exist") {
|
|
return err
|
|
}
|
|
}
|
|
_, err := runCommand(ctx, "ipset", "destroy", name)
|
|
// A set that was already gone makes removal idempotent. Every other failure —
|
|
// notably "Set cannot be destroyed: it is in use by a kernel component" when a
|
|
// live rule still references the set — is real and must be surfaced rather than
|
|
// reported as success while the set remains.
|
|
if err != nil && !strings.Contains(err.Error(), "does not exist") {
|
|
return err
|
|
}
|
|
return f.persistIPSets(ctx)
|
|
}
|
|
|
|
// AddAddressSetEntry adds an entry to the named set.
|
|
func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
if _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist"); err != nil {
|
|
return err
|
|
}
|
|
return f.persistIPSets(ctx)
|
|
}
|
|
|
|
// RemoveAddressSetEntry removes an entry from the named set.
|
|
func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
_, err := runCommand(ctx, "ipset", "del", name, entry, "-exist")
|
|
// A missing entry (or missing set) makes removal idempotent; any other failure
|
|
// is real. Persist the resulting set state on success or a no-op removal.
|
|
if err != nil && !strings.Contains(err.Error(), "does not exist") {
|
|
return err
|
|
}
|
|
return f.persistIPSets(ctx)
|
|
}
|
|
|
|
// persistIPSets writes the live ipsets into the layout's save file so a reboot
|
|
// restores them before the iptables rules that reference them. When the restore
|
|
// unit is installed but not enabled it is enabled first, so it runs on boot.
|
|
// When no persistence mechanism is present the sets are left live-only and a
|
|
// warning is logged rather than returning an error — the set itself was already
|
|
// created live, and the caller asked to add a set, not to guarantee reboot
|
|
// persistence the host cannot provide.
|
|
func (f *IPTables) persistIPSets(ctx context.Context) error {
|
|
if f.IPSetPath == "" {
|
|
log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot")
|
|
return nil
|
|
}
|
|
// Ensure the restore unit runs on boot before the rules unit loads.
|
|
if f.IPSetService != "" {
|
|
if err := f.ensureUnitEnabled(ctx, f.IPSetService); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Save every live set (foreign sets included: the library persists the actual
|
|
// firewall state) into the file the restore unit reads on boot.
|
|
out, err := runCommand(ctx, "ipset", "save")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data := strings.Join(out, "\n")
|
|
if data != "" {
|
|
data += "\n"
|
|
}
|
|
return writeConfigFile(f.IPSetPath, []byte(data), 0600)
|
|
}
|
|
|
|
// ensureUnitEnabled enables service persistently when it is installed but not
|
|
// already enabled (or static), so a merely-disabled restore unit still runs on
|
|
// boot. A unit that is not installed is a no-op — detectIPSetLayout only records
|
|
// a service it confirmed present.
|
|
func (f *IPTables) ensureUnitEnabled(ctx context.Context, service string) error {
|
|
state, present, err := f.unitFileState(ctx, service)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading state of %s: %s", service, err)
|
|
}
|
|
if !present {
|
|
return nil
|
|
}
|
|
switch state {
|
|
case "enabled", "enabled-runtime", "static":
|
|
return nil
|
|
}
|
|
if _, _, err := f.Conn.EnableUnitFilesContext(ctx, []string{service}, false, true); err != nil {
|
|
return fmt.Errorf("failed to enable %s: %s", service, err)
|
|
}
|
|
// Reload so systemd picks up the new enablement symlinks.
|
|
return f.Conn.ReloadContext(ctx)
|
|
}
|
|
|
|
// restartUnit restarts a systemd service and waits for the job to complete.
|
|
// The result channel is buffered so the D-Bus goroutine can always deliver
|
|
// the job result even if an early return means we never read it.
|
|
func (f *IPTables) restartUnit(ctx context.Context, service string) error {
|
|
reschan := make(chan string, 1)
|
|
_, err := f.Conn.RestartUnitContext(ctx, service, "replace", reschan)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to restart %s: %s", service, err)
|
|
}
|
|
if job := <-reschan; job != "done" {
|
|
return fmt.Errorf("failed to restart %s: %s", service, "job is not done")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reload reloads the manager to activate new rules.
|
|
func (f *IPTables) Reload(ctx context.Context) error {
|
|
if err := f.restartUnit(ctx, f.IP4Service); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The Debian layout restores both families from one unit
|
|
// (netfilter-persistent.service); restarting it twice is redundant.
|
|
if f.IP6Service == f.IP4Service {
|
|
return nil
|
|
}
|
|
return f.restartUnit(ctx, f.IP6Service)
|
|
}
|
|
|
|
// Close closes the connection to the manager.
|
|
func (f *IPTables) Close(ctx context.Context) error {
|
|
f.Conn.Close()
|
|
return nil
|
|
}
|
|
|
|
// applyRulesBatch rewrites the *filter table of each family file so it holds the
|
|
// requested rules. When replace is false the existing filter rules are kept and
|
|
// the new rules appended (skipping duplicates); when true the filter rules are
|
|
// replaced outright. The nat table and chain-policy lines are preserved.
|
|
func (f *IPTables) applyRulesBatch(rules []*Rule, replace bool) error {
|
|
// Fan each DirAny rule out into an input row plus its swapped output row before
|
|
// the per-family loop, so each half marshals into its own chain.
|
|
var expanded []*Rule
|
|
for _, r := range rules {
|
|
expanded = append(expanded, expandDirections(r)...)
|
|
}
|
|
rules = expanded
|
|
|
|
for _, fam := range []Family{IPv4, IPv6} {
|
|
path := f.IP4Path
|
|
if fam == IPv6 {
|
|
path = f.IP6Path
|
|
}
|
|
|
|
// Assemble the desired rule set for this family.
|
|
var desired []*Rule
|
|
if !replace {
|
|
existing, err := f.parseFilterFile(path, fam)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
desired = append(desired, existing...)
|
|
}
|
|
for _, r := range rules {
|
|
rf := r.impliedFamily()
|
|
if rf != FamilyAny && rf != fam {
|
|
continue
|
|
}
|
|
c := *r
|
|
if c.Family == FamilyAny {
|
|
c.Family = fam
|
|
}
|
|
dup := false
|
|
for _, e := range desired {
|
|
if e.EqualBase(&c, true) {
|
|
dup = true
|
|
break
|
|
}
|
|
}
|
|
if dup {
|
|
continue
|
|
}
|
|
desired = append(desired, &c)
|
|
}
|
|
|
|
// Marshal the desired rules to save-file lines.
|
|
var ruleLines []string
|
|
for _, r := range desired {
|
|
rl, err := f.marshalRuleLines(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ruleLines = append(ruleLines, rl...)
|
|
}
|
|
|
|
if err := f.rewriteFilterRules(path, ruleLines); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddRulesBatch adds every rule in a single rewrite of each family's save file,
|
|
// rather than one read-modify-write per rule. It implements RuleBatcher.
|
|
func (f *IPTables) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
return f.applyRulesBatch(rules, false)
|
|
}
|
|
|
|
// ReplaceRulesBatch rewrites each family's filter table to hold exactly rules,
|
|
// preserving the nat table and chain policies. It implements RuleBatcher.
|
|
func (f *IPTables) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
|
|
return f.applyRulesBatch(rules, true)
|
|
}
|
|
|
|
// prepareAddRuleFile writes an updated copy of filePath, with r inserted, to a
|
|
// staged atomicFile and returns it uncommitted. It returns a nil handle (and nil
|
|
// error) when no change is needed because the rule already exists. On error the
|
|
// staged file is cleaned up. The caller is responsible for committing a returned
|
|
// handle (or aborting it).
|
|
func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, error) {
|
|
// Skip if an equivalent logical rule already exists (LOG+action lines are
|
|
// coalesced, so a logged rule is compared as one unit).
|
|
existing, err := f.parseFilterFile(filePath, FamilyAny)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range existing {
|
|
if e.EqualBase(r, true) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// Encode the rule's line(s): a logged rule is a LOG line plus an action line.
|
|
ruleLines, err := f.marshalRuleLines(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fd, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
|
|
// Stage the rewrite, preserving the save file's mode and ownership.
|
|
af, err := newAtomicFile(filePath, 0644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Scan each line to find where we should insert our rule.
|
|
scanner := bufio.NewScanner(fd)
|
|
writtenRule := false
|
|
filterFound := false
|
|
writeRule := func() {
|
|
for _, l := range ruleLines {
|
|
_, _ = fmt.Fprintln(af, l)
|
|
}
|
|
writtenRule = true
|
|
}
|
|
for scanner.Scan() {
|
|
// Trim line and check if we skip the line.
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
// Look for the filter, and skip if not reached.
|
|
if !filterFound {
|
|
if line == "*filter" {
|
|
filterFound = true
|
|
}
|
|
_, _ = fmt.Fprintln(af, line)
|
|
continue
|
|
}
|
|
|
|
// Insert the new rule before the first existing rule of any chain (or
|
|
// before COMMIT when the table has no rules yet), so AddRule places it at
|
|
// the top of the chain.
|
|
if !writtenRule {
|
|
if line == "COMMIT" {
|
|
writeRule()
|
|
} else if strings.HasPrefix(f.ruleLineBody(line), "-A ") {
|
|
writeRule()
|
|
}
|
|
}
|
|
|
|
// Write the original line back to the new file.
|
|
_, _ = fmt.Fprintln(af, line)
|
|
}
|
|
|
|
// A read error means the staged file is truncated; discard it rather than
|
|
// installing a partial ruleset.
|
|
if err := scanner.Err(); err != nil {
|
|
af.Abort()
|
|
return nil, err
|
|
}
|
|
|
|
// A rule that was never written means the filter table was malformed.
|
|
if !writtenRule {
|
|
af.Abort()
|
|
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
|
|
}
|
|
|
|
return af, nil
|
|
}
|
|
|
|
// prepareRemoveRuleFile writes a copy of filePath, with r removed, to a staged
|
|
// atomicFile and returns it uncommitted. It returns a nil handle (and nil error)
|
|
// when the rule was not present so no change is needed. On error the staged file
|
|
// is cleaned up. The caller is responsible for committing a returned handle (or
|
|
// aborting it).
|
|
//
|
|
// It shares its rule-location logic with prepareMoveRuleFile: both locate the
|
|
// first rule equal to r (and its LOG partner, if any) via extractRuleLines and
|
|
// splice those lines out, so the LOG+action pairing and the *nat/*mangle scoping
|
|
// it depends on are defined in exactly one place.
|
|
func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) {
|
|
lines, err := f.readAllLines(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
extracted, removedIdx, err := f.extractRuleLines(lines, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if removedIdx < 0 {
|
|
// The rule was not present; no change is needed.
|
|
return nil, nil
|
|
}
|
|
|
|
without := make([]string, 0, len(lines)-len(extracted))
|
|
for i, l := range lines {
|
|
if i >= removedIdx && i < removedIdx+len(extracted) {
|
|
continue
|
|
}
|
|
without = append(without, l)
|
|
}
|
|
|
|
return f.stageLines(filePath, without)
|
|
}
|
|
|
|
// requireUnitEnabled returns IPTablesNoService unless service's UnitFileState
|
|
// is "enabled".
|
|
func (f *IPTables) requireUnitEnabled(ctx context.Context, service string) error {
|
|
prop, err := f.Conn.GetUnitPropertyContext(ctx, service, "UnitFileState")
|
|
if err != nil {
|
|
return fmt.Errorf("error getting service %s property: %s", service, err)
|
|
}
|
|
if prop.Value.Value() != "enabled" {
|
|
return errors.New(IPTablesNoService)
|
|
}
|
|
return nil
|
|
}
|