Add address-set support to APF/CSF and ipset reboot persistence to iptables

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.
This commit is contained in:
James Coleman 2026-07-08 17:20:38 -05:00
parent 9b747f7acb
commit 073c9ad7f0
14 changed files with 5153 additions and 4625 deletions

View file

@ -144,7 +144,7 @@ func (f *APF) Capabilities() Capabilities {
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
AddressSets: true,
Comments: true,
}
}
@ -1907,9 +1907,14 @@ func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
if err != nil {
return nil, err
}
// Backup captures the full filter and NAT rule state; Restore removes the
// current rules and re-adds these, so every rule read is preserved.
return &Backup{Rules: rules, NATRules: natRules}, nil
// Backup captures the full filter and NAT rule state plus the hook's address
// sets; Restore removes the current rules and re-adds these, so every rule read
// is preserved.
backup := &Backup{Rules: rules, NATRules: natRules}
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
return nil, err
}
return backup, nil
}
// Restore replaces the managed rules with the contents of a Backup.
@ -1938,6 +1943,13 @@ func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) erro
}
}
// Recreate the address sets before the rules so a set-referencing rule resolves
// when apf sources the hook. The old rules are already gone, and editAddressSet
// rewrites each set's block idempotently, so cleanFirst is unnecessary.
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
return err
}
// Re-add rules from backup.
for _, r := range backup.Rules {
if err := f.addRule(ctx, zoneName, r, false); err != nil {
@ -1962,34 +1974,56 @@ func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *Def
return unsupportedPolicy(f.Type())
}
// GetAddressSets is unsupported: apf has no address-set support.
// GetAddressSets returns the address sets carried by the apf pre-hook.
func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
return nil, unsupportedSet(f.Type())
return f.hook().getAddressSets()
}
// GetAddressSet is unsupported: apf has no address-set support.
// GetAddressSet returns a single address set by name, or an error if absent.
func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
return nil, unsupportedSet(f.Type())
sets, err := f.hook().getAddressSets()
if err != nil {
return nil, err
}
for _, s := range sets {
if s.Name == name {
return s, nil
}
}
return nil, fmt.Errorf("address set %q not found", name)
}
// AddAddressSet is unsupported: apf has no address-set support.
// AddAddressSet writes a set as ipset commands in the pre-hook; apf --restart
// (Reload) sources the hook to create the set. Re-adding a set is idempotent.
func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error {
return unsupportedSet(f.Type())
if set == nil || set.Name == "" {
return fmt.Errorf("an address set requires a name")
}
changed, err := f.hook().editAddressSet(set, false)
f.ConfigChanged = f.ConfigChanged || changed
return err
}
// RemoveAddressSet is unsupported: apf has no address-set support.
// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a
// hook rule still references the set.
func (f *APF) RemoveAddressSet(ctx context.Context, name string) error {
return unsupportedSet(f.Type())
changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true)
f.ConfigChanged = f.ConfigChanged || changed
return err
}
// AddAddressSetEntry is unsupported: apf has no address-set support.
// AddAddressSetEntry adds an entry to an existing set in the pre-hook.
func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
changed, err := f.hook().editAddressSetEntry(name, entry, false)
f.ConfigChanged = f.ConfigChanged || changed
return err
}
// RemoveAddressSetEntry is unsupported: apf has no address-set support.
// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook.
func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
changed, err := f.hook().editAddressSetEntry(name, entry, true)
f.ConfigChanged = f.ConfigChanged || changed
return err
}
// Reload restarts apf to apply config changes, but only when a mutation changed its files.

View file

@ -14,12 +14,11 @@ func TestCSFAPFStillUnsupported(t *testing.T) {
// Logging, rate limiting, connection-state and interface matching are now
// expressed by injecting iptables rules through the pre-hook (see
// TestHookScriptRoundTrip), so those route to the hook rather than being
// rejected. What remains genuinely unsupported: address sets and explicit
// rule ordering on both backends, plus source NAT on CSF.
// TestHookScriptRoundTrip), and address sets by persisting ipset commands in
// that same hook, so those route to the hook rather than being rejected. What
// remains genuinely unsupported: explicit rule ordering on both backends, plus
// source NAT on CSF.
for _, m := range []Manager{csf, apf} {
require.ErrorIs(t, m.AddAddressSet(ctx, &AddressSet{Name: "x"}), ErrUnsupportedSet,
"%s should reject address sets", m.Type())
require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering,
"%s should reject explicit ordering", m.Type())
// NAT ordering is unsupported on both even though they store NAT rules.
@ -41,9 +40,9 @@ func TestSentinelErrors(t *testing.T) {
ctx := context.Background()
csf := &CSF{}
// NAT, policy and address-set rejections carry their specific sentinel. CSF
// supports destination NAT (csf.redirect) but not source NAT, so a SNAT rule
// carries the NAT sentinel.
// NAT and policy rejections carry their specific sentinel. CSF supports
// destination NAT (csf.redirect) but not source NAT, so a SNAT rule carries the
// NAT sentinel.
nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
err := csf.AddNATRule(ctx, "", nat)
require.ErrorIs(t, err, ErrUnsupportedNAT)
@ -52,9 +51,6 @@ func TestSentinelErrors(t *testing.T) {
_, err = csf.GetDefaultPolicy(ctx, "")
require.ErrorIs(t, err, ErrUnsupportedPolicy)
err = csf.AddAddressSet(ctx, &AddressSet{Name: "x"})
require.ErrorIs(t, err, ErrUnsupportedSet)
// The shared per-rule reject helper wraps sentinels too (used by the wf backend).
err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf")
require.ErrorIs(t, err, ErrUnsupportedLog)
@ -78,20 +74,22 @@ func TestCapabilities(t *testing.T) {
require.True(t, ipt.Capabilities().DefaultPolicy)
require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets")
// CSF is a deliberately minimal backend: no counters, no policy, no sets.
// CSF is a deliberately minimal backend: no counters, no policy.
require.False(t, csf.Capabilities().RuleCounters)
require.False(t, csf.Capabilities().DefaultPolicy)
require.False(t, csf.Capabilities().AddressSets)
// CSF does express (destination) NAT through csf.redirect and per-port
// connection limiting through CONNLIMIT.
require.True(t, csf.Capabilities().NAT)
require.True(t, csf.Capabilities().ConnLimit)
// CSF gains address sets by persisting ipset commands in its pre-hook.
require.True(t, csf.Capabilities().AddressSets)
// APF likewise gains NAT (routing files) and connection limiting
// (IG_*_CLIMIT) from its native config, but still no address sets.
// (IG_*_CLIMIT) from its native config, and address sets from ipset commands
// persisted in its pre-hook.
require.True(t, apf.Capabilities().NAT)
require.True(t, apf.Capabilities().ConnLimit)
require.False(t, apf.Capabilities().AddressSets)
require.True(t, apf.Capabilities().AddressSets)
// Both gain logging, rate limiting, connection-state, interface matching and
// forward-chain rules by injecting iptables rules through the pre-hook.

File diff suppressed because it is too large Load diff

View file

@ -2056,21 +2056,6 @@ type Manager interface {
// RemoveNATRule removes a NAT rule from the zone.
RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error
// Backup captures the current filter and NAT rules the manager reports, plus —
// on backends that advertise them — the default policy and the managed address
// sets. On container backends (nftables table, pf anchor, firewalld zone) this
// is scoped to the library's container by construction; on tag/comment backends
// it is the whole chain, foreign rules included. It does not filter on the
// HasPrefix flag.
Backup(ctx context.Context, zoneName string) (*Backup, error)
// Restore reconciles the firewall to the contents of a Backup. The captured
// address sets are recreated first (so a set-referencing rule resolves), then
// existing filter and NAT rules the backend acts on are removed and the backup
// rules added, and finally the captured default policy is re-asserted. Like Sync
// it reconciles the actual state and does not filter on HasPrefix.
Restore(ctx context.Context, zoneName string, backup *Backup) error
// GetDefaultPolicy returns the default action applied to packets that match
// no rule. A direction the backend cannot express is returned as
// ActionInvalid. Backends that cannot manage a default policy at all return
@ -2107,6 +2092,21 @@ type Manager interface {
// cannot manage address sets return an unsupported error.
RemoveAddressSetEntry(ctx context.Context, name, entry string) error
// Backup captures the current filter and NAT rules the manager reports, plus —
// on backends that advertise them — the default policy and the managed address
// sets. On container backends (nftables table, pf anchor, firewalld zone) this
// is scoped to the library's container by construction; on tag/comment backends
// it is the whole chain, foreign rules included. It does not filter on the
// HasPrefix flag.
Backup(ctx context.Context, zoneName string) (*Backup, error)
// Restore reconciles the firewall to the contents of a Backup. The captured
// address sets are recreated first (so a set-referencing rule resolves), then
// existing filter and NAT rules the backend acts on are removed and the backup
// rules added, and finally the captured default policy is re-asserted. Like Sync
// it reconciles the actual state and does not filter on HasPrefix.
Restore(ctx context.Context, zoneName string, backup *Backup) error
// Reload reloads the manager to activate new rules.
Reload(ctx context.Context) error

View file

@ -14,34 +14,6 @@ import (
// FirewallDType is the backend identifier reported by FirewallD.Type.
const FirewallDType = "firewalld"
// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an
// add idempotent: re-adding an element that is already present is a no-op.
func (f *FirewallD) ignoreAlreadyEnabled(err error) error {
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
return nil
}
return err
}
// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove
// idempotent: removing an element that is not present is a no-op.
func (f *FirewallD) ignoreNotEnabled(err error) error {
if errors.Is(err, firewalld.ErrNotEnabled) {
return nil
}
return err
}
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
}
// NewFirewallD connects to firewalld and returns a manager, or an error when
// firewalld cannot be reached.
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
@ -63,6 +35,27 @@ func (f *FirewallD) Type() string {
return FirewallDType
}
// Capabilities reports which optional features this backend supports.
func (f *FirewallD) Capabilities() Capabilities {
return Capabilities{
Output: false,
Zones: true,
Priority: true,
ICMPv6: true,
PortList: false,
ConnState: false,
InterfaceMatch: false,
Logging: true,
RateLimit: true,
ConnLimit: false,
NAT: true,
RuleOrdering: false,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: true,
}
}
// GetZone returns the firewalld zone bound to the interface, falling back to the
// default zone when the interface is unbound.
func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
@ -87,15 +80,19 @@ func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string,
return "", fmt.Errorf("unable to find zone")
}
// resolveZoneName substitutes the default zone when zoneName is empty. The rest
// of go-firewall treats an empty zone as "the default" (zoneless backends ignore
// it entirely), but firewalld's permanent config interface rejects an empty zone
// name with INVALID_ZONE, so every zone-scoped method resolves it here first.
func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) {
if zoneName != "" {
return zoneName, nil
// icmpTypeTable selects the IPv4 or IPv6 name/number table by family.
func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 {
if isV6 {
return fwICMPv6Types
}
return f.Conn.DefaultZone(ctx)
return fwICMPv4Types
}
// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in
// the given family, and whether the name is known.
func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) {
n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)]
return n, ok
}
// splitRichRuleFields tokenizes a firewalld rich rule on whitespace while
@ -427,6 +424,131 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
return
}
// resolveZoneName substitutes the default zone when zoneName is empty. The rest
// of go-firewall treats an empty zone as "the default" (zoneless backends ignore
// it entirely), but firewalld's permanent config interface rejects an empty zone
// name with INVALID_ZONE, so every zone-scoped method resolves it here first.
func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) {
if zoneName != "" {
return zoneName, nil
}
return f.Conn.DefaultZone(ctx)
}
// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts)
// to allow rules, one per entry. source selects whether the range binds to the
// source-port or destination-port fields. A port on an unmodeled protocol (e.g.
// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible
// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject.
// This mirrors the protocols loop's guard.
func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule {
var rules []*Rule
for _, port := range ports {
pr, perr := ParsePortRange(port.Port)
if perr != nil {
continue
}
proto := GetProtocol(port.Protocol)
if !proto.HasPorts() {
continue
}
rule := &Rule{Proto: proto, Action: Accept}
switch {
case source && pr.Start == pr.End:
rule.SourcePort = pr.Start
case source:
rule.SourcePorts = []PortRange{pr}
case pr.Start == pr.End:
rule.Port = pr.Start
default:
rule.Ports = []PortRange{pr}
}
rules = append(rules, rule)
}
return rules
}
// GetRules returns the filter rules for a zone, resolving an empty zone to the default.
func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
zoneName, err = f.resolveZoneName(ctx, zoneName)
if err != nil {
return
}
// Get the zone settings.
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
if err != nil {
return
}
// Named services (settings.Services) have no Rule representation and are
// intentionally not surfaced here; only ports, source ports, sources and
// rich rules map to managed rules.
// Add port allows to rule list. A zone port entry may be a single port or a
// contiguous range (e.g. "49152-49215"), so parse it as a range and collapse
// a single-port range back onto the scalar Port field.
rules = append(rules, f.zonePortRules(settings.Ports, false)...)
// Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list.
// firewalld stores these as a zone protocol entry rather than a rich rule, so
// surface each recognized one as a portless-protocol rule; otherwise it is
// invisible to Sync/Restore and can never be reconciled. An unrecognized
// protocol has no Rule representation and is left unmanaged.
for _, proto := range settings.Protocols {
if p := GetProtocol(proto); p != ProtocolAny {
rules = append(rules, &Rule{Proto: p, Action: Accept})
}
}
// Add source-port allows to rule list, likewise reading a single port or a
// contiguous range.
rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...)
// Add source allows to rule list.
for _, source := range settings.Sources {
rule := &Rule{
Source: source,
Action: Accept,
}
rules = append(rules, rule)
}
// Parse and add rich rules.
for _, richRule := range settings.RichRules {
rule, err := f.UnmarshalRichRule(richRule)
if err != nil {
continue
}
rules = append(rules, rule)
}
// Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single
// FamilyAny rule, as every other backend's GetRules does, so a rule added
// family-agnostically reads back the same way.
rules = mergeFamilies(rules)
// firewalld isolates rules by zone; this read is already scoped to a single
// zone, so every rule read here lives in zoneName — record the zone and flag it
// as carrying the prefix.
for _, r := range rules {
r.table = zoneName
r.HasPrefix = true
}
return
}
// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in
// the given family, and whether the type is expressible as a rich rule element.
func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) {
for name, n := range f.icmpTypeTable(isV6) {
if n == typ {
return name, true
}
}
return "", false
}
// protoValue returns the protocol name firewalld's `protocol value=` element
// expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols.
func (f *FirewallD) protoValue(p Protocol) string {
@ -472,32 +594,6 @@ var fwICMPv6Types = map[string]uint8{
"redirect": 137,
}
// icmpTypeTable selects the IPv4 or IPv6 name/number table by family.
func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 {
if isV6 {
return fwICMPv6Types
}
return fwICMPv4Types
}
// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in
// the given family, and whether the type is expressible as a rich rule element.
func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) {
for name, n := range f.icmpTypeTable(isV6) {
if n == typ {
return name, true
}
}
return "", false
}
// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in
// the given family, and whether the name is known.
func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) {
n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)]
return n, ok
}
// rateUnit maps a RateUnit to the single-letter time unit a firewalld rich
// rule's `limit value="N/unit"` expects (s/m/h/d).
func (f *FirewallD) rateUnit(u RateUnit) string {
@ -726,107 +822,27 @@ func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
return strings.Join(parts, " "), nil
}
// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts)
// to allow rules, one per entry. source selects whether the range binds to the
// source-port or destination-port fields. A port on an unmodeled protocol (e.g.
// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible
// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject.
// This mirrors the protocols loop's guard.
func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule {
var rules []*Rule
for _, port := range ports {
pr, perr := ParsePortRange(port.Port)
if perr != nil {
continue
// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an
// add idempotent: re-adding an element that is already present is a no-op.
func (f *FirewallD) ignoreAlreadyEnabled(err error) error {
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
return nil
}
proto := GetProtocol(port.Protocol)
if !proto.HasPorts() {
continue
}
rule := &Rule{Proto: proto, Action: Accept}
switch {
case source && pr.Start == pr.End:
rule.SourcePort = pr.Start
case source:
rule.SourcePorts = []PortRange{pr}
case pr.Start == pr.End:
rule.Port = pr.Start
default:
rule.Ports = []PortRange{pr}
}
rules = append(rules, rule)
}
return rules
return err
}
// GetRules returns the filter rules for a zone, resolving an empty zone to the default.
func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
zoneName, err = f.resolveZoneName(ctx, zoneName)
if err != nil {
return
}
// Get the zone settings.
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
if err != nil {
return
}
// Named services (settings.Services) have no Rule representation and are
// intentionally not surfaced here; only ports, source ports, sources and
// rich rules map to managed rules.
// Add port allows to rule list. A zone port entry may be a single port or a
// contiguous range (e.g. "49152-49215"), so parse it as a range and collapse
// a single-port range back onto the scalar Port field.
rules = append(rules, f.zonePortRules(settings.Ports, false)...)
// Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list.
// firewalld stores these as a zone protocol entry rather than a rich rule, so
// surface each recognized one as a portless-protocol rule; otherwise it is
// invisible to Sync/Restore and can never be reconciled. An unrecognized
// protocol has no Rule representation and is left unmanaged.
for _, proto := range settings.Protocols {
if p := GetProtocol(proto); p != ProtocolAny {
rules = append(rules, &Rule{Proto: p, Action: Accept})
}
}
// Add source-port allows to rule list, likewise reading a single port or a
// contiguous range.
rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...)
// Add source allows to rule list.
for _, source := range settings.Sources {
rule := &Rule{
Source: source,
Action: Accept,
}
rules = append(rules, rule)
}
// Parse and add rich rules.
for _, richRule := range settings.RichRules {
rule, err := f.UnmarshalRichRule(richRule)
if err != nil {
continue
}
rules = append(rules, rule)
}
// Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single
// FamilyAny rule, as every other backend's GetRules does, so a rule added
// family-agnostically reads back the same way.
rules = mergeFamilies(rules)
// firewalld isolates rules by zone; this read is already scoped to a single
// zone, so every rule read here lives in zoneName — record the zone and flag it
// as carrying the prefix.
for _, r := range rules {
r.table = zoneName
r.HasPrefix = true
}
return
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
// map to a firewalld zone source: no protocol match, no destination port, no
// source port, and a non-negated source. A source combined with a concrete
// protocol or a port is a rich rule (firewalld encodes those as
// `source address="..." protocol value="..."`/`port ...`), so it is excluded here
// — encoding such a rule as a bare zone source would silently drop the protocol or
// port match and widen it. AddRule and RemoveRule share this so their zone-source
// routing stays symmetric; they differ only in which source *forms* they accept (a
// MAC source is added as a rich rule but removed via the zone-source path).
func (f *FirewallD) sourceZoneShape(r *Rule) bool {
return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny &&
!r.HasPorts() && !r.HasSourcePorts()
}
// zoneEntryEligible reports whether a rule can be expressed as a firewalld
@ -917,10 +933,38 @@ func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error
return f.ignoreAlreadyEnabled(zone.AddRichRule(ctx, richRule))
}
// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are
// not positionally ordered.
func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove
// idempotent: removing an element that is not present is a no-op.
func (f *FirewallD) ignoreNotEnabled(err error) error {
if errors.Is(err, firewalld.ErrNotEnabled) {
return nil
}
return err
}
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
}
// isZoneSource reports whether a source string is a form firewalld stores as a
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
// RemoveRule uses it to decide whether a bare source can be cleared with
// RemoveSource rather than falling through to the rich-rule path.
func (f *FirewallD) isZoneSource(s string) bool {
if _, _, err := net.ParseCIDR(s); err == nil {
return true
@ -937,21 +981,8 @@ func (f *FirewallD) isZoneSource(s string) bool {
return false
}
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
// map to a firewalld zone source: no protocol match, no destination port, no
// source port, and a non-negated source. A source combined with a concrete
// protocol or a port is a rich rule (firewalld encodes those as
// `source address="..." protocol value="..."`/`port ...`), so it is excluded here
// — encoding such a rule as a bare zone source would silently drop the protocol or
// port match and widen it. AddRule and RemoveRule share this so their zone-source
// routing stays symmetric; they differ only in which source *forms* they accept (a
// MAC source is added as a rich rule but removed via the zone-source path).
func (f *FirewallD) sourceZoneShape(r *Rule) bool {
return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny &&
!r.HasPorts() && !r.HasSourcePorts()
}
// RemoveRule removes a filter rule from a zone, mirroring how AddRule stored it.
// RemoveRule uses it to decide whether a bare source can be cleared with
// RemoveSource rather than falling through to the rich-rule path.
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A DirAny rule degrades to its input half on firewalld (no output concept),
// mirroring AddRule so a both-directions rule is found and removed as stored.
@ -1120,42 +1151,6 @@ func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) er
return f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule))
}
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
// carries only these four fields, so any source, destination or interface match —
// or a port list — cannot be expressed through it and is rejected. (firewalld can
// express a source-scoped forward-port in a rich rule, but this backend manages
// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port
// would not round-trip, so it is intentionally not emitted here.)
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
if r.Proto != TCP && r.Proto != UDP {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
}
if !r.HasPorts() {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
}
specs := r.PortSpecs()
if len(specs) > 1 {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
}
if r.Interface != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
}
if r.Source != "" || r.Destination != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
}
fp := firewalld.ForwardPort{
Port: specs[0].String(),
Protocol: r.Proto.String(),
// ToAddr is empty for a Redirect (same-host) and set for a DNAT.
ToAddr: r.ToAddress,
}
if r.ToPort != 0 {
fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10)
}
return fp, nil
}
// GetNATRules returns the NAT rules for a zone, mapping forward ports and masquerade.
func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
zoneName, err = f.resolveZoneName(ctx, zoneName)
@ -1213,6 +1208,42 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
return rules, nil
}
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
// carries only these four fields, so any source, destination or interface match —
// or a port list — cannot be expressed through it and is rejected. (firewalld can
// express a source-scoped forward-port in a rich rule, but this backend manages
// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port
// would not round-trip, so it is intentionally not emitted here.)
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
if r.Proto != TCP && r.Proto != UDP {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
}
if !r.HasPorts() {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
}
specs := r.PortSpecs()
if len(specs) > 1 {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
}
if r.Interface != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
}
if r.Source != "" || r.Destination != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
}
fp := firewalld.ForwardPort{
Port: specs[0].String(),
Protocol: r.Proto.String(),
// ToAddr is empty for a Redirect (same-host) and set for a DNAT.
ToAddr: r.ToAddress,
}
if r.ToPort != 0 {
fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10)
}
return fp, nil
}
// AddNATRule adds a NAT rule to a zone via firewalld's forward-port or masquerade API.
func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
if err := r.validate(); err != nil {
@ -1248,6 +1279,12 @@ func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule)
return fmt.Errorf("invalid nat kind")
}
// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich
// rules, which carry no explicit ordering.
func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedOrdering(f.Type())
}
// RemoveNATRule removes a NAT rule from a zone via firewalld's forward-port or masquerade API.
func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
// Get the zone.
@ -1272,23 +1309,6 @@ func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRu
return fmt.Errorf("invalid nat kind")
}
// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are
// not positionally ordered.
func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich
// rules, which carry no explicit ordering.
func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// Backup captures the current filter and NAT rules managed by this backend.
func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
@ -1387,37 +1407,6 @@ func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup
return applyBackupPolicy(ctx, f, zoneName, backup)
}
// Reload reloads firewalld's permanent configuration into the runtime.
func (f *FirewallD) Reload(ctx context.Context) error {
return f.Conn.Reload(ctx)
}
// Close releases the D-Bus connection to firewalld.
func (f *FirewallD) Close(ctx context.Context) error {
return f.Conn.Close()
}
// Capabilities reports which optional features this backend supports.
func (f *FirewallD) Capabilities() Capabilities {
return Capabilities{
Output: false,
Zones: true,
Priority: true,
ICMPv6: true,
PortList: false,
ConnState: false,
InterfaceMatch: false,
Logging: true,
RateLimit: true,
ConnLimit: false,
NAT: true,
RuleOrdering: false,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: true,
}
}
// policyFromTarget maps a firewalld zone target to a default action. The
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
// accepts explicitly being ACCEPT and DROP.
@ -1481,34 +1470,6 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
// --- address sets (firewalld ipsets) ----------------------------------------
// ipSetType maps an AddressSet type to a firewalld ipset type string.
func (f *FirewallD) ipSetType(t SetType) string {
if t == SetHashNet {
return "hash:net"
}
return "hash:ip"
}
// GetAddressSets returns all permanent firewalld ipsets as address sets.
func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
names, err := f.Conn.Permanent().IPSetNames(ctx)
if err != nil {
return nil, err
}
result := make([]*AddressSet, 0, len(names))
for _, name := range names {
set, err := f.getAddressSet(ctx, name)
if err != nil {
return nil, err
}
if set == nil {
continue
}
result = append(result, set)
}
return result, nil
}
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
@ -1531,6 +1492,26 @@ func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet
return set, nil
}
// GetAddressSets returns all permanent firewalld ipsets as address sets.
func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
names, err := f.Conn.Permanent().IPSetNames(ctx)
if err != nil {
return nil, err
}
result := make([]*AddressSet, 0, len(names))
for _, name := range names {
set, err := f.getAddressSet(ctx, name)
if err != nil {
return nil, err
}
if set == nil {
continue
}
result = append(result, set)
}
return result, nil
}
// GetAddressSet returns the named permanent ipset, or an error if it does not exist.
func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
set, err := f.getAddressSet(ctx, name)
@ -1543,6 +1524,14 @@ func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet
return set, nil
}
// ipSetType maps an AddressSet type to a firewalld ipset type string.
func (f *FirewallD) ipSetType(t SetType) string {
if t == SetHashNet {
return "hash:net"
}
return "hash:ip"
}
// AddAddressSet creates the permanent ipset, or updates it in place when it already exists.
func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error {
if set == nil || set.Name == "" {
@ -1609,3 +1598,13 @@ func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry strin
}
return err
}
// Reload reloads firewalld's permanent configuration into the runtime.
func (f *FirewallD) Reload(ctx context.Context) error {
return f.Conn.Reload(ctx)
}
// Close releases the D-Bus connection to firewalld.
func (f *FirewallD) Close(ctx context.Context) error {
return f.Conn.Close()
}

View file

@ -38,12 +38,15 @@ type hookScript struct {
// ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot
// express in their native config and so must be injected as a raw iptables rule
// through the hook: a forward-chain (routed) rule, connection-state matching,
// per-rule interface matching, logging, rate limiting, ICMPv6, or a transport
// per-rule interface matching, logging, rate limiting, ICMPv6, a transport
// protocol their native config does not model (SCTP and the portless IP protocols
// GRE, ESP and AH).
// GRE, ESP and AH), or an address-set reference (@set) — csf.allow/apf trust files
// take literal addresses only, so a `-m set --match-set` match lives in the hook
// beside the ipset commands that create the set.
func ruleNeedsHook(r *Rule) bool {
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto)
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) ||
isSetRef(r.Source) || isSetRef(r.Destination)
}
// bareHostShape reports whether a rule has the shape a plain csf.allow/apf
@ -55,6 +58,11 @@ func bareHostShape(r *Rule) bool {
if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny {
return false
}
// A set reference (@set) is not a literal host: it matches through the hook's
// `-m set` clause (ruleNeedsHook routes it there), never a plain trust-file line.
if isSetRef(r.Source) || isSetRef(r.Destination) {
return false
}
return (r.Source != "") != (r.Destination != "")
}
@ -391,3 +399,219 @@ func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
return true, h.writeHook(lines, existed)
}
// --- address sets (ipset commands in the hook) -----------------------------
//
// CSF and APF have no native address-set construct, so the library persists a
// set as `ipset` commands in the same hook that carries its raw iptables rules.
// The firewall sources the hook on every (re)start, so the ipset commands
// recreate the set before the `-m set --match-set` rule lines that follow can
// reference it — the set survives a reboot exactly as the hook's rules do. Every
// ipset line is kept ahead of every iptables/ip6tables line to preserve that
// ordering. Reading foreign, user-authored ipset lines is intended, as with
// rules: the library manages the actual hook state.
// ipsetLinesFor renders the hook lines that (re)create a set and load its
// entries: an idempotent create (-exist, so a reload does not fail on the
// existing set), a flush (so a reload drops entries removed since the last
// write, making the entry list declarative), then one add per entry.
func ipsetLinesFor(set *AddressSet) []string {
fam := "inet"
if set.Family == IPv6 {
fam = "inet6"
}
lines := []string{
fmt.Sprintf("ipset create %s %s family %s -exist", set.Name, set.Type.String(), fam),
fmt.Sprintf("ipset flush %s", set.Name),
}
for _, e := range set.Entries {
lines = append(lines, fmt.Sprintf("ipset add %s %s", set.Name, e))
}
return lines
}
// hookIPSetName returns the set a hook ipset line operates on, or "" when the
// line is not one of the library's ipset commands. Every such line names the set
// in its third field (`ipset <verb> <name> ...`).
func hookIPSetName(line string) string {
f := strings.Fields(line)
if len(f) >= 3 && f[0] == "ipset" {
return f[2]
}
return ""
}
// isHookRuleLine reports whether a hook line is an iptables/ip6tables command, as
// opposed to an ipset command or user-authored shell.
func isHookRuleLine(line string) bool {
t := strings.TrimSpace(line)
return strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ")
}
// setInUse reports whether any hook rule line references name through an
// `-m set --match-set <name>` match, so a set is not removed out from under a
// rule that still uses it (the kernel enforces the same on a live destroy).
func setInUse(lines []string, name string) bool {
for _, l := range lines {
if !isHookRuleLine(l) {
continue
}
f := strings.Fields(l)
for i := 0; i+1 < len(f); i++ {
if f[i] == "--match-set" && f[i+1] == name {
return true
}
}
}
return false
}
// getAddressSets parses the sets the hook carries, in the order their create
// lines appear. An ipset is pinned to a single family, so each create yields one
// set and its add lines supply the entries; flush lines carry no state and are
// ignored.
func (h *hookScript) getAddressSets() ([]*AddressSet, error) {
lines, _, err := h.readHookLines()
if err != nil {
return nil, err
}
// ipsetParseType is an IPTables method that ignores its receiver; a zero value
// reuses the same create-line parser the iptables backend uses.
ipt := &IPTables{}
sets := map[string]*AddressSet{}
var order []string
for _, line := range lines {
f := strings.Fields(line)
if len(f) >= 4 && f[0] == "ipset" && f[1] == "create" {
// ipsetParseType scans a `create NAME <type> family <fam> ...` slice from
// its third element, so drop the leading `ipset` word to line it up.
fam, typ := ipt.ipsetParseType(f[1:])
sets[f[2]] = &AddressSet{Name: f[2], Family: fam, Type: typ}
order = append(order, f[2])
}
}
for _, line := range lines {
f := strings.Fields(line)
if len(f) == 4 && f[0] == "ipset" && f[1] == "add" {
if s, ok := sets[f[2]]; ok {
s.Entries = append(s.Entries, f[3])
}
}
}
out := make([]*AddressSet, 0, len(order))
for _, n := range order {
out = append(out, sets[n])
}
return out, nil
}
// editAddressSet writes or removes a set's ipset lines in the hook. Adding drops
// any prior lines for the set and reinserts its block ahead of the first
// iptables/ip6tables line, so the set exists before any rule matches it; the
// write is idempotent. Removing drops the set's lines but refuses when a hook
// rule still references it. Every other hook line — user shell, rules, other
// sets — is preserved; it reports whether the hook changed.
func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) {
lines, existed, err := h.readHookLines()
if err != nil {
return false, err
}
if remove && setInUse(lines, set.Name) {
return false, fmt.Errorf("address set %q is in use by a rule", set.Name)
}
// Drop any existing lines for this set (idempotent re-add; also the removal path).
kept := make([]string, 0, len(lines))
dropped := false
for _, l := range lines {
if hookIPSetName(l) == set.Name {
dropped = true
continue
}
kept = append(kept, l)
}
if remove {
if !dropped {
return false, nil
}
return true, h.writeHook(kept, existed)
}
// Insert the set's block ahead of the first rule line (or at the end when the
// hook has none yet), keeping every ipset line before every rule line.
block := ipsetLinesFor(set)
next := make([]string, 0, len(kept)+len(block))
inserted := false
for _, l := range kept {
if !inserted && isHookRuleLine(l) {
next = append(next, block...)
inserted = true
}
next = append(next, l)
}
if !inserted {
next = append(next, block...)
}
if equalLines(lines, next) {
return false, nil
}
return true, h.writeHook(next, existed)
}
// editAddressSetEntry adds or removes a single entry in an existing set by
// rewriting the set's block. The set must already exist in the hook.
func (h *hookScript) editAddressSetEntry(name, entry string, remove bool) (bool, error) {
sets, err := h.getAddressSets()
if err != nil {
return false, err
}
var target *AddressSet
for _, s := range sets {
if s.Name == name {
target = s
break
}
}
if target == nil {
return false, fmt.Errorf("address set %q not found", name)
}
if remove {
next := target.Entries[:0]
found := false
for _, e := range target.Entries {
if e == entry {
found = true
continue
}
next = append(next, e)
}
if !found {
return false, nil
}
target.Entries = next
} else {
for _, e := range target.Entries {
if e == entry {
return false, nil
}
}
target.Entries = append(target.Entries, entry)
}
return h.editAddressSet(target, false)
}
// equalLines reports whether two hook line slices are identical.
func equalLines(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -21,6 +21,10 @@ func TestRuleNeedsHook(t *testing.T) {
// A forward rule has no native CSF/APF config path, so it routes through the
// raw-iptables hook (which emits an -A FORWARD rule).
{Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept},
// A set-referencing rule (Source names an ipset, not an address) has no
// literal trust-file form, so it routes through the hook beside the ipset
// commands that create the set.
{Family: IPv4, Source: "blocklist", Action: Drop},
}
for _, r := range needs {
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
@ -446,3 +450,134 @@ func TestHostNeedsHook(t *testing.T) {
require.Equal(t, c.want, hostNeedsHook(c.rule), c.name)
}
}
// A set reference is not a literal host, so bareHostShape must reject it (else
// APF/CSF would write the set name into a trust file) while ruleNeedsHook routes
// it to the hook. A literal address keeps the opposite verdicts.
func TestSetRefIsNotBareHost(t *testing.T) {
setRef := &Rule{Family: IPv4, Source: "blocklist", Action: Drop}
require.False(t, bareHostShape(setRef), "a set reference is not a bare host")
require.True(t, ruleNeedsHook(setRef), "a set reference routes to the hook")
literal := &Rule{Family: IPv4, Source: "10.0.0.1", Action: Drop}
require.True(t, bareHostShape(literal), "a literal address is a bare host")
require.False(t, ruleNeedsHook(literal), "a literal-address host stays native")
}
func newTestHook(t *testing.T) *hookScript {
t.Helper()
return &hookScript{
rulePrefix: "go_firewall",
hookPath: filepath.Join(t.TempDir(), "csfpre.sh"),
hookPerm: 0700,
}
}
// A set written to the hook round-trips through getAddressSets with its family,
// type and entries intact, for both IPv4 and IPv6, and re-adding an identical set
// is idempotent.
func TestHookAddressSetRoundTrip(t *testing.T) {
h := newTestHook(t)
v4 := &AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.7"}}
changed, err := h.editAddressSet(v4, false)
require.NoError(t, err)
require.True(t, changed)
// Re-adding the identical set does not rewrite the hook.
changed, err = h.editAddressSet(v4, false)
require.NoError(t, err)
require.False(t, changed, "re-adding an identical set must be idempotent")
v6 := &AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}
_, err = h.editAddressSet(v6, false)
require.NoError(t, err)
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 2)
byName := map[string]*AddressSet{}
for _, s := range sets {
byName[s.Name] = s
}
require.Equal(t, IPv4, byName["blocklist"].Family)
require.Equal(t, SetHashNet, byName["blocklist"].Type)
require.ElementsMatch(t, []string{"192.0.2.0/24", "198.51.100.7"}, byName["blocklist"].Entries)
require.Equal(t, IPv6, byName["v6drop"].Family)
require.Equal(t, SetHashIP, byName["v6drop"].Type)
require.Equal(t, []string{"2001:db8::1"}, byName["v6drop"].Entries)
}
// The ipset commands for a set must be written ahead of any rule that references
// it, even when the rule was added first, so the set exists when the hook runs.
func TestHookAddressSetOrderedBeforeRules(t *testing.T) {
h := newTestHook(t)
// Add the referencing rule first — edit appends it at the end of the hook.
_, err := h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false)
require.NoError(t, err)
// Then add the set; its block must be spliced in before the rule line.
_, err = h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
data, err := os.ReadFile(h.hookPath)
require.NoError(t, err)
body := string(data)
ipsetAt := strings.Index(body, "ipset create blocklist")
ruleAt := strings.Index(body, "--match-set blocklist")
require.GreaterOrEqual(t, ipsetAt, 0, "the create command must be present")
require.GreaterOrEqual(t, ruleAt, 0, "the referencing rule must be present")
require.Less(t, ipsetAt, ruleAt, "ipset commands must precede the rule that references the set")
}
// Removing a set a rule still references is refused (the kernel enforces the same
// on a live destroy); once the rule is gone the removal succeeds.
func TestHookAddressSetInUseGuard(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
_, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false)
require.NoError(t, err)
_, err = h.editAddressSet(&AddressSet{Name: "blocklist"}, true)
require.Error(t, err, "removing a set a rule references must fail")
_, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, true)
require.NoError(t, err)
changed, err := h.editAddressSet(&AddressSet{Name: "blocklist"}, true)
require.NoError(t, err)
require.True(t, changed)
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Empty(t, sets, "the set must be gone after removal")
}
// Entry edits add and remove a single address in an existing set idempotently,
// and editing a set that does not exist is an error.
func TestHookAddressSetEntryEdits(t *testing.T) {
h := newTestHook(t)
_, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false)
require.NoError(t, err)
changed, err := h.editAddressSetEntry("blocklist", "203.0.113.9", false)
require.NoError(t, err)
require.True(t, changed)
changed, err = h.editAddressSetEntry("blocklist", "203.0.113.9", false)
require.NoError(t, err)
require.False(t, changed, "adding an existing entry must be idempotent")
sets, err := h.getAddressSets()
require.NoError(t, err)
require.Len(t, sets, 1)
require.ElementsMatch(t, []string{"203.0.113.5", "203.0.113.9"}, sets[0].Entries)
changed, err = h.editAddressSetEntry("blocklist", "203.0.113.5", true)
require.NoError(t, err)
require.True(t, changed)
sets, err = h.getAddressSets()
require.NoError(t, err)
require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries)
_, err = h.editAddressSetEntry("missing", "1.2.3.4", false)
require.Error(t, err, "editing an entry in a set that does not exist must fail")
}

File diff suppressed because it is too large Load diff

View file

@ -394,6 +394,9 @@ func TestIPTablesLayoutDetection(t *testing.T) {
require.True(t, ok, "the RHEL layout should be found when both save files are present")
require.Equal(t, "iptables.service", l.ip4Service)
require.Equal(t, "ip6tables.service", l.ip6Service)
require.Equal(t, "/etc/sysconfig/ipset", l.ipsetPath, "the RHEL layout persists sets to the ipset-service compat file")
require.Equal(t, "ipset.service", l.ipsetService)
require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset.service unit, not a plugin file")
// RHEL layout: v4 present but v6 missing is an incomplete pair, not a match.
root = t.TempDir()
@ -411,6 +414,8 @@ func TestIPTablesLayoutDetection(t *testing.T) {
require.True(t, ok, "the Debian layout should be found when both save files are present")
require.Equal(t, "netfilter-persistent.service", l.ip4Service)
require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit")
require.Equal(t, "/etc/iptables/ipsets", l.ipsetPath, "the Debian layout persists sets alongside the rules files")
require.NotEmpty(t, l.ipsetPlugin, "the Debian layout gates ipset persistence on the netfilter-persistent plugin file")
}
func TestCombineSplitComment(t *testing.T) {

File diff suppressed because it is too large Load diff

1978
pf.go

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@ import sys
# 1: receiver variable (None for a plain function)
# 2: receiver type (None for a plain function)
# 3: function name
DECL_RE = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(")
DECL_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(")
def split_blocks(text):

View file

@ -137,31 +137,105 @@ func (f *UFW) Type() string {
return UFWType
}
// Capabilities reports the features this backend supports.
func (f *UFW) 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: false,
AddressSets: true,
Comments: true,
}
}
// --- default policy ---------------------------------------------------------
// GetZone reports no zone; ufw has no zone support.
func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to
// the IPv4 form when the family is unspecified.
func (f *UFW) zeroNet(fam Family) string {
if fam == IPv6 {
return "::/0"
// ipTablesChain maps a ufw iptables chain to a rule direction, reporting
// whether it is one this backend surfaces. Internal chains (logging, not-local,
// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`)
// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its
// chains with the `ufw6-` prefix.
func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) {
switch chain {
case "ufw-before-input", "ufw-after-input", "ufw-user-input",
"ufw6-before-input", "ufw6-after-input", "ufw6-user-input":
return DirInput, true
case "ufw-before-output", "ufw-after-output", "ufw-user-output",
"ufw6-before-output", "ufw6-after-output", "ufw6-user-output":
return DirOutput, true
case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward",
"ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward":
return DirForward, true
}
return "0.0.0.0/0"
return DirInput, false
}
// anyAddr returns the address literal used to stand in for an unspecified
// endpoint when ufw's grammar forces one. A concrete family uses its
// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw
// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is
// family-specific) would silently pin the rule to a single family and break the
// round-trip back to FamilyAny.
func (f *UFW) anyAddr(fam Family) string {
if fam == FamilyAny {
return "any"
// ParseIPTablesRules parses a ufw before/after rules file, which is in
// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line on
// an input/output/forward chain is reparsed with the iptables rulespec parser;
// lines whose match or action this model cannot represent are skipped.
func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) {
fd, err := os.Open(filePath)
if err != nil {
// A missing iptables rules file simply contributes no rules.
if os.IsNotExist(err) {
return nil, nil
}
return f.zeroNet(fam)
return nil, err
}
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") {
continue
}
dir, ok := f.ipTablesChain(fields[1])
if !ok {
continue
}
// Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the
// iptables parser.
spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ")
rule, perr := unmarshalIPTablesRule(spec, family)
if perr != nil {
continue
}
// Strip the prefix so only the user-facing comment surfaces, and flag
// whether the prefix marked this as one of our rules.
text, hasPrefix := prefixedComment(f.rulePrefix, rule.Comment)
rule.Comment = text
rule.HasPrefix = hasPrefix
rules = append(rules, rule)
}
if err := scanner.Err(); err != nil {
return nil, err
}
// A logged rule is a LOG line followed by its action line; fold the pair
// back into one logical rule.
return coalesceLoggedRules(rules), nil
}
// parseAddr validates a ufw tuple address token (an IP or CIDR) and returns
@ -326,6 +400,201 @@ func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
return
}
// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###`
// line, in file order: the parsed rule, or nil for a non-empty tuple this backend
// does not model (one that fails to parse). ufw counts every tuple in its own
// numbered list, so keeping such rows as nil lets callers map a representable rule
// to its true physical position. Only a tuple whose body is empty after stripping
// the comment is dropped without occupying a slot.
func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) {
fd, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
var rows []*Rule
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
line := scanner.Text()
// Ignore non-tuple lines.
tuplePrefix := "### tuple ### "
if !strings.HasPrefix(line, tuplePrefix) {
continue
}
line = strings.TrimPrefix(line, tuplePrefix)
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// A trailing ` comment=<hex>` carries the ufw rule comment, hex-encoded
// UTF-8. Capture and decode it, then strip it before parsing the tuple.
var comment string
if ci = strings.LastIndex(line, " comment="); ci >= 0 {
hexVal := strings.TrimSpace(line[ci+len(" comment="):])
if b, derr := hex.DecodeString(hexVal); derr == nil {
comment = string(b)
}
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
continue
}
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule)
// is kept as a nil row so it still occupies a physical position.
rule, err := f.UnmarshalRule(line, family)
if err != nil {
rows = append(rows, nil)
continue
}
// Strip the prefix so only the user-facing comment surfaces, and flag
// whether the prefix marked this as one of our rules.
text, hasPrefix := prefixedComment(f.rulePrefix, comment)
rule.Comment = text
rule.HasPrefix = hasPrefix
rows = append(rows, rule)
}
if serr := scanner.Err(); serr != nil {
return nil, serr
}
return rows, nil
}
// ParseRules reads a ufw rules file and returns the rules it models, in file order.
func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) {
rows, err := f.parseTupleRows(filePath, family)
if err != nil {
return nil, err
}
for _, r := range rows {
if r != nil {
rules = append(rules, r)
}
}
return rules, nil
}
// GetRules reports it) to the 1-based position ufw's own numbered list uses for
// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4
// tuple then every IPv6 tuple without merging, so the two index spaces diverge
// once a dual-family rule and a single-family rule coexist. The pre-merge tuple
// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so
// the merged position's anchor row in that list is its native position. A position
// past the last logical rule maps past the native count, which ufw rejects and
func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Parse IPv4 user rules.
tupleRules, err := f.ParseRules(UFWIPv4, IPv4)
if err != nil {
return nil, err
}
// Parse IPv6 user rules.
v6Rules, err := f.ParseRules(UFWIPv6, IPv6)
if err != nil {
return nil, err
}
tupleRules = append(tupleRules, v6Rules...)
// Number the tuple rules as one ordered list: `ufw insert` positions within a
// single numbered list spanning both families. The raw before.rules entries read
// below sit outside that list, so they keep Number 0.
numberSequential(tupleRules)
rules = append(rules, tupleRules...)
// Parse the before.rules iptables files, which carry ICMP and other rules the
// user-rule tuple format cannot express. Only the before.rules files are read:
// this backend writes and removes raw rules exclusively there (see
// iptablesFilesFor), so reading after.rules too would surface rules it cannot
// remove — Restore then re-added them into before.rules, duplicating them.
iptablesFiles := []struct {
path string
family Family
}{
{UFWBefore, IPv4},
{UFWBefore6, IPv6},
}
for _, ff := range iptablesFiles {
iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family)
if ferr != nil {
return nil, ferr
}
rules = append(rules, iptablesRules...)
}
// Merge rules across families, then renumber the ufw-list rules so a collapsed
// v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are
// resequenced; before.rules entries kept Number 0 and stay outside the list.
rules = mergeFamilies(rules)
n := 0
for _, r := range rules {
if r.Number != 0 {
n++
r.Number = n
}
}
// Collapse each input/output twin into one DirAny rule after numbering, so the
// surviving tuple rows keep their list position.
rules = mergeDirections(rules)
return
}
// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to
// the IPv4 form when the family is unspecified.
func (f *UFW) zeroNet(fam Family) string {
if fam == IPv6 {
return "::/0"
}
return "0.0.0.0/0"
}
// anyAddr returns the address literal used to stand in for an unspecified
// endpoint when ufw's grammar forces one. A concrete family uses its
// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw
// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is
// family-specific) would silently pin the rule to a single family and break the
// round-trip back to FamilyAny.
func (f *UFW) anyAddr(fam Family) string {
if fam == FamilyAny {
return "any"
}
return f.zeroNet(fam)
}
// isNativeLimit reports whether r is expressible as ufw's built-in `limit`
// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s,
// modeled as 12/minute burst 6) and no other modifier the tuple form cannot
// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the
// signature that round-trips through the CLI rather than the before.rules files.
func (f *UFW) isNativeLimit(r *Rule) bool {
// Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which
// UnmarshalRule decodes back into this same shape with Log set. Excluding
// logged limits would route such a rule to the before.rules files even though
// it lives in user.rules, leaving it unremovable there and duplicating it on
// Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit
// carrying one stays false here and is routed to before.rules (which can).
return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" &&
r.RateLimit != nil && *r.RateLimit == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}
}
// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's
// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's
// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre
// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through
// the iptables rules files.
func (f *UFW) protoNeedsRaw(p Protocol) bool {
return p.IsICMP() || p == SCTP
}
// MarshalRule encodes a firewall rule into a ufw rulespec.
func (f *UFW) MarshalRule(r *Rule) (string, error) {
// Features this backend cannot express in its rule model are rejected up
@ -530,277 +799,11 @@ func (f *UFW) MarshalRule(r *Rule) (string, error) {
return strings.Join(parts, " "), nil
}
// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###`
// line, in file order: the parsed rule, or nil for a non-empty tuple this backend
// does not model (one that fails to parse). ufw counts every tuple in its own
// numbered list, so keeping such rows as nil lets callers map a representable rule
// to its true physical position. Only a tuple whose body is empty after stripping
// the comment is dropped without occupying a slot.
func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) {
fd, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
var rows []*Rule
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
// Get the line.
line := scanner.Text()
// Ignore non-tuple lines.
tuplePrefix := "### tuple ### "
if !strings.HasPrefix(line, tuplePrefix) {
continue
}
line = strings.TrimPrefix(line, tuplePrefix)
// Remove comments.
ci := strings.IndexByte(line, '#')
if ci >= 0 {
line = line[:ci]
}
// A trailing ` comment=<hex>` carries the ufw rule comment, hex-encoded
// UTF-8. Capture and decode it, then strip it before parsing the tuple.
var comment string
if ci = strings.LastIndex(line, " comment="); ci >= 0 {
hexVal := strings.TrimSpace(line[ci+len(" comment="):])
if b, derr := hex.DecodeString(hexVal); derr == nil {
comment = string(b)
}
line = line[:ci]
}
// Trim spaces.
line = strings.TrimSpace(line)
// Ignore zero lines.
if len(line) == 0 {
continue
}
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule)
// is kept as a nil row so it still occupies a physical position.
rule, err := f.UnmarshalRule(line, family)
if err != nil {
rows = append(rows, nil)
continue
}
// Strip the prefix so only the user-facing comment surfaces, and flag
// whether the prefix marked this as one of our rules.
text, hasPrefix := prefixedComment(f.rulePrefix, comment)
rule.Comment = text
rule.HasPrefix = hasPrefix
rows = append(rows, rule)
}
if serr := scanner.Err(); serr != nil {
return nil, serr
}
return rows, nil
}
// ParseRules reads a ufw rules file and returns the rules it models, in file order.
func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) {
rows, err := f.parseTupleRows(filePath, family)
if err != nil {
return nil, err
}
for _, r := range rows {
if r != nil {
rules = append(rules, r)
}
}
return rules, nil
}
// ipTablesChain maps a ufw iptables chain to a rule direction, reporting
// whether it is one this backend surfaces. Internal chains (logging, not-local,
// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`)
// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its
// chains with the `ufw6-` prefix.
func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) {
switch chain {
case "ufw-before-input", "ufw-after-input", "ufw-user-input",
"ufw6-before-input", "ufw6-after-input", "ufw6-user-input":
return DirInput, true
case "ufw-before-output", "ufw-after-output", "ufw-user-output",
"ufw6-before-output", "ufw6-after-output", "ufw6-user-output":
return DirOutput, true
case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward",
"ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward":
return DirForward, true
}
return DirInput, false
}
// ParseIPTablesRules parses a ufw before/after rules file, which is in
// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line on
// an input/output/forward chain is reparsed with the iptables rulespec parser;
// lines whose match or action this model cannot represent are skipped.
func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) {
fd, err := os.Open(filePath)
if err != nil {
// A missing iptables rules file simply contributes no rules.
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func() { _ = fd.Close() }()
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") {
continue
}
dir, ok := f.ipTablesChain(fields[1])
if !ok {
continue
}
// Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the
// iptables parser.
spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ")
rule, perr := unmarshalIPTablesRule(spec, family)
if perr != nil {
continue
}
// Strip the prefix so only the user-facing comment surfaces, and flag
// whether the prefix marked this as one of our rules.
text, hasPrefix := prefixedComment(f.rulePrefix, rule.Comment)
rule.Comment = text
rule.HasPrefix = hasPrefix
rules = append(rules, rule)
}
if err := scanner.Err(); err != nil {
return nil, err
}
// A logged rule is a LOG line followed by its action line; fold the pair
// back into one logical rule.
return coalesceLoggedRules(rules), nil
}
// GetRules returns the current filter rules for the zone.
func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Parse IPv4 user rules.
tupleRules, err := f.ParseRules(UFWIPv4, IPv4)
if err != nil {
return nil, err
}
// Parse IPv6 user rules.
v6Rules, err := f.ParseRules(UFWIPv6, IPv6)
if err != nil {
return nil, err
}
tupleRules = append(tupleRules, v6Rules...)
// Number the tuple rules as one ordered list: `ufw insert` positions within a
// single numbered list spanning both families. The raw before.rules entries read
// below sit outside that list, so they keep Number 0.
numberSequential(tupleRules)
rules = append(rules, tupleRules...)
// Parse the before.rules iptables files, which carry ICMP and other rules the
// user-rule tuple format cannot express. Only the before.rules files are read:
// this backend writes and removes raw rules exclusively there (see
// iptablesFilesFor), so reading after.rules too would surface rules it cannot
// remove — Restore then re-added them into before.rules, duplicating them.
iptablesFiles := []struct {
path string
family Family
}{
{UFWBefore, IPv4},
{UFWBefore6, IPv6},
}
for _, ff := range iptablesFiles {
iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family)
if ferr != nil {
return nil, ferr
}
rules = append(rules, iptablesRules...)
}
// Merge rules across families, then renumber the ufw-list rules so a collapsed
// v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are
// resequenced; before.rules entries kept Number 0 and stay outside the list.
rules = mergeFamilies(rules)
n := 0
for _, r := range rules {
if r.Number != 0 {
n++
r.Number = n
}
}
// Collapse each input/output twin into one DirAny rule after numbering, so the
// surviving tuple rows keep their list position.
rules = mergeDirections(rules)
return
}
// needsIPTablesRules reports whether a rule must be written as raw iptables
// rules rather than through ufw's command line. The ufw CLI and its user.rules
// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log
// prefix or a rate/connection limit, but the before.rules files can. Plain
// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so
// it stays on the CLI path.
func (f *UFW) needsIPTablesRules(r *Rule) bool {
if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil {
return true
}
// ufw's tuple format takes only addresses in from/to; an ipset reference is
// written as a raw before.rules rule (`-m set --match-set`) instead.
if isSetRef(r.Source) || isSetRef(r.Destination) {
return true
}
// ufw's tuple grammar has no address negation, but before.rules can express it
// as `iptables ! -s/-d`, so a negated plain address routes there rather than
// being rejected. (A negated ipset reference is already covered above.)
if neg, _ := splitAddrNeg(r.Source); neg {
return true
}
if neg, _ := splitAddrNeg(r.Destination); neg {
return true
}
// ufw's built-in `limit` action is expressed through the CLI/user.rules, so a
// rule carrying exactly that rate stays on the tuple path; any other rate
// limit can only be written as raw iptables in the before.rules files.
if r.RateLimit != nil && !f.isNativeLimit(r) {
return true
}
return false
}
// isNativeLimit reports whether r is expressible as ufw's built-in `limit`
// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s,
// modeled as 12/minute burst 6) and no other modifier the tuple form cannot
// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the
// signature that round-trips through the CLI rather than the before.rules files.
func (f *UFW) isNativeLimit(r *Rule) bool {
// Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which
// UnmarshalRule decodes back into this same shape with Log set. Excluding
// logged limits would route such a rule to the before.rules files even though
// it lives in user.rules, leaving it unremovable there and duplicating it on
// Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit
// carrying one stays false here and is routed to before.rules (which can).
return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" &&
r.RateLimit != nil && *r.RateLimit == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}
}
// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's
// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's
// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre
// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through
// the iptables rules files.
func (f *UFW) protoNeedsRaw(p Protocol) bool {
return p.IsICMP() || p == SCTP
// commentFor returns the comment text ufw should tag a rule with: the configured
// prefix carried alongside the user-supplied comment (prefix + " " + comment), so
// rules this library creates stay identifiable.
func (f *UFW) commentFor(r *Rule) string {
return combineComment(f.rulePrefix, r.Comment)
}
// rewriteToChain rewrites an iptables `-A INPUT/OUTPUT ...` line to use ufw's
@ -845,20 +848,6 @@ func (f *UFW) marshalIPTablesLines(r *Rule, family Family) ([]string, error) {
return out, nil
}
// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP
// protocol pins the family; a family-agnostic rule (e.g. a bare state match)
// touches both the IPv4 and IPv6 files.
func (f *UFW) iptablesFilesFor(r *Rule) []string {
switch r.impliedFamily() {
case IPv4:
return []string{UFWBefore}
case IPv6:
return []string{UFWBefore6}
default:
return []string{UFWBefore, UFWBefore6}
}
}
// parseIPTablesLine parses a raw before.rules line into the rule it represents
// (one line, so a LOG line yields a rule with Log set and no action), reporting
// whether the line is an input/output iptables rule this model surfaces.
@ -991,6 +980,20 @@ func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove
return true, f.writeIPTablesRulesFile(path, out)
}
// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP
// protocol pins the family; a family-agnostic rule (e.g. a bare state match)
// touches both the IPv4 and IPv6 files.
func (f *UFW) iptablesFilesFor(r *Rule) []string {
switch r.impliedFamily() {
case IPv4:
return []string{UFWBefore}
case IPv6:
return []string{UFWBefore6}
default:
return []string{UFWBefore, UFWBefore6}
}
}
// editIPTablesRules applies an add/remove across every before.rules file the rule
// touches, recording whether a reload is needed.
func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
@ -1010,7 +1013,39 @@ func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
return nil
}
// AddRule adds a filter rule to the zone.
// needsIPTablesRules reports whether a rule must be written as raw iptables
// rules rather than through ufw's command line. The ufw CLI and its user.rules
// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log
// prefix or a rate/connection limit, but the before.rules files can. Plain
// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so
// it stays on the CLI path.
func (f *UFW) needsIPTablesRules(r *Rule) bool {
if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil {
return true
}
// ufw's tuple format takes only addresses in from/to; an ipset reference is
// written as a raw before.rules rule (`-m set --match-set`) instead.
if isSetRef(r.Source) || isSetRef(r.Destination) {
return true
}
// ufw's tuple grammar has no address negation, but before.rules can express it
// as `iptables ! -s/-d`, so a negated plain address routes there rather than
// being rejected. (A negated ipset reference is already covered above.)
if neg, _ := splitAddrNeg(r.Source); neg {
return true
}
if neg, _ := splitAddrNeg(r.Destination); neg {
return true
}
// ufw's built-in `limit` action is expressed through the CLI/user.rules, so a
// rule carrying exactly that rate stays on the tuple path; any other rate
// limit can only be written as raw iptables in the before.rules files.
if r.RateLimit != nil && !f.isNativeLimit(r) {
return true
}
return false
}
// ruleArgs builds the argument list for a ufw rule command: the optional
// command verb tokens (e.g. {"prepend"}, {"insert", "3"}, {"delete"}, or none for
// a plain tail append) followed by the marshaled rule spec split into tokens. A
@ -1027,6 +1062,7 @@ func (f *UFW) ruleArgs(r *Rule, verb []string, spec string) []string {
return args
}
// AddRule adds a filter rule to the zone.
func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
// A DirAny rule fans out into an inbound tuple plus its role-swapped outbound
// tuple; add each concrete-direction half (either may route to before.rules).
@ -1061,34 +1097,23 @@ func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return err
}
// commentFor returns the comment text ufw should tag a rule with: the configured
// prefix carried alongside the user-supplied comment (prefix + " " + comment), so
// rules this library creates stay identifiable.
func (f *UFW) commentFor(r *Rule) string {
return combineComment(f.rulePrefix, r.Comment)
}
// nativeInsertPosition maps a 1-based merged position (a rule's Number, as
// GetRules reports it) to the 1-based position ufw's own numbered list uses for
// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4
// tuple then every IPv6 tuple without merging, so the two index spaces diverge
// once a dual-family rule and a single-family rule coexist. The pre-merge tuple
// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so
// the merged position's anchor row in that list is its native position. A position
// past the last logical rule maps past the native count, which ufw rejects and
// InsertRule appends instead.
func (f *UFW) nativeInsertPosition(position int) (int, error) {
v4, err := f.parseTupleRows(UFWIPv4, IPv4)
// appendRule adds a rule at the end of ufw's numbered list with a plain
// `ufw <rule>` (ufw appends a non-inserted rule). It mirrors AddRule but does not
// use `ufw prepend`, so callers that need a tail append — InsertRule past the end,
// and MoveRule to the end — get end placement rather than front placement. Its
// only caller, InsertRule, already diverts raw rules to editIPTablesRules before
// reaching here, so r is always a native ufw rule at this point.
func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
rule, err := f.MarshalRule(r)
if err != nil {
return 0, err
return err
}
v6, err := f.parseTupleRows(UFWIPv6, IPv6)
if err != nil {
return 0, err
args := f.ruleArgs(r, nil, rule)
if c := f.commentFor(r); c != "" {
args = append(args, "comment", c)
}
// Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered
// order.
return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil
_, err = runCommand(ctx, "ufw", args...)
return err
}
// nativeInsertPositionFromRows maps a 1-based merged position to ufw's 1-based
@ -1117,6 +1142,21 @@ func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
return physPos[repIdx]
}
// nativeInsertPosition maps a 1-based merged position (a rule's Number, as
func (f *UFW) nativeInsertPosition(position int) (int, error) {
v4, err := f.parseTupleRows(UFWIPv4, IPv4)
if err != nil {
return 0, err
}
v6, err := f.parseTupleRows(UFWIPv6, IPv6)
if err != nil {
return 0, err
}
// Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered
// order.
return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil
}
// InsertRule inserts rule before the given 1-based position using `ufw insert`.
// position <= 0 is treated as 1; a position larger than the current rule count
// appends the rule (ufw itself rejects an out-of-range position, so that case
@ -1172,37 +1212,6 @@ func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *
return err
}
// appendRule adds a rule at the end of ufw's numbered list with a plain
// `ufw <rule>` (ufw appends a non-inserted rule). It mirrors AddRule but does not
// use `ufw prepend`, so callers that need a tail append — InsertRule past the end,
// and MoveRule to the end — get end placement rather than front placement. Its
// only caller, InsertRule, already diverts raw rules to editIPTablesRules before
// reaching here, so r is always a native ufw rule at this point.
func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
rule, err := f.MarshalRule(r)
if err != nil {
return err
}
args := f.ruleArgs(r, nil, rule)
if c := f.commentFor(r); c != "" {
args = append(args, "comment", c)
}
_, err = runCommand(ctx, "ufw", args...)
return err
}
// MoveRule repositions an existing rule. ufw has no native move verb, so a move
// is a positional delete-then-insert: the rule is removed and re-inserted at the
// requested slot. It is therefore not atomic — if the re-insert fails the rule is
// left removed. A position larger than the rule count moves the rule to the end
// (via InsertRule's append fallback).
func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
if err := f.RemoveRule(ctx, zoneName, r); err != nil {
return err
}
return f.InsertRule(ctx, zoneName, position, r)
}
// RemoveRule removes a filter rule from the zone.
func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
// A DirAny target removes both its inbound and outbound tuple.
@ -1235,6 +1244,18 @@ func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return nil
}
// MoveRule repositions an existing rule. ufw has no native move verb, so a move
// is a positional delete-then-insert: the rule is removed and re-inserted at the
// requested slot. It is therefore not atomic — if the re-insert fails the rule is
// left removed. A position larger than the rule count moves the rule to the end
// (via InsertRule's append fallback).
func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
if err := f.RemoveRule(ctx, zoneName, r); err != nil {
return err
}
return f.InsertRule(ctx, zoneName, position, r)
}
// natHelper returns an iptables backend scoped to ufw's before.rules files, so
// the iptables nat-table machinery (marshal/parse/edit) can be reused: ufw's
// before.rules is loaded through iptables-restore and takes a standard `*nat`
@ -1366,47 +1387,6 @@ func (f *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) erro
return applyBackupPolicy(ctx, f, zoneName, backup)
}
// Reload re-applies edits to the iptables rules files; rules added through the
// ufw CLI apply immediately, but edits to those files only take effect after a
// reload.
func (f *UFW) Reload(ctx context.Context) error {
if f.iptablesRulesChanged {
if _, err := runCommand(ctx, "ufw", "reload"); err != nil {
return err
}
f.iptablesRulesChanged = false
}
return nil
}
// Close releases any resources held by the backend.
func (f *UFW) Close(ctx context.Context) error {
return nil
}
// Capabilities reports the features this backend supports.
func (f *UFW) 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: false,
AddressSets: true,
Comments: true,
}
}
// --- default policy ---------------------------------------------------------
// policyKey is the /etc/default/ufw key for a direction's default policy.
func (f *UFW) policyKey(d Direction) string {
switch d {
@ -1457,16 +1437,23 @@ func (f *UFW) readPolicy() (*DefaultPolicy, error) {
return policy, nil
}
// set assigns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) set(d Direction, a Action) {
switch d {
case DirOutput:
p.Output = a
case DirForward:
p.Forward = a
default:
p.Input = a
// GetDefaultPolicy returns the default filter policy for each direction.
func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return f.readPolicy()
}
// policyValue renders an action as ufw's quoted policy token
// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file.
func (f *UFW) policyValue(a Action) string {
switch a {
case Accept:
return `"ACCEPT"`
case Drop:
return `"DROP"`
case Reject:
return `"REJECT"`
}
return ""
}
// writePolicy writes the default policy for each direction into
@ -1505,36 +1492,6 @@ func (f *UFW) writePolicy(policy *DefaultPolicy) error {
return writeConfigFile(UFWDefaults, []byte(strings.Join(lines, "\n")), 0640)
}
// get returns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) get(d Direction) Action {
switch d {
case DirOutput:
return p.Output
case DirForward:
return p.Forward
}
return p.Input
}
// policyValue renders an action as ufw's quoted policy token
// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file.
func (f *UFW) policyValue(a Action) string {
switch a {
case Accept:
return `"ACCEPT"`
case Drop:
return `"DROP"`
case Reject:
return `"REJECT"`
}
return ""
}
// GetDefaultPolicy returns the default filter policy for each direction.
func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return f.readPolicy()
}
// SetDefaultPolicy sets the default filter policy for each direction.
func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
if policy == nil {
@ -1586,3 +1543,44 @@ func (f *UFW) AddAddressSetEntry(ctx context.Context, name, entry string) error
func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
return f.setHelper().RemoveAddressSetEntry(ctx, name, entry)
}
// Reload re-applies edits to the iptables rules files; rules added through the
// ufw CLI apply immediately, but edits to those files only take effect after a
// reload.
func (f *UFW) Reload(ctx context.Context) error {
if f.iptablesRulesChanged {
if _, err := runCommand(ctx, "ufw", "reload"); err != nil {
return err
}
f.iptablesRulesChanged = false
}
return nil
}
// Close releases any resources held by the backend.
func (f *UFW) Close(ctx context.Context) error {
return nil
}
// get returns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) get(d Direction) Action {
switch d {
case DirOutput:
return p.Output
case DirForward:
return p.Forward
}
return p.Input
}
// set assigns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) set(d Direction, a Action) {
switch d {
case DirOutput:
p.Output = a
case DirForward:
p.Forward = a
default:
p.Input = a
}
}

View file

@ -59,6 +59,26 @@ func (f *WF) Type() string {
return WFType
}
// Capabilities returns the set of features the Windows firewall backend can express.
func (f *WF) Capabilities() Capabilities {
return Capabilities{
Output: true,
ICMPv6: true,
PortList: true,
ConnState: false,
InterfaceMatch: false,
Logging: false,
RateLimit: false,
ConnLimit: false,
NAT: false,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
Comments: true,
}
}
// GetZone reports no zone; Windows Firewall is profile-based, so an interface maps to no single zone.
func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
if err := ctx.Err(); err != nil {
@ -371,6 +391,29 @@ func (f *WF) hasPrefix(fr wapi.FWRule) bool {
return f.rulePrefix != "" && strings.HasPrefix(fr.Name, f.rulePrefix+" ")
}
// profileFilter maps a zone name to the single Windows profile bit that GetRules
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
// zone names no specific profile, meaning every profile is in scope (matching an
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
switch {
case strings.EqualFold(zoneName, "public"):
return wapi.NET_FW_PROFILE2_PUBLIC, true
case strings.EqualFold(zoneName, "private"):
return wapi.NET_FW_PROFILE2_PRIVATE, true
case strings.EqualFold(zoneName, "domain"):
return wapi.NET_FW_PROFILE2_DOMAIN, true
}
return 0, false
}
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
if !useFilter {
return true
}
return rulesProfiles == filterProfile
}
// GetRules returns the existing filter rules from the zone.
func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
if err := ctx.Err(); err != nil {
@ -415,39 +458,6 @@ func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err
return rules, nil
}
// profileFilter maps a zone name to the single Windows profile bit that GetRules
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
// zone names no specific profile, meaning every profile is in scope (matching an
// AddRule that stored the rule under the all-profiles default). A rule matches only
// when its Profiles exactly equals this single bit: AddRule always stores a named
// zone's rule under exactly one profile bit or the all-profiles default (never a
// combination), so an exact-equality test is what keeps a specific zone's rules
// disjoint from another zone's and from an all-profiles rule. Testing overlap
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
// affecting every other zone too.
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
switch {
case strings.EqualFold(zoneName, "public"):
return wapi.NET_FW_PROFILE2_PUBLIC, true
case strings.EqualFold(zoneName, "private"):
return wapi.NET_FW_PROFILE2_PRIVATE, true
case strings.EqualFold(zoneName, "domain"):
return wapi.NET_FW_PROFILE2_DOMAIN, true
}
return 0, false
}
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
// profileFilter result: every rule matches when useFilter is false, otherwise
// only a rule scoped to exactly filterProfile (see profileFilter on why exact).
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
if !useFilter {
return true
}
return rulesProfiles == filterProfile
}
// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone.
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
// The Windows Firewall rule model has only inbound and outbound directions;
@ -645,7 +655,14 @@ func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
return fwRule, nil
}
// AddRule adds a rule to the zone.
// AddRule that stored the rule under the all-profiles default). A rule matches only
// when its Profiles exactly equals this single bit: AddRule always stores a named
// zone's rule under exactly one profile bit or the all-profiles default (never a
// combination), so an exact-equality test is what keeps a specific zone's rules
// disjoint from another zone's and from an all-profiles rule. Testing overlap
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
// affecting every other zone too.
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
if err := ctx.Err(); err != nil {
return err
@ -785,34 +802,54 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return nil
}
// Reload is a no-op; Windows Firewall applies rule changes immediately.
func (f *WF) Reload(ctx context.Context) error {
return nil
// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on
// Windows is handled out of band (netsh portproxy or RRAS).
func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
return nil, unsupportedNAT(f.Type())
}
// Close releases any resources held by the backend; the Windows firewall holds none.
func (f *WF) Close(ctx context.Context) error {
return nil
// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// Capabilities returns the set of features the Windows firewall backend can express.
func (f *WF) Capabilities() Capabilities {
return Capabilities{
Output: true,
ICMPv6: true,
PortList: true,
ConnState: false,
InterfaceMatch: false,
Logging: false,
RateLimit: false,
ConnLimit: false,
NAT: false,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
Comments: true,
// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// Backup captures the current filter rules managed by this backend.
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
if err != nil {
return nil, err
}
// Backup captures the full filter rule state; Restore reconciles the live rules
// to this set, so every rule read is preserved.
return &Backup{Rules: rules}, nil
}
// Restore replaces the managed rules with the contents of a Backup.
func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Reconcile the live rule set to the backup with a minimal add/remove diff
// rather than removing every rule and re-adding it. Removing all rules first
// leaves a window with no matching filter, and WFP drops in-flight connections
// that no longer match one — including a foreign inbound-allow rule the backup
// itself captured (e.g. the rule keeping this host reachable over SSH while a
// remote restore runs). Sync leaves a rule present in both the firewall and the
// backup untouched, so such a rule is never briefly removed. WFP has no NAT, so
// backup.NATRules is not applied here.
_, _, err := Sync(ctx, f, zoneName, backup.Rules)
return err
}
// GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
@ -855,52 +892,12 @@ func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) erro
return unsupportedSet(f.Type())
}
// Backup captures the current filter rules managed by this backend.
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
if err != nil {
return nil, err
}
// Backup captures the full filter rule state; Restore reconciles the live rules
// to this set, so every rule read is preserved.
return &Backup{Rules: rules}, nil
// Reload is a no-op; Windows Firewall applies rule changes immediately.
func (f *WF) Reload(ctx context.Context) error {
return nil
}
// Restore replaces the managed rules with the contents of a Backup.
func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Reconcile the live rule set to the backup with a minimal add/remove diff
// rather than removing every rule and re-adding it. Removing all rules first
// leaves a window with no matching filter, and WFP drops in-flight connections
// that no longer match one — including a foreign inbound-allow rule the backup
// itself captured (e.g. the rule keeping this host reachable over SSH while a
// remote restore runs). Sync leaves a rule present in both the firewall and the
// backup untouched, so such a rule is never briefly removed. WFP has no NAT, so
// backup.NATRules is not applied here.
_, _, err := Sync(ctx, f, zoneName, backup.Rules)
return err
}
// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on
// Windows is handled out of band (netsh portproxy or RRAS).
func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
return nil, unsupportedNAT(f.Type())
}
// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
// Close releases any resources held by the backend; the Windows firewall holds none.
func (f *WF) Close(ctx context.Context) error {
return nil
}