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,
|
RuleOrdering: false,
|
||||||
DefaultPolicy: false,
|
DefaultPolicy: false,
|
||||||
RuleCounters: false,
|
RuleCounters: false,
|
||||||
AddressSets: false,
|
AddressSets: true,
|
||||||
Comments: true,
|
Comments: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1907,9 +1907,14 @@ func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Backup captures the full filter and NAT rule state; Restore removes the
|
// Backup captures the full filter and NAT rule state plus the hook's address
|
||||||
// current rules and re-adds these, so every rule read is preserved.
|
// sets; Restore removes the current rules and re-adds these, so every rule read
|
||||||
return &Backup{Rules: rules, NATRules: natRules}, nil
|
// 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.
|
// 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.
|
// Re-add rules from backup.
|
||||||
for _, r := range backup.Rules {
|
for _, r := range backup.Rules {
|
||||||
if err := f.addRule(ctx, zoneName, r, false); err != nil {
|
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())
|
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) {
|
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) {
|
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 {
|
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 {
|
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 {
|
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 {
|
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.
|
// 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
|
// Logging, rate limiting, connection-state and interface matching are now
|
||||||
// expressed by injecting iptables rules through the pre-hook (see
|
// expressed by injecting iptables rules through the pre-hook (see
|
||||||
// TestHookScriptRoundTrip), so those route to the hook rather than being
|
// TestHookScriptRoundTrip), and address sets by persisting ipset commands in
|
||||||
// rejected. What remains genuinely unsupported: address sets and explicit
|
// that same hook, so those route to the hook rather than being rejected. What
|
||||||
// rule ordering on both backends, plus source NAT on CSF.
|
// remains genuinely unsupported: explicit rule ordering on both backends, plus
|
||||||
|
// source NAT on CSF.
|
||||||
for _, m := range []Manager{csf, apf} {
|
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,
|
require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering,
|
||||||
"%s should reject explicit ordering", m.Type())
|
"%s should reject explicit ordering", m.Type())
|
||||||
// NAT ordering is unsupported on both even though they store NAT rules.
|
// NAT ordering is unsupported on both even though they store NAT rules.
|
||||||
|
|
@ -41,9 +40,9 @@ func TestSentinelErrors(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
csf := &CSF{}
|
csf := &CSF{}
|
||||||
|
|
||||||
// NAT, policy and address-set rejections carry their specific sentinel. CSF
|
// NAT and policy rejections carry their specific sentinel. CSF supports
|
||||||
// supports destination NAT (csf.redirect) but not source NAT, so a SNAT rule
|
// destination NAT (csf.redirect) but not source NAT, so a SNAT rule carries the
|
||||||
// carries the NAT sentinel.
|
// NAT sentinel.
|
||||||
nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
|
nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
|
||||||
err := csf.AddNATRule(ctx, "", nat)
|
err := csf.AddNATRule(ctx, "", nat)
|
||||||
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
||||||
|
|
@ -52,9 +51,6 @@ func TestSentinelErrors(t *testing.T) {
|
||||||
_, err = csf.GetDefaultPolicy(ctx, "")
|
_, err = csf.GetDefaultPolicy(ctx, "")
|
||||||
require.ErrorIs(t, err, ErrUnsupportedPolicy)
|
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).
|
// 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")
|
err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf")
|
||||||
require.ErrorIs(t, err, ErrUnsupportedLog)
|
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().DefaultPolicy)
|
||||||
require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets")
|
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().RuleCounters)
|
||||||
require.False(t, csf.Capabilities().DefaultPolicy)
|
require.False(t, csf.Capabilities().DefaultPolicy)
|
||||||
require.False(t, csf.Capabilities().AddressSets)
|
|
||||||
// CSF does express (destination) NAT through csf.redirect and per-port
|
// CSF does express (destination) NAT through csf.redirect and per-port
|
||||||
// connection limiting through CONNLIMIT.
|
// connection limiting through CONNLIMIT.
|
||||||
require.True(t, csf.Capabilities().NAT)
|
require.True(t, csf.Capabilities().NAT)
|
||||||
require.True(t, csf.Capabilities().ConnLimit)
|
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
|
// 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().NAT)
|
||||||
require.True(t, apf.Capabilities().ConnLimit)
|
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
|
// Both gain logging, rate limiting, connection-state, interface matching and
|
||||||
// forward-chain rules by injecting iptables rules through the pre-hook.
|
// 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 removes a NAT rule from the zone.
|
||||||
RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error
|
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
|
// GetDefaultPolicy returns the default action applied to packets that match
|
||||||
// no rule. A direction the backend cannot express is returned as
|
// no rule. A direction the backend cannot express is returned as
|
||||||
// ActionInvalid. Backends that cannot manage a default policy at all return
|
// 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.
|
// cannot manage address sets return an unsupported error.
|
||||||
RemoveAddressSetEntry(ctx context.Context, name, entry string) 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 reloads the manager to activate new rules.
|
||||||
Reload(ctx context.Context) error
|
Reload(ctx context.Context) error
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,34 +14,6 @@ import (
|
||||||
// FirewallDType is the backend identifier reported by FirewallD.Type.
|
// FirewallDType is the backend identifier reported by FirewallD.Type.
|
||||||
const FirewallDType = "firewalld"
|
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
|
// NewFirewallD connects to firewalld and returns a manager, or an error when
|
||||||
// firewalld cannot be reached.
|
// firewalld cannot be reached.
|
||||||
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
||||||
|
|
@ -63,6 +35,27 @@ func (f *FirewallD) Type() string {
|
||||||
return FirewallDType
|
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
|
// GetZone returns the firewalld zone bound to the interface, falling back to the
|
||||||
// default zone when the interface is unbound.
|
// default zone when the interface is unbound.
|
||||||
func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
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")
|
return "", fmt.Errorf("unable to find zone")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveZoneName substitutes the default zone when zoneName is empty. The rest
|
// icmpTypeTable selects the IPv4 or IPv6 name/number table by family.
|
||||||
// of go-firewall treats an empty zone as "the default" (zoneless backends ignore
|
func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 {
|
||||||
// it entirely), but firewalld's permanent config interface rejects an empty zone
|
if isV6 {
|
||||||
// name with INVALID_ZONE, so every zone-scoped method resolves it here first.
|
return fwICMPv6Types
|
||||||
func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) {
|
|
||||||
if zoneName != "" {
|
|
||||||
return zoneName, nil
|
|
||||||
}
|
}
|
||||||
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
|
// splitRichRuleFields tokenizes a firewalld rich rule on whitespace while
|
||||||
|
|
@ -427,6 +424,131 @@ func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
||||||
return
|
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
|
// protoValue returns the protocol name firewalld's `protocol value=` element
|
||||||
// expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols.
|
// expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols.
|
||||||
func (f *FirewallD) protoValue(p Protocol) string {
|
func (f *FirewallD) protoValue(p Protocol) string {
|
||||||
|
|
@ -472,32 +594,6 @@ var fwICMPv6Types = map[string]uint8{
|
||||||
"redirect": 137,
|
"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
|
// rateUnit maps a RateUnit to the single-letter time unit a firewalld rich
|
||||||
// rule's `limit value="N/unit"` expects (s/m/h/d).
|
// rule's `limit value="N/unit"` expects (s/m/h/d).
|
||||||
func (f *FirewallD) rateUnit(u RateUnit) string {
|
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
|
return strings.Join(parts, " "), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts)
|
// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an
|
||||||
// to allow rules, one per entry. source selects whether the range binds to the
|
// add idempotent: re-adding an element that is already present is a no-op.
|
||||||
// source-port or destination-port fields. A port on an unmodeled protocol (e.g.
|
func (f *FirewallD) ignoreAlreadyEnabled(err error) error {
|
||||||
// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible
|
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
|
||||||
// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject.
|
return nil
|
||||||
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRules returns the filter rules for a zone, resolving an empty zone to the default.
|
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
|
||||||
func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
// map to a firewalld zone source: no protocol match, no destination port, no
|
||||||
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
// source port, and a non-negated source. A source combined with a concrete
|
||||||
if err != nil {
|
// protocol or a port is a rich rule (firewalld encodes those as
|
||||||
return
|
// `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
|
||||||
// Get the zone settings.
|
// routing stays symmetric; they differ only in which source *forms* they accept (a
|
||||||
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
// MAC source is added as a rich rule but removed via the zone-source path).
|
||||||
if err != nil {
|
func (f *FirewallD) sourceZoneShape(r *Rule) bool {
|
||||||
return
|
return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny &&
|
||||||
}
|
!r.HasPorts() && !r.HasSourcePorts()
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// zoneEntryEligible reports whether a rule can be expressed as a firewalld
|
// 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))
|
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
|
// 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>).
|
// 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 {
|
func (f *FirewallD) isZoneSource(s string) bool {
|
||||||
if _, _, err := net.ParseCIDR(s); err == nil {
|
if _, _, err := net.ParseCIDR(s); err == nil {
|
||||||
return true
|
return true
|
||||||
|
|
@ -937,21 +981,8 @@ func (f *FirewallD) isZoneSource(s string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
|
// RemoveRule uses it to decide whether a bare source can be cleared with
|
||||||
// map to a firewalld zone source: no protocol match, no destination port, no
|
// RemoveSource rather than falling through to the rich-rule path.
|
||||||
// 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.
|
|
||||||
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
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),
|
// 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.
|
// 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))
|
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.
|
// 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) {
|
func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
|
||||||
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
||||||
|
|
@ -1213,6 +1208,42 @@ func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*
|
||||||
return rules, nil
|
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.
|
// 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 {
|
func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
if err := r.validate(); err != nil {
|
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")
|
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.
|
// 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 {
|
func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
// Get the zone.
|
// Get the zone.
|
||||||
|
|
@ -1272,23 +1309,6 @@ func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRu
|
||||||
return fmt.Errorf("invalid nat kind")
|
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.
|
// Backup captures the current filter and NAT rules managed by this backend.
|
||||||
func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
||||||
rules, err := f.GetRules(ctx, zoneName)
|
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)
|
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
|
// policyFromTarget maps a firewalld zone target to a default action. The
|
||||||
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
|
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
|
||||||
// accepts explicitly being ACCEPT and DROP.
|
// accepts explicitly being ACCEPT and DROP.
|
||||||
|
|
@ -1481,34 +1470,6 @@ func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, polic
|
||||||
|
|
||||||
// --- address sets (firewalld ipsets) ----------------------------------------
|
// --- 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.
|
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
|
||||||
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
||||||
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
|
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
|
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.
|
// 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) {
|
func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
||||||
set, err := f.getAddressSet(ctx, name)
|
set, err := f.getAddressSet(ctx, name)
|
||||||
|
|
@ -1543,6 +1524,14 @@ func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet
|
||||||
return set, nil
|
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.
|
// AddAddressSet creates the permanent ipset, or updates it in place when it already exists.
|
||||||
func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
||||||
if set == nil || set.Name == "" {
|
if set == nil || set.Name == "" {
|
||||||
|
|
@ -1609,3 +1598,13 @@ func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry strin
|
||||||
}
|
}
|
||||||
return err
|
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
|
// 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
|
// 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,
|
// 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
|
// 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 {
|
func ruleNeedsHook(r *Rule) bool {
|
||||||
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
|
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
|
// 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 {
|
if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny {
|
||||||
return false
|
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 != "")
|
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)
|
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
|
// A forward rule has no native CSF/APF config path, so it routes through the
|
||||||
// raw-iptables hook (which emits an -A FORWARD rule).
|
// raw-iptables hook (which emits an -A FORWARD rule).
|
||||||
{Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept},
|
{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 {
|
for _, r := range needs {
|
||||||
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
|
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)
|
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.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, "iptables.service", l.ip4Service)
|
||||||
require.Equal(t, "ip6tables.service", l.ip6Service)
|
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.
|
// RHEL layout: v4 present but v6 missing is an incomplete pair, not a match.
|
||||||
root = t.TempDir()
|
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.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, "netfilter-persistent.service", l.ip4Service)
|
||||||
require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit")
|
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) {
|
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)
|
# 1: receiver variable (None for a plain function)
|
||||||
# 2: receiver type (None for a plain function)
|
# 2: receiver type (None for a plain function)
|
||||||
# 3: function name
|
# 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):
|
def split_blocks(text):
|
||||||
|
|
|
||||||
874
ufw_linux.go
874
ufw_linux.go
|
|
@ -137,31 +137,105 @@ func (f *UFW) Type() string {
|
||||||
return UFWType
|
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.
|
// GetZone reports no zone; ufw has no zone support.
|
||||||
func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to
|
// ipTablesChain maps a ufw iptables chain to a rule direction, reporting
|
||||||
// the IPv4 form when the family is unspecified.
|
// whether it is one this backend surfaces. Internal chains (logging, not-local,
|
||||||
func (f *UFW) zeroNet(fam Family) string {
|
// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`)
|
||||||
if fam == IPv6 {
|
// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its
|
||||||
return "::/0"
|
// 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
|
// ParseIPTablesRules parses a ufw before/after rules file, which is in
|
||||||
// endpoint when ufw's grammar forces one. A concrete family uses its
|
// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line on
|
||||||
// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw
|
// an input/output/forward chain is reparsed with the iptables rulespec parser;
|
||||||
// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is
|
// lines whose match or action this model cannot represent are skipped.
|
||||||
// family-specific) would silently pin the rule to a single family and break the
|
func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) {
|
||||||
// round-trip back to FamilyAny.
|
fd, err := os.Open(filePath)
|
||||||
func (f *UFW) anyAddr(fam Family) string {
|
if err != nil {
|
||||||
if fam == FamilyAny {
|
// A missing iptables rules file simply contributes no rules.
|
||||||
return "any"
|
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
|
// 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
|
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.
|
// MarshalRule encodes a firewall rule into a ufw rulespec.
|
||||||
func (f *UFW) MarshalRule(r *Rule) (string, error) {
|
func (f *UFW) MarshalRule(r *Rule) (string, error) {
|
||||||
// Features this backend cannot express in its rule model are rejected up
|
// 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
|
return strings.Join(parts, " "), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###`
|
// commentFor returns the comment text ufw should tag a rule with: the configured
|
||||||
// line, in file order: the parsed rule, or nil for a non-empty tuple this backend
|
// prefix carried alongside the user-supplied comment (prefix + " " + comment), so
|
||||||
// does not model (one that fails to parse). ufw counts every tuple in its own
|
// rules this library creates stay identifiable.
|
||||||
// numbered list, so keeping such rows as nil lets callers map a representable rule
|
func (f *UFW) commentFor(r *Rule) string {
|
||||||
// to its true physical position. Only a tuple whose body is empty after stripping
|
return combineComment(f.rulePrefix, r.Comment)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteToChain rewrites an iptables `-A INPUT/OUTPUT ...` line to use ufw's
|
// 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
|
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
|
// 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
|
// (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.
|
// 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)
|
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
|
// editIPTablesRules applies an add/remove across every before.rules file the rule
|
||||||
// touches, recording whether a reload is needed.
|
// touches, recording whether a reload is needed.
|
||||||
func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
|
func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
|
||||||
|
|
@ -1010,7 +1013,39 @@ func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
|
||||||
return nil
|
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
|
// ruleArgs builds the argument list for a ufw rule command: the optional
|
||||||
// command verb tokens (e.g. {"prepend"}, {"insert", "3"}, {"delete"}, or none for
|
// 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
|
// 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
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddRule adds a filter rule to the zone.
|
||||||
func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
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
|
// 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).
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// commentFor returns the comment text ufw should tag a rule with: the configured
|
// appendRule adds a rule at the end of ufw's numbered list with a plain
|
||||||
// prefix carried alongside the user-supplied comment (prefix + " " + comment), so
|
// `ufw <rule>` (ufw appends a non-inserted rule). It mirrors AddRule but does not
|
||||||
// rules this library creates stay identifiable.
|
// use `ufw prepend`, so callers that need a tail append — InsertRule past the end,
|
||||||
func (f *UFW) commentFor(r *Rule) string {
|
// and MoveRule to the end — get end placement rather than front placement. Its
|
||||||
return combineComment(f.rulePrefix, r.Comment)
|
// 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 {
|
||||||
// nativeInsertPosition maps a 1-based merged position (a rule's Number, as
|
rule, err := f.MarshalRule(r)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
v6, err := f.parseTupleRows(UFWIPv6, IPv6)
|
args := f.ruleArgs(r, nil, rule)
|
||||||
if err != nil {
|
if c := f.commentFor(r); c != "" {
|
||||||
return 0, err
|
args = append(args, "comment", c)
|
||||||
}
|
}
|
||||||
// Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered
|
_, err = runCommand(ctx, "ufw", args...)
|
||||||
// order.
|
return err
|
||||||
return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeInsertPositionFromRows maps a 1-based merged position to ufw's 1-based
|
// 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]
|
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`.
|
// 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
|
// 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
|
// 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
|
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.
|
// RemoveRule removes a filter rule from the zone.
|
||||||
func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
// A DirAny target removes both its inbound and outbound tuple.
|
// 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
|
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
|
// 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
|
// 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`
|
// 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)
|
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.
|
// policyKey is the /etc/default/ufw key for a direction's default policy.
|
||||||
func (f *UFW) policyKey(d Direction) string {
|
func (f *UFW) policyKey(d Direction) string {
|
||||||
switch d {
|
switch d {
|
||||||
|
|
@ -1457,16 +1437,23 @@ func (f *UFW) readPolicy() (*DefaultPolicy, error) {
|
||||||
return policy, nil
|
return policy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// set assigns the action for a direction on a DefaultPolicy.
|
// GetDefaultPolicy returns the default filter policy for each direction.
|
||||||
func (p *DefaultPolicy) set(d Direction, a Action) {
|
func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
||||||
switch d {
|
return f.readPolicy()
|
||||||
case DirOutput:
|
}
|
||||||
p.Output = a
|
|
||||||
case DirForward:
|
// policyValue renders an action as ufw's quoted policy token
|
||||||
p.Forward = a
|
// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file.
|
||||||
default:
|
func (f *UFW) policyValue(a Action) string {
|
||||||
p.Input = a
|
switch a {
|
||||||
|
case Accept:
|
||||||
|
return `"ACCEPT"`
|
||||||
|
case Drop:
|
||||||
|
return `"DROP"`
|
||||||
|
case Reject:
|
||||||
|
return `"REJECT"`
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePolicy writes the default policy for each direction into
|
// 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)
|
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.
|
// SetDefaultPolicy sets the default filter policy for each direction.
|
||||||
func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
||||||
if policy == nil {
|
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 {
|
func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
||||||
return f.setHelper().RemoveAddressSetEntry(ctx, name, entry)
|
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
|
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.
|
// 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) {
|
func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
||||||
if err := ctx.Err(); err != nil {
|
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+" ")
|
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.
|
// GetRules returns the existing filter rules from the zone.
|
||||||
func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
||||||
if err := ctx.Err(); err != nil {
|
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
|
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.
|
// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone.
|
||||||
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
|
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
|
||||||
// The Windows Firewall rule model has only inbound and outbound directions;
|
// 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
|
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 {
|
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -785,34 +802,54 @@ func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload is a no-op; Windows Firewall applies rule changes immediately.
|
// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on
|
||||||
func (f *WF) Reload(ctx context.Context) error {
|
// Windows is handled out of band (netsh portproxy or RRAS).
|
||||||
return nil
|
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.
|
// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
func (f *WF) Close(ctx context.Context) error {
|
func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
return nil
|
return unsupportedNAT(f.Type())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capabilities returns the set of features the Windows firewall backend can express.
|
// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
func (f *WF) Capabilities() Capabilities {
|
func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
||||||
return Capabilities{
|
return unsupportedNAT(f.Type())
|
||||||
Output: true,
|
}
|
||||||
ICMPv6: true,
|
|
||||||
PortList: true,
|
// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
ConnState: false,
|
func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
InterfaceMatch: false,
|
return unsupportedNAT(f.Type())
|
||||||
Logging: false,
|
}
|
||||||
RateLimit: false,
|
|
||||||
ConnLimit: false,
|
// Backup captures the current filter rules managed by this backend.
|
||||||
NAT: false,
|
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
||||||
RuleOrdering: false,
|
rules, err := f.GetRules(ctx, zoneName)
|
||||||
DefaultPolicy: false,
|
if err != nil {
|
||||||
RuleCounters: false,
|
return nil, err
|
||||||
AddressSets: false,
|
|
||||||
Comments: true,
|
|
||||||
}
|
}
|
||||||
|
// 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.
|
// 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())
|
return unsupportedSet(f.Type())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup captures the current filter rules managed by this backend.
|
// Reload is a no-op; Windows Firewall applies rule changes immediately.
|
||||||
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
func (f *WF) Reload(ctx context.Context) error {
|
||||||
rules, err := f.GetRules(ctx, zoneName)
|
return nil
|
||||||
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.
|
// Close releases any resources held by the backend; the Windows firewall holds none.
|
||||||
func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
func (f *WF) Close(ctx context.Context) error {
|
||||||
if backup == nil {
|
return 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())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue