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.
1610 lines
58 KiB
Go
1610 lines
58 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
|
|
firewalld "github.com/grmrgecko/go-firewalld"
|
|
)
|
|
|
|
// FirewallDType is the backend identifier reported by FirewallD.Type.
|
|
const FirewallDType = "firewalld"
|
|
|
|
// NewFirewallD connects to firewalld and returns a manager, or an error when
|
|
// firewalld cannot be reached.
|
|
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
|
|
// Attempt to connect, and failure means no firewalld.
|
|
conn, err := firewalld.Connect(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = conn.DefaultZone(ctx)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("firewalld cannot be reached: %s", err)
|
|
}
|
|
return &FirewallD{Conn: conn, rulePrefix: rulePrefix}, nil
|
|
}
|
|
|
|
// Type returns the backend identifier for firewalld.
|
|
func (f *FirewallD) Type() string {
|
|
return FirewallDType
|
|
}
|
|
|
|
// Capabilities reports which optional features this backend supports.
|
|
func (f *FirewallD) Capabilities() Capabilities {
|
|
return Capabilities{
|
|
Output: false,
|
|
Zones: true,
|
|
Priority: true,
|
|
ICMPv6: true,
|
|
PortList: false,
|
|
ConnState: false,
|
|
InterfaceMatch: false,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: false,
|
|
NAT: true,
|
|
RuleOrdering: false,
|
|
DefaultPolicy: true,
|
|
RuleCounters: false,
|
|
AddressSets: true,
|
|
}
|
|
}
|
|
|
|
// GetZone returns the firewalld zone bound to the interface, falling back to the
|
|
// default zone when the interface is unbound.
|
|
func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
|
// Ask firewalld directly which permanent zone the interface is bound to.
|
|
// This returns the zone id (e.g. "public"), which is what the other backend
|
|
// methods expect to pass back into Permanent().Zone. An empty result or an
|
|
// error means the interface is not bound to a zone, so we fall through to
|
|
// the default zone below.
|
|
zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface)
|
|
if err == nil && zoneName != "" {
|
|
return zoneName, nil
|
|
}
|
|
|
|
// If we did not find a zone for the specified interface, use the default
|
|
// zone if it exists.
|
|
defaultZone, derr := f.Conn.DefaultZone(ctx)
|
|
if derr == nil && defaultZone != "" {
|
|
return defaultZone, nil
|
|
}
|
|
|
|
// If we were unable to find the zone or a default zone, error out.
|
|
return "", fmt.Errorf("unable to find zone")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
// keeping a double-quoted value as a single token, quotes included (so the
|
|
// existing trimQuotes callers still work). firewalld quotes rich-rule attribute
|
|
// values, and some — a log prefix, an address — legitimately contain spaces;
|
|
// plain strings.Fields would split those and break the parse.
|
|
func (f *FirewallD) splitRichRuleFields(s string) []string {
|
|
var tokens []string
|
|
var b strings.Builder
|
|
inQuote := false
|
|
flush := func() {
|
|
if b.Len() > 0 {
|
|
tokens = append(tokens, b.String())
|
|
b.Reset()
|
|
}
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '"':
|
|
inQuote = !inQuote
|
|
b.WriteRune(r)
|
|
case (r == ' ' || r == '\t') && !inQuote:
|
|
flush()
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
flush()
|
|
return tokens
|
|
}
|
|
|
|
// UnmarshalRichRule takes a rich-rule string and returns a parsed rule for supported rules.
|
|
func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) {
|
|
// Setup new rule.
|
|
r = new(Rule)
|
|
|
|
// Get tokens for rule. splitRichRuleFields keeps a quoted value with spaces
|
|
// as a single token (see its doc).
|
|
tokens := f.splitRichRuleFields(richRule)
|
|
if len(tokens) == 0 {
|
|
return nil, fmt.Errorf("empty rule")
|
|
}
|
|
|
|
// Confirm this is a rich rule.
|
|
if tokens[0] != "rule" {
|
|
return nil, fmt.Errorf("invalid rule format")
|
|
}
|
|
|
|
// Process the rule.
|
|
for i := 1; i < len(tokens); i++ {
|
|
// Check the token type and parse.
|
|
if strings.HasPrefix(tokens[i], "family=") {
|
|
// Family can only be IPv4 or IPv6.
|
|
family := trimQuotes(strings.TrimPrefix(tokens[i], "family="))
|
|
if strings.EqualFold(family, "ipv4") {
|
|
r.Family = IPv4
|
|
} else if strings.EqualFold(family, "ipv6") {
|
|
r.Family = IPv6
|
|
} else {
|
|
return nil, fmt.Errorf("invalid family value")
|
|
}
|
|
} else if strings.HasPrefix(tokens[i], "priority=") {
|
|
// Parse the priority int.
|
|
priority := trimQuotes(strings.TrimPrefix(tokens[i], "priority="))
|
|
p, err := strconv.Atoi(priority)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.Priority = p
|
|
} else if tokens[i] == "source" {
|
|
// The source must contain at least one value after.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing source value")
|
|
}
|
|
|
|
// It is possible to define as a NOT match by adding NOT.
|
|
not := false
|
|
if strings.EqualFold(tokens[i], "NOT") {
|
|
not = true
|
|
|
|
// Check that there is a source defined after the not.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing source value")
|
|
}
|
|
}
|
|
|
|
// Check the source value, to parse out the type.
|
|
source := tokens[i]
|
|
if strings.HasPrefix(source, "address=") {
|
|
address := trimQuotes(strings.TrimPrefix(source, "address="))
|
|
if not {
|
|
r.Source = "!" + address
|
|
} else {
|
|
r.Source = address
|
|
}
|
|
} else if strings.HasPrefix(source, "mac=") {
|
|
mac := trimQuotes(strings.TrimPrefix(source, "mac="))
|
|
if not {
|
|
r.Source = "!" + mac
|
|
} else {
|
|
r.Source = mac
|
|
}
|
|
} else if strings.HasPrefix(source, "ipset=") {
|
|
ipset := trimQuotes(strings.TrimPrefix(source, "ipset="))
|
|
if not {
|
|
r.Source = "!" + ipset
|
|
} else {
|
|
r.Source = ipset
|
|
}
|
|
} else { // If the source is not defined on a none key=value, source is invalid.
|
|
return nil, fmt.Errorf("the source argument has no type defined")
|
|
}
|
|
} else if tokens[i] == "destination" {
|
|
// The destination must contain at least one value after.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing destination value")
|
|
}
|
|
|
|
// It is possible to define as a NOT match by adding NOT.
|
|
not := false
|
|
if strings.EqualFold(tokens[i], "NOT") {
|
|
not = true
|
|
|
|
// Check that there is a destination defined after the not.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing destination value")
|
|
}
|
|
}
|
|
|
|
// Parse the destination, which firewalld expresses as either an address
|
|
// or an ipset (mirroring the source grammar).
|
|
if strings.HasPrefix(tokens[i], "address=") {
|
|
address := trimQuotes(strings.TrimPrefix(tokens[i], "address="))
|
|
if not {
|
|
r.Destination = "!" + address
|
|
} else {
|
|
r.Destination = address
|
|
}
|
|
r.Direction = DirOutput
|
|
} else if strings.HasPrefix(tokens[i], "ipset=") {
|
|
ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset="))
|
|
if not {
|
|
r.Destination = "!" + ipset
|
|
} else {
|
|
r.Destination = ipset
|
|
}
|
|
r.Direction = DirOutput
|
|
} else {
|
|
return nil, fmt.Errorf("the destination argument has no address or ipset defined")
|
|
}
|
|
} else if tokens[i] == "log" {
|
|
// Record that the rule logs, and capture the optional prefix. Any
|
|
// key=value qualifiers that follow (prefix="...", level="...") are
|
|
// consumed here; only the prefix is stored, the rest are ignored.
|
|
r.Log = true
|
|
for i+1 < len(tokens) && strings.Contains(tokens[i+1], "=") {
|
|
q := tokens[i+1]
|
|
if strings.HasPrefix(q, "prefix=") {
|
|
r.LogPrefix = trimQuotes(strings.TrimPrefix(q, "prefix="))
|
|
}
|
|
i++
|
|
}
|
|
} else if tokens[i] == "limit" {
|
|
// A rule-level rate limit: limit value="N/unit" where unit is one of
|
|
// s/m/h/d. Parse it into the rule's RateLimit.
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing limit value")
|
|
}
|
|
if !strings.HasPrefix(tokens[i], "value=") {
|
|
return nil, fmt.Errorf("the limit element has no value")
|
|
}
|
|
val := trimQuotes(strings.TrimPrefix(tokens[i], "value="))
|
|
num, unitStr, ok := strings.Cut(val, "/")
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid limit value %q", val)
|
|
}
|
|
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid limit value %q", val)
|
|
}
|
|
unit, err := ParseRateUnit(unitStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.RateLimit = &RateLimit{Rate: uint(n), Unit: unit}
|
|
} else if tokens[i] == "audit" {
|
|
// Ignore audit element.
|
|
} else if tokens[i] == "port" {
|
|
// The port must contain the port and protocol definitions.
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("missing port parameters")
|
|
}
|
|
i++
|
|
|
|
// Parse the port parameter, which may be a single port or a dash
|
|
// range such as "1000-2000".
|
|
if strings.HasPrefix(tokens[i], "port=") {
|
|
port := trimQuotes(strings.TrimPrefix(tokens[i], "port="))
|
|
pr, err := ParsePortRange(port)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("the port element has no defined port")
|
|
}
|
|
i++
|
|
|
|
// Parse the protocol.
|
|
if strings.HasPrefix(tokens[i], "protocol=") {
|
|
proto := trimQuotes(strings.TrimPrefix(tokens[i], "protocol="))
|
|
r.Proto = GetProtocol(proto)
|
|
} else {
|
|
return nil, fmt.Errorf("the port element has no defined protocol")
|
|
}
|
|
// firewalld allows a port on a protocol this library cannot model
|
|
// (e.g. dccp, which GetProtocol widens to ProtocolAny) or on a modeled
|
|
// but portless protocol. Such a rule cannot round-trip — MarshalRichRule
|
|
// rejects a port without a concrete tcp/udp/sctp protocol — so reject it
|
|
// here, matching the zone-port path, so GetRules skips it rather than
|
|
// surfacing a rule Restore could never re-add.
|
|
if !r.Proto.HasPorts() {
|
|
return nil, fmt.Errorf("the port element uses a protocol that cannot carry a port")
|
|
}
|
|
} else if tokens[i] == "source-port" {
|
|
// A source-port element mirrors the port element but matches the
|
|
// packet's source port: source-port port="1024" protocol="tcp".
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("missing source-port parameters")
|
|
}
|
|
i++
|
|
|
|
// Parse the source port, which may be a single port or a dash range.
|
|
if strings.HasPrefix(tokens[i], "port=") {
|
|
port := trimQuotes(strings.TrimPrefix(tokens[i], "port="))
|
|
pr, err := ParsePortRange(port)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the source-port argument %s is invalid", tokens[i])
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.SourcePort = pr.Start
|
|
} else {
|
|
r.SourcePorts = []PortRange{pr}
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("the source-port element has no defined port")
|
|
}
|
|
i++
|
|
|
|
// Parse the protocol.
|
|
if strings.HasPrefix(tokens[i], "protocol=") {
|
|
proto := trimQuotes(strings.TrimPrefix(tokens[i], "protocol="))
|
|
r.Proto = GetProtocol(proto)
|
|
} else {
|
|
return nil, fmt.Errorf("the source-port element has no defined protocol")
|
|
}
|
|
// See the port element above: a source-port on a protocol that cannot
|
|
// carry a port cannot round-trip, so reject rather than surface it.
|
|
if !r.Proto.HasPorts() {
|
|
return nil, fmt.Errorf("the source-port element uses a protocol that cannot carry a port")
|
|
}
|
|
} else if tokens[i] == "protocol" {
|
|
// A bare protocol element (no port), e.g. an ICMP match:
|
|
// protocol value="icmp" / value="ipv6-icmp".
|
|
i++
|
|
if i >= len(tokens) {
|
|
return nil, fmt.Errorf("missing protocol value")
|
|
}
|
|
if !strings.HasPrefix(tokens[i], "value=") {
|
|
return nil, fmt.Errorf("the protocol element has no defined value")
|
|
}
|
|
proto := GetProtocol(trimQuotes(strings.TrimPrefix(tokens[i], "value=")))
|
|
if proto == ProtocolAny {
|
|
return nil, fmt.Errorf("unsupported protocol value")
|
|
}
|
|
r.Proto = proto
|
|
} else if tokens[i] == "icmp-type" {
|
|
// An icmp-type element restricts to a single ICMP message type by
|
|
// firewalld name, e.g. icmp-type name="echo-request". The numeric type
|
|
// and the ICMP protocol both depend on the rule's family, which appears
|
|
// earlier in the rule string, so r.Family is already set here.
|
|
i++
|
|
if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") {
|
|
return nil, fmt.Errorf("the icmp-type element has no defined name")
|
|
}
|
|
isV6 := r.Family == IPv6
|
|
num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name=")))
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported icmp-type name")
|
|
}
|
|
r.ICMPType = Ptr(num)
|
|
if isV6 {
|
|
r.Proto = ICMPv6
|
|
} else {
|
|
r.Proto = ICMP
|
|
}
|
|
} else if tokens[i] == "accept" {
|
|
r.Action = Accept
|
|
} else if tokens[i] == "reject" {
|
|
r.Action = Reject
|
|
|
|
// Ignore type definition for reject.
|
|
if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "type=") {
|
|
i++
|
|
}
|
|
} else if tokens[i] == "drop" {
|
|
r.Action = Drop
|
|
} else {
|
|
return nil, fmt.Errorf("the element %s is unsupported", tokens[i])
|
|
}
|
|
}
|
|
|
|
// If no action provided, error.
|
|
if r.Action == ActionInvalid {
|
|
return nil, fmt.Errorf("no valid action was provided")
|
|
}
|
|
|
|
// Return the parsed rule.
|
|
return
|
|
}
|
|
|
|
// resolveZoneName substitutes the default zone when zoneName is empty. The rest
|
|
// of go-firewall treats an empty zone as "the default" (zoneless backends ignore
|
|
// it entirely), but firewalld's permanent config interface rejects an empty zone
|
|
// name with INVALID_ZONE, so every zone-scoped method resolves it here first.
|
|
func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) {
|
|
if zoneName != "" {
|
|
return zoneName, nil
|
|
}
|
|
return f.Conn.DefaultZone(ctx)
|
|
}
|
|
|
|
// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts)
|
|
// to allow rules, one per entry. source selects whether the range binds to the
|
|
// source-port or destination-port fields. A port on an unmodeled protocol (e.g.
|
|
// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible
|
|
// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject.
|
|
// This mirrors the protocols loop's guard.
|
|
func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule {
|
|
var rules []*Rule
|
|
for _, port := range ports {
|
|
pr, perr := ParsePortRange(port.Port)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
proto := GetProtocol(port.Protocol)
|
|
if !proto.HasPorts() {
|
|
continue
|
|
}
|
|
rule := &Rule{Proto: proto, Action: Accept}
|
|
switch {
|
|
case source && pr.Start == pr.End:
|
|
rule.SourcePort = pr.Start
|
|
case source:
|
|
rule.SourcePorts = []PortRange{pr}
|
|
case pr.Start == pr.End:
|
|
rule.Port = pr.Start
|
|
default:
|
|
rule.Ports = []PortRange{pr}
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// GetRules returns the filter rules for a zone, resolving an empty zone to the default.
|
|
func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
|
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Get the zone settings.
|
|
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Named services (settings.Services) have no Rule representation and are
|
|
// intentionally not surfaced here; only ports, source ports, sources and
|
|
// rich rules map to managed rules.
|
|
|
|
// Add port allows to rule list. A zone port entry may be a single port or a
|
|
// contiguous range (e.g. "49152-49215"), so parse it as a range and collapse
|
|
// a single-port range back onto the scalar Port field.
|
|
rules = append(rules, f.zonePortRules(settings.Ports, false)...)
|
|
|
|
// Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list.
|
|
// firewalld stores these as a zone protocol entry rather than a rich rule, so
|
|
// surface each recognized one as a portless-protocol rule; otherwise it is
|
|
// invisible to Sync/Restore and can never be reconciled. An unrecognized
|
|
// protocol has no Rule representation and is left unmanaged.
|
|
for _, proto := range settings.Protocols {
|
|
if p := GetProtocol(proto); p != ProtocolAny {
|
|
rules = append(rules, &Rule{Proto: p, Action: Accept})
|
|
}
|
|
}
|
|
|
|
// Add source-port allows to rule list, likewise reading a single port or a
|
|
// contiguous range.
|
|
rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...)
|
|
|
|
// Add source allows to rule list.
|
|
for _, source := range settings.Sources {
|
|
rule := &Rule{
|
|
Source: source,
|
|
Action: Accept,
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
// Parse and add rich rules.
|
|
for _, richRule := range settings.RichRules {
|
|
rule, err := f.UnmarshalRichRule(richRule)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
// Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single
|
|
// FamilyAny rule, as every other backend's GetRules does, so a rule added
|
|
// family-agnostically reads back the same way.
|
|
rules = mergeFamilies(rules)
|
|
|
|
// firewalld isolates rules by zone; this read is already scoped to a single
|
|
// zone, so every rule read here lives in zoneName — record the zone and flag it
|
|
// as carrying the prefix.
|
|
for _, r := range rules {
|
|
r.table = zoneName
|
|
r.HasPrefix = true
|
|
}
|
|
return
|
|
}
|
|
|
|
// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in
|
|
// the given family, and whether the type is expressible as a rich rule element.
|
|
func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) {
|
|
for name, n := range f.icmpTypeTable(isV6) {
|
|
if n == typ {
|
|
return name, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// protoValue returns the protocol name firewalld's `protocol value=` element
|
|
// expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols.
|
|
func (f *FirewallD) protoValue(p Protocol) string {
|
|
if p == ICMPv6 {
|
|
return "ipv6-icmp"
|
|
}
|
|
return p.String()
|
|
}
|
|
|
|
// fwICMPv4Types maps firewalld's IPv4 icmp-type names to their numeric ICMP type,
|
|
// covering the stock firewalld icmptype set. A rich rule's icmp-type element
|
|
// carries the name; the model carries the number, so these tables translate
|
|
// between the two. Only types firewalld names as a plain type match are listed —
|
|
// a number absent here cannot be expressed and marshalling reports it.
|
|
var fwICMPv4Types = map[string]uint8{
|
|
"echo-reply": 0,
|
|
"destination-unreachable": 3,
|
|
"source-quench": 4,
|
|
"redirect": 5,
|
|
"echo-request": 8,
|
|
"router-advertisement": 9,
|
|
"router-solicitation": 10,
|
|
"time-exceeded": 11,
|
|
"parameter-problem": 12,
|
|
"timestamp-request": 13,
|
|
"timestamp-reply": 14,
|
|
}
|
|
|
|
// fwICMPv6Types maps firewalld's ICMPv6 icmp-type names to their numeric type.
|
|
// The same name (e.g. echo-request) resolves to a different number than IPv4, so
|
|
// the family selects the table.
|
|
var fwICMPv6Types = map[string]uint8{
|
|
"destination-unreachable": 1,
|
|
"packet-too-big": 2,
|
|
"time-exceeded": 3,
|
|
"parameter-problem": 4,
|
|
"echo-request": 128,
|
|
"echo-reply": 129,
|
|
"router-solicitation": 133,
|
|
"router-advertisement": 134,
|
|
"neighbour-solicitation": 135,
|
|
"neighbour-advertisement": 136,
|
|
"redirect": 137,
|
|
}
|
|
|
|
// rateUnit maps a RateUnit to the single-letter time unit a firewalld rich
|
|
// rule's `limit value="N/unit"` expects (s/m/h/d).
|
|
func (f *FirewallD) rateUnit(u RateUnit) string {
|
|
switch u {
|
|
case PerMinute:
|
|
return "m"
|
|
case PerHour:
|
|
return "h"
|
|
case PerDay:
|
|
return "d"
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
// MarshalRichRule encodes a rule into a firewalld rich-rule string.
|
|
func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) {
|
|
// firewalld's zone/rich-rule model has no forward chain, so a forward rule
|
|
// cannot be expressed.
|
|
if r.IsForward() {
|
|
return "", unsupportedForward("firewalld")
|
|
}
|
|
// A port in a rich rule requires a concrete protocol; `protocol="any"` is
|
|
// not valid, so reject rather than emit a rule firewalld will refuse.
|
|
if r.PortNeedsConcreteProtocol() {
|
|
return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol")
|
|
}
|
|
|
|
// Features a rich rule cannot express are rejected rather than dropped. A
|
|
// rich rule port element takes a single port or dash range, not a list, and
|
|
// has no conntrack-state or per-rule interface match (interfaces bind to
|
|
// zones instead).
|
|
if r.State != 0 {
|
|
return "", fmt.Errorf("firewalld does not support connection-state matching in a rich rule: %w", ErrUnsupportedState)
|
|
}
|
|
if r.InInterface != "" || r.OutInterface != "" {
|
|
return "", fmt.Errorf("firewalld binds interfaces to zones, not individual rules: %w", ErrUnsupportedInterface)
|
|
}
|
|
if len(r.PortSpecs()) > 1 {
|
|
return "", fmt.Errorf("firewalld does not support a port list in a rich rule: %w", ErrUnsupported)
|
|
}
|
|
// A rich rule source-port element takes a single port or dash range, not a
|
|
// list, and a rich rule carries only one port element — so a destination
|
|
// port and a source port cannot be matched by the same rule.
|
|
if len(r.SourcePortSpecs()) > 1 {
|
|
return "", fmt.Errorf("firewalld does not support a source-port list in a rich rule: %w", ErrUnsupportedSourcePort)
|
|
}
|
|
if len(r.PortSpecs()) == 1 && len(r.SourcePortSpecs()) == 1 {
|
|
return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort)
|
|
}
|
|
// An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with
|
|
// anything else before we try to render an icmp-type element.
|
|
if err := r.checkICMPType(); err != nil {
|
|
return "", err
|
|
}
|
|
// A rich rule can log and rate-limit, but has no way to express a
|
|
// connection-count limit.
|
|
if r.ConnLimit != nil {
|
|
return "", fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
|
|
}
|
|
// A rich rule limit is a bare rate (value="N/unit") with no burst allowance,
|
|
// so a specific non-zero burst cannot be expressed and is rejected rather than
|
|
// silently dropped. The netfilter default burst (5) is treated as "unset"
|
|
// everywhere else in the library (normBurst/eqRateLimit), so a rule carrying
|
|
// Burst=5 normalizes to 0 and round-trips cleanly here — it must be accepted,
|
|
// keeping a desired set portable across backends.
|
|
if r.RateLimit != nil && normBurst(r.RateLimit.Burst) != 0 {
|
|
return "", fmt.Errorf("firewalld does not support a rate-limit burst in a rich rule: %w", ErrUnsupportedRateLimit)
|
|
}
|
|
|
|
// Start with the base rule.
|
|
parts := []string{"rule"}
|
|
|
|
// Add priority.
|
|
if r.Priority != 0 {
|
|
parts = append(parts, fmt.Sprintf(`priority="%d"`, r.Priority))
|
|
}
|
|
|
|
// Add family. firewalld requires a family whenever a rich rule matches an IP
|
|
// address, and stores the rule under that concrete family, so a FamilyAny rule
|
|
// carrying a source or destination is qualified with that address's family
|
|
// rather than emitting a familyless — and, with an address, invalid — rule. A
|
|
// bare (untyped) ICMP/ICMPv6 protocol match needs no such qualification:
|
|
// firewalld accepts a familyless `protocol value="ipv6-icmp"` rule just like
|
|
// any other protocol (it only requires a family for a source/destination
|
|
// address, never for a protocol), and this library's own read path recovers
|
|
// ICMP vs ICMPv6 directly from the protocol value string regardless of
|
|
// family. A *typed*
|
|
// ICMP match (icmp-type name=...) is different: firewalld resolves the type
|
|
// name without a protocol element (see the icmp-type element emitted below), so the emitted
|
|
// family is this library's own disambiguator on read — ICMPv4 and ICMPv6
|
|
// reuse several of the same type names for different numbers — not something
|
|
// firewalld itself requires.
|
|
fam := r.Family
|
|
if fam == FamilyAny {
|
|
switch {
|
|
case r.Proto.IsICMP() && r.ICMPType != nil:
|
|
if r.Proto == ICMPv6 {
|
|
fam = IPv6
|
|
} else {
|
|
fam = IPv4
|
|
}
|
|
default:
|
|
if af := familyOfAddr(r.Source); af != FamilyAny {
|
|
fam = af
|
|
} else if af := familyOfAddr(r.Destination); af != FamilyAny {
|
|
fam = af
|
|
}
|
|
}
|
|
}
|
|
if fam != FamilyAny {
|
|
parts = append(parts, fmt.Sprintf(`family="%s"`, fam.String()))
|
|
} else if dst := strings.TrimPrefix(r.Destination, "!"); dst != "" {
|
|
// firewalld requires an explicit family for `destination ipset="..."`
|
|
// (unlike `source ipset="..."`, which it accepts familyless) since an
|
|
// ipset's members could be either family. familyOfAddr above already
|
|
// resolves a destination CIDR/IP; this only fires when the destination
|
|
// is an ipset name and the caller left Family unset, in which case there
|
|
// is no address to infer a family from — reject rather than emit a rule
|
|
// firewalld refuses with MISSING_FAMILY.
|
|
if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil {
|
|
return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match")
|
|
}
|
|
}
|
|
|
|
// Add source.
|
|
if r.Source != "" {
|
|
parts = append(parts, "source")
|
|
|
|
// If not defined, append NOT.
|
|
src := r.Source
|
|
if src[0] == '!' {
|
|
parts = append(parts, "NOT")
|
|
src = src[1:]
|
|
}
|
|
|
|
// Check if CIDR.
|
|
_, _, err := net.ParseCIDR(src)
|
|
ip := net.ParseIP(src)
|
|
if err == nil || ip != nil {
|
|
parts = append(parts, fmt.Sprintf(`address="%s"`, src))
|
|
} else {
|
|
// Check if MAC.
|
|
_, err := net.ParseMAC(src)
|
|
if err == nil {
|
|
parts = append(parts, fmt.Sprintf(`mac="%s"`, src))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf(`ipset="%s"`, src))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add destination.
|
|
if r.Destination != "" {
|
|
parts = append(parts, "destination")
|
|
|
|
// If not defined, append NOT.
|
|
dst := r.Destination
|
|
if dst[0] == '!' {
|
|
parts = append(parts, "NOT")
|
|
dst = dst[1:]
|
|
}
|
|
|
|
// Check if CIDR or IP; otherwise it is an ipset name, which firewalld's
|
|
// destination grammar accepts (`destination ipset="..."`) like the source.
|
|
_, _, err := net.ParseCIDR(dst)
|
|
ip := net.ParseIP(dst)
|
|
if err == nil || ip != nil {
|
|
parts = append(parts, fmt.Sprintf(`address="%s"`, dst))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf(`ipset="%s"`, dst))
|
|
}
|
|
}
|
|
|
|
// An ICMP/ICMPv6 rule matches by protocol value, or — when a specific type is
|
|
// requested — by an icmp-type element. firewalld resolves the type name against
|
|
// the rule's family; the ICMP protocol pins it (ICMP => IPv4, ICMPv6 => IPv6),
|
|
// so the variant is derived from the protocol rather than requiring the caller
|
|
// to set Family (the rule is already qualified with that family above).
|
|
if r.Proto.IsICMP() {
|
|
if r.ICMPType != nil {
|
|
isV6 := r.Proto == ICMPv6
|
|
name, ok := f.icmpTypeName(isV6, *r.ICMPType)
|
|
if !ok {
|
|
return "", fmt.Errorf("firewalld cannot express icmp type %d for %s: %w", *r.ICMPType, fam.String(), ErrUnsupported)
|
|
}
|
|
parts = append(parts, "icmp-type", fmt.Sprintf(`name="%s"`, name))
|
|
} else {
|
|
parts = append(parts, "protocol", fmt.Sprintf(`value="%s"`, f.protoValue(r.Proto)))
|
|
}
|
|
} else if specs := r.PortSpecs(); len(specs) == 1 {
|
|
// A single port or dash range, e.g. port="23" or port="1000-2000".
|
|
parts = append(parts, "port", fmt.Sprintf(`port="%s"`, specs[0].String()), fmt.Sprintf(`protocol="%s"`, r.Proto.String()))
|
|
} else if specs := r.SourcePortSpecs(); len(specs) == 1 {
|
|
// A source-port match, e.g. source-port port="1024" protocol="tcp".
|
|
// Emitted only when there is no destination port, since a rich rule
|
|
// carries a single port element.
|
|
parts = append(parts, "source-port", fmt.Sprintf(`port="%s"`, specs[0].String()), fmt.Sprintf(`protocol="%s"`, r.Proto.String()))
|
|
} else if r.Proto != ProtocolAny {
|
|
// A bare protocol match with no port, such as GRE, ESP or AH (or a
|
|
// portless tcp/udp/sctp rule): protocol value="gre".
|
|
parts = append(parts, "protocol", fmt.Sprintf(`value="%s"`, f.protoValue(r.Proto)))
|
|
}
|
|
|
|
// A log element attaches to the whole rule, immediately before the action:
|
|
// rule ... [log] action.
|
|
if r.Log {
|
|
if r.LogPrefix != "" {
|
|
parts = append(parts, "log", fmt.Sprintf(`prefix="%s"`, r.LogPrefix), `level="info"`)
|
|
} else {
|
|
parts = append(parts, "log", `level="info"`)
|
|
}
|
|
}
|
|
|
|
// Add the action, then any rate limit. In firewalld's rich-rule grammar a
|
|
// limit is not a standalone element: it is a trailing attribute of the
|
|
// action (or of log/audit). Emitting it before the action produces a rule
|
|
// firewalld rejects, or — after a log element — one that rate-limits the
|
|
// logging rather than the packet. Attaching it to the action rate-limits the
|
|
// action, matching the RateLimit semantics on the other backends.
|
|
parts = append(parts, r.Action.String())
|
|
if r.RateLimit != nil {
|
|
parts = append(parts, "limit", fmt.Sprintf(`value="%d/%s"`, r.RateLimit.Rate, f.rateUnit(r.RateLimit.Unit)))
|
|
}
|
|
|
|
// Return the built parts joined with spaces.
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
|
|
// map to a firewalld zone source: no protocol match, no destination port, no
|
|
// source port, and a non-negated source. A source combined with a concrete
|
|
// protocol or a port is a rich rule (firewalld encodes those as
|
|
// `source address="..." protocol value="..."`/`port ...`), so it is excluded here
|
|
// — encoding such a rule as a bare zone source would silently drop the protocol or
|
|
// port match and widen it. AddRule and RemoveRule share this so their zone-source
|
|
// routing stays symmetric; they differ only in which source *forms* they accept (a
|
|
// MAC source is added as a rich rule but removed via the zone-source path).
|
|
func (f *FirewallD) sourceZoneShape(r *Rule) bool {
|
|
return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny &&
|
|
!r.HasPorts() && !r.HasSourcePorts()
|
|
}
|
|
|
|
// zoneEntryEligible reports whether a rule can be expressed as a firewalld
|
|
// zone-level entry (a zone port, source port, or source) rather than a rich
|
|
// rule. firewalld stores a zone port/source-port as a single port OR a single
|
|
// contiguous range, so a lone range is eligible; only a genuine multi-element
|
|
// port list (or any of the rich-only features — a concrete family/priority, a
|
|
// destination, state/interface/ICMP matching, logging or rate limiting) forces
|
|
// the rich-rule path. Eligibility keys on the port-list length rather than
|
|
// HasPortSet, which is also true for a single range and would misroute a
|
|
// zone-port range onto the rich-rule path.
|
|
func (f *FirewallD) zoneEntryEligible(r *Rule) bool {
|
|
return r.Action == Accept && r.Family == FamilyAny && r.Priority == 0 && r.Destination == "" &&
|
|
r.State == 0 && r.InInterface == "" && r.OutInterface == "" && !r.Proto.IsICMP() &&
|
|
len(r.PortSpecs()) <= 1 && len(r.SourcePortSpecs()) <= 1 &&
|
|
!r.Log && r.RateLimit == nil
|
|
}
|
|
|
|
// AddRule adds a filter rule to a zone, using a zone-level entry when the rule
|
|
// fits one and a rich rule otherwise.
|
|
func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
// firewalld has no output chain (Capabilities().Output is false); it models a
|
|
// "destination" match rather than a true outbound direction. A both-directions
|
|
// DirAny rule cannot be expressed, so it degrades to its input half.
|
|
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
|
|
|
// A connection-count limit cannot be expressed in a firewalld rich rule.
|
|
if r.ConnLimit != nil {
|
|
return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit)
|
|
}
|
|
|
|
// Get the zone.
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zone := f.Conn.Permanent().Zone(zoneName)
|
|
|
|
// Check if rule may be a non-rich rule. Basically only accept actions. Any
|
|
// of the newer match features force the rich-rule path so they are handled
|
|
// (ICMP) or rejected there rather than silently dropped by the port/source
|
|
// shortcuts below. Logging and rate limiting also force the rich-rule path
|
|
// because the AddPort/AddSource shortcuts cannot carry them. A single
|
|
// destination port, a single source port, or a source address each map to a
|
|
// zone-level entry; anything more (a set, or both port dimensions together)
|
|
// falls through to the rich-rule path.
|
|
if f.zoneEntryEligible(r) {
|
|
// A single destination port (or contiguous range) with no source match maps
|
|
// to a zone port. Test HasPorts (not r.Port), so a single port carried in the
|
|
// Ports slice takes this path too rather than falling through to the source
|
|
// shortcut and losing the port match. A concrete tcp/udp/sctp protocol is
|
|
// required; otherwise fall through to the rich-rule path for its clean
|
|
// rejection instead of asking firewalld for a protocol="any" port.
|
|
if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() {
|
|
return f.ignoreAlreadyEnabled(zone.AddPort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}))
|
|
}
|
|
// A single source port with no destination port and no source address
|
|
// maps to a zone source port.
|
|
if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" {
|
|
return f.ignoreAlreadyEnabled(zone.AddSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}))
|
|
}
|
|
// A bare source address maps to a zone source: an IP, a subnet, or an ipset
|
|
// reference (ipset:<name>). GetRules reports a zone ipset source in that exact
|
|
// `ipset:<name>` form and RemoveRule clears it via isZoneSource, so AddRule
|
|
// must add it the same way — otherwise it falls through to the rich-rule path,
|
|
// which bakes the `ipset:` prefix into the ipset name (source ipset="ipset:name")
|
|
// and references a nonexistent ipset. (A MAC source stays on the rich-rule path,
|
|
// where it round-trips as source mac="...".)
|
|
// A zone source encodes only the address, so it must not carry a concrete
|
|
// protocol: a source+protocol rule (e.g. tcp from 1.2.3.4) belongs on the
|
|
// rich-rule path (see sourceZoneShape). Taking AddSource here would silently
|
|
// drop the protocol and widen the rule to every protocol, and GetRules would
|
|
// read it back as ProtocolAny.
|
|
if f.sourceZoneShape(r) {
|
|
_, _, cidrErr := net.ParseCIDR(r.Source)
|
|
ip := net.ParseIP(r.Source)
|
|
if cidrErr == nil || ip != nil || strings.HasPrefix(r.Source, "ipset:") {
|
|
return f.ignoreAlreadyEnabled(zone.AddSource(ctx, r.Source))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encode the rich rule and add it.
|
|
richRule, err := f.MarshalRichRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.ignoreAlreadyEnabled(zone.AddRichRule(ctx, richRule))
|
|
}
|
|
|
|
// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are
|
|
// not positionally ordered.
|
|
func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
return unsupportedOrdering(f.Type())
|
|
}
|
|
|
|
// MoveRule is unsupported for the same reason as InsertRule.
|
|
func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
|
return unsupportedOrdering(f.Type())
|
|
}
|
|
|
|
// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove
|
|
// idempotent: removing an element that is not present is a no-op.
|
|
func (f *FirewallD) ignoreNotEnabled(err error) error {
|
|
if errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
|
|
// interface onto firewalld's zones, rich rules, and ipsets.
|
|
type FirewallD struct {
|
|
Conn *firewalld.Conn
|
|
// rulePrefix is accepted for a consistent constructor signature across
|
|
// backends. firewalld organizes rules into zones rather than a private
|
|
// namespace, so the prefix is not applied to individual rules.
|
|
rulePrefix string
|
|
}
|
|
|
|
// isZoneSource reports whether a source string is a form firewalld stores as a
|
|
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
|
|
func (f *FirewallD) isZoneSource(s string) bool {
|
|
if _, _, err := net.ParseCIDR(s); err == nil {
|
|
return true
|
|
}
|
|
if net.ParseIP(s) != nil {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(s, "ipset:") {
|
|
return true
|
|
}
|
|
if _, err := net.ParseMAC(s); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RemoveRule uses it to decide whether a bare source can be cleared with
|
|
// RemoveSource rather than falling through to the rich-rule path.
|
|
func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
// A DirAny rule degrades to its input half on firewalld (no output concept),
|
|
// mirroring AddRule so a both-directions rule is found and removed as stored.
|
|
r = dirAnyInputFallback(r, f.Capabilities().Output)
|
|
|
|
// Get the zone.
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zone := f.Conn.Permanent().Zone(zoneName)
|
|
|
|
// Check if rule maps to a zone element rather than a rich rule, mirroring how
|
|
// AddRule stores it. Any of the newer match features force the rich-rule path
|
|
// so they are handled (ICMP) or rejected there rather than silently dropped by
|
|
// the port/source shortcuts below. Logging and rate limiting also force the
|
|
// rich-rule path. Each shortcut removes the element directly and relies on
|
|
// firewalld's typed errors: a real error is returned, but NOT_ENABLED (the
|
|
// element is absent as a zone entry) falls through to the rich-rule path in
|
|
// case the rule was stored in that form out of band.
|
|
if f.zoneEntryEligible(r) {
|
|
// A single destination port (or contiguous range) maps to a zone port entry.
|
|
// Mirror AddRule: key on HasPorts (so a single port in the Ports slice
|
|
// matches) and require a concrete port protocol.
|
|
if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() {
|
|
port := firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}
|
|
if err := zone.RemovePort(ctx, port); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return err
|
|
}
|
|
} else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" {
|
|
// A single source port maps to a zone source-port entry.
|
|
sp := firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}
|
|
if err := zone.RemoveSourcePort(ctx, sp); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return err
|
|
}
|
|
} else if f.sourceZoneShape(r) {
|
|
// A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC
|
|
// address, or an ipset reference (ipset:<name>) as a zone source, and
|
|
// GetRules reports each verbatim, so removal must try RemoveSource for all
|
|
// of them. A value that is not a zone source form falls through to the
|
|
// rich-rule path below.
|
|
// sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol
|
|
// rule is a rich rule, so it must be removed as one rather than clearing
|
|
// the bare zone source.
|
|
if f.isZoneSource(r.Source) {
|
|
if err := zone.RemoveSource(ctx, r.Source); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return err
|
|
}
|
|
}
|
|
} else if r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts() {
|
|
// A bare protocol allow (no port or address) maps to a zone protocol
|
|
// entry (firewall-cmd --add-protocol), which GetRules now surfaces.
|
|
// Mirror the port/source shortcuts so such a foreign entry is removable;
|
|
// a NOT_ENABLED result falls through to the rich-rule path for a rule the
|
|
// library stored as a rich-rule protocol match instead.
|
|
if err := zone.RemoveProtocol(ctx, r.Proto.String()); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Split a dual-stack zone entry on a concrete-family removal. firewalld zone
|
|
// ports, source ports, and protocols carry no family, so a FamilyAny rule of
|
|
// those shapes is stored as one shared entry; zoneEntryEligible (and the
|
|
// shortcuts above) require FamilyAny, so a concrete-family target skips them and
|
|
// would otherwise no-op — leaving both families in place. Remove the shared
|
|
// entry, then re-add the untargeted family, which — being concrete — becomes a
|
|
// rich rule, so its coverage survives. A NOT_ENABLED result means the rule was
|
|
// not stored as that zone entry, so fall through to the rich-rule path (which
|
|
// runs its own split). A bare zone source is excluded: an address carries its own
|
|
// family, so there is no dual-stack entry to split.
|
|
if fam := r.impliedFamily(); fam != FamilyAny {
|
|
rAny := *r
|
|
rAny.Family = FamilyAny
|
|
if f.zoneEntryEligible(&rAny) && r.Source == "" {
|
|
var removeErr error
|
|
matched := true
|
|
switch {
|
|
case r.HasPorts() && r.Proto.HasPorts() && !r.HasSourcePorts():
|
|
removeErr = zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})
|
|
case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts():
|
|
removeErr = zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})
|
|
case r.Proto != ProtocolAny && !r.HasPorts() && !r.HasSourcePorts():
|
|
removeErr = zone.RemoveProtocol(ctx, r.Proto.String())
|
|
default:
|
|
matched = false
|
|
}
|
|
if matched {
|
|
if removeErr == nil {
|
|
if s := splitDualRow(&rAny, r); s != nil {
|
|
return f.AddRule(ctx, zoneName, s)
|
|
}
|
|
return nil
|
|
}
|
|
if !errors.Is(removeErr, firewalld.ErrNotEnabled) {
|
|
return removeErr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A foreign bare ICMP/ICMPv6 protocol allow (firewall-cmd --add-protocol=icmp)
|
|
// is surfaced by GetRules as a portless ICMP-protocol rule, but ICMP is excluded
|
|
// from zoneEntryEligible (the library writes its own ICMP matches as rich
|
|
// rules), so the zone-protocol shortcut above never runs for it. Attempt the
|
|
// zone-protocol removal here for exactly that bare shape; a NOT_ENABLED result
|
|
// falls through to the rich-rule path for an ICMP match the library stored as a
|
|
// rich rule instead.
|
|
if r.Proto.IsICMP() && r.ICMPType == nil && r.Action == Accept && r.Source == "" &&
|
|
r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && r.State == 0 &&
|
|
r.InInterface == "" && r.OutInterface == "" && !r.Log && r.RateLimit == nil && r.Priority == 0 {
|
|
proto := "icmp"
|
|
if r.Proto == ICMPv6 {
|
|
proto = "ipv6-icmp"
|
|
}
|
|
if err := zone.RemoveProtocol(ctx, proto); !errors.Is(err, firewalld.ErrNotEnabled) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Rich-rule path: read the zone settings and remove every stored rich rule
|
|
// whose parsed form matches the target, so a rule firewalld stored with
|
|
// different formatting than our marshaller produces is still matched. Match with
|
|
// EqualForRemoval rather than the family-strict Equal: GetRules merges an
|
|
// IPv4/IPv6 rich-rule twin (what a concrete-family bare-port accept becomes)
|
|
// into one FamilyAny rule, so removing that read-back rule must clear both
|
|
// underlying rich rules, while a concrete-family target still removes only its
|
|
// own family.
|
|
settings, err := zone.Settings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
removedRich := false
|
|
var reAdd *Rule
|
|
for _, richRule := range settings.RichRules {
|
|
rule, err := f.UnmarshalRichRule(richRule)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if rule.EqualForRemoval(r, false) {
|
|
if err := f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule)); err != nil {
|
|
return err
|
|
}
|
|
removedRich = true
|
|
// A concrete-family target that matched a genuine dual-family rich rule
|
|
// (one stored with no family= attribute) would drop both families; re-add
|
|
// the untargeted family below so its coverage survives.
|
|
if s := splitDualRow(rule, r); s != nil {
|
|
reAdd = s
|
|
}
|
|
}
|
|
}
|
|
if removedRich {
|
|
if reAdd != nil {
|
|
return f.AddRule(ctx, zoneName, reAdd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// If no stored rule matched, encode the rule and remove it directly; an absent
|
|
// rule is treated as already removed.
|
|
richRule, err := f.MarshalRichRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule))
|
|
}
|
|
|
|
// GetNATRules returns the NAT rules for a zone, mapping forward ports and masquerade.
|
|
func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
|
|
zoneName, err = f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get the zone settings so we can read forward ports and masquerade.
|
|
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Each forward port maps to a DNAT (when it targets another address) or a
|
|
// Redirect (same host, port only). firewalld's model carries no family, so
|
|
// these are returned as FamilyAny.
|
|
for _, fp := range settings.ForwardPorts {
|
|
pr, perr := ParsePortRange(fp.Port)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
rule := &NATRule{Proto: GetProtocol(fp.Protocol)}
|
|
if pr.Start == pr.End {
|
|
rule.Port = pr.Start
|
|
} else {
|
|
rule.Ports = []PortRange{pr}
|
|
}
|
|
if fp.ToPort != "" {
|
|
tp, terr := strconv.ParseUint(fp.ToPort, 10, 16)
|
|
if terr != nil {
|
|
continue
|
|
}
|
|
rule.ToPort = uint16(tp)
|
|
}
|
|
rule.ToAddress = fp.ToAddr
|
|
if fp.ToAddr != "" {
|
|
rule.Kind = DNAT
|
|
} else {
|
|
rule.Kind = Redirect
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
// Zone masquerade maps to a single Masquerade rule.
|
|
if settings.Masquerade {
|
|
rules = append(rules, &NATRule{Kind: Masquerade})
|
|
}
|
|
|
|
// firewalld isolates NAT by zone; this read is scoped to one zone, so every
|
|
// rule lives in zoneName — record the zone and flag it as carrying the prefix.
|
|
for _, r := range rules {
|
|
r.table = zoneName
|
|
r.HasPrefix = true
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
|
|
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
|
|
// carries only these four fields, so any source, destination or interface match —
|
|
// or a port list — cannot be expressed through it and is rejected. (firewalld can
|
|
// express a source-scoped forward-port in a rich rule, but this backend manages
|
|
// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port
|
|
// would not round-trip, so it is intentionally not emitted here.)
|
|
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
|
|
if r.Proto != TCP && r.Proto != UDP {
|
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
|
|
}
|
|
if !r.HasPorts() {
|
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
|
|
}
|
|
specs := r.PortSpecs()
|
|
if len(specs) > 1 {
|
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
|
|
}
|
|
if r.Interface != "" {
|
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
|
|
}
|
|
if r.Source != "" || r.Destination != "" {
|
|
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
|
|
}
|
|
fp := firewalld.ForwardPort{
|
|
Port: specs[0].String(),
|
|
Protocol: r.Proto.String(),
|
|
// ToAddr is empty for a Redirect (same-host) and set for a DNAT.
|
|
ToAddr: r.ToAddress,
|
|
}
|
|
if r.ToPort != 0 {
|
|
fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10)
|
|
}
|
|
return fp, nil
|
|
}
|
|
|
|
// AddNATRule adds a NAT rule to a zone via firewalld's forward-port or masquerade API.
|
|
func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := r.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the zone.
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zone := f.Conn.Permanent().Zone(zoneName)
|
|
|
|
switch r.Kind {
|
|
case DNAT, Redirect:
|
|
fp, err := f.forwardPort(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.ignoreAlreadyEnabled(zone.AddForwardPort(ctx, fp))
|
|
case Masquerade:
|
|
// This backend manages masquerade through firewalld's per-zone toggle, which
|
|
// carries no match, so reject a rule that asks for one. (firewalld can scope a
|
|
// masquerade to a source in a rich rule, but the zone toggle is what
|
|
// GetNATRules reads back; a rich-rule masquerade would not round-trip.)
|
|
if r.Interface != "" || r.Source != "" || r.Destination != "" || r.Proto != ProtocolAny || r.HasPorts() {
|
|
return fmt.Errorf("this backend's zone masquerade cannot match on interface, address, protocol or port: %w", ErrUnsupportedNAT)
|
|
}
|
|
return f.ignoreAlreadyEnabled(zone.AddMasquerade(ctx))
|
|
case SNAT:
|
|
return fmt.Errorf("firewalld does not support snat in this model: %w", ErrUnsupportedNAT)
|
|
}
|
|
return fmt.Errorf("invalid nat kind")
|
|
}
|
|
|
|
// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich
|
|
// rules, which carry no explicit ordering.
|
|
func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
|
return unsupportedOrdering(f.Type())
|
|
}
|
|
|
|
// RemoveNATRule removes a NAT rule from a zone via firewalld's forward-port or masquerade API.
|
|
func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
// Get the zone.
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zone := f.Conn.Permanent().Zone(zoneName)
|
|
|
|
switch r.Kind {
|
|
case DNAT, Redirect:
|
|
fp, err := f.forwardPort(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.ignoreNotEnabled(zone.RemoveForwardPort(ctx, fp))
|
|
case Masquerade:
|
|
return f.ignoreNotEnabled(zone.RemoveMasquerade(ctx))
|
|
case SNAT:
|
|
return fmt.Errorf("firewalld does not support snat in this model: %w", ErrUnsupportedNAT)
|
|
}
|
|
return fmt.Errorf("invalid nat kind")
|
|
}
|
|
|
|
// Backup captures the current filter and NAT rules managed by this backend.
|
|
func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
|
rules, err := f.GetRules(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
natRules, err := f.GetNATRules(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// GetRules/GetNATRules are already scoped to this zone, so the backup captures
|
|
// exactly the zone's rules; captureBackupState adds the zone's default policy
|
|
// (its target) and the managed ipsets.
|
|
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.
|
|
func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
|
if backup == nil {
|
|
return fmt.Errorf("backup cannot be nil")
|
|
}
|
|
|
|
// Get the zone.
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zone := f.Conn.Permanent().Zone(zoneName)
|
|
|
|
settings, err := zone.Settings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove all ports, sources, and rich rules that we can decode as managed.
|
|
for _, port := range settings.Ports {
|
|
if err := zone.RemovePort(ctx, port); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, sp := range settings.SourcePorts {
|
|
if err := zone.RemoveSourcePort(ctx, sp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, source := range settings.Sources {
|
|
if err := zone.RemoveSource(ctx, source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, proto := range settings.Protocols {
|
|
if err := zone.RemoveProtocol(ctx, proto); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, richRule := range settings.RichRules {
|
|
if err := zone.RemoveRichRule(ctx, richRule); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Remove NAT rules.
|
|
for _, fp := range settings.ForwardPorts {
|
|
if err := zone.RemoveForwardPort(ctx, fp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if settings.Masquerade {
|
|
if err := zone.RemoveMasquerade(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Recreate the ipsets before the rules that reference them (the managed rich
|
|
// rules were removed above, so nothing holds a set reference).
|
|
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-add rules from backup.
|
|
for _, r := range backup.Rules {
|
|
if err := f.AddRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range backup.NATRules {
|
|
if err := f.AddNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Re-assert the zone's captured default policy (its target).
|
|
return applyBackupPolicy(ctx, f, zoneName, backup)
|
|
}
|
|
|
|
// policyFromTarget maps a firewalld zone target to a default action. The
|
|
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
|
|
// accepts explicitly being ACCEPT and DROP.
|
|
func (f *FirewallD) policyFromTarget(target string) Action {
|
|
switch strings.ToUpper(target) {
|
|
case "ACCEPT":
|
|
return Accept
|
|
case "DROP":
|
|
return Drop
|
|
case "", "DEFAULT", "%%REJECT%%", "REJECT":
|
|
return Reject
|
|
}
|
|
return Reject
|
|
}
|
|
|
|
// GetDefaultPolicy returns the zone's default input policy, derived from its target.
|
|
func (f *FirewallD) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// firewalld zones only model the input side; a packet that matches no rule
|
|
// is handled by the zone target.
|
|
return &DefaultPolicy{Input: f.policyFromTarget(string(settings.Target))}, nil
|
|
}
|
|
|
|
// SetDefaultPolicy sets the zone's default input policy via its target; firewalld
|
|
// zones only expose the input direction.
|
|
func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
|
if policy == nil {
|
|
return fmt.Errorf("policy cannot be nil")
|
|
}
|
|
// firewalld zones only expose the input direction.
|
|
if policy.Output != ActionInvalid || policy.Forward != ActionInvalid {
|
|
return fmt.Errorf("firewalld only manages the input default policy: %w", ErrUnsupportedPolicy)
|
|
}
|
|
if policy.Input == ActionInvalid {
|
|
return nil
|
|
}
|
|
var target firewalld.Target
|
|
switch policy.Input {
|
|
case Accept:
|
|
target = firewalld.TargetACCEPT
|
|
case Drop:
|
|
target = firewalld.TargetDROP
|
|
case Reject:
|
|
target = firewalld.TargetReject
|
|
default:
|
|
return fmt.Errorf("invalid default policy action")
|
|
}
|
|
zoneName, err := f.resolveZoneName(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Conn.Permanent().Zone(zoneName).SetTarget(ctx, target)
|
|
}
|
|
|
|
// --- address sets (firewalld ipsets) ----------------------------------------
|
|
|
|
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
|
|
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
|
|
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
set := &AddressSet{Name: name, Entries: settings.Entries, Type: SetHashIP}
|
|
if settings.Type == "hash:net" {
|
|
set.Type = SetHashNet
|
|
}
|
|
switch settings.Options["family"] {
|
|
case "inet6":
|
|
set.Family = IPv6
|
|
case "inet", "":
|
|
set.Family = IPv4
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// GetAddressSets returns all permanent firewalld ipsets as address sets.
|
|
func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|
names, err := f.Conn.Permanent().IPSetNames(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]*AddressSet, 0, len(names))
|
|
for _, name := range names {
|
|
set, err := f.getAddressSet(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if set == nil {
|
|
continue
|
|
}
|
|
result = append(result, set)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetAddressSet returns the named permanent ipset, or an error if it does not exist.
|
|
func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
set, err := f.getAddressSet(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if set == nil {
|
|
return nil, fmt.Errorf("address set %q not found", name)
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// ipSetType maps an AddressSet type to a firewalld ipset type string.
|
|
func (f *FirewallD) ipSetType(t SetType) string {
|
|
if t == SetHashNet {
|
|
return "hash:net"
|
|
}
|
|
return "hash:ip"
|
|
}
|
|
|
|
// AddAddressSet creates the permanent ipset, or updates it in place when it already exists.
|
|
func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|
if set == nil || set.Name == "" {
|
|
return fmt.Errorf("an address set requires a name")
|
|
}
|
|
settings := firewalld.IPSetSettings{
|
|
Name: set.Name,
|
|
Type: f.ipSetType(set.Type),
|
|
Entries: set.Entries,
|
|
Options: map[string]string{"family": "inet"},
|
|
}
|
|
if set.Family == IPv6 {
|
|
settings.Options["family"] = "inet6"
|
|
}
|
|
|
|
// If the set already exists, update it in place; otherwise create it.
|
|
names, err := f.Conn.Permanent().IPSetNames(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, n := range names {
|
|
if n == set.Name {
|
|
return f.Conn.Permanent().IPSet(set.Name).Update(ctx, settings)
|
|
}
|
|
}
|
|
if _, err := f.Conn.Permanent().AddIPSet(ctx, set.Name, settings); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveAddressSet removes the named permanent ipset. Remove resolves the ipset
|
|
// by name itself and returns firewalld.ErrInvalidIPSet if it does not exist, so a
|
|
// separate existence pre-check would just double the D-Bus round trips.
|
|
func (f *FirewallD) RemoveAddressSet(ctx context.Context, name string) error {
|
|
err := f.Conn.Permanent().IPSet(name).Remove(ctx)
|
|
// A missing set is a no-op; any other error (D-Bus disconnect, permission
|
|
// denial, ...) must surface.
|
|
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AddAddressSetEntry adds entry to the named permanent ipset. AddEntry resolves
|
|
// the ipset by name itself and returns firewalld.ErrInvalidIPSet if it does not
|
|
// exist, so a separate existence pre-check would just double the D-Bus round trips.
|
|
func (f *FirewallD) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
err := f.Conn.Permanent().IPSet(name).AddEntry(ctx, entry)
|
|
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
|
return fmt.Errorf("address set %q not found", name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RemoveAddressSetEntry removes entry from the named permanent ipset. RemoveEntry
|
|
// resolves the ipset by name itself and returns firewalld.ErrInvalidIPSet if it
|
|
// does not exist, so a separate existence pre-check would just double the D-Bus
|
|
// round trips.
|
|
func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
err := f.Conn.Permanent().IPSet(name).RemoveEntry(ctx, entry)
|
|
if errors.Is(err, firewalld.ErrInvalidIPSet) {
|
|
return fmt.Errorf("address set %q not found", name)
|
|
}
|
|
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()
|
|
}
|