Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
1639 lines
59 KiB
Go
1639 lines
59 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
|
|
firewalld "github.com/grmrgecko/go-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)
|
|
}
|
|
|
|
// Every entry above is reported as firewalld stores it. A rich rule with no
|
|
// `family=` attribute, and a zone port or source, cover both IP families as one
|
|
// object and read back as FamilyAny on their own; a rich rule's port element
|
|
// carries exactly one protocol, so a both-transports rule is two entries and is
|
|
// reported as two 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 rich rule's port element carries a single protocol, so a TCPUDP rule
|
|
// has no single rich-rule form; AddRule/RemoveRule fan it into a tcp row and a
|
|
// udp row with expandProtocols before this row-level marshaller runs. A TCPUDP
|
|
// rule reaching here means that fan-out was skipped.
|
|
if err := r.CheckExpandedProtocol(); err != nil {
|
|
return "", err
|
|
}
|
|
// 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 firewalld zone port and rich rule each carry a single protocol, so a
|
|
// TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of
|
|
// which routes through the zone-port or rich-rule path below on its own.
|
|
if subs := expandProtocols(r); len(subs) > 1 {
|
|
for _, sub := range subs {
|
|
if err := f.AddRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
|
|
// firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp
|
|
// entry), never one shared row, so such a removal fans into a tcp remove and a
|
|
// udp remove — the exact inverse of AddRule's fan-out. Removing a concrete
|
|
// transport still deletes only its own row, so no protocol-axis split is needed.
|
|
if subs := expandProtocols(r); len(subs) > 1 {
|
|
for _, sub := range subs {
|
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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: a FamilyAny target must
|
|
// clear both the familyless rich rule it names and any family-pinned rich rules
|
|
// it covers, while a concrete-family target still removes only its own family
|
|
// (splitting a familyless rich rule when it matches one).
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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()
|
|
}
|