go-firewall/firewalld_linux.go
2026-07-08 15:54:48 -05:00

1611 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"
// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an
// add idempotent: re-adding an element that is already present is a no-op.
func (f *FirewallD) ignoreAlreadyEnabled(err error) error {
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
return nil
}
return err
}
// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove
// idempotent: removing an element that is not present is a no-op.
func (f *FirewallD) ignoreNotEnabled(err error) error {
if errors.Is(err, firewalld.ErrNotEnabled) {
return nil
}
return err
}
// FirewallD manages a firewalld instance over D-Bus, mapping the Manager
// interface onto firewalld's zones, rich rules, and ipsets.
type FirewallD struct {
Conn *firewalld.Conn
// rulePrefix is accepted for a consistent constructor signature across
// backends. firewalld organizes rules into zones rather than a private
// namespace, so the prefix is not applied to individual rules.
rulePrefix string
}
// NewFirewallD connects to firewalld and returns a manager, or an error when
// firewalld cannot be reached.
func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) {
// 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
}
// 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")
}
// 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)
}
// 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
}
// 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,
}
// icmpTypeTable selects the IPv4 or IPv6 name/number table by family.
func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 {
if isV6 {
return fwICMPv6Types
}
return fwICMPv4Types
}
// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in
// the given family, and whether the type is expressible as a rich rule element.
func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) {
for name, n := range f.icmpTypeTable(isV6) {
if n == typ {
return name, true
}
}
return "", false
}
// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in
// the given family, and whether the name is known.
func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) {
n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)]
return n, ok
}
// rateUnit maps a RateUnit to the single-letter time unit a firewalld rich
// rule's `limit value="N/unit"` expects (s/m/h/d).
func (f *FirewallD) rateUnit(u RateUnit) string {
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
}
// 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
}
// 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))
}
// isZoneSource reports whether a source string is a form firewalld stores as a
// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:<name>).
// RemoveRule uses it to decide whether a bare source can be cleared with
// RemoveSource rather than falling through to the rich-rule path.
func (f *FirewallD) isZoneSource(s string) bool {
if _, _, err := net.ParseCIDR(s); err == nil {
return true
}
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
}
// sourceZoneShape reports whether a rule's non-address shape lets a plain source
// map to a firewalld zone source: no protocol match, no destination port, no
// source port, and a non-negated source. A source combined with a concrete
// protocol or a port is a rich rule (firewalld encodes those as
// `source address="..." protocol value="..."`/`port ...`), so it is excluded here
// — encoding such a rule as a bare zone source would silently drop the protocol or
// port match and widen it. AddRule and RemoveRule share this so their zone-source
// routing stays symmetric; they differ only in which source *forms* they accept (a
// MAC source is added as a rich rule but removed via the zone-source path).
func (f *FirewallD) sourceZoneShape(r *Rule) bool {
return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny &&
!r.HasPorts() && !r.HasSourcePorts()
}
// RemoveRule removes a filter rule from a zone, mirroring how AddRule stored it.
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))
}
// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's
// per-zone port-forward API expects (port, protocol, toport, toaddr). That API
// carries only these four fields, so any source, destination or interface match —
// or a port list — cannot be expressed through it and is rejected. (firewalld can
// express a source-scoped forward-port in a rich rule, but this backend manages
// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port
// would not round-trip, so it is intentionally not emitted here.)
func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) {
if r.Proto != TCP && r.Proto != UDP {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol")
}
if !r.HasPorts() {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port")
}
specs := r.PortSpecs()
if len(specs) > 1 {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward")
}
if r.Interface != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface")
}
if r.Source != "" || r.Destination != "" {
return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching")
}
fp := firewalld.ForwardPort{
Port: specs[0].String(),
Protocol: r.Proto.String(),
// ToAddr is empty for a Redirect (same-host) and set for a DNAT.
ToAddr: r.ToAddress,
}
if r.ToPort != 0 {
fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10)
}
return fp, nil
}
// GetNATRules returns the NAT rules for a zone, mapping forward ports and masquerade.
func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
zoneName, err = f.resolveZoneName(ctx, zoneName)
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
}
// 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")
}
// 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")
}
// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are
// not positionally ordered.
func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich
// rules, which carry no explicit ordering.
func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// Backup captures the current filter and NAT rules managed by this backend.
func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
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()
}
// Capabilities reports which optional features this backend supports.
func (f *FirewallD) Capabilities() Capabilities {
return Capabilities{
Output: false,
Zones: true,
Priority: true,
ICMPv6: true,
PortList: false,
ConnState: false,
InterfaceMatch: false,
Logging: true,
RateLimit: true,
ConnLimit: false,
NAT: true,
RuleOrdering: false,
DefaultPolicy: true,
RuleCounters: false,
AddressSets: true,
}
}
// policyFromTarget maps a firewalld zone target to a default action. The
// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone
// accepts explicitly being ACCEPT and DROP.
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) ----------------------------------------
// ipSetType maps an AddressSet type to a firewalld ipset type string.
func (f *FirewallD) ipSetType(t SetType) string {
if t == SetHashNet {
return "hash:net"
}
return "hash:ip"
}
// GetAddressSets returns all permanent firewalld ipsets as address sets.
func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
names, err := f.Conn.Permanent().IPSetNames(ctx)
if err != nil {
return nil, err
}
result := make([]*AddressSet, 0, len(names))
for _, name := range names {
set, err := f.getAddressSet(ctx, name)
if err != nil {
return nil, err
}
if set == nil {
continue
}
result = append(result, set)
}
return result, nil
}
// getAddressSet reads a single firewalld ipset, or nil if it does not exist.
func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) {
settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx)
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
}
// 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
}
// 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
}