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:
parent
9b747f7acb
commit
073c9ad7f0
14 changed files with 5153 additions and 4625 deletions
66
apf_linux.go
66
apf_linux.go
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1360
csf_linux.go
1360
csf_linux.go
File diff suppressed because it is too large
Load diff
30
firewall.go
30
firewall.go
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
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)
|
||||
// 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 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()
|
||||
}
|
||||
|
|
|
|||
230
hooks_linux.go
230
hooks_linux.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
2713
iptables_linux.go
2713
iptables_linux.go
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
1587
nft_linux.go
1587
nft_linux.go
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
|
|
|
|||
874
ufw_linux.go
874
ufw_linux.go
|
|
@ -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 nil, err
|
||||
}
|
||||
return f.zeroNet(fam)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
203
wf_windows.go
203
wf_windows.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue