first commit

This commit is contained in:
James Coleman 2026-07-02 09:46:32 -05:00
commit 5eb6ba8201
26 changed files with 4359 additions and 0 deletions

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Binaries / build output
*.exe
*.exe~
*.dll
*.so
*.dylib
/dist/
/bin/
# Test artifacts
*.test
*.out
coverage.*
# Editor / OS
.DS_Store
*.swp
.idea/
.vscode/

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# go-firewalld
[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/go-firewalld.svg)](https://pkg.go.dev/github.com/grmrgecko/go-firewalld)
A comprehensive Go client for the [firewalld](https://firewalld.org) D-Bus API,
built on [`github.com/godbus/dbus/v5`](https://github.com/godbus/dbus).
- **Runtime + permanent** operations, mirroring `firewall-cmd` (transient vs `--permanent`).
- **Version/capability aware:** one binary serves both old (EL7, firewalld 0.6.x)
and new (EL9+, firewalld 1.x/2.x) servers. The dict (`a{sv}`) settings path and
zone-to-zone policies are used where supported and transparently fall back to
the v1 tuple path where they are not.
- **Typed, `errors.Is`-able errors** mapped from firewalld exceptions
(`ErrAlreadyEnabled`, `ErrNotEnabled`, `ErrInvalidZone`, …).
## Install
```sh
go get github.com/grmrgecko/go-firewalld
```
## Usage
```go
ctx := context.Background()
conn, err := firewalld.Connect(ctx)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
fmt.Println(conn.Version()) // e.g. "1.3.4"
fmt.Println(conn.Supports(firewalld.Policies)) // false on EL7, true on EL9+
// Permanent edits (apply after a reload).
zone := conn.Permanent().Zone("public")
if err := zone.AddPort(ctx, firewalld.Port{Port: "4242", Protocol: "udp"}); err != nil {
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
// idempotent no-op
} else {
log.Fatal(err)
}
}
_ = zone.SetTarget(ctx, firewalld.TargetDROP)
_ = conn.Reload(ctx)
// Read settings (transport chosen automatically by server capability).
s, _ := conn.Permanent().Zone("public").Settings(ctx)
fmt.Println(s.Services, s.Ports, s.Forward)
// Runtime (transient) edits, with an optional timeout.
_ = conn.Runtime().Zone("public").AddRichRule(ctx,
`rule family="ipv4" source address="10.0.0.0/8" reject`, 30*time.Second)
// Low-level escape hatch for anything not yet wrapped.
var out []string
_ = conn.Call(ctx, "/org/fedoraproject/FirewallD1",
"org.fedoraproject.FirewallD1.zone", "getZones", []any{&out})
```
## Coverage
| Area | Types / handles |
|---|---|
| Connection & lifecycle | `Connect`, `Open`, `Version`, `Supports`, `Reload`, `CompleteReload`, `RuntimeToPermanent`, `ResetToDefaults`, `CheckPermanentConfig`, panic mode, log-denied, default zone |
| Zones (permanent) | `Permanent().Zone(name)`: settings, update, add/remove/query for port/source/source-port/service/protocol/forward-port/masquerade/icmp-block/icmp-block-inversion/interface/rich-rule, target/short/description/version, rename, remove, load-defaults |
| Zones (runtime) | `Runtime().Zone(name)`: same element set with timeouts; change-interface/change-source (move between zones); active zones, zone-of-interface/source; wholesale `Runtime().SetSettings` (dict servers) |
| Config management | zone names/paths, add-zone, zone-of-interface/source |
| Daemon properties | `RuntimeInfo` (state, IPv4/IPv6/IPSet/bridge support, ipset & icmp types), `DaemonConfig` (backend, rp-filter, cleanup, …) get/set |
| IPSets | runtime + permanent: settings, entries, options, existence query |
| Services | list, read (tuple/dict), permanent editor incl. includes |
| ICMP types | list, read, permanent editor |
| Helpers (conntrack) | list, read, permanent editor |
| Policies (≥ 0.9) | runtime + permanent, capability-gated |
| Direct interface | runtime chains/rules/passthroughs + permanent direct config blob |
| Lockdown | enable/disable/query + whitelist (command/context/user/uid) |
| Signals | `WatchSignals`, `WatchReloaded` |
Deliberately omitted as redundant with `Settings()`/`Update()`: the per-field bulk
`setX` list-setters and scalar `getX` getters on config objects. Legacy/no-op
methods (`authorizeAll`, `isImmutable`, `*AutomaticHelpers`, the `changeZone`
alias) are also skipped.
## Testing
Unit tests need no bus and cover encode/decode round-trips plus
`dbus.SignatureOf` assertions for every compound type:
```sh
go test ./...
```
Integration tests run against a live firewalld and are gated behind a build tag.
They operate only on throwaway zones/ipsets/policies they create and remove, so a
failure cannot disturb the default zone or an SSH session. Build a test binary and
run it on the target host (no Go toolchain needed there):
```sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go test -c -tags firewalld_integration -o firewalld.itest .
scp firewalld.itest root@host:/tmp/
ssh root@host '/tmp/firewalld.itest -test.v'
```
Verified against firewalld **0.6.3** (CentOS 7, iptables backend) and **1.3.4**
(Rocky 9, nftables backend); signature tests additionally cover **2.4.3**.

88
caps.go Normal file
View file

@ -0,0 +1,88 @@
package firewalld
import (
"strconv"
"strings"
)
// Capability names a version-gated feature of the firewalld D-Bus surface.
// Callers test support with Conn.Supports; internal code version-gates transports
// (tuple vs dict) on these flags.
type Capability int
const (
// DictZoneSettings gates the a{sv} zone-settings path (getSettings2/update2 on
// config.zone, getZoneSettings2/setZoneSettings2 on the runtime .zone). Added
// in firewalld 0.9. Absent on EL7 0.6.3, which uses the v1 tuple only.
DictZoneSettings Capability = iota
// Policies gates zone-to-zone policy objects (config listPolicies/addPolicy,
// runtime getPolicySettings/getActivePolicies). Added in 0.9. Absent on EL7.
Policies
// ServiceSettings2 gates getServiceSettings2/addService2 (a{sv} services).
// Present on 1.3.4, absent on 0.6.3.
ServiceSettings2
// AddZone2 gates config.addZone2/addService2 (dict-based creation). Added 0.9.
AddZone2
// ResetToDefaults gates the main resetToDefaults method. Present on 1.x.
ResetToDefaults
)
// Version is a parsed firewalld version. Missing components read as zero, so a
// server reporting "1" compares equal to "1.0.0".
type Version struct {
Major, Minor, Patch int
Raw string
}
// parseVersion parses a "X.Y.Z" firewalld version string. Trailing non-numeric
// suffixes on a component (e.g. a distro tag) are tolerated by reading the leading
// digits. Unparseable components read as zero rather than failing the connection.
func parseVersion(raw string) Version {
v := Version{Raw: raw}
parts := strings.SplitN(raw, ".", 3)
dst := []*int{&v.Major, &v.Minor, &v.Patch}
for i := 0; i < len(parts) && i < 3; i++ {
*dst[i] = leadingInt(parts[i])
}
return v
}
// leadingInt reads the leading base-10 digits of s, ignoring any suffix.
func leadingInt(s string) int {
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0
}
n, err := strconv.Atoi(s[:end])
if err != nil {
return 0
}
return n
}
// atLeast reports whether v is >= the given major.minor.
func (v Version) atLeast(major, minor int) bool {
if v.Major != major {
return v.Major > major
}
return v.Minor >= minor
}
// String returns the raw version string as reported by the server.
func (v Version) String() string { return v.Raw }
// capsFor derives the capability set from a parsed version. Gates follow the
// introspection findings: dict settings and policies land at 0.9; the *2 service
// APIs and resetToDefaults are 1.x.
func capsFor(v Version) map[Capability]bool {
return map[Capability]bool{
DictZoneSettings: v.atLeast(0, 9),
Policies: v.atLeast(0, 9),
AddZone2: v.atLeast(0, 9),
ServiceSettings2: v.atLeast(1, 0),
ResetToDefaults: v.atLeast(1, 0),
}
}

61
caps_test.go Normal file
View file

@ -0,0 +1,61 @@
package firewalld
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseVersion(t *testing.T) {
cases := []struct {
raw string
maj, min, patch int
}{
{"0.6.3", 0, 6, 3},
{"1.3.4", 1, 3, 4},
{"2.4.3", 2, 4, 3},
{"1", 1, 0, 0},
{"0.9", 0, 9, 0},
{"1.2.3-4.el9", 1, 2, 3},
{"", 0, 0, 0},
{"garbage", 0, 0, 0},
}
for _, tc := range cases {
v := parseVersion(tc.raw)
assert.Equal(t, tc.maj, v.Major, "major of %q", tc.raw)
assert.Equal(t, tc.min, v.Minor, "minor of %q", tc.raw)
assert.Equal(t, tc.patch, v.Patch, "patch of %q", tc.raw)
}
}
func TestVersionAtLeast(t *testing.T) {
assert.True(t, parseVersion("1.3.4").atLeast(0, 9))
assert.True(t, parseVersion("0.9.0").atLeast(0, 9))
assert.False(t, parseVersion("0.6.3").atLeast(0, 9))
assert.True(t, parseVersion("1.0.0").atLeast(1, 0))
assert.False(t, parseVersion("0.9.9").atLeast(1, 0))
assert.True(t, parseVersion("2.0.0").atLeast(1, 5))
}
// TestCapsByVersion locks the capability gates to the three real server versions:
// EL7 0.6.3 (no dict, no policies), EL9 1.3.4 and local 2.4.3 (full).
func TestCapsByVersion(t *testing.T) {
el7 := capsFor(parseVersion("0.6.3"))
assert.False(t, el7[DictZoneSettings])
assert.False(t, el7[Policies])
assert.False(t, el7[ServiceSettings2])
assert.False(t, el7[ResetToDefaults])
el9 := capsFor(parseVersion("1.3.4"))
assert.True(t, el9[DictZoneSettings])
assert.True(t, el9[Policies])
assert.True(t, el9[ServiceSettings2])
assert.True(t, el9[ResetToDefaults])
assert.True(t, el9[AddZone2])
// The 0.9 boundary: dict + policies land, the 1.x-only flags do not.
el09 := capsFor(parseVersion("0.9.0"))
assert.True(t, el09[DictZoneSettings])
assert.True(t, el09[Policies])
assert.False(t, el09[ServiceSettings2])
}

68
config.go Normal file
View file

@ -0,0 +1,68 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// Permanent is the entry point for operations on firewalld's on-disk permanent
// configuration, mirroring firewall-cmd --permanent. Changes take effect after a
// Reload. It also exposes the config-object management methods (listing and
// creating zones, ipsets, services, and so on).
type Permanent struct{ c *Conn }
// Permanent returns the permanent configuration namespace.
func (c *Conn) Permanent() *Permanent { return &Permanent{c: c} }
// Config is an alias for Permanent, matching the firewalld config object naming
// used by callers that think in terms of "the config interface".
func (c *Conn) Config() *Permanent { return &Permanent{c: c} }
// Zone returns a handle for permanent operations on the named zone. The object
// path is resolved lazily on first use via getZoneByName.
func (p *Permanent) Zone(name string) *PermZone {
return &PermZone{c: p.c, name: name}
}
// ZoneNames lists the names of all permanently configured zones.
func (p *Permanent) ZoneNames(ctx context.Context) ([]string, error) {
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneNames", []any{&names})
return names, err
}
// ZonePaths lists the object paths of all permanent zones (listZones -> ao).
func (p *Permanent) ZonePaths(ctx context.Context) ([]dbus.ObjectPath, error) {
var paths []dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "listZones", []any{&paths})
return paths, err
}
// AddZone creates a new permanent zone from the given settings and returns its
// object path. On dict-capable servers it uses addZone2; otherwise addZone with
// the v1 tuple.
func (p *Permanent) AddZone(ctx context.Context, name string, settings ZoneSettings) (dbus.ObjectPath, error) {
var path dbus.ObjectPath
if p.c.caps[AddZone2] {
err := p.c.call(ctx, configPath, ifaceConfig, "addZone2", []any{&path}, name, settings.toDict())
return path, err
}
tuple := settings.toTuple()
err := p.c.call(ctx, configPath, ifaceConfig, "addZone", []any{&path}, name, tuple)
return path, err
}
// ZoneOfInterface returns the permanent zone bound to an interface, or "" if none.
func (p *Permanent) ZoneOfInterface(ctx context.Context, iface string) (string, error) {
var zone string
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneOfInterface", []any{&zone}, iface)
return zone, err
}
// ZoneOfSource returns the permanent zone bound to a source, or "" if none.
func (p *Permanent) ZoneOfSource(ctx context.Context, source string) (string, error) {
var zone string
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneOfSource", []any{&zone}, source)
return zone, err
}

195
decode.go Normal file
View file

@ -0,0 +1,195 @@
package firewalld
import (
"reflect"
"github.com/godbus/dbus/v5"
)
// This file holds the length-tolerant decode helpers shared by every settings
// decoder. Two facts make the coercion messy and force the tolerance here:
//
// 1. firewalld returns compound values either as a positional tuple (godbus hands
// these back as []any) or as an a{sv} dict that OMITS unset keys.
// 2. Inside the dict, firewalld's Python side serializes port/forward-port lists
// as "aav" (array of array of variant), NOT the "a(ss)"/"a(ssss)" tuple it
// accepts on the way in. So a decoded port element may be []any{Variant,
// Variant} rather than a clean string pair.
//
// Every helper therefore unwraps variants and reads through reflection so it
// tolerates []string, []any, [][]any, and []dbus.Variant interchangeably, and
// never indexes a fixed tuple offset without a bounds check.
// unwrap peels dbus.Variant wrappers off a decoded value, recursively, so callers
// see the underlying Go value regardless of how deeply firewalld boxed it.
func unwrap(v any) any {
for {
vr, ok := v.(dbus.Variant)
if !ok {
return v
}
v = vr.Value()
}
}
// sliceElems returns the elements of any slice-shaped value as a []any, with each
// element unwrapped from a variant. It handles the whole zoo of concrete slice
// types godbus may produce (including nested [][]any) via reflection.
func sliceElems(v any) []any {
v = unwrap(v)
if v == nil {
return nil
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil
}
out := make([]any, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = unwrap(rv.Index(i).Interface())
}
return out
}
// tupleField returns element i of a decoded D-Bus tuple, or nil if the tuple is
// shorter than i+1. This is the guard that keeps a short EL7 tuple from panicking.
func tupleField(raw []any, i int) any {
if i < 0 || i >= len(raw) {
return nil
}
return raw[i]
}
// asString coerces a decoded value to a string, unwrapping variants first.
func asString(v any) string {
s, _ := unwrap(v).(string)
return s
}
// asBool coerces a decoded value to a bool, unwrapping variants first.
func asBool(v any) bool {
b, _ := unwrap(v).(bool)
return b
}
// asInt32 coerces a decoded integer value to int32, tolerating the several
// integer widths godbus may hand back.
func asInt32(v any) int32 {
switch n := unwrap(v).(type) {
case int32:
return n
case int:
return int32(n)
case int64:
return int32(n)
case uint32:
return int32(n)
default:
return 0
}
}
// asStrings coerces a decoded "as" value to []string.
func asStrings(v any) []string {
els := sliceElems(v)
if els == nil {
return nil
}
out := make([]string, 0, len(els))
for _, e := range els {
out = append(out, asString(e))
}
return out
}
// pair extracts the first two string elements of a decoded "(ss)" tuple, however
// it was serialized (string pair or variant pair).
func pair(v any) (string, string) {
els := sliceElems(v)
var a, b string
if len(els) > 0 {
a = asString(els[0])
}
if len(els) > 1 {
b = asString(els[1])
}
return a, b
}
// quad extracts the four string elements of a decoded "(ssss)" tuple.
func quad(v any) (string, string, string, string) {
els := sliceElems(v)
var a [4]string
for i := 0; i < len(els) && i < 4; i++ {
a[i] = asString(els[i])
}
return a[0], a[1], a[2], a[3]
}
// asPorts coerces a decoded "a(ss)" (or its "aav" dict form) to []Port.
func asPorts(v any) []Port {
if ps, ok := v.([]Port); ok {
return ps
}
els := sliceElems(v)
if els == nil {
return nil
}
out := make([]Port, 0, len(els))
for _, e := range els {
p, proto := pair(e)
out = append(out, Port{Port: p, Protocol: proto})
}
return out
}
// asForwardPorts coerces a decoded "a(ssss)" (or "aav") value to []ForwardPort.
func asForwardPorts(v any) []ForwardPort {
if fs, ok := v.([]ForwardPort); ok {
return fs
}
els := sliceElems(v)
if els == nil {
return nil
}
out := make([]ForwardPort, 0, len(els))
for _, e := range els {
p, proto, toport, toaddr := quad(e)
out = append(out, ForwardPort{Port: p, Protocol: proto, ToPort: toport, ToAddr: toaddr})
}
return out
}
// asStringMap coerces a decoded "a{ss}" value to map[string]string, unwrapping
// variant values (firewalld may hand back a{sv} in the dict forms).
func asStringMap(v any) map[string]string {
switch m := unwrap(v).(type) {
case map[string]string:
return m
case map[string]any:
out := make(map[string]string, len(m))
for k, e := range m {
out[k] = asString(e)
}
return out
case map[string]dbus.Variant:
out := make(map[string]string, len(m))
for k, e := range m {
out[k] = asString(e)
}
return out
default:
return nil
}
}
// dictValue reads key k from an a{sv} settings dict, returning the unwrapped value
// and whether it was present. A missing key means firewalld left the field at its
// default, so callers treat !ok as the zero value.
func dictValue(d map[string]dbus.Variant, k string) (any, bool) {
v, ok := d[k]
if !ok {
return nil, false
}
return v.Value(), true
}

322
direct.go Normal file
View file

@ -0,0 +1,322 @@
package firewalld
import "context"
// Direct is the entry point for firewalld's direct interface, which passes rules
// straight to the underlying iptables/nftables backend. It is a low-level escape
// hatch: firewalld does not manage or reconcile direct rules the way it does
// zones. All operations here are runtime (the direct interface has no per-element
// permanent methods; the permanent form is a single config.direct blob).
type Direct struct{ c *Conn }
// Direct returns the direct-interface namespace.
func (c *Conn) Direct() *Direct { return &Direct{c: c} }
// DirectChain is a custom chain added through the direct interface.
type DirectChain struct {
IPV string // "ipv4", "ipv6", or "eb" (ebtables)
Table string // e.g. "filter", "nat"
Chain string
}
// DirectRule is a rule added through the direct interface, including the chain it
// belongs to, its priority, and the raw backend arguments.
type DirectRule struct {
IPV string
Table string
Chain string
Priority int32
Args []string
}
// AddChain adds a custom chain (ipv, table, chain).
func (d *Direct) AddChain(ctx context.Context, ch DirectChain) error {
return d.c.call(ctx, basePath, ifaceDirect, "addChain", nil, ch.IPV, ch.Table, ch.Chain)
}
// RemoveChain removes a custom chain.
func (d *Direct) RemoveChain(ctx context.Context, ch DirectChain) error {
return d.c.call(ctx, basePath, ifaceDirect, "removeChain", nil, ch.IPV, ch.Table, ch.Chain)
}
// QueryChain reports whether a custom chain exists.
func (d *Direct) QueryChain(ctx context.Context, ch DirectChain) (bool, error) {
var ok bool
err := d.c.call(ctx, basePath, ifaceDirect, "queryChain", []any{&ok}, ch.IPV, ch.Table, ch.Chain)
return ok, err
}
// Chains lists the custom chains in a table for an IP family.
func (d *Direct) Chains(ctx context.Context, ipv, table string) ([]string, error) {
var chains []string
err := d.c.call(ctx, basePath, ifaceDirect, "getChains", []any{&chains}, ipv, table)
return chains, err
}
// AllChains lists every custom chain across all families and tables.
func (d *Direct) AllChains(ctx context.Context) ([]DirectChain, error) {
var raw [][]any
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllChains", []any{&raw}); err != nil {
return nil, err
}
out := make([]DirectChain, 0, len(raw))
for _, e := range raw {
ipv, table, chain, _ := quad(e)
out = append(out, DirectChain{IPV: ipv, Table: table, Chain: chain})
}
return out, nil
}
// AddRule adds a direct rule at the given priority.
func (d *Direct) AddRule(ctx context.Context, r DirectRule) error {
return d.c.call(ctx, basePath, ifaceDirect, "addRule", nil, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
}
// RemoveRule removes a direct rule matching the given fields exactly.
func (d *Direct) RemoveRule(ctx context.Context, r DirectRule) error {
return d.c.call(ctx, basePath, ifaceDirect, "removeRule", nil, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
}
// RemoveRules removes all direct rules in a chain.
func (d *Direct) RemoveRules(ctx context.Context, ipv, table, chain string) error {
return d.c.call(ctx, basePath, ifaceDirect, "removeRules", nil, ipv, table, chain)
}
// QueryRule reports whether a direct rule is present.
func (d *Direct) QueryRule(ctx context.Context, r DirectRule) (bool, error) {
var ok bool
err := d.c.call(ctx, basePath, ifaceDirect, "queryRule", []any{&ok}, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
return ok, err
}
// Rules lists the rules in a chain as (priority, args) pairs. The IPV/Table/Chain
// on each returned rule are filled in from the query arguments.
func (d *Direct) Rules(ctx context.Context, ipv, table, chain string) ([]DirectRule, error) {
var raw [][]any
if err := d.c.call(ctx, basePath, ifaceDirect, "getRules", []any{&raw}, ipv, table, chain); err != nil {
return nil, err
}
out := make([]DirectRule, 0, len(raw))
for _, e := range raw {
r := DirectRule{IPV: ipv, Table: table, Chain: chain}
if len(e) >= 1 {
r.Priority = asInt32(e[0])
}
if len(e) >= 2 {
r.Args = asStrings(e[1])
}
out = append(out, r)
}
return out, nil
}
// AllRules lists every direct rule across all families, tables, and chains.
func (d *Direct) AllRules(ctx context.Context) ([]DirectRule, error) {
var raw [][]any
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllRules", []any{&raw}); err != nil {
return nil, err
}
out := make([]DirectRule, 0, len(raw))
for _, e := range raw {
var r DirectRule
if len(e) >= 1 {
r.IPV = asString(e[0])
}
if len(e) >= 2 {
r.Table = asString(e[1])
}
if len(e) >= 3 {
r.Chain = asString(e[2])
}
if len(e) >= 4 {
r.Priority = asInt32(e[3])
}
if len(e) >= 5 {
r.Args = asStrings(e[4])
}
out = append(out, r)
}
return out, nil
}
// Passthrough sends a raw command straight to the backend for an IP family and
// returns the backend's output.
func (d *Direct) Passthrough(ctx context.Context, ipv string, args []string) (string, error) {
var out string
err := d.c.call(ctx, basePath, ifaceDirect, "passthrough", []any{&out}, ipv, args)
return out, err
}
// AddPassthrough persists a passthrough rule for an IP family.
func (d *Direct) AddPassthrough(ctx context.Context, ipv string, args []string) error {
return d.c.call(ctx, basePath, ifaceDirect, "addPassthrough", nil, ipv, args)
}
// RemovePassthrough removes a passthrough rule.
func (d *Direct) RemovePassthrough(ctx context.Context, ipv string, args []string) error {
return d.c.call(ctx, basePath, ifaceDirect, "removePassthrough", nil, ipv, args)
}
// QueryPassthrough reports whether a passthrough rule is present.
func (d *Direct) QueryPassthrough(ctx context.Context, ipv string, args []string) (bool, error) {
var ok bool
err := d.c.call(ctx, basePath, ifaceDirect, "queryPassthrough", []any{&ok}, ipv, args)
return ok, err
}
// Passthroughs lists the passthrough rules for an IP family, each as an argv.
func (d *Direct) Passthroughs(ctx context.Context, ipv string) ([][]string, error) {
var raw [][]string
err := d.c.call(ctx, basePath, ifaceDirect, "getPassthroughs", []any{&raw}, ipv)
return raw, err
}
// DirectPassthrough is a passthrough rule tagged with its IP family, as returned
// by AllPassthroughs.
type DirectPassthrough struct {
IPV string
Args []string
}
// AllPassthroughs lists every passthrough rule across all IP families.
func (d *Direct) AllPassthroughs(ctx context.Context) ([]DirectPassthrough, error) {
var raw [][]any
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllPassthroughs", []any{&raw}); err != nil {
return nil, err
}
out := make([]DirectPassthrough, 0, len(raw))
for _, e := range raw {
var pt DirectPassthrough
if len(e) >= 1 {
pt.IPV = asString(e[0])
}
if len(e) >= 2 {
pt.Args = asStrings(e[1])
}
out = append(out, pt)
}
return out, nil
}
// RemoveAllPassthroughs clears all passthrough rules.
func (d *Direct) RemoveAllPassthroughs(ctx context.Context) error {
return d.c.call(ctx, basePath, ifaceDirect, "removeAllPassthroughs", nil)
}
// DirectConfig is the complete permanent direct configuration: all custom chains,
// rules, and passthroughs. It maps to the config.direct tuple
// "(a(sss)a(sssias)a(sas))".
type DirectConfig struct {
Chains []DirectChain
Rules []DirectRule
Passthroughs []DirectPassthrough
}
// The concrete encoder structs for the permanent direct tuple. Field order is
// load-bearing and must match firewalld's signatures exactly.
type directChainTuple struct {
IPV, Table, Chain string // (sss)
}
type directRuleTuple struct {
IPV, Table, Chain string
Priority int32
Args []string // (sssias)
}
type directPassthroughTuple struct {
IPV string
Args []string // (sas)
}
type directConfigTuple struct {
Chains []directChainTuple
Rules []directRuleTuple
Passthroughs []directPassthroughTuple
}
func (c DirectConfig) toTuple() directConfigTuple {
t := directConfigTuple{
Chains: make([]directChainTuple, 0, len(c.Chains)),
Rules: make([]directRuleTuple, 0, len(c.Rules)),
Passthroughs: make([]directPassthroughTuple, 0, len(c.Passthroughs)),
}
for _, ch := range c.Chains {
t.Chains = append(t.Chains, directChainTuple{ch.IPV, ch.Table, ch.Chain})
}
for _, r := range c.Rules {
args := r.Args
if args == nil {
args = []string{}
}
t.Rules = append(t.Rules, directRuleTuple{r.IPV, r.Table, r.Chain, r.Priority, args})
}
for _, pt := range c.Passthroughs {
args := pt.Args
if args == nil {
args = []string{}
}
t.Passthroughs = append(t.Passthroughs, directPassthroughTuple{pt.IPV, args})
}
return t
}
// directConfigFromTuple decodes the raw config.direct tuple, length-tolerant.
func directConfigFromTuple(raw []any) DirectConfig {
var c DirectConfig
for _, e := range sliceElems(tupleField(raw, 0)) {
ipv, table, chain, _ := quad(e)
c.Chains = append(c.Chains, DirectChain{IPV: ipv, Table: table, Chain: chain})
}
for _, e := range sliceElems(tupleField(raw, 1)) {
els := sliceElems(e)
var r DirectRule
if len(els) >= 1 {
r.IPV = asString(els[0])
}
if len(els) >= 2 {
r.Table = asString(els[1])
}
if len(els) >= 3 {
r.Chain = asString(els[2])
}
if len(els) >= 4 {
r.Priority = asInt32(els[3])
}
if len(els) >= 5 {
r.Args = asStrings(els[4])
}
c.Rules = append(c.Rules, r)
}
for _, e := range sliceElems(tupleField(raw, 2)) {
els := sliceElems(e)
var pt DirectPassthrough
if len(els) >= 1 {
pt.IPV = asString(els[0])
}
if len(els) >= 2 {
pt.Args = asStrings(els[1])
}
c.Passthroughs = append(c.Passthroughs, pt)
}
return c
}
// Direct returns a handle for the permanent direct configuration.
func (p *Permanent) Direct() *PermDirect { return &PermDirect{c: p.c} }
// PermDirect reads and writes firewalld's permanent direct configuration as a
// single blob (the config.direct object), the permanent counterpart to the
// runtime Direct interface.
type PermDirect struct{ c *Conn }
// Settings reads the whole permanent direct configuration.
func (p *PermDirect) Settings(ctx context.Context) (DirectConfig, error) {
var raw []any
if err := p.c.call(ctx, configPath, ifaceConfigDirect, "getSettings", []any{&raw}); err != nil {
return DirectConfig{}, err
}
return directConfigFromTuple(raw), nil
}
// Update replaces the whole permanent direct configuration.
func (p *PermDirect) Update(ctx context.Context, cfg DirectConfig) error {
return p.c.call(ctx, configPath, ifaceConfigDirect, "update", nil, cfg.toTuple())
}

134
errors.go Normal file
View file

@ -0,0 +1,134 @@
package firewalld
import (
"errors"
"strings"
"github.com/godbus/dbus/v5"
)
// exceptionName is the D-Bus error name firewalld raises for all of its domain
// errors. The specific condition (ALREADY_ENABLED, INVALID_ZONE, ...) arrives as
// the leading token of the error message, not as a distinct D-Bus name.
const exceptionName = "org.fedoraproject.FirewallD1.Exception"
// Sentinel errors mapped from firewalld exception messages. Callers match with
// errors.Is. ErrAlreadyEnabled/ErrNotEnabled are the idempotency signals: an
// add of something already present, or a remove of something absent.
var (
ErrAlreadyEnabled = errors.New("firewalld: already enabled")
ErrNotEnabled = errors.New("firewalld: not enabled")
ErrInvalidZone = errors.New("firewalld: invalid zone")
ErrInvalidPort = errors.New("firewalld: invalid port")
ErrInvalidProtocol = errors.New("firewalld: invalid protocol")
ErrInvalidService = errors.New("firewalld: invalid service")
ErrInvalidICMPType = errors.New("firewalld: invalid icmptype")
ErrInvalidIPSet = errors.New("firewalld: invalid ipset")
ErrInvalidInterface = errors.New("firewalld: invalid interface")
ErrInvalidSource = errors.New("firewalld: invalid source")
ErrInvalidPolicy = errors.New("firewalld: invalid policy")
ErrInvalidHelper = errors.New("firewalld: invalid helper")
ErrInvalidCommand = errors.New("firewalld: invalid command")
ErrNameConflict = errors.New("firewalld: name conflict")
ErrZoneConflict = errors.New("firewalld: zone conflict")
ErrBuiltinZone = errors.New("firewalld: builtin zone")
ErrAlreadySet = errors.New("firewalld: already set")
ErrMissingName = errors.New("firewalld: missing name")
ErrNotRunning = errors.New("firewalld: not running")
ErrNotApplicable = errors.New("firewalld: not applicable")
// ErrUnsupported is returned by this library (not firewalld) when a caller
// asks for a feature the connected server's version does not provide.
ErrUnsupported = errors.New("firewalld: operation not supported by this server version")
)
// exceptionMap keys the leading token of a firewalld exception message to a
// sentinel. Tokens are the firewalld INVALID_*/ALREADY_*/... error codes.
var exceptionMap = map[string]error{
"ALREADY_ENABLED": ErrAlreadyEnabled,
"NOT_ENABLED": ErrNotEnabled,
"ZONE_ALREADY_SET": ErrAlreadySet,
"ALREADY_SET": ErrAlreadySet,
"INVALID_ZONE": ErrInvalidZone,
"INVALID_PORT": ErrInvalidPort,
"INVALID_PROTOCOL": ErrInvalidProtocol,
"INVALID_SERVICE": ErrInvalidService,
"INVALID_ICMPTYPE": ErrInvalidICMPType,
"INVALID_IPSET": ErrInvalidIPSet,
"INVALID_INTERFACE": ErrInvalidInterface,
"INVALID_SOURCE": ErrInvalidSource,
"INVALID_POLICY": ErrInvalidPolicy,
"INVALID_HELPER": ErrInvalidHelper,
"INVALID_COMMAND": ErrInvalidCommand,
"NAME_CONFLICT": ErrNameConflict,
"ZONE_CONFLICT": ErrZoneConflict,
"BUILTIN_ZONE": ErrBuiltinZone,
"MISSING_NAME": ErrMissingName,
"NOT_RUNNING": ErrNotRunning,
"NOT_APPLICABLE": ErrNotApplicable,
}
// Error wraps a firewalld D-Bus exception, preserving the raw code and message
// while chaining to a sentinel (via Unwrap) so errors.Is works. Errors that are
// not firewalld exceptions are returned unchanged by mapError.
type Error struct {
Code string // firewalld token, e.g. "INVALID_ZONE"; empty if none parsed
Message string // full exception message from firewalld
sentinel error
}
// Error renders the underlying firewalld message.
func (e *Error) Error() string { return "firewalld: " + e.Message }
// Unwrap exposes the mapped sentinel so errors.Is matches (e.g. ErrInvalidZone).
func (e *Error) Unwrap() error { return e.sentinel }
// mapError converts a raw error from a D-Bus call into a typed *Error when it is a
// firewalld exception, chaining to the matching sentinel. Non-exception errors
// (transport failures, unknown names) are returned unchanged.
func mapError(err error) error {
if err == nil {
return nil
}
var derr dbus.Error
if !errors.As(err, &derr) || derr.Name != exceptionName {
return err
}
msg := dbusErrorMessage(derr)
code := leadingToken(msg)
return &Error{Code: code, Message: msg, sentinel: exceptionMap[code]}
}
// dbusErrorMessage extracts the string body of a firewalld D-Bus error, whose
// first body element is the human-readable message.
func dbusErrorMessage(derr dbus.Error) string {
if len(derr.Body) > 0 {
if s, ok := derr.Body[0].(string); ok {
return s
}
}
return derr.Error()
}
// leadingToken returns the leading UPPER_SNAKE code of a firewalld message, e.g.
// "INVALID_ZONE" from "INVALID_ZONE: 'bogus'". Returns "" when the message does
// not start with such a token.
func leadingToken(msg string) string {
msg = strings.TrimSpace(msg)
end := 0
for end < len(msg) {
c := msg[end]
if (c >= 'A' && c <= 'Z') || c == '_' || (c >= '0' && c <= '9') {
end++
continue
}
break
}
token := msg[:end]
// A real firewalld code is an all-caps run of two or more characters. This
// rejects a stray leading capital from a plain-sentence error message; an
// unmapped-but-well-formed token still round-trips harmlessly (sentinel nil).
if len(token) < 2 {
return ""
}
return token
}

91
errors_test.go Normal file
View file

@ -0,0 +1,91 @@
package firewalld
import (
"errors"
"testing"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert"
)
// makeException builds a dbus.Error shaped like a firewalld exception with the
// given message body.
func makeException(msg string) dbus.Error {
return dbus.Error{Name: exceptionName, Body: []any{msg}}
}
func TestMapErrorSentinels(t *testing.T) {
cases := []struct {
msg string
want error
}{
{"ALREADY_ENABLED", ErrAlreadyEnabled},
{"NOT_ENABLED", ErrNotEnabled},
{"INVALID_ZONE: 'bogus'", ErrInvalidZone},
{"INVALID_PORT: '99999'", ErrInvalidPort},
{"INVALID_ICMPTYPE: 'nope'", ErrInvalidICMPType},
{"NAME_CONFLICT: 'dup'", ErrNameConflict},
{"INVALID_IPSET: 'x'", ErrInvalidIPSet},
}
for _, tc := range cases {
err := mapError(makeException(tc.msg))
assert.ErrorIs(t, err, tc.want, "message %q", tc.msg)
var fe *Error
if assert.ErrorAs(t, err, &fe) {
assert.Equal(t, tc.msg, fe.Message)
}
}
}
// TestMapErrorPassthrough leaves non-firewalld errors untouched.
func TestMapErrorPassthrough(t *testing.T) {
assert.Nil(t, mapError(nil))
plain := errors.New("connection reset")
assert.Equal(t, plain, mapError(plain))
// A D-Bus error that is not a firewalld exception passes through unchanged.
other := dbus.Error{Name: "org.freedesktop.DBus.Error.NoReply", Body: []any{"timeout"}}
assert.Equal(t, other, mapError(other))
}
// TestMapErrorUnknownCode maps an unrecognised firewalld code to a typed *Error
// with no sentinel, so errors.Is against a known sentinel is false but the code
// and message are preserved.
func TestMapErrorUnknownCode(t *testing.T) {
err := mapError(makeException("SOMETHING_NEW: detail"))
var fe *Error
assert.ErrorAs(t, err, &fe)
assert.Equal(t, "SOMETHING_NEW", fe.Code)
assert.False(t, errors.Is(err, ErrInvalidZone))
}
func TestLeadingToken(t *testing.T) {
assert.Equal(t, "INVALID_ZONE", leadingToken("INVALID_ZONE: 'x'"))
assert.Equal(t, "ALREADY_ENABLED", leadingToken("ALREADY_ENABLED"))
assert.Equal(t, "", leadingToken("Firewall is not running"))
assert.Equal(t, "", leadingToken(""))
}
func TestPolicyDictSignature(t *testing.T) {
s := PolicySettings{
IngressZones: []string{"public"},
EgressZones: []string{"HOST"},
Ports: []Port{{Port: "80", Protocol: "tcp"}},
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp"}},
Priority: -1,
}
d := s.toDict()
assert.Equal(t, "a{sv}", sigOf(d))
assert.Equal(t, "a(ss)", d["ports"].Signature().String())
assert.Equal(t, "a(ssss)", d["forward_ports"].Signature().String())
assert.Equal(t, "i", d["priority"].Signature().String())
// Round-trip the dict back through the decoder.
out := policySettingsFromDict(d)
assert.Equal(t, s.IngressZones, out.IngressZones)
assert.Equal(t, s.EgressZones, out.EgressZones)
assert.Equal(t, s.Ports, out.Ports)
assert.Equal(t, int32(-1), out.Priority)
}

215
firewalld.go Normal file
View file

@ -0,0 +1,215 @@
// Package firewalld is a comprehensive Go client for the firewalld D-Bus API.
//
// It exposes both runtime (transient) and permanent configuration operations,
// mirroring firewall-cmd's default and --permanent modes, and adapts to the
// connected server's version so a single binary serves both old (EL7 0.6.x) and
// new (EL9 1.x) firewalld. Compound values are encoded as concrete Go structs so
// godbus emits the D-Bus tuples firewalld requires (see CLAUDE.md).
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// D-Bus names for the firewalld service and its well-known object paths.
const (
dest = "org.fedoraproject.FirewallD1"
basePath = dbus.ObjectPath("/org/fedoraproject/FirewallD1")
configPath = dbus.ObjectPath("/org/fedoraproject/FirewallD1/config")
// Interface names.
ifaceMain = "org.fedoraproject.FirewallD1"
ifaceZone = "org.fedoraproject.FirewallD1.zone"
ifaceIPSet = "org.fedoraproject.FirewallD1.ipset"
ifaceDirect = "org.fedoraproject.FirewallD1.direct"
ifacePolicy = "org.fedoraproject.FirewallD1.policy"
ifaceLockdown = "org.fedoraproject.FirewallD1.policies"
ifaceConfig = "org.fedoraproject.FirewallD1.config"
ifaceConfigZone = "org.fedoraproject.FirewallD1.config.zone"
ifaceConfigIPSet = "org.fedoraproject.FirewallD1.config.ipset"
ifaceConfigPolicy = "org.fedoraproject.FirewallD1.config.policy"
ifaceConfigService = "org.fedoraproject.FirewallD1.config.service"
ifaceConfigICMP = "org.fedoraproject.FirewallD1.config.icmptype"
ifaceConfigHelper = "org.fedoraproject.FirewallD1.config.helper"
ifaceConfigDirect = "org.fedoraproject.FirewallD1.config.direct"
ifaceProperties = "org.freedesktop.DBus.Properties"
)
// Conn is a connection to the firewalld service on the system bus. It caches the
// server version and derived capabilities so callers can branch on Supports and
// internal code can pick the tuple or dict transport. A Conn is safe for
// concurrent use to the extent godbus's connection is.
type Conn struct {
conn *dbus.Conn
version Version
caps map[Capability]bool
// ownConn records whether we dialed the bus (and must close it) or adopted a
// caller-provided connection (and must leave it open).
ownConn bool
}
// Connect dials the system bus, reads the firewalld version property, and derives
// the capability set. The context bounds the connection and initial version read.
func Connect(ctx context.Context) (*Conn, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, err
}
c, err := newConn(ctx, conn, true)
if err != nil {
conn.Close()
return nil, err
}
return c, nil
}
// Open builds a Conn on a caller-supplied bus connection. The caller retains
// ownership: Close will not tear the bus down. Useful for sharing a bus or for
// tests against a private connection.
func Open(ctx context.Context, bus *dbus.Conn) (*Conn, error) {
return newConn(ctx, bus, false)
}
// newConn wires up the object handles and probes the version/caps.
func newConn(ctx context.Context, bus *dbus.Conn, own bool) (*Conn, error) {
c := &Conn{
conn: bus,
ownConn: own,
}
v, err := c.readVersion(ctx)
if err != nil {
return nil, err
}
c.version = v
c.caps = capsFor(v)
return c, nil
}
// readVersion reads the "version" property on the main interface via a
// context-bounded Properties.Get call.
func (c *Conn) readVersion(ctx context.Context) (Version, error) {
var variant dbus.Variant
err := c.call(ctx, basePath, ifaceProperties, "Get", []any{&variant}, ifaceMain, "version")
if err != nil {
return Version{}, err
}
s, _ := variant.Value().(string)
return parseVersion(s), nil
}
// Version returns the firewalld version reported by the server.
func (c *Conn) Version() Version { return c.version }
// Supports reports whether the connected server provides a capability.
func (c *Conn) Supports(cap Capability) bool { return c.caps[cap] }
// Close releases the connection. If the bus was dialed by Connect it is closed;
// a bus adopted via Open is left open for its owner.
func (c *Conn) Close() error {
if c.ownConn && c.conn != nil {
return c.conn.Close()
}
return nil
}
// call invokes a method on an arbitrary object path/interface and decodes the
// return values into rets. It is the single choke point through which every typed
// method flows, so error mapping happens in exactly one place.
func (c *Conn) call(ctx context.Context, path dbus.ObjectPath, iface, method string, rets []any, args ...any) error {
obj := c.conn.Object(dest, path)
call := obj.CallWithContext(ctx, iface+"."+method, 0, args...)
if call.Err != nil {
return mapError(call.Err)
}
if len(rets) == 0 {
return nil
}
return mapError(call.Store(rets...))
}
// Call is the low-level escape hatch for any method not yet wrapped by a typed
// helper. It targets the given object path and interface, passes args verbatim,
// and stores results into rets (pointers). Errors are mapped to sentinels.
func (c *Conn) Call(ctx context.Context, path dbus.ObjectPath, iface, method string, rets []any, args ...any) error {
return c.call(ctx, path, iface, method, rets, args...)
}
// Reload reloads firewalld's permanent configuration into the runtime, keeping
// active bindings where possible.
func (c *Conn) Reload(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "reload", nil)
}
// CompleteReload reloads and also re-reads interface-to-zone bindings, dropping
// active runtime state. Heavier than Reload; use when the permanent set changed
// structurally.
func (c *Conn) CompleteReload(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "completeReload", nil)
}
// RuntimeToPermanent persists the current runtime configuration as permanent.
func (c *Conn) RuntimeToPermanent(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "runtimeToPermanent", nil)
}
// ResetToDefaults resets the permanent configuration to firewalld's defaults.
// Requires firewalld >= 1.0; returns ErrUnsupported on older servers.
func (c *Conn) ResetToDefaults(ctx context.Context) error {
if !c.caps[ResetToDefaults] {
return ErrUnsupported
}
return c.call(ctx, basePath, ifaceMain, "resetToDefaults", nil)
}
// CheckPermanentConfig validates the on-disk permanent configuration, returning a
// firewalld error if it is inconsistent.
func (c *Conn) CheckPermanentConfig(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "checkPermanentConfig", nil)
}
// Panic-mode controls. Panic mode drops all traffic; callers on a remote bus must
// take care not to lock themselves out.
// EnablePanicMode drops all inbound and outbound packets.
func (c *Conn) EnablePanicMode(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "enablePanicMode", nil)
}
// DisablePanicMode restores normal packet processing.
func (c *Conn) DisablePanicMode(ctx context.Context) error {
return c.call(ctx, basePath, ifaceMain, "disablePanicMode", nil)
}
// QueryPanicMode reports whether panic mode is active.
func (c *Conn) QueryPanicMode(ctx context.Context) (bool, error) {
var on bool
err := c.call(ctx, basePath, ifaceMain, "queryPanicMode", []any{&on})
return on, err
}
// LogDenied returns the current LogDenied setting ("off", "all", "unicast", ...).
func (c *Conn) LogDenied(ctx context.Context) (string, error) {
var s string
err := c.call(ctx, basePath, ifaceMain, "getLogDenied", []any{&s})
return s, err
}
// SetLogDenied sets the LogDenied value.
func (c *Conn) SetLogDenied(ctx context.Context, value string) error {
return c.call(ctx, basePath, ifaceMain, "setLogDenied", nil, value)
}
// DefaultZone returns the name of the default zone.
func (c *Conn) DefaultZone(ctx context.Context) (string, error) {
var s string
err := c.call(ctx, basePath, ifaceMain, "getDefaultZone", []any{&s})
return s, err
}
// SetDefaultZone sets the default zone by name.
func (c *Conn) SetDefaultZone(ctx context.Context, zone string) error {
return c.call(ctx, basePath, ifaceMain, "setDefaultZone", nil, zone)
}

15
go.mod Normal file
View file

@ -0,0 +1,15 @@
module github.com/grmrgecko/go-firewalld
go 1.26.4
require (
github.com/godbus/dbus/v5 v5.2.2
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.27.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

14
go.sum Normal file
View file

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

165
helper.go Normal file
View file

@ -0,0 +1,165 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// HelperSettings is the transport-neutral representation of a conntrack helper. It
// maps to the tuple "(sssssa(ss))": version, short, description, family, module,
// ports.
type HelperSettings struct {
Version string
Short string
Description string
Family string // "", "ipv4", or "ipv6"
Module string // e.g. "nf_conntrack_ftp"
Ports []Port
}
// helperTuple is the concrete struct godbus encodes to "(sssssa(ss))".
type helperTuple struct {
Version string
Short string
Description string
Family string
Module string
Ports []Port
}
func (s *HelperSettings) toTuple() helperTuple {
return helperTuple{
Version: s.Version,
Short: s.Short,
Description: s.Description,
Family: s.Family,
Module: s.Module,
Ports: s.Ports,
}
}
func helperSettingsFromTuple(raw []any) HelperSettings {
return HelperSettings{
Version: asString(tupleField(raw, 0)),
Short: asString(tupleField(raw, 1)),
Description: asString(tupleField(raw, 2)),
Family: asString(tupleField(raw, 3)),
Module: asString(tupleField(raw, 4)),
Ports: asPorts(tupleField(raw, 5)),
}
}
// Helpers lists the names of all conntrack helpers known to the runtime.
func (c *Conn) Helpers(ctx context.Context) ([]string, error) {
var names []string
err := c.call(ctx, basePath, ifaceMain, "getHelpers", []any{&names})
return names, err
}
// HelperSettings reads a conntrack helper definition from the running firewall.
func (c *Conn) HelperSettings(ctx context.Context, helper string) (HelperSettings, error) {
var raw []any
if err := c.call(ctx, basePath, ifaceMain, "getHelperSettings", []any{&raw}, helper); err != nil {
return HelperSettings{}, err
}
return helperSettingsFromTuple(raw), nil
}
// HelperNames lists the names of all permanent conntrack helpers.
func (p *Permanent) HelperNames(ctx context.Context) ([]string, error) {
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getHelperNames", []any{&names})
return names, err
}
// HelperPaths lists the config object paths of all permanent helpers.
func (p *Permanent) HelperPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
var paths []dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "listHelpers", []any{&paths})
return paths, err
}
// AddHelper creates a new permanent conntrack helper and returns its config path.
func (p *Permanent) AddHelper(ctx context.Context, name string, settings HelperSettings) (dbus.ObjectPath, error) {
var path dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "addHelper", []any{&path}, name, settings.toTuple())
return path, err
}
// Helper returns a handle for permanent operations on the named helper.
func (p *Permanent) Helper(name string) *PermHelper {
return &PermHelper{c: p.c, name: name}
}
// PermHelper is a handle for permanent edits to a single conntrack helper,
// operating on the helper's config child object (/config/helper/N).
type PermHelper struct {
c *Conn
name string
path dbus.ObjectPath
}
func (s *PermHelper) resolve(ctx context.Context) (dbus.ObjectPath, error) {
if s.path != "" {
return s.path, nil
}
var path dbus.ObjectPath
if err := s.c.call(ctx, configPath, ifaceConfig, "getHelperByName", []any{&path}, s.name); err != nil {
return "", err
}
s.path = path
return path, nil
}
func (s *PermHelper) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := s.resolve(ctx)
if err != nil {
return err
}
return s.c.call(ctx, path, ifaceConfigHelper, method, rets, args...)
}
// Settings reads the helper's permanent settings.
func (s *PermHelper) Settings(ctx context.Context) (HelperSettings, error) {
var raw []any
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
return HelperSettings{}, err
}
return helperSettingsFromTuple(raw), nil
}
// Update replaces the helper's permanent settings.
func (s *PermHelper) Update(ctx context.Context, settings HelperSettings) error {
return s.callOn(ctx, "update", nil, settings.toTuple())
}
// SetFamily sets the helper's IP family ("", "ipv4", "ipv6").
func (s *PermHelper) SetFamily(ctx context.Context, family string) error {
return s.callOn(ctx, "setFamily", nil, family)
}
// SetModule sets the helper's netfilter module name.
func (s *PermHelper) SetModule(ctx context.Context, module string) error {
return s.callOn(ctx, "setModule", nil, module)
}
// AddPort adds a port to the permanent helper.
func (s *PermHelper) AddPort(ctx context.Context, p Port) error {
return s.callOn(ctx, "addPort", nil, p.Port, p.Protocol)
}
// RemovePort removes a port from the permanent helper.
func (s *PermHelper) RemovePort(ctx context.Context, p Port) error {
return s.callOn(ctx, "removePort", nil, p.Port, p.Protocol)
}
// SetDescription sets the permanent helper description.
func (s *PermHelper) SetDescription(ctx context.Context, description string) error {
return s.callOn(ctx, "setDescription", nil, description)
}
// Remove deletes the helper from the permanent configuration.
func (s *PermHelper) Remove(ctx context.Context) error {
return s.callOn(ctx, "remove", nil)
}

151
icmptype.go Normal file
View file

@ -0,0 +1,151 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// ICMPTypeSettings is the transport-neutral representation of an ICMP type. It
// maps to the tuple "(sssas)": version, short, description, destinations. The
// destinations list holds the IP families the type applies to ("ipv4", "ipv6").
type ICMPTypeSettings struct {
Version string
Short string
Description string
Destinations []string
}
// icmpTuple is the concrete struct godbus encodes to "(sssas)".
type icmpTuple struct {
Version string
Short string
Description string
Destinations []string
}
func (s *ICMPTypeSettings) toTuple() icmpTuple {
dst := s.Destinations
if dst == nil {
dst = []string{}
}
return icmpTuple{
Version: s.Version,
Short: s.Short,
Description: s.Description,
Destinations: dst,
}
}
func icmpSettingsFromTuple(raw []any) ICMPTypeSettings {
return ICMPTypeSettings{
Version: asString(tupleField(raw, 0)),
Short: asString(tupleField(raw, 1)),
Description: asString(tupleField(raw, 2)),
Destinations: asStrings(tupleField(raw, 3)),
}
}
// ICMPTypes lists the names of all ICMP types known to the runtime.
func (c *Conn) ICMPTypes(ctx context.Context) ([]string, error) {
var names []string
err := c.call(ctx, basePath, ifaceMain, "listIcmpTypes", []any{&names})
return names, err
}
// ICMPTypeSettings reads an ICMP type definition from the running firewall.
func (c *Conn) ICMPTypeSettings(ctx context.Context, icmptype string) (ICMPTypeSettings, error) {
var raw []any
if err := c.call(ctx, basePath, ifaceMain, "getIcmpTypeSettings", []any{&raw}, icmptype); err != nil {
return ICMPTypeSettings{}, err
}
return icmpSettingsFromTuple(raw), nil
}
// ICMPTypeNames lists the names of all permanent ICMP types.
func (p *Permanent) ICMPTypeNames(ctx context.Context) ([]string, error) {
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getIcmpTypeNames", []any{&names})
return names, err
}
// AddICMPType creates a new permanent ICMP type and returns its config path.
func (p *Permanent) AddICMPType(ctx context.Context, name string, settings ICMPTypeSettings) (dbus.ObjectPath, error) {
var path dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "addIcmpType", []any{&path}, name, settings.toTuple())
return path, err
}
// ICMPType returns a handle for permanent operations on the named ICMP type.
func (p *Permanent) ICMPType(name string) *PermICMPType {
return &PermICMPType{c: p.c, name: name}
}
// PermICMPType is a handle for permanent edits to a single ICMP type, operating
// on the icmptype's config child object (/config/icmptype/N).
type PermICMPType struct {
c *Conn
name string
path dbus.ObjectPath
}
func (s *PermICMPType) resolve(ctx context.Context) (dbus.ObjectPath, error) {
if s.path != "" {
return s.path, nil
}
var path dbus.ObjectPath
if err := s.c.call(ctx, configPath, ifaceConfig, "getIcmpTypeByName", []any{&path}, s.name); err != nil {
return "", err
}
s.path = path
return path, nil
}
func (s *PermICMPType) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := s.resolve(ctx)
if err != nil {
return err
}
return s.c.call(ctx, path, ifaceConfigICMP, method, rets, args...)
}
// Settings reads the ICMP type's permanent settings.
func (s *PermICMPType) Settings(ctx context.Context) (ICMPTypeSettings, error) {
var raw []any
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
return ICMPTypeSettings{}, err
}
return icmpSettingsFromTuple(raw), nil
}
// Update replaces the ICMP type's permanent settings.
func (s *PermICMPType) Update(ctx context.Context, settings ICMPTypeSettings) error {
return s.callOn(ctx, "update", nil, settings.toTuple())
}
// AddDestination adds an IP family ("ipv4"/"ipv6") the type applies to.
func (s *PermICMPType) AddDestination(ctx context.Context, family string) error {
return s.callOn(ctx, "addDestination", nil, family)
}
// RemoveDestination removes an IP family from the type.
func (s *PermICMPType) RemoveDestination(ctx context.Context, family string) error {
return s.callOn(ctx, "removeDestination", nil, family)
}
// QueryDestination reports whether the type applies to an IP family.
func (s *PermICMPType) QueryDestination(ctx context.Context, family string) (bool, error) {
var ok bool
err := s.callOn(ctx, "queryDestination", []any{&ok}, family)
return ok, err
}
// SetDescription sets the permanent ICMP type description.
func (s *PermICMPType) SetDescription(ctx context.Context, description string) error {
return s.callOn(ctx, "setDescription", nil, description)
}
// Remove deletes the ICMP type from the permanent configuration.
func (s *PermICMPType) Remove(ctx context.Context) error {
return s.callOn(ctx, "remove", nil)
}

519
integration_test.go Normal file
View file

@ -0,0 +1,519 @@
//go:build firewalld_integration
// Integration tests exercised against a live firewalld over the system bus. They
// are gated behind the firewalld_integration build tag so the default `go test`
// stays bus-free. Run them on each target server (they need firewalld and root):
//
// go test -c -tags firewalld_integration -o firewalld.itest .
// scp firewalld.itest root@<host>:/tmp/ && ssh root@<host> /tmp/firewalld.itest -test.v
//
// Safety: every test operates on a throwaway zone/ipset/policy it creates and
// removes. Nothing here touches the default zone, real interfaces, panic mode, or
// lockdown, so a failure cannot cut the SSH session.
package firewalld
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testCtx returns a context bounded to keep a hung call from stalling the suite.
func testCtx(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
return ctx
}
// dial connects to the live firewalld or skips the test if unavailable.
func dial(t *testing.T) *Conn {
t.Helper()
c, err := Connect(testCtx(t))
if err != nil {
t.Skipf("no firewalld on the system bus: %v", err)
}
t.Cleanup(func() { c.Close() })
return c
}
// uniqueName derives a collision-resistant throwaway name.
func uniqueName(prefix string) string {
return fmt.Sprintf("%s%d", prefix, os.Getpid())
}
func TestIntegrationConnect(t *testing.T) {
c := dial(t)
v := c.Version()
t.Logf("firewalld version %s (major=%d minor=%d)", v.Raw, v.Major, v.Minor)
assert.NotEmpty(t, v.Raw)
t.Logf("caps: dict=%v policies=%v svc2=%v reset=%v",
c.Supports(DictZoneSettings), c.Supports(Policies),
c.Supports(ServiceSettings2), c.Supports(ResetToDefaults))
}
func TestIntegrationDefaultZoneAndList(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
dz, err := c.DefaultZone(ctx)
require.NoError(t, err)
assert.NotEmpty(t, dz)
t.Logf("default zone: %s", dz)
names, err := c.Permanent().ZoneNames(ctx)
require.NoError(t, err)
assert.Contains(t, names, "public")
}
// TestIntegrationZoneSettingsTransport reads the public zone through whichever
// transport the server supports (tuple on EL7, dict on EL9+) and sanity-checks it.
func TestIntegrationZoneSettingsTransport(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
s, err := c.Permanent().Zone("public").Settings(ctx)
require.NoError(t, err)
assert.Equal(t, "Public", s.Short)
assert.Equal(t, TargetDefault, s.Target)
t.Logf("public services=%v ports=%v forward=%v", s.Services, s.Ports, s.Forward)
// The runtime read path too.
rs, err := c.Runtime().Settings(ctx, "public")
require.NoError(t, err)
assert.Equal(t, "Public", rs.Short)
}
// TestIntegrationPermanentZoneLifecycle is the core round-trip: create a zone,
// mutate every element kind, read it back through Settings, then remove it.
func TestIntegrationPermanentZoneLifecycle(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
name := uniqueName("gofwz")
// Clean any leftover from a previous crashed run, then create fresh.
_ = perm.Zone(name).Remove(ctx)
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name, Target: TargetDefault})
require.NoError(t, err, "addZone")
t.Cleanup(func() {
_ = perm.Zone(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
z := perm.Zone(name)
require.NoError(t, z.SetTarget(ctx, TargetDROP))
require.NoError(t, z.AddPort(ctx, Port{Port: "4242", Protocol: "udp"}))
require.NoError(t, z.AddPort(ctx, Port{Port: "8080", Protocol: "tcp"}))
require.NoError(t, z.AddSourcePort(ctx, Port{Port: "1024", Protocol: "tcp"}))
require.NoError(t, z.AddSource(ctx, "10.9.8.0/24"))
require.NoError(t, z.AddService(ctx, "ssh"))
require.NoError(t, z.AddProtocol(ctx, "gre"))
require.NoError(t, z.AddMasquerade(ctx))
require.NoError(t, z.AddForwardPort(ctx, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"}))
require.NoError(t, z.AddRichRule(ctx, `rule family="ipv4" source address="10.9.8.0/24" reject`))
// Read everything back through the settings decoder.
s, err := z.Settings(ctx)
require.NoError(t, err)
assert.Equal(t, TargetDROP, s.Target)
assert.Contains(t, s.Ports, Port{Port: "4242", Protocol: "udp"})
assert.Contains(t, s.Ports, Port{Port: "8080", Protocol: "tcp"})
assert.Contains(t, s.SourcePorts, Port{Port: "1024", Protocol: "tcp"})
assert.Contains(t, s.Sources, "10.9.8.0/24")
assert.Contains(t, s.Services, "ssh")
assert.Contains(t, s.Protocols, "gre")
assert.True(t, s.Masquerade)
assert.Contains(t, s.ForwardPorts, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"})
assert.Len(t, s.RichRules, 1)
// Query helpers agree with the settings snapshot.
ok, err := z.QueryPort(ctx, Port{Port: "4242", Protocol: "udp"})
require.NoError(t, err)
assert.True(t, ok)
// Remove a port and confirm it is gone.
require.NoError(t, z.RemovePort(ctx, Port{Port: "8080", Protocol: "tcp"}))
ok, err = z.QueryPort(ctx, Port{Port: "8080", Protocol: "tcp"})
require.NoError(t, err)
assert.False(t, ok)
}
// TestIntegrationUpdateWholesale verifies the Update path (tuple or dict) writes a
// full settings struct that reads back intact.
func TestIntegrationUpdateWholesale(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
name := uniqueName("gofwu")
_ = perm.Zone(name).Remove(ctx)
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
require.NoError(t, err)
t.Cleanup(func() {
_ = perm.Zone(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
want := ZoneSettings{
Short: name,
Description: "wholesale update",
Target: TargetACCEPT,
Services: []string{"ssh", "http"},
Ports: []Port{{Port: "5353", Protocol: "udp"}},
Masquerade: true,
}
require.NoError(t, perm.Zone(name).Update(ctx, want))
got, err := perm.Zone(name).Settings(ctx)
require.NoError(t, err)
assert.Equal(t, TargetACCEPT, got.Target)
assert.Equal(t, "wholesale update", got.Description)
assert.ElementsMatch(t, want.Services, got.Services)
assert.Contains(t, got.Ports, Port{Port: "5353", Protocol: "udp"})
assert.True(t, got.Masquerade)
}
// TestIntegrationRuntimeZone exercises transient edits with a timeout against a
// throwaway zone that has been reloaded into the runtime.
func TestIntegrationRuntimeZone(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
name := uniqueName("gofwr")
_ = perm.Zone(name).Remove(ctx)
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
require.NoError(t, err)
require.NoError(t, c.Reload(ctx)) // materialise the zone at runtime
t.Cleanup(func() {
_ = perm.Zone(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
rz := c.Runtime().Zone(name)
require.NoError(t, rz.AddPort(ctx, Port{Port: "7000", Protocol: "tcp"}, 0))
ports, err := rz.Ports(ctx)
require.NoError(t, err)
assert.Contains(t, ports, Port{Port: "7000", Protocol: "tcp"})
ok, err := rz.QueryPort(ctx, Port{Port: "7000", Protocol: "tcp"})
require.NoError(t, err)
assert.True(t, ok)
require.NoError(t, rz.RemovePort(ctx, Port{Port: "7000", Protocol: "tcp"}))
}
// TestIntegrationIdempotency confirms firewalld's idempotency signals map to the
// sentinels callers rely on.
func TestIntegrationIdempotency(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
name := uniqueName("gofwi")
_ = perm.Zone(name).Remove(ctx)
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
require.NoError(t, err)
t.Cleanup(func() {
_ = perm.Zone(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
z := perm.Zone(name)
require.NoError(t, z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"}))
// Adding the same port again is ALREADY_ENABLED.
err = z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"})
assert.ErrorIs(t, err, ErrAlreadyEnabled)
// Removing a port that is not present is NOT_ENABLED.
err = z.RemovePort(ctx, Port{Port: "1234", Protocol: "tcp"})
assert.ErrorIs(t, err, ErrNotEnabled)
}
// TestIntegrationErrorMapping checks a precise INVALID_* mapping.
func TestIntegrationErrorMapping(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
_, err := c.Permanent().Zone("definitely-not-a-zone").Settings(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidZone)
}
func TestIntegrationIPSetLifecycle(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
name := uniqueName("gofwset")
_ = perm.IPSet(name).Remove(ctx)
_, err := perm.AddIPSet(ctx, name, IPSetSettings{
Type: "hash:ip",
Options: map[string]string{"family": "inet"},
})
require.NoError(t, err)
t.Cleanup(func() {
_ = perm.IPSet(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
set := perm.IPSet(name)
require.NoError(t, set.AddEntry(ctx, "10.7.7.1"))
require.NoError(t, set.AddEntry(ctx, "10.7.7.2"))
entries, err := set.Entries(ctx)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"10.7.7.1", "10.7.7.2"}, entries)
s, err := set.Settings(ctx)
require.NoError(t, err)
assert.Equal(t, "hash:ip", s.Type)
assert.Equal(t, "inet", s.Options["family"])
require.NoError(t, set.RemoveEntry(ctx, "10.7.7.1"))
ok, err := set.QueryEntry(ctx, "10.7.7.1")
require.NoError(t, err)
assert.False(t, ok)
}
func TestIntegrationServiceRead(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
s, err := c.ServiceSettings(ctx, "ssh")
require.NoError(t, err)
assert.Contains(t, s.Ports, Port{Port: "22", Protocol: "tcp"})
}
func TestIntegrationICMPTypeRead(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
names, err := c.ICMPTypes(ctx)
require.NoError(t, err)
assert.NotEmpty(t, names)
s, err := c.ICMPTypeSettings(ctx, "echo-request")
require.NoError(t, err)
assert.NotEmpty(t, s.Short)
}
// TestIntegrationPolicyLifecycle is skipped on servers without policy support.
func TestIntegrationPolicyLifecycle(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
if !c.Supports(Policies) {
t.Skip("server predates zone-to-zone policies")
}
perm := c.Permanent()
name := uniqueName("gofwp")
_ = perm.Policy(name).Remove(ctx)
_, err := perm.AddPolicy(ctx, name, PolicySettings{
Target: "CONTINUE",
Priority: -1,
IngressZones: []string{"public"},
EgressZones: []string{"HOST"},
Ports: []Port{{Port: "6000", Protocol: "tcp"}},
})
require.NoError(t, err)
t.Cleanup(func() {
_ = perm.Policy(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
s, err := perm.Policy(name).Settings(ctx)
require.NoError(t, err)
assert.Equal(t, "CONTINUE", s.Target)
assert.Equal(t, int32(-1), s.Priority)
assert.Contains(t, s.IngressZones, "public")
assert.Contains(t, s.Ports, Port{Port: "6000", Protocol: "tcp"})
// Reload so the permanent policy appears in the runtime, then exercise the
// runtime .policy interface. These calls target the .policy interface (not
// .zone); a regression to the wrong interface would fault here with
// UnknownMethod rather than returning the policy.
require.NoError(t, c.Reload(ctx))
names, err := c.Runtime().Policies(ctx)
require.NoError(t, err)
assert.Contains(t, names, name)
rs, err := c.Runtime().PolicySettings(ctx, name)
require.NoError(t, err)
assert.Equal(t, "CONTINUE", rs.Target)
assert.Contains(t, rs.IngressZones, "public")
_, err = c.Runtime().ActivePolicies(ctx)
require.NoError(t, err)
}
// TestIntegrationDirect exercises the direct interface with a throwaway custom
// chain and rule, cleaning up after itself.
func TestIntegrationDirect(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
d := c.Direct()
chain := DirectChain{IPV: "ipv4", Table: "filter", Chain: uniqueName("GOFW")}
_ = d.RemoveChain(ctx, chain)
require.NoError(t, d.AddChain(ctx, chain))
t.Cleanup(func() {
_ = d.RemoveRules(context.Background(), chain.IPV, chain.Table, chain.Chain)
_ = d.RemoveChain(context.Background(), chain)
})
ok, err := d.QueryChain(ctx, chain)
require.NoError(t, err)
assert.True(t, ok)
chains, err := d.AllChains(ctx)
require.NoError(t, err)
assert.Contains(t, chains, chain)
rule := DirectRule{IPV: "ipv4", Table: "filter", Chain: chain.Chain, Priority: 0, Args: []string{"-j", "ACCEPT"}}
require.NoError(t, d.AddRule(ctx, rule))
ok, err = d.QueryRule(ctx, rule)
require.NoError(t, err)
assert.True(t, ok)
rules, err := d.Rules(ctx, chain.IPV, chain.Table, chain.Chain)
require.NoError(t, err)
require.Len(t, rules, 1)
assert.Equal(t, []string{"-j", "ACCEPT"}, rules[0].Args)
require.NoError(t, d.RemoveRule(ctx, rule))
}
// TestIntegrationSignals subscribes to firewalld signals and confirms a Reloaded
// event arrives after an explicit reload.
func TestIntegrationSignals(t *testing.T) {
c := dial(t)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reloaded, err := c.WatchReloaded(ctx)
require.NoError(t, err)
// Give the match rule a moment to register before triggering.
time.Sleep(200 * time.Millisecond)
require.NoError(t, c.Reload(ctx))
select {
case <-reloaded:
// success
case <-time.After(10 * time.Second):
t.Fatal("did not receive Reloaded signal within timeout")
}
}
// TestIntegrationProperties reads the runtime state and daemon config properties.
func TestIntegrationProperties(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
state, err := c.State(ctx)
require.NoError(t, err)
assert.Equal(t, "RUNNING", state)
info, err := c.RuntimeInfo(ctx)
require.NoError(t, err)
assert.Equal(t, "RUNNING", info.State)
t.Logf("runtime: ipv4=%v ipv6=%v ipset=%v bridge=%v ipsetTypes=%d",
info.IPv4, info.IPv6, info.IPSet, info.Bridge, len(info.IPSetTypes))
dc, err := c.Permanent().DaemonConfig(ctx)
require.NoError(t, err)
assert.NotEmpty(t, dc.DefaultZone)
t.Logf("daemon: backend=%q defaultZone=%q ipv6rpfilter=%q cleanup=%q",
dc.FirewallBackend, dc.DefaultZone, dc.IPv6RPFilter, dc.CleanupOnExit)
t.Logf("daemon 2.x knobs: nftCounters=%q nftFlowtable=%q strictForwardPorts=%q",
dc.NftablesCounters, dc.NftablesFlowtable, dc.StrictForwardPorts)
}
// TestIntegrationServiceIncludes exercises the service includes editor on a
// throwaway service (firewalld >= 1.0).
func TestIntegrationServiceIncludes(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
if !c.Supports(ServiceSettings2) {
t.Skip("server predates service includes")
}
perm := c.Permanent()
name := uniqueName("gofwsvc")
_ = perm.Service(name).Remove(ctx)
_, err := perm.AddService(ctx, name, ServiceSettings{Short: name})
require.NoError(t, err)
t.Cleanup(func() {
_ = perm.Service(name).Remove(context.Background())
_ = c.Reload(context.Background())
})
svc := perm.Service(name)
require.NoError(t, svc.AddPort(ctx, Port{Port: "1717", Protocol: "tcp"}))
require.NoError(t, svc.AddInclude(ctx, "ssh"))
ok, err := svc.QueryInclude(ctx, "ssh")
require.NoError(t, err)
assert.True(t, ok)
incs, err := svc.Includes(ctx)
require.NoError(t, err)
assert.Contains(t, incs, "ssh")
got, err := svc.Settings(ctx)
require.NoError(t, err)
assert.Contains(t, got.Ports, Port{Port: "1717", Protocol: "tcp"})
}
// TestIntegrationChangeZoneOfSource confirms a source can be moved between zones
// at runtime without an ALREADY-bound error.
func TestIntegrationChangeZoneOfSource(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
perm := c.Permanent()
za, zb := uniqueName("gofwca"), uniqueName("gofwcb")
source := "10.44.44.0/24"
for _, n := range []string{za, zb} {
_ = perm.Zone(n).Remove(ctx)
_, err := perm.AddZone(ctx, n, ZoneSettings{Short: n})
require.NoError(t, err)
}
require.NoError(t, c.Reload(ctx))
t.Cleanup(func() {
for _, n := range []string{za, zb} {
_ = perm.Zone(n).Remove(context.Background())
}
_ = c.Reload(context.Background())
})
require.NoError(t, c.Runtime().Zone(za).AddSource(ctx, source))
// Move it to zb; ChangeSource must succeed even though it is bound to za.
require.NoError(t, c.Runtime().Zone(zb).ChangeSource(ctx, source))
zone, err := c.Runtime().ZoneOfSource(ctx, source)
require.NoError(t, err)
assert.Equal(t, zb, zone)
}
// TestIntegrationUnsupportedGuards ensures the library refuses policy calls on old
// servers rather than emitting an UnknownMethod fault.
func TestIntegrationUnsupportedGuards(t *testing.T) {
c := dial(t)
ctx := testCtx(t)
if c.Supports(Policies) {
t.Skip("server supports policies; nothing to guard")
}
_, err := c.Runtime().Policies(ctx)
assert.True(t, errors.Is(err, ErrUnsupported))
}

252
ipset.go Normal file
View file

@ -0,0 +1,252 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// IPSetSettings is the transport-neutral representation of an ipset. It maps to
// the D-Bus tuple "(ssssa{ss}as)": version, name, description, type, options,
// entries. ipsets have no dict form; the tuple is used on every server.
type IPSetSettings struct {
Version string
Name string
Description string
Type string // e.g. "hash:ip", "hash:net"
Options map[string]string // e.g. {"family": "inet"}
Entries []string
}
// ipsetTuple is the concrete struct godbus encodes to "(ssssa{ss}as)". Field
// order matches firewalld's ipset settings tuple exactly.
type ipsetTuple struct {
Version string
Name string
Description string
Type string
Options map[string]string
Entries []string
}
// toTuple builds the wire tuple, defaulting nil maps/slices to empty.
func (s *IPSetSettings) toTuple() ipsetTuple {
opts := s.Options
if opts == nil {
opts = map[string]string{}
}
entries := s.Entries
if entries == nil {
entries = []string{}
}
return ipsetTuple{
Version: s.Version,
Name: s.Name,
Description: s.Description,
Type: s.Type,
Options: opts,
Entries: entries,
}
}
// ipsetSettingsFromTuple decodes a raw ipset tuple, length-tolerant throughout.
func ipsetSettingsFromTuple(raw []any) IPSetSettings {
return IPSetSettings{
Version: asString(tupleField(raw, 0)),
Name: asString(tupleField(raw, 1)),
Description: asString(tupleField(raw, 2)),
Type: asString(tupleField(raw, 3)),
Options: asStringMap(tupleField(raw, 4)),
Entries: asStrings(tupleField(raw, 5)),
}
}
// IPSetNames lists the names of all permanent ipsets.
func (p *Permanent) IPSetNames(ctx context.Context) ([]string, error) {
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getIPSetNames", []any{&names})
return names, err
}
// AddIPSet creates a new permanent ipset from the given settings and returns its
// config object path.
func (p *Permanent) AddIPSet(ctx context.Context, name string, settings IPSetSettings) (dbus.ObjectPath, error) {
var path dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "addIPSet", []any{&path}, name, settings.toTuple())
return path, err
}
// IPSet returns a handle for permanent operations on the named ipset.
func (p *Permanent) IPSet(name string) *PermIPSet {
return &PermIPSet{c: p.c, name: name}
}
// IPSets lists the names of ipsets known to the runtime.
func (r *Runtime) IPSets(ctx context.Context) ([]string, error) {
var names []string
err := r.c.call(ctx, basePath, ifaceIPSet, "getIPSets", []any{&names})
return names, err
}
// IPSetExists reports whether an ipset of the given name exists at runtime.
func (r *Runtime) IPSetExists(ctx context.Context, name string) (bool, error) {
var ok bool
err := r.c.call(ctx, basePath, ifaceIPSet, "queryIPSet", []any{&ok}, name)
return ok, err
}
// IPSetPaths lists the config object paths of all permanent ipsets (listIPSets).
func (p *Permanent) IPSetPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
var paths []dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "listIPSets", []any{&paths})
return paths, err
}
// IPSet returns a handle for runtime operations on the named ipset.
func (r *Runtime) IPSet(name string) *RuntimeIPSet {
return &RuntimeIPSet{c: r.c, name: name}
}
// RuntimeIPSet is a handle for transient ipset entry operations on the running
// firewall. ipset runtime methods live on the main object's .ipset interface.
type RuntimeIPSet struct {
c *Conn
name string
}
// Settings reads the runtime settings of the ipset.
func (s *RuntimeIPSet) Settings(ctx context.Context) (IPSetSettings, error) {
var raw []any
if err := s.c.call(ctx, basePath, ifaceIPSet, "getIPSetSettings", []any{&raw}, s.name); err != nil {
return IPSetSettings{}, err
}
return ipsetSettingsFromTuple(raw), nil
}
// AddEntry adds a single entry to the ipset at runtime.
func (s *RuntimeIPSet) AddEntry(ctx context.Context, entry string) error {
return s.c.call(ctx, basePath, ifaceIPSet, "addEntry", nil, s.name, entry)
}
// RemoveEntry removes a single entry from the ipset at runtime.
func (s *RuntimeIPSet) RemoveEntry(ctx context.Context, entry string) error {
return s.c.call(ctx, basePath, ifaceIPSet, "removeEntry", nil, s.name, entry)
}
// QueryEntry reports whether an entry is present at runtime.
func (s *RuntimeIPSet) QueryEntry(ctx context.Context, entry string) (bool, error) {
var ok bool
err := s.c.call(ctx, basePath, ifaceIPSet, "queryEntry", []any{&ok}, s.name, entry)
return ok, err
}
// Entries returns all entries of the ipset at runtime.
func (s *RuntimeIPSet) Entries(ctx context.Context) ([]string, error) {
var entries []string
err := s.c.call(ctx, basePath, ifaceIPSet, "getEntries", []any{&entries}, s.name)
return entries, err
}
// SetEntries replaces all entries of the ipset at runtime.
func (s *RuntimeIPSet) SetEntries(ctx context.Context, entries []string) error {
if entries == nil {
entries = []string{}
}
return s.c.call(ctx, basePath, ifaceIPSet, "setEntries", nil, s.name, entries)
}
// PermIPSet is a handle for permanent edits to a single ipset, operating on the
// ipset's config child object (/config/ipset/N).
type PermIPSet struct {
c *Conn
name string
path dbus.ObjectPath
}
// resolve looks up and caches the config object path for the ipset.
func (s *PermIPSet) resolve(ctx context.Context) (dbus.ObjectPath, error) {
if s.path != "" {
return s.path, nil
}
var path dbus.ObjectPath
if err := s.c.call(ctx, configPath, ifaceConfig, "getIPSetByName", []any{&path}, s.name); err != nil {
return "", err
}
s.path = path
return path, nil
}
// callOn resolves the ipset path and invokes a config.ipset method.
func (s *PermIPSet) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := s.resolve(ctx)
if err != nil {
return err
}
return s.c.call(ctx, path, ifaceConfigIPSet, method, rets, args...)
}
// Settings reads the ipset's permanent settings.
func (s *PermIPSet) Settings(ctx context.Context) (IPSetSettings, error) {
var raw []any
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
return IPSetSettings{}, err
}
return ipsetSettingsFromTuple(raw), nil
}
// Update replaces the ipset's permanent settings wholesale.
func (s *PermIPSet) Update(ctx context.Context, settings IPSetSettings) error {
return s.callOn(ctx, "update", nil, settings.toTuple())
}
// AddEntry adds an entry to the permanent ipset.
func (s *PermIPSet) AddEntry(ctx context.Context, entry string) error {
return s.callOn(ctx, "addEntry", nil, entry)
}
// RemoveEntry removes an entry from the permanent ipset.
func (s *PermIPSet) RemoveEntry(ctx context.Context, entry string) error {
return s.callOn(ctx, "removeEntry", nil, entry)
}
// QueryEntry reports whether an entry is present in the permanent ipset.
func (s *PermIPSet) QueryEntry(ctx context.Context, entry string) (bool, error) {
var ok bool
err := s.callOn(ctx, "queryEntry", []any{&ok}, entry)
return ok, err
}
// Entries returns all entries of the permanent ipset.
func (s *PermIPSet) Entries(ctx context.Context) ([]string, error) {
var entries []string
err := s.callOn(ctx, "getEntries", []any{&entries})
return entries, err
}
// SetEntries replaces all entries of the permanent ipset.
func (s *PermIPSet) SetEntries(ctx context.Context, entries []string) error {
if entries == nil {
entries = []string{}
}
return s.callOn(ctx, "setEntries", nil, entries)
}
// AddOption sets an option (key/value) on the permanent ipset.
func (s *PermIPSet) AddOption(ctx context.Context, key, value string) error {
return s.callOn(ctx, "addOption", nil, key, value)
}
// RemoveOption removes an option from the permanent ipset.
func (s *PermIPSet) RemoveOption(ctx context.Context, key string) error {
return s.callOn(ctx, "removeOption", nil, key)
}
// SetDescription sets the permanent ipset description.
func (s *PermIPSet) SetDescription(ctx context.Context, description string) error {
return s.callOn(ctx, "setDescription", nil, description)
}
// Remove deletes the ipset from the permanent configuration.
func (s *PermIPSet) Remove(ctx context.Context) error {
return s.callOn(ctx, "remove", nil)
}

101
lockdown.go Normal file
View file

@ -0,0 +1,101 @@
package firewalld
import "context"
// Lockdown controls firewalld's lockdown feature and its whitelist. Lockdown, when
// enabled, restricts which applications may change the firewall; the whitelist
// names the commands, contexts, users, and uids that remain allowed. These live on
// the main object's .policies interface (distinct from zone-to-zone policies).
type Lockdown struct{ c *Conn }
// Lockdown returns the lockdown namespace.
func (c *Conn) Lockdown() *Lockdown { return &Lockdown{c: c} }
// Enable turns lockdown on.
func (l *Lockdown) Enable(ctx context.Context) error {
return l.c.call(ctx, basePath, ifaceLockdown, "enableLockdown", nil)
}
// Disable turns lockdown off.
func (l *Lockdown) Disable(ctx context.Context) error {
return l.c.call(ctx, basePath, ifaceLockdown, "disableLockdown", nil)
}
// Query reports whether lockdown is currently enabled.
func (l *Lockdown) Query(ctx context.Context) (bool, error) {
var on bool
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdown", []any{&on})
return on, err
}
// Whitelist command operations. A command entry may end in "*" to match a prefix.
func (l *Lockdown) AddCommand(ctx context.Context, command string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistCommand", nil, command)
}
func (l *Lockdown) RemoveCommand(ctx context.Context, command string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistCommand", nil, command)
}
func (l *Lockdown) QueryCommand(ctx context.Context, command string) (bool, error) {
var ok bool
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistCommand", []any{&ok}, command)
return ok, err
}
func (l *Lockdown) Commands(ctx context.Context) ([]string, error) {
var xs []string
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistCommands", []any{&xs})
return xs, err
}
// Whitelist context (SELinux) operations.
func (l *Lockdown) AddContext(ctx context.Context, selinuxContext string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistContext", nil, selinuxContext)
}
func (l *Lockdown) RemoveContext(ctx context.Context, selinuxContext string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistContext", nil, selinuxContext)
}
func (l *Lockdown) QueryContext(ctx context.Context, selinuxContext string) (bool, error) {
var ok bool
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistContext", []any{&ok}, selinuxContext)
return ok, err
}
func (l *Lockdown) Contexts(ctx context.Context) ([]string, error) {
var xs []string
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistContexts", []any{&xs})
return xs, err
}
// Whitelist user operations (by user name).
func (l *Lockdown) AddUser(ctx context.Context, user string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistUser", nil, user)
}
func (l *Lockdown) RemoveUser(ctx context.Context, user string) error {
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistUser", nil, user)
}
func (l *Lockdown) QueryUser(ctx context.Context, user string) (bool, error) {
var ok bool
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistUser", []any{&ok}, user)
return ok, err
}
func (l *Lockdown) Users(ctx context.Context) ([]string, error) {
var xs []string
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistUsers", []any{&xs})
return xs, err
}
// Whitelist uid operations (by numeric uid).
func (l *Lockdown) AddUID(ctx context.Context, uid int32) error {
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistUid", nil, uid)
}
func (l *Lockdown) RemoveUID(ctx context.Context, uid int32) error {
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistUid", nil, uid)
}
func (l *Lockdown) QueryUID(ctx context.Context, uid int32) (bool, error) {
var ok bool
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistUid", []any{&ok}, uid)
return ok, err
}
func (l *Lockdown) UIDs(ctx context.Context) ([]int32, error) {
var xs []int32
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistUids", []any{&xs})
return xs, err
}

269
permzone.go Normal file
View file

@ -0,0 +1,269 @@
package firewalld
import (
"context"
"sync"
"github.com/godbus/dbus/v5"
)
// PermZone is a handle for permanent edits to a single zone, operating on the
// zone's config child object (/config/zone/N). The object path is resolved once
// from the zone name and cached. Permanent element methods take no timeout and
// return nothing; changes apply after a Reload.
type PermZone struct {
c *Conn
name string
mu sync.Mutex
path dbus.ObjectPath // cached; resolved lazily via getZoneByName
}
// Name returns the zone name this handle targets.
func (z *PermZone) Name() string { return z.name }
// resolve returns the cached config object path, looking it up on first use.
func (z *PermZone) resolve(ctx context.Context) (dbus.ObjectPath, error) {
z.mu.Lock()
defer z.mu.Unlock()
if z.path != "" {
return z.path, nil
}
var path dbus.ObjectPath
if err := z.c.call(ctx, configPath, ifaceConfig, "getZoneByName", []any{&path}, z.name); err != nil {
return "", err
}
z.path = path
return path, nil
}
// callOn resolves the zone path and invokes a config.zone method against it.
func (z *PermZone) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := z.resolve(ctx)
if err != nil {
return err
}
return z.c.call(ctx, path, ifaceConfigZone, method, rets, args...)
}
func (z *PermZone) add(ctx context.Context, method string, args ...any) error {
return z.callOn(ctx, method, nil, args...)
}
func (z *PermZone) query(ctx context.Context, method string, args ...any) (bool, error) {
var ok bool
err := z.callOn(ctx, method, []any{&ok}, args...)
return ok, err
}
func (z *PermZone) getStrings(ctx context.Context, method string) ([]string, error) {
var xs []string
err := z.callOn(ctx, method, []any{&xs})
return xs, err
}
// Settings reads the zone's permanent settings, choosing the dict transport
// (getSettings2) when supported and falling back to the v1 tuple (getSettings).
func (z *PermZone) Settings(ctx context.Context) (ZoneSettings, error) {
if z.c.caps[DictZoneSettings] {
var d map[string]dbus.Variant
if err := z.callOn(ctx, "getSettings2", []any{&d}); err != nil {
return ZoneSettings{}, err
}
return zoneSettingsFromDict(d), nil
}
var raw []any
if err := z.callOn(ctx, "getSettings", []any{&raw}); err != nil {
return ZoneSettings{}, err
}
return zoneSettingsFromTuple(raw), nil
}
// Update replaces the zone's permanent settings wholesale, using update2 (dict)
// when supported and update (v1 tuple) otherwise.
func (z *PermZone) Update(ctx context.Context, settings ZoneSettings) error {
if z.c.caps[DictZoneSettings] {
return z.callOn(ctx, "update2", nil, settings.toDict())
}
return z.callOn(ctx, "update", nil, settings.toTuple())
}
// Port operations.
func (z *PermZone) AddPort(ctx context.Context, p Port) error {
return z.add(ctx, "addPort", p.Port, p.Protocol)
}
func (z *PermZone) RemovePort(ctx context.Context, p Port) error {
return z.add(ctx, "removePort", p.Port, p.Protocol)
}
func (z *PermZone) QueryPort(ctx context.Context, p Port) (bool, error) {
return z.query(ctx, "queryPort", p.Port, p.Protocol)
}
// Protocol operations.
func (z *PermZone) AddProtocol(ctx context.Context, proto string) error {
return z.add(ctx, "addProtocol", proto)
}
func (z *PermZone) RemoveProtocol(ctx context.Context, proto string) error {
return z.add(ctx, "removeProtocol", proto)
}
func (z *PermZone) QueryProtocol(ctx context.Context, proto string) (bool, error) {
return z.query(ctx, "queryProtocol", proto)
}
// SourcePort operations.
func (z *PermZone) AddSourcePort(ctx context.Context, p Port) error {
return z.add(ctx, "addSourcePort", p.Port, p.Protocol)
}
func (z *PermZone) RemoveSourcePort(ctx context.Context, p Port) error {
return z.add(ctx, "removeSourcePort", p.Port, p.Protocol)
}
func (z *PermZone) QuerySourcePort(ctx context.Context, p Port) (bool, error) {
return z.query(ctx, "querySourcePort", p.Port, p.Protocol)
}
// Service operations.
func (z *PermZone) AddService(ctx context.Context, service string) error {
return z.add(ctx, "addService", service)
}
func (z *PermZone) RemoveService(ctx context.Context, service string) error {
return z.add(ctx, "removeService", service)
}
func (z *PermZone) QueryService(ctx context.Context, service string) (bool, error) {
return z.query(ctx, "queryService", service)
}
// Source operations.
func (z *PermZone) AddSource(ctx context.Context, source string) error {
return z.add(ctx, "addSource", source)
}
func (z *PermZone) RemoveSource(ctx context.Context, source string) error {
return z.add(ctx, "removeSource", source)
}
func (z *PermZone) QuerySource(ctx context.Context, source string) (bool, error) {
return z.query(ctx, "querySource", source)
}
// RichRule operations.
func (z *PermZone) AddRichRule(ctx context.Context, rule string) error {
return z.add(ctx, "addRichRule", rule)
}
func (z *PermZone) RemoveRichRule(ctx context.Context, rule string) error {
return z.add(ctx, "removeRichRule", rule)
}
func (z *PermZone) QueryRichRule(ctx context.Context, rule string) (bool, error) {
return z.query(ctx, "queryRichRule", rule)
}
// ForwardPort operations.
func (z *PermZone) AddForwardPort(ctx context.Context, fp ForwardPort) error {
return z.add(ctx, "addForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
func (z *PermZone) RemoveForwardPort(ctx context.Context, fp ForwardPort) error {
return z.add(ctx, "removeForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
func (z *PermZone) QueryForwardPort(ctx context.Context, fp ForwardPort) (bool, error) {
return z.query(ctx, "queryForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
// Masquerade operations.
func (z *PermZone) AddMasquerade(ctx context.Context) error {
return z.add(ctx, "addMasquerade")
}
func (z *PermZone) RemoveMasquerade(ctx context.Context) error {
return z.add(ctx, "removeMasquerade")
}
func (z *PermZone) QueryMasquerade(ctx context.Context) (bool, error) {
return z.query(ctx, "queryMasquerade")
}
// IcmpBlock operations.
func (z *PermZone) AddIcmpBlock(ctx context.Context, icmptype string) error {
return z.add(ctx, "addIcmpBlock", icmptype)
}
func (z *PermZone) RemoveIcmpBlock(ctx context.Context, icmptype string) error {
return z.add(ctx, "removeIcmpBlock", icmptype)
}
func (z *PermZone) QueryIcmpBlock(ctx context.Context, icmptype string) (bool, error) {
return z.query(ctx, "queryIcmpBlock", icmptype)
}
// IcmpBlockInversion operations.
func (z *PermZone) AddIcmpBlockInversion(ctx context.Context) error {
return z.add(ctx, "addIcmpBlockInversion")
}
func (z *PermZone) RemoveIcmpBlockInversion(ctx context.Context) error {
return z.add(ctx, "removeIcmpBlockInversion")
}
func (z *PermZone) QueryIcmpBlockInversion(ctx context.Context) (bool, error) {
return z.query(ctx, "queryIcmpBlockInversion")
}
// Interface operations.
func (z *PermZone) AddInterface(ctx context.Context, iface string) error {
return z.add(ctx, "addInterface", iface)
}
func (z *PermZone) RemoveInterface(ctx context.Context, iface string) error {
return z.add(ctx, "removeInterface", iface)
}
func (z *PermZone) QueryInterface(ctx context.Context, iface string) (bool, error) {
return z.query(ctx, "queryInterface", iface)
}
// Scalar setters for zone metadata and default policy.
func (z *PermZone) SetTarget(ctx context.Context, target Target) error {
return z.callOn(ctx, "setTarget", nil, string(target))
}
func (z *PermZone) SetShort(ctx context.Context, short string) error {
return z.callOn(ctx, "setShort", nil, short)
}
func (z *PermZone) SetDescription(ctx context.Context, description string) error {
return z.callOn(ctx, "setDescription", nil, description)
}
func (z *PermZone) SetVersion(ctx context.Context, version string) error {
return z.callOn(ctx, "setVersion", nil, version)
}
// Getters for the current permanent element lists.
func (z *PermZone) Services(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getServices")
}
func (z *PermZone) Interfaces(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getInterfaces")
}
func (z *PermZone) Sources(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getSources")
}
func (z *PermZone) Protocols(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getProtocols")
}
func (z *PermZone) RichRules(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getRichRules")
}
func (z *PermZone) Target(ctx context.Context) (Target, error) {
var t string
err := z.callOn(ctx, "getTarget", []any{&t})
return Target(t), err
}
// LoadDefaults resets the zone to its built-in defaults.
func (z *PermZone) LoadDefaults(ctx context.Context) error {
return z.callOn(ctx, "loadDefaults", nil)
}
// Rename changes the zone's name. The cached path is invalidated so subsequent
// calls re-resolve under the new name.
func (z *PermZone) Rename(ctx context.Context, newName string) error {
if err := z.callOn(ctx, "rename", nil, newName); err != nil {
return err
}
z.mu.Lock()
z.name = newName
z.path = ""
z.mu.Unlock()
return nil
}
// Remove deletes the zone from the permanent configuration.
func (z *PermZone) Remove(ctx context.Context) error {
return z.callOn(ctx, "remove", nil)
}

252
policy.go Normal file
View file

@ -0,0 +1,252 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// PolicySettings is the transport-neutral representation of a zone-to-zone policy
// (firewalld >= 0.9). Policies have no v1 tuple form; they are exchanged only as
// an a{sv} dict. Ingress and egress zones define the traffic the policy governs;
// the remaining fields mirror a zone's filtering elements.
type PolicySettings struct {
Version string
Short string
Description string
Target string // CONTINUE, ACCEPT, DROP, REJECT
Priority int32
IngressZones []string
EgressZones []string
Services []string
Ports []Port
SourcePorts []Port
ICMPBlocks []string
Masquerade bool
ForwardPorts []ForwardPort
RichRules []string
Protocols []string
}
// toDict builds the a{sv} policy dict with concrete typed values so godbus emits
// the tuple signatures firewalld requires.
func (s *PolicySettings) toDict() map[string]dbus.Variant {
target := s.Target
if target == "" {
target = "CONTINUE"
}
return map[string]dbus.Variant{
"version": dbus.MakeVariant(s.Version),
"short": dbus.MakeVariant(s.Short),
"description": dbus.MakeVariant(s.Description),
"target": dbus.MakeVariant(target),
"priority": dbus.MakeVariant(s.Priority),
"ingress_zones": dbus.MakeVariant(nonNilStrings(s.IngressZones)),
"egress_zones": dbus.MakeVariant(nonNilStrings(s.EgressZones)),
"services": dbus.MakeVariant(nonNilStrings(s.Services)),
"ports": dbus.MakeVariant(nonNilPorts(s.Ports)),
"source_ports": dbus.MakeVariant(nonNilPorts(s.SourcePorts)),
"icmp_blocks": dbus.MakeVariant(nonNilStrings(s.ICMPBlocks)),
"masquerade": dbus.MakeVariant(s.Masquerade),
"forward_ports": dbus.MakeVariant(nonNilForwardPorts(s.ForwardPorts)),
// Policies name rich rules "rich_rules" in their dict (zones use
// "rules_str"); firewalld rejects the wrong key with INVALID_OPTION.
"rich_rules": dbus.MakeVariant(nonNilStrings(s.RichRules)),
"protocols": dbus.MakeVariant(nonNilStrings(s.Protocols)),
}
}
// policySettingsFromDict decodes an a{sv} policy dict, tolerating omitted keys.
func policySettingsFromDict(d map[string]dbus.Variant) PolicySettings {
var s PolicySettings
if v, ok := dictValue(d, "version"); ok {
s.Version = asString(v)
}
if v, ok := dictValue(d, "short"); ok {
s.Short = asString(v)
}
if v, ok := dictValue(d, "description"); ok {
s.Description = asString(v)
}
if v, ok := dictValue(d, "target"); ok {
s.Target = asString(v)
}
if v, ok := dictValue(d, "priority"); ok {
s.Priority = asInt32(v)
}
if v, ok := dictValue(d, "ingress_zones"); ok {
s.IngressZones = asStrings(v)
}
if v, ok := dictValue(d, "egress_zones"); ok {
s.EgressZones = asStrings(v)
}
if v, ok := dictValue(d, "services"); ok {
s.Services = asStrings(v)
}
if v, ok := dictValue(d, "ports"); ok {
s.Ports = asPorts(v)
}
if v, ok := dictValue(d, "source_ports"); ok {
s.SourcePorts = asPorts(v)
}
if v, ok := dictValue(d, "icmp_blocks"); ok {
s.ICMPBlocks = asStrings(v)
}
if v, ok := dictValue(d, "masquerade"); ok {
s.Masquerade = asBool(v)
}
if v, ok := dictValue(d, "forward_ports"); ok {
s.ForwardPorts = asForwardPorts(v)
}
if v, ok := dictValue(d, "rules_str"); ok {
s.RichRules = asStrings(v)
} else if v, ok := dictValue(d, "rich_rules"); ok {
s.RichRules = asStrings(v)
}
if v, ok := dictValue(d, "protocols"); ok {
s.Protocols = asStrings(v)
}
return s
}
// Policies lists the names of all policies known to the runtime. Returns
// ErrUnsupported on servers that predate policy support (firewalld < 0.9).
func (r *Runtime) Policies(ctx context.Context) ([]string, error) {
if !r.c.caps[Policies] {
return nil, ErrUnsupported
}
var names []string
err := r.c.call(ctx, basePath, ifacePolicy, "getPolicies", []any{&names})
return names, err
}
// ActivePolicies returns the policies that currently have zones bound.
func (r *Runtime) ActivePolicies(ctx context.Context) ([]string, error) {
if !r.c.caps[Policies] {
return nil, ErrUnsupported
}
var active map[string]map[string][]string
if err := r.c.call(ctx, basePath, ifacePolicy, "getActivePolicies", []any{&active}); err != nil {
return nil, err
}
names := make([]string, 0, len(active))
for name := range active {
names = append(names, name)
}
return names, nil
}
// PolicySettings reads a policy definition from the running firewall.
func (r *Runtime) PolicySettings(ctx context.Context, policy string) (PolicySettings, error) {
if !r.c.caps[Policies] {
return PolicySettings{}, ErrUnsupported
}
var d map[string]dbus.Variant
if err := r.c.call(ctx, basePath, ifacePolicy, "getPolicySettings", []any{&d}, policy); err != nil {
return PolicySettings{}, err
}
return policySettingsFromDict(d), nil
}
// SetPolicySettings replaces a policy's runtime settings wholesale.
func (r *Runtime) SetPolicySettings(ctx context.Context, policy string, settings PolicySettings) error {
if !r.c.caps[Policies] {
return ErrUnsupported
}
return r.c.call(ctx, basePath, ifacePolicy, "setPolicySettings", nil, policy, settings.toDict())
}
// PolicyNames lists the names of all permanent policies.
func (p *Permanent) PolicyNames(ctx context.Context) ([]string, error) {
if !p.c.caps[Policies] {
return nil, ErrUnsupported
}
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getPolicyNames", []any{&names})
return names, err
}
// PolicyPaths lists the config object paths of all permanent policies.
func (p *Permanent) PolicyPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
if !p.c.caps[Policies] {
return nil, ErrUnsupported
}
var paths []dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "listPolicies", []any{&paths})
return paths, err
}
// AddPolicy creates a new permanent policy and returns its config object path.
func (p *Permanent) AddPolicy(ctx context.Context, name string, settings PolicySettings) (dbus.ObjectPath, error) {
if !p.c.caps[Policies] {
return "", ErrUnsupported
}
var path dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "addPolicy", []any{&path}, name, settings.toDict())
return path, err
}
// Policy returns a handle for permanent operations on the named policy.
func (p *Permanent) Policy(name string) *PermPolicy {
return &PermPolicy{c: p.c, name: name}
}
// PermPolicy is a handle for permanent edits to a single policy, operating on the
// policy's config child object (/config/policy/N).
type PermPolicy struct {
c *Conn
name string
path dbus.ObjectPath
}
func (s *PermPolicy) resolve(ctx context.Context) (dbus.ObjectPath, error) {
if !s.c.caps[Policies] {
return "", ErrUnsupported
}
if s.path != "" {
return s.path, nil
}
var path dbus.ObjectPath
if err := s.c.call(ctx, configPath, ifaceConfig, "getPolicyByName", []any{&path}, s.name); err != nil {
return "", err
}
s.path = path
return path, nil
}
func (s *PermPolicy) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := s.resolve(ctx)
if err != nil {
return err
}
return s.c.call(ctx, path, ifaceConfigPolicy, method, rets, args...)
}
// Settings reads the policy's permanent settings.
func (s *PermPolicy) Settings(ctx context.Context) (PolicySettings, error) {
var d map[string]dbus.Variant
if err := s.callOn(ctx, "getSettings", []any{&d}); err != nil {
return PolicySettings{}, err
}
return policySettingsFromDict(d), nil
}
// Update replaces the policy's permanent settings wholesale.
func (s *PermPolicy) Update(ctx context.Context, settings PolicySettings) error {
return s.callOn(ctx, "update", nil, settings.toDict())
}
// Rename changes the policy's name and invalidates the cached path.
func (s *PermPolicy) Rename(ctx context.Context, newName string) error {
if err := s.callOn(ctx, "rename", nil, newName); err != nil {
return err
}
s.name = newName
s.path = ""
return nil
}
// Remove deletes the policy from the permanent configuration.
func (s *PermPolicy) Remove(ctx context.Context) error {
return s.callOn(ctx, "remove", nil)
}

163
properties.go Normal file
View file

@ -0,0 +1,163 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// This file exposes firewalld's D-Bus properties: read-only runtime state on the
// main object, and the read/write daemon settings (firewalld.conf knobs) on the
// config object. Both are read through org.freedesktop.DBus.Properties.
// getProperty reads a single property via Properties.Get.
func (c *Conn) getProperty(ctx context.Context, path dbus.ObjectPath, iface, name string) (dbus.Variant, error) {
var v dbus.Variant
err := c.call(ctx, path, ifaceProperties, "Get", []any{&v}, iface, name)
return v, err
}
// getAllProperties reads every property of an interface via Properties.GetAll.
func (c *Conn) getAllProperties(ctx context.Context, path dbus.ObjectPath, iface string) (map[string]dbus.Variant, error) {
var m map[string]dbus.Variant
err := c.call(ctx, path, ifaceProperties, "GetAll", []any{&m}, iface)
return m, err
}
// setProperty writes a property via Properties.Set.
func (c *Conn) setProperty(ctx context.Context, path dbus.ObjectPath, iface, name string, value any) error {
return c.call(ctx, path, ifaceProperties, "Set", nil, iface, name, dbus.MakeVariant(value))
}
// State returns firewalld's runtime state, e.g. "RUNNING".
func (c *Conn) State(ctx context.Context) (string, error) {
v, err := c.getProperty(ctx, basePath, ifaceMain, "state")
if err != nil {
return "", err
}
return asString(v.Value()), nil
}
// MainProperty reads a single read-only property from the main object (e.g.
// "IPv4", "IPSetTypes", "nf_conntrack_helpers") for values not covered by a typed
// accessor.
func (c *Conn) MainProperty(ctx context.Context, name string) (dbus.Variant, error) {
return c.getProperty(ctx, basePath, ifaceMain, name)
}
// RuntimeInfo captures the main object's read-only capability and state
// properties, describing what the running firewalld supports.
type RuntimeInfo struct {
State string
InterfaceVer string
IPv4 bool
IPv6 bool
IPv6RPFilter bool
Bridge bool
IPSet bool
IPSetTypes []string
IPv4ICMPTypes []string
IPv6ICMPTypes []string
}
// RuntimeInfo reads the main object's runtime properties in one round-trip.
func (c *Conn) RuntimeInfo(ctx context.Context) (RuntimeInfo, error) {
m, err := c.getAllProperties(ctx, basePath, ifaceMain)
if err != nil {
return RuntimeInfo{}, err
}
get := func(k string) any {
if v, ok := m[k]; ok {
return v.Value()
}
return nil
}
return RuntimeInfo{
State: asString(get("state")),
InterfaceVer: asString(get("interface_version")),
IPv4: asBool(get("IPv4")),
IPv6: asBool(get("IPv6")),
IPv6RPFilter: asBool(get("IPv6_rpfilter")),
Bridge: asBool(get("BRIDGE")),
IPSet: asBool(get("IPSet")),
IPSetTypes: asStrings(get("IPSetTypes")),
IPv4ICMPTypes: asStrings(get("IPv4ICMPTypes")),
IPv6ICMPTypes: asStrings(get("IPv6ICMPTypes")),
}, nil
}
// DaemonConfig holds firewalld's permanent daemon settings (the firewalld.conf
// knobs). Fields absent on the connected version read as empty. Most values are
// firewalld's "yes"/"no" strings or an enum ("iptables"/"nftables"); they are
// exposed verbatim rather than coerced, since firewalld's own semantics vary.
type DaemonConfig struct {
DefaultZone string
MinimalMark int32
CleanupOnExit string
CleanupModulesOnExit string
Lockdown string
IPv6RPFilter string
IPv6RPFilter2 string // finer-grained rp_filter (newer firewalld)
IndividualCalls string
LogDenied string
AutomaticHelpers string
FirewallBackend string
FlushAllOnReload string
RFC3964IPv4 string
AllowZoneDrifting string
NftablesTableOwner string
NftablesCounters string // nftables rule counters (firewalld >= 2.0)
NftablesFlowtable string // nftables flowtable offload (firewalld >= 2.0)
StrictForwardPorts string // strict forward-port handling (firewalld >= 2.0)
}
// DaemonConfig reads the config object's daemon-settings properties.
func (p *Permanent) DaemonConfig(ctx context.Context) (DaemonConfig, error) {
m, err := p.c.getAllProperties(ctx, configPath, ifaceConfig)
if err != nil {
return DaemonConfig{}, err
}
get := func(k string) any {
if v, ok := m[k]; ok {
return v.Value()
}
return nil
}
return DaemonConfig{
DefaultZone: asString(get("DefaultZone")),
MinimalMark: asInt32(get("MinimalMark")),
CleanupOnExit: asString(get("CleanupOnExit")),
CleanupModulesOnExit: asString(get("CleanupModulesOnExit")),
Lockdown: asString(get("Lockdown")),
IPv6RPFilter: asString(get("IPv6_rpfilter")),
IPv6RPFilter2: asString(get("IPv6_rpfilter2")),
IndividualCalls: asString(get("IndividualCalls")),
LogDenied: asString(get("LogDenied")),
AutomaticHelpers: asString(get("AutomaticHelpers")),
FirewallBackend: asString(get("FirewallBackend")),
FlushAllOnReload: asString(get("FlushAllOnReload")),
RFC3964IPv4: asString(get("RFC3964_IPv4")),
AllowZoneDrifting: asString(get("AllowZoneDrifting")),
NftablesTableOwner: asString(get("NftablesTableOwner")),
NftablesCounters: asString(get("NftablesCounters")),
NftablesFlowtable: asString(get("NftablesFlowtable")),
StrictForwardPorts: asString(get("StrictForwardPorts")),
}, nil
}
// ConfigProperty reads a single daemon-config property by its firewalld name
// (e.g. "FirewallBackend", "IPv6_rpfilter").
func (p *Permanent) ConfigProperty(ctx context.Context, name string) (dbus.Variant, error) {
return p.c.getProperty(ctx, configPath, ifaceConfig, name)
}
// SetConfigProperty writes a daemon-config property. Changes are permanent and
// take effect after a reload; most require privilege.
func (p *Permanent) SetConfigProperty(ctx context.Context, name string, value any) error {
return p.c.setProperty(ctx, configPath, ifaceConfig, name, value)
}
// SetFirewallBackend selects the packet backend ("nftables" or "iptables").
func (p *Permanent) SetFirewallBackend(ctx context.Context, backend string) error {
return p.SetConfigProperty(ctx, "FirewallBackend", backend)
}

257
service.go Normal file
View file

@ -0,0 +1,257 @@
package firewalld
import (
"context"
"github.com/godbus/dbus/v5"
)
// ServiceSettings is the transport-neutral representation of a firewalld service.
// It maps to the v1 tuple "(sssa(ss)asa{ss}asa(ss))": version, short,
// description, ports, modules, destinations, protocols, source_ports. Newer
// firewalld also exposes includes/helpers via the dict form (getSettings2), which
// are modelled here and populated when the dict transport is available.
type ServiceSettings struct {
Version string
Short string
Description string
Ports []Port
Modules []string // netfilter helper modules
Destinations map[string]string // family -> address, e.g. {"ipv4": "224.0.0.0/8"}
Protocols []string
SourcePorts []Port
Includes []string // dict-only (firewalld >= 1.0)
Helpers []string // dict-only (firewalld >= 1.0)
}
// serviceTuple is the concrete struct godbus encodes to the v1 service signature.
type serviceTuple struct {
Version string
Short string
Description string
Ports []Port
Modules []string
Destinations map[string]string
Protocols []string
SourcePorts []Port
}
func (s *ServiceSettings) toTuple() serviceTuple {
dst := s.Destinations
if dst == nil {
dst = map[string]string{}
}
return serviceTuple{
Version: s.Version,
Short: s.Short,
Description: s.Description,
Ports: s.Ports,
Modules: s.Modules,
Destinations: dst,
Protocols: s.Protocols,
SourcePorts: s.SourcePorts,
}
}
// serviceSettingsFromTuple decodes a raw v1 service tuple, length-tolerant.
func serviceSettingsFromTuple(raw []any) ServiceSettings {
return ServiceSettings{
Version: asString(tupleField(raw, 0)),
Short: asString(tupleField(raw, 1)),
Description: asString(tupleField(raw, 2)),
Ports: asPorts(tupleField(raw, 3)),
Modules: asStrings(tupleField(raw, 4)),
Destinations: asStringMap(tupleField(raw, 5)),
Protocols: asStrings(tupleField(raw, 6)),
SourcePorts: asPorts(tupleField(raw, 7)),
}
}
// serviceSettingsFromDict decodes the a{sv} service form, including the
// dict-only includes/helpers keys. Missing keys read as zero values.
func serviceSettingsFromDict(d map[string]dbus.Variant) ServiceSettings {
var s ServiceSettings
if v, ok := dictValue(d, "version"); ok {
s.Version = asString(v)
}
if v, ok := dictValue(d, "short"); ok {
s.Short = asString(v)
}
if v, ok := dictValue(d, "description"); ok {
s.Description = asString(v)
}
if v, ok := dictValue(d, "ports"); ok {
s.Ports = asPorts(v)
}
if v, ok := dictValue(d, "module"); ok {
s.Modules = asStrings(v)
} else if v, ok := dictValue(d, "modules"); ok {
s.Modules = asStrings(v)
}
if v, ok := dictValue(d, "destination"); ok {
s.Destinations = asStringMap(v)
} else if v, ok := dictValue(d, "destinations"); ok {
s.Destinations = asStringMap(v)
}
if v, ok := dictValue(d, "protocols"); ok {
s.Protocols = asStrings(v)
}
if v, ok := dictValue(d, "source_ports"); ok {
s.SourcePorts = asPorts(v)
}
if v, ok := dictValue(d, "includes"); ok {
s.Includes = asStrings(v)
}
if v, ok := dictValue(d, "helpers"); ok {
s.Helpers = asStrings(v)
}
return s
}
// Services lists the names of all services known to the runtime.
func (c *Conn) Services(ctx context.Context) ([]string, error) {
var names []string
err := c.call(ctx, basePath, ifaceMain, "listServices", []any{&names})
return names, err
}
// ServiceSettings reads a service definition from the running firewall, choosing
// the dict transport (getServiceSettings2) when available so includes/helpers are
// populated, and falling back to the v1 tuple otherwise.
func (c *Conn) ServiceSettings(ctx context.Context, service string) (ServiceSettings, error) {
if c.caps[ServiceSettings2] {
var d map[string]dbus.Variant
if err := c.call(ctx, basePath, ifaceMain, "getServiceSettings2", []any{&d}, service); err != nil {
return ServiceSettings{}, err
}
return serviceSettingsFromDict(d), nil
}
var raw []any
if err := c.call(ctx, basePath, ifaceMain, "getServiceSettings", []any{&raw}, service); err != nil {
return ServiceSettings{}, err
}
return serviceSettingsFromTuple(raw), nil
}
// ServiceNames lists the names of all permanent services.
func (p *Permanent) ServiceNames(ctx context.Context) ([]string, error) {
var names []string
err := p.c.call(ctx, configPath, ifaceConfig, "getServiceNames", []any{&names})
return names, err
}
// AddService creates a new permanent service and returns its config object path.
func (p *Permanent) AddService(ctx context.Context, name string, settings ServiceSettings) (dbus.ObjectPath, error) {
var path dbus.ObjectPath
err := p.c.call(ctx, configPath, ifaceConfig, "addService", []any{&path}, name, settings.toTuple())
return path, err
}
// Service returns a handle for permanent operations on the named service.
func (p *Permanent) Service(name string) *PermService {
return &PermService{c: p.c, name: name}
}
// PermService is a handle for permanent edits to a single service definition,
// operating on the service's config child object (/config/service/N).
type PermService struct {
c *Conn
name string
path dbus.ObjectPath
}
func (s *PermService) resolve(ctx context.Context) (dbus.ObjectPath, error) {
if s.path != "" {
return s.path, nil
}
var path dbus.ObjectPath
if err := s.c.call(ctx, configPath, ifaceConfig, "getServiceByName", []any{&path}, s.name); err != nil {
return "", err
}
s.path = path
return path, nil
}
func (s *PermService) callOn(ctx context.Context, method string, rets []any, args ...any) error {
path, err := s.resolve(ctx)
if err != nil {
return err
}
return s.c.call(ctx, path, ifaceConfigService, method, rets, args...)
}
// Settings reads the service's permanent settings via the v1 tuple.
func (s *PermService) Settings(ctx context.Context) (ServiceSettings, error) {
var raw []any
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
return ServiceSettings{}, err
}
return serviceSettingsFromTuple(raw), nil
}
// Update replaces the service's permanent settings via the v1 tuple.
func (s *PermService) Update(ctx context.Context, settings ServiceSettings) error {
return s.callOn(ctx, "update", nil, settings.toTuple())
}
// Port operations.
func (s *PermService) AddPort(ctx context.Context, p Port) error {
return s.callOn(ctx, "addPort", nil, p.Port, p.Protocol)
}
func (s *PermService) RemovePort(ctx context.Context, p Port) error {
return s.callOn(ctx, "removePort", nil, p.Port, p.Protocol)
}
// Protocol operations.
func (s *PermService) AddProtocol(ctx context.Context, proto string) error {
return s.callOn(ctx, "addProtocol", nil, proto)
}
func (s *PermService) RemoveProtocol(ctx context.Context, proto string) error {
return s.callOn(ctx, "removeProtocol", nil, proto)
}
// Module (netfilter helper) operations.
func (s *PermService) AddModule(ctx context.Context, module string) error {
return s.callOn(ctx, "addModule", nil, module)
}
func (s *PermService) RemoveModule(ctx context.Context, module string) error {
return s.callOn(ctx, "removeModule", nil, module)
}
// SetDestination sets the destination address for an IP family ("ipv4"/"ipv6").
func (s *PermService) SetDestination(ctx context.Context, family, address string) error {
return s.callOn(ctx, "setDestination", nil, family, address)
}
// RemoveDestination clears the destination for an IP family.
func (s *PermService) RemoveDestination(ctx context.Context, family string) error {
return s.callOn(ctx, "removeDestination", nil, family)
}
// Include operations compose other services into this one (firewalld >= 1.0).
func (s *PermService) AddInclude(ctx context.Context, service string) error {
return s.callOn(ctx, "addInclude", nil, service)
}
func (s *PermService) RemoveInclude(ctx context.Context, service string) error {
return s.callOn(ctx, "removeInclude", nil, service)
}
func (s *PermService) QueryInclude(ctx context.Context, service string) (bool, error) {
var ok bool
err := s.callOn(ctx, "queryInclude", []any{&ok}, service)
return ok, err
}
func (s *PermService) Includes(ctx context.Context) ([]string, error) {
var xs []string
err := s.callOn(ctx, "getIncludes", []any{&xs})
return xs, err
}
// SetDescription sets the permanent service description.
func (s *PermService) SetDescription(ctx context.Context, description string) error {
return s.callOn(ctx, "setDescription", nil, description)
}
// Remove deletes the service from the permanent configuration.
func (s *PermService) Remove(ctx context.Context) error {
return s.callOn(ctx, "remove", nil)
}

112
signals.go Normal file
View file

@ -0,0 +1,112 @@
package firewalld
import (
"context"
"strings"
"github.com/godbus/dbus/v5"
)
// Signal is a firewalld D-Bus signal delivered to a watcher. Member is the short
// signal name (e.g. "Reloaded", "PortAdded"); Interface is the emitting interface;
// Body carries the signal's arguments in firewalld's declared order.
type Signal struct {
Member string
Interface string
Path dbus.ObjectPath
Body []any
}
// signalInterfaces are the firewalld interfaces whose signals a watcher subscribes
// to. Matching by interface keeps unrelated bus traffic out of the channel.
var signalInterfaces = []string{
ifaceMain, ifaceZone, ifaceIPSet, ifaceDirect, ifacePolicy, ifaceLockdown,
ifaceConfig, ifaceConfigZone, ifaceConfigIPSet, ifaceConfigPolicy,
ifaceConfigService, ifaceConfigICMP, ifaceConfigHelper, ifaceConfigDirect,
}
// WatchSignals subscribes to firewalld's signals and returns a channel of typed
// events. The subscription and channel are torn down when ctx is cancelled. The
// channel is buffered; a slow consumer that lets it fill will drop no earlier
// events but may block delivery of later ones until drained.
func (c *Conn) WatchSignals(ctx context.Context) (<-chan Signal, error) {
for _, iface := range signalInterfaces {
if err := c.conn.AddMatchSignal(dbus.WithMatchInterface(iface)); err != nil {
return nil, err
}
}
raw := make(chan *dbus.Signal, 64)
c.conn.Signal(raw)
out := make(chan Signal, 64)
go func() {
defer close(out)
defer c.removeSignalMatches(raw)
for {
select {
case <-ctx.Done():
return
case sig, ok := <-raw:
if !ok {
return
}
iface, member := splitSignalName(sig.Name)
// Ignore anything outside firewalld's namespace.
if !strings.HasPrefix(iface, ifaceMain) {
continue
}
ev := Signal{Member: member, Interface: iface, Path: sig.Path, Body: sig.Body}
select {
case out <- ev:
case <-ctx.Done():
return
}
}
}
}()
return out, nil
}
// WatchReloaded returns a channel that receives an empty struct each time
// firewalld emits its Reloaded signal, a convenience for callers that only need to
// re-read state after a reload.
func (c *Conn) WatchReloaded(ctx context.Context) (<-chan struct{}, error) {
signals, err := c.WatchSignals(ctx)
if err != nil {
return nil, err
}
out := make(chan struct{}, 4)
go func() {
defer close(out)
for sig := range signals {
if sig.Member == "Reloaded" {
select {
case out <- struct{}{}:
case <-ctx.Done():
return
}
}
}
}()
return out, nil
}
// removeSignalMatches unregisters the channel and drops the match rules added by
// WatchSignals. Best-effort: match removal errors are ignored during teardown.
func (c *Conn) removeSignalMatches(raw chan *dbus.Signal) {
c.conn.RemoveSignal(raw)
for _, iface := range signalInterfaces {
_ = c.conn.RemoveMatchSignal(dbus.WithMatchInterface(iface))
}
}
// splitSignalName splits a fully-qualified signal name "iface.Member" into its
// interface and member parts.
func splitSignalName(full string) (iface, member string) {
i := strings.LastIndex(full, ".")
if i < 0 {
return "", full
}
return full[:i], full[i+1:]
}

183
signatures_test.go Normal file
View file

@ -0,0 +1,183 @@
package firewalld
import (
"testing"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// sigOf returns the D-Bus signature godbus would emit for v. This is the exact
// check that catches the []interface{}->"av" regression: a compound value that
// does not encode to firewalld's expected tuple signature would fail here before
// it ever reaches a bus.
func sigOf(v any) string { return dbus.SignatureOf(v).String() }
// TestCompoundSignatures locks every compound type to the signature firewalld
// requires. These are pure encode checks; no bus is involved.
func TestCompoundSignatures(t *testing.T) {
cases := []struct {
name string
val any
want string
}{
{"Port", Port{}, "(ss)"},
{"ForwardPort", ForwardPort{}, "(ssss)"},
{"[]Port", []Port{}, "a(ss)"},
{"[]ForwardPort", []ForwardPort{}, "a(ssss)"},
{"zoneTuple", zoneTuple{}, "(sssbsasa(ss)asba(ssss)asasasasa(ss)b)"},
{"ipsetTuple", ipsetTuple{}, "(ssssa{ss}as)"},
{"serviceTuple", serviceTuple{}, "(sssa(ss)asa{ss}asa(ss))"},
{"icmpTuple", icmpTuple{}, "(sssas)"},
{"helperTuple", helperTuple{}, "(sssssa(ss))"},
{"directChainTuple", directChainTuple{}, "(sss)"},
{"directRuleTuple", directRuleTuple{}, "(sssias)"},
{"directPassthroughTuple", directPassthroughTuple{}, "(sas)"},
{"directConfigTuple", directConfigTuple{}, "(a(sss)a(sssias)a(sas))"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, sigOf(tc.val), "signature mismatch for %s", tc.name)
})
}
}
// TestZoneDictSignatures verifies the a{sv} dict encodes as a{sv} and that each
// compound value wrapped in a variant carries the tuple signature, not "av".
func TestZoneDictSignatures(t *testing.T) {
s := ZoneSettings{
Ports: []Port{{Port: "80", Protocol: "tcp"}},
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp", ToPort: "2222"}},
Services: []string{"ssh"},
}
d := s.toDict()
assert.Equal(t, "a{sv}", sigOf(d))
// The variants must carry concrete tuple signatures.
assert.Equal(t, "a(ss)", d["ports"].Signature().String())
assert.Equal(t, "a(ssss)", d["forward_ports"].Signature().String())
assert.Equal(t, "as", d["services"].Signature().String())
assert.Equal(t, "b", d["masquerade"].Signature().String())
}
// TestZoneTupleRoundTrip encodes settings to the tuple form, then decodes the
// same struct back through the length-tolerant tuple decoder to confirm field
// alignment and value fidelity.
func TestZoneTupleRoundTrip(t *testing.T) {
in := ZoneSettings{
Version: "1",
Short: "Test",
Description: "a test zone",
Target: TargetDROP,
Services: []string{"ssh", "http"},
Ports: []Port{{Port: "80", Protocol: "tcp"}, {Port: "53", Protocol: "udp"}},
ICMPBlocks: []string{"echo-request"},
Masquerade: true,
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp", ToPort: "2222", ToAddr: "10.0.0.5"}},
Interfaces: []string{"eth0"},
Sources: []string{"10.0.0.0/8"},
RichRules: []string{`rule family="ipv4" reject`},
Protocols: []string{"gre"},
SourcePorts: []Port{{Port: "1024", Protocol: "udp"}},
ICMPBlockInversion: true,
}
tuple := in.toTuple()
// Simulate the wire decode: godbus hands a tuple back as []any positionally.
raw := []any{
tuple.Version, tuple.Short, tuple.Description, tuple.Unused, tuple.Target,
tuple.Services, portsToAny(tuple.Ports), tuple.ICMPBlocks, tuple.Masquerade,
forwardsToAny(tuple.ForwardPorts), tuple.Interfaces, tuple.Sources,
tuple.RichRules, tuple.Protocols, portsToAny(tuple.SourcePorts),
tuple.ICMPBlockInversion,
}
out := zoneSettingsFromTuple(raw)
assert.Equal(t, in.Version, out.Version)
assert.Equal(t, in.Short, out.Short)
assert.Equal(t, in.Description, out.Description)
assert.Equal(t, in.Target, out.Target)
assert.Equal(t, in.Services, out.Services)
assert.Equal(t, in.Ports, out.Ports)
assert.Equal(t, in.ICMPBlocks, out.ICMPBlocks)
assert.Equal(t, in.Masquerade, out.Masquerade)
assert.Equal(t, in.ForwardPorts, out.ForwardPorts)
assert.Equal(t, in.Interfaces, out.Interfaces)
assert.Equal(t, in.Sources, out.Sources)
assert.Equal(t, in.RichRules, out.RichRules)
assert.Equal(t, in.Protocols, out.Protocols)
assert.Equal(t, in.SourcePorts, out.SourcePorts)
assert.Equal(t, in.ICMPBlockInversion, out.ICMPBlockInversion)
}
// TestZoneTupleShortTolerant proves the decoder never panics or misaligns on a
// truncated tuple from an old firewalld: trailing fields simply read as zero.
func TestZoneTupleShortTolerant(t *testing.T) {
// Only the first five fields present (version..target).
raw := []any{"1", "Short", "desc", false, "ACCEPT"}
require.NotPanics(t, func() {
out := zoneSettingsFromTuple(raw)
assert.Equal(t, "1", out.Version)
assert.Equal(t, Target("ACCEPT"), out.Target)
assert.Nil(t, out.Services)
assert.False(t, out.Masquerade)
assert.False(t, out.ICMPBlockInversion)
})
// Empty tuple: everything zero, still no panic.
require.NotPanics(t, func() {
out := zoneSettingsFromTuple(nil)
assert.Equal(t, ZoneSettings{}, out)
})
}
// TestZoneDictRoundTrip confirms dict encode/decode is lossless for the modelled
// fields, including the v2-only forward flag.
func TestZoneDictRoundTrip(t *testing.T) {
in := ZoneSettings{
Short: "Test",
Target: TargetACCEPT,
Services: []string{"ssh"},
Ports: []Port{{Port: "443", Protocol: "tcp"}},
Masquerade: true,
Forward: true,
SourcePorts: []Port{{Port: "68", Protocol: "udp"}},
ForwardPorts: []ForwardPort{{Port: "80", Protocol: "tcp", ToPort: "8080"}},
}
d := in.toDict()
// Re-wrap variants the way getSettings2 hands them back (plain values).
back := make(map[string]dbus.Variant, len(d))
for k, v := range d {
back[k] = v
}
out := zoneSettingsFromDict(back)
assert.Equal(t, in.Short, out.Short)
assert.Equal(t, in.Target, out.Target)
assert.Equal(t, in.Services, out.Services)
assert.Equal(t, in.Ports, out.Ports)
assert.Equal(t, in.Masquerade, out.Masquerade)
assert.Equal(t, in.Forward, out.Forward)
assert.Equal(t, in.SourcePorts, out.SourcePorts)
assert.Equal(t, in.ForwardPorts, out.ForwardPorts)
}
// portsToAny mirrors how godbus decodes a(ss) in raw mode: each element becomes a
// []any pair. Used to feed the tuple decoder realistic input in tests.
func portsToAny(ports []Port) []any {
out := make([]any, len(ports))
for i, p := range ports {
out[i] = []any{p.Port, p.Protocol}
}
return out
}
func forwardsToAny(fps []ForwardPort) []any {
out := make([]any, len(fps))
for i, f := range fps {
out[i] = []any{f.Port, f.Protocol, f.ToPort, f.ToAddr}
}
return out
}

40
types.go Normal file
View file

@ -0,0 +1,40 @@
package firewalld
// This file defines the concrete Go structs used for every compound D-Bus value
// firewalld exchanges. Encoding compound values as Go structs (not []any)
// is mandatory: godbus encodes a struct as a D-Bus struct/tuple "(...)", which is
// what firewalld's Python side requires. A []any would encode as "av" and
// firewalld rejects it with INVALID_TYPE. See CLAUDE.md for the full rationale.
// Port is a port-or-range with its protocol. Encodes to the D-Bus tuple "(ss)".
type Port struct {
Port string // single port "80" or range "1000-2000"
Protocol string // "tcp" | "udp" | "sctp" | "dccp"
}
// ForwardPort describes a port-forward rule. Encodes to the tuple "(ssss)" in the
// field order firewalld expects: port, protocol, toport, toaddr.
type ForwardPort struct {
Port string // incoming port or range
Protocol string // "tcp" | "udp"
ToPort string // destination port; empty keeps the same port
ToAddr string // destination address; empty forwards locally
}
// Target is a zone's default packet policy for unmatched traffic.
type Target string
const (
TargetDefault Target = "default"
TargetACCEPT Target = "ACCEPT"
TargetDROP Target = "DROP"
TargetReject Target = "%%REJECT%%"
)
// Family selects the IP family for ipsets, rich rules, and helpers.
type Family string
const (
FamilyIPv4 Family = "inet"
FamilyIPv6 Family = "inet6"
)

338
zone.go Normal file
View file

@ -0,0 +1,338 @@
package firewalld
import (
"context"
"time"
"github.com/godbus/dbus/v5"
)
// timeoutSeconds converts a Go duration to firewalld's int32 timeout in seconds.
// A zero or negative duration means "no timeout" (the runtime edit persists until
// reload), which firewalld encodes as 0.
func timeoutSeconds(d time.Duration) int32 {
if d <= 0 {
return 0
}
return int32(d / time.Second)
}
// Runtime is the entry point for transient operations on the running firewall.
// Edits made here last until the next reload unless given a timeout, mirroring
// firewall-cmd without --permanent.
type Runtime struct{ c *Conn }
// Runtime returns the runtime operation namespace.
func (c *Conn) Runtime() *Runtime { return &Runtime{c: c} }
// Zone returns a handle for runtime operations on the named zone.
func (r *Runtime) Zone(name string) *RuntimeZone {
return &RuntimeZone{c: r.c, name: name}
}
// Zones lists the names of all defined zones (runtime view).
func (r *Runtime) Zones(ctx context.Context) ([]string, error) {
var zones []string
err := r.c.call(ctx, basePath, ifaceZone, "getZones", []any{&zones})
return zones, err
}
// ActiveZone pairs a zone name with the interfaces and sources currently bound to
// it, as reported by getActiveZones.
type ActiveZone struct {
Name string
Interfaces []string
Sources []string
}
// ActiveZones returns the zones that currently have interfaces or sources bound.
// getActiveZones returns a{sa{sas}}: zone -> {"interfaces": [...], "sources": [...]}.
func (r *Runtime) ActiveZones(ctx context.Context) ([]ActiveZone, error) {
var raw map[string]map[string][]string
if err := r.c.call(ctx, basePath, ifaceZone, "getActiveZones", []any{&raw}); err != nil {
return nil, err
}
out := make([]ActiveZone, 0, len(raw))
for name, binding := range raw {
out = append(out, ActiveZone{
Name: name,
Interfaces: binding["interfaces"],
Sources: binding["sources"],
})
}
return out, nil
}
// ZoneOfInterface returns the zone an interface is bound to at runtime, or "" if
// none.
func (r *Runtime) ZoneOfInterface(ctx context.Context, iface string) (string, error) {
var zone string
err := r.c.call(ctx, basePath, ifaceZone, "getZoneOfInterface", []any{&zone}, iface)
return zone, err
}
// ZoneOfSource returns the zone a source is bound to at runtime, or "" if none.
func (r *Runtime) ZoneOfSource(ctx context.Context, source string) (string, error) {
var zone string
err := r.c.call(ctx, basePath, ifaceZone, "getZoneOfSource", []any{&zone}, source)
return zone, err
}
// Settings reads the runtime settings of a zone. On dict-capable servers it uses
// getZoneSettings2; otherwise it falls back to the main getZoneSettings tuple.
func (r *Runtime) Settings(ctx context.Context, zone string) (ZoneSettings, error) {
if r.c.caps[DictZoneSettings] {
var d map[string]dbus.Variant
if err := r.c.call(ctx, basePath, ifaceZone, "getZoneSettings2", []any{&d}, zone); err != nil {
return ZoneSettings{}, err
}
return zoneSettingsFromDict(d), nil
}
var raw []any
if err := r.c.call(ctx, basePath, ifaceMain, "getZoneSettings", []any{&raw}, zone); err != nil {
return ZoneSettings{}, err
}
return zoneSettingsFromTuple(raw), nil
}
// SetSettings replaces a zone's runtime settings wholesale via setZoneSettings2.
// This is a dict-only path (firewalld >= 0.9); older servers expose no runtime
// settings writer, so it returns ErrUnsupported there.
func (r *Runtime) SetSettings(ctx context.Context, zone string, settings ZoneSettings) error {
if !r.c.caps[DictZoneSettings] {
return ErrUnsupported
}
return r.c.call(ctx, basePath, ifaceZone, "setZoneSettings2", nil, zone, settings.toDict())
}
// RuntimeZone is a handle for transient edits to a single zone. Add methods take a
// timeout (zero for none) and return the affected zone name from firewalld.
type RuntimeZone struct {
c *Conn
name string
}
// Name returns the zone name this handle targets.
func (z *RuntimeZone) Name() string { return z.name }
// addElement invokes a runtime add* method: (zone, args..., timeout) -> zone.
func (z *RuntimeZone) addElement(ctx context.Context, method string, timeout time.Duration, args ...any) error {
full := append([]any{z.name}, args...)
full = append(full, timeoutSeconds(timeout))
return z.c.call(ctx, basePath, ifaceZone, method, nil, full...)
}
// removeElement invokes a runtime remove* method: (zone, args...) -> zone.
func (z *RuntimeZone) removeElement(ctx context.Context, method string, args ...any) error {
full := append([]any{z.name}, args...)
return z.c.call(ctx, basePath, ifaceZone, method, nil, full...)
}
// queryElement invokes a runtime query* method: (zone, args...) -> bool.
func (z *RuntimeZone) queryElement(ctx context.Context, method string, args ...any) (bool, error) {
var ok bool
full := append([]any{z.name}, args...)
err := z.c.call(ctx, basePath, ifaceZone, method, []any{&ok}, full...)
return ok, err
}
// getStrings invokes a runtime get* method returning "as": (zone) -> []string.
func (z *RuntimeZone) getStrings(ctx context.Context, method string) ([]string, error) {
var xs []string
err := z.c.call(ctx, basePath, ifaceZone, method, []any{&xs}, z.name)
return xs, err
}
// Port operations.
func (z *RuntimeZone) AddPort(ctx context.Context, p Port, timeout time.Duration) error {
return z.addElement(ctx, "addPort", timeout, p.Port, p.Protocol)
}
func (z *RuntimeZone) RemovePort(ctx context.Context, p Port) error {
return z.removeElement(ctx, "removePort", p.Port, p.Protocol)
}
func (z *RuntimeZone) QueryPort(ctx context.Context, p Port) (bool, error) {
return z.queryElement(ctx, "queryPort", p.Port, p.Protocol)
}
// Protocol operations.
func (z *RuntimeZone) AddProtocol(ctx context.Context, proto string, timeout time.Duration) error {
return z.addElement(ctx, "addProtocol", timeout, proto)
}
func (z *RuntimeZone) RemoveProtocol(ctx context.Context, proto string) error {
return z.removeElement(ctx, "removeProtocol", proto)
}
func (z *RuntimeZone) QueryProtocol(ctx context.Context, proto string) (bool, error) {
return z.queryElement(ctx, "queryProtocol", proto)
}
// SourcePort operations.
func (z *RuntimeZone) AddSourcePort(ctx context.Context, p Port, timeout time.Duration) error {
return z.addElement(ctx, "addSourcePort", timeout, p.Port, p.Protocol)
}
func (z *RuntimeZone) RemoveSourcePort(ctx context.Context, p Port) error {
return z.removeElement(ctx, "removeSourcePort", p.Port, p.Protocol)
}
func (z *RuntimeZone) QuerySourcePort(ctx context.Context, p Port) (bool, error) {
return z.queryElement(ctx, "querySourcePort", p.Port, p.Protocol)
}
// Service operations.
func (z *RuntimeZone) AddService(ctx context.Context, service string, timeout time.Duration) error {
return z.addElement(ctx, "addService", timeout, service)
}
func (z *RuntimeZone) RemoveService(ctx context.Context, service string) error {
return z.removeElement(ctx, "removeService", service)
}
func (z *RuntimeZone) QueryService(ctx context.Context, service string) (bool, error) {
return z.queryElement(ctx, "queryService", service)
}
// Source operations. Unlike ports/services, a source binding is not timed, so
// AddSource takes no timeout (firewalld's addSource has no timeout argument).
func (z *RuntimeZone) AddSource(ctx context.Context, source string) error {
return z.removeElement(ctx, "addSource", source)
}
func (z *RuntimeZone) RemoveSource(ctx context.Context, source string) error {
return z.removeElement(ctx, "removeSource", source)
}
func (z *RuntimeZone) QuerySource(ctx context.Context, source string) (bool, error) {
return z.queryElement(ctx, "querySource", source)
}
// RichRule operations.
func (z *RuntimeZone) AddRichRule(ctx context.Context, rule string, timeout time.Duration) error {
return z.addElement(ctx, "addRichRule", timeout, rule)
}
func (z *RuntimeZone) RemoveRichRule(ctx context.Context, rule string) error {
return z.removeElement(ctx, "removeRichRule", rule)
}
func (z *RuntimeZone) QueryRichRule(ctx context.Context, rule string) (bool, error) {
return z.queryElement(ctx, "queryRichRule", rule)
}
// ForwardPort operations.
func (z *RuntimeZone) AddForwardPort(ctx context.Context, fp ForwardPort, timeout time.Duration) error {
return z.addElement(ctx, "addForwardPort", timeout, fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
func (z *RuntimeZone) RemoveForwardPort(ctx context.Context, fp ForwardPort) error {
return z.removeElement(ctx, "removeForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
func (z *RuntimeZone) QueryForwardPort(ctx context.Context, fp ForwardPort) (bool, error) {
return z.queryElement(ctx, "queryForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
}
// Masquerade operations. Add takes a timeout; the others do not.
func (z *RuntimeZone) AddMasquerade(ctx context.Context, timeout time.Duration) error {
return z.addElement(ctx, "addMasquerade", timeout)
}
func (z *RuntimeZone) RemoveMasquerade(ctx context.Context) error {
return z.removeElement(ctx, "removeMasquerade")
}
func (z *RuntimeZone) QueryMasquerade(ctx context.Context) (bool, error) {
return z.queryElement(ctx, "queryMasquerade")
}
// IcmpBlock operations.
func (z *RuntimeZone) AddIcmpBlock(ctx context.Context, icmptype string, timeout time.Duration) error {
return z.addElement(ctx, "addIcmpBlock", timeout, icmptype)
}
func (z *RuntimeZone) RemoveIcmpBlock(ctx context.Context, icmptype string) error {
return z.removeElement(ctx, "removeIcmpBlock", icmptype)
}
func (z *RuntimeZone) QueryIcmpBlock(ctx context.Context, icmptype string) (bool, error) {
return z.queryElement(ctx, "queryIcmpBlock", icmptype)
}
// IcmpBlockInversion operations. Add takes a timeout; the others do not.
func (z *RuntimeZone) AddIcmpBlockInversion(ctx context.Context, timeout time.Duration) error {
return z.addElement(ctx, "addIcmpBlockInversion", timeout)
}
func (z *RuntimeZone) RemoveIcmpBlockInversion(ctx context.Context) error {
return z.removeElement(ctx, "removeIcmpBlockInversion")
}
func (z *RuntimeZone) QueryIcmpBlockInversion(ctx context.Context) (bool, error) {
return z.queryElement(ctx, "queryIcmpBlockInversion")
}
// Interface operations bind and unbind network interfaces to the zone at runtime.
func (z *RuntimeZone) AddInterface(ctx context.Context, iface string) error {
return z.removeElement(ctx, "addInterface", iface) // no timeout arg on interface add
}
func (z *RuntimeZone) RemoveInterface(ctx context.Context, iface string) error {
return z.removeElement(ctx, "removeInterface", iface)
}
func (z *RuntimeZone) QueryInterface(ctx context.Context, iface string) (bool, error) {
return z.queryElement(ctx, "queryInterface", iface)
}
// ChangeInterface moves an interface into this zone, detaching it from whatever
// zone currently owns it. Unlike AddInterface, it does not fail if the interface
// is already bound elsewhere (firewall-cmd --change-interface).
func (z *RuntimeZone) ChangeInterface(ctx context.Context, iface string) error {
return z.removeElement(ctx, "changeZoneOfInterface", iface)
}
// ChangeSource moves a source into this zone, detaching it from its current zone.
func (z *RuntimeZone) ChangeSource(ctx context.Context, source string) error {
return z.removeElement(ctx, "changeZoneOfSource", source)
}
// Getters for the current runtime element lists.
func (z *RuntimeZone) Services(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getServices")
}
func (z *RuntimeZone) Interfaces(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getInterfaces")
}
func (z *RuntimeZone) Sources(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getSources")
}
func (z *RuntimeZone) Protocols(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getProtocols")
}
func (z *RuntimeZone) RichRules(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getRichRules")
}
func (z *RuntimeZone) ICMPBlocks(ctx context.Context) ([]string, error) {
return z.getStrings(ctx, "getIcmpBlocks")
}
// Ports returns the runtime ports as typed pairs.
func (z *RuntimeZone) Ports(ctx context.Context) ([]Port, error) {
var raw [][]any
err := z.c.call(ctx, basePath, ifaceZone, "getPorts", []any{&raw}, z.name)
if err != nil {
return nil, err
}
return asPorts(toAnySlice(raw)), nil
}
// SourcePorts returns the runtime source ports as typed pairs.
func (z *RuntimeZone) SourcePorts(ctx context.Context) ([]Port, error) {
var raw [][]any
err := z.c.call(ctx, basePath, ifaceZone, "getSourcePorts", []any{&raw}, z.name)
if err != nil {
return nil, err
}
return asPorts(toAnySlice(raw)), nil
}
// ForwardPorts returns the runtime forward ports as typed values.
func (z *RuntimeZone) ForwardPorts(ctx context.Context) ([]ForwardPort, error) {
var raw [][]any
err := z.c.call(ctx, basePath, ifaceZone, "getForwardPorts", []any{&raw}, z.name)
if err != nil {
return nil, err
}
return asForwardPorts(toAnySlice(raw)), nil
}
// toAnySlice re-wraps a [][]any as []any so the shared tuple
// helpers (asPorts/asForwardPorts) can consume it.
func toAnySlice(raw [][]any) []any {
out := make([]any, len(raw))
for i := range raw {
out[i] = raw[i]
}
return out
}

229
zonesettings.go Normal file
View file

@ -0,0 +1,229 @@
package firewalld
import "github.com/godbus/dbus/v5"
// ZoneSettings is the complete, transport-neutral representation of a zone's
// configuration. It is populated from either the v1 tuple (EL7 and up) or the v2
// dict (firewalld >= 0.9) and encodes back through whichever transport the server
// supports. The final three fields exist only in the v2 dict; on the tuple path
// they are ignored on write and left zero on read.
type ZoneSettings struct {
Version string
Short string
Description string
Target Target
Services []string
Ports []Port
ICMPBlocks []string
Masquerade bool
ForwardPorts []ForwardPort
Interfaces []string
Sources []string
RichRules []string
Protocols []string
SourcePorts []Port
ICMPBlockInversion bool
// v2-only (dict) fields.
Forward bool // intra-zone forwarding (firewalld >= 0.9)
EgressPriority int32 // zone egress priority (newer firewalld)
IngressPriority int32 // zone ingress priority (newer firewalld)
}
// zoneTuple is the concrete Go struct godbus encodes to the v1 zone-settings
// signature "(sssbsasa(ss)asba(ssss)asasasasa(ss)b)". Field order is load-bearing:
// it must match firewalld's tuple exactly. The Unused bool is firewalld's dead
// field 3 (historically "immutable").
type zoneTuple struct {
Version string
Short string
Description string
Unused bool
Target string
Services []string
Ports []Port
ICMPBlocks []string
Masquerade bool
ForwardPorts []ForwardPort
Interfaces []string
Sources []string
RichRules []string
Protocols []string
SourcePorts []Port
ICMPBlockInversion bool
}
// toTuple builds the v1 tuple struct from settings. Nil slices encode as empty
// D-Bus arrays, which is what firewalld expects for "no entries".
func (s *ZoneSettings) toTuple() zoneTuple {
target := string(s.Target)
if target == "" {
target = string(TargetDefault)
}
return zoneTuple{
Version: s.Version,
Short: s.Short,
Description: s.Description,
Target: target,
Services: s.Services,
Ports: s.Ports,
ICMPBlocks: s.ICMPBlocks,
Masquerade: s.Masquerade,
ForwardPorts: s.ForwardPorts,
Interfaces: s.Interfaces,
Sources: s.Sources,
RichRules: s.RichRules,
Protocols: s.Protocols,
SourcePorts: s.SourcePorts,
ICMPBlockInversion: s.ICMPBlockInversion,
}
}
// zoneSettingsFromTuple decodes a raw v1 tuple (godbus []any) into
// ZoneSettings. Every field is read through the length-tolerant helpers so a
// short tuple from an old firewalld yields zero values instead of a panic.
func zoneSettingsFromTuple(raw []any) ZoneSettings {
return ZoneSettings{
Version: asString(tupleField(raw, 0)),
Short: asString(tupleField(raw, 1)),
Description: asString(tupleField(raw, 2)),
// field 3 is the unused bool
Target: Target(asString(tupleField(raw, 4))),
Services: asStrings(tupleField(raw, 5)),
Ports: asPorts(tupleField(raw, 6)),
ICMPBlocks: asStrings(tupleField(raw, 7)),
Masquerade: asBool(tupleField(raw, 8)),
ForwardPorts: asForwardPorts(tupleField(raw, 9)),
Interfaces: asStrings(tupleField(raw, 10)),
Sources: asStrings(tupleField(raw, 11)),
RichRules: asStrings(tupleField(raw, 12)),
Protocols: asStrings(tupleField(raw, 13)),
SourcePorts: asPorts(tupleField(raw, 14)),
ICMPBlockInversion: asBool(tupleField(raw, 15)),
}
}
// toDict builds the a{sv} dict for the v2 transport. Every modelled field is sent
// with a concrete typed value so godbus emits the tuple signatures firewalld
// requires (ports as a(ss), forward_ports as a(ssss)). The egress/ingress
// priority keys are only included when non-zero, so we never send a key an
// intermediate 0.9.x server would reject.
func (s *ZoneSettings) toDict() map[string]dbus.Variant {
target := string(s.Target)
if target == "" {
target = string(TargetDefault)
}
d := map[string]dbus.Variant{
"version": dbus.MakeVariant(s.Version),
"short": dbus.MakeVariant(s.Short),
"description": dbus.MakeVariant(s.Description),
"target": dbus.MakeVariant(target),
"services": dbus.MakeVariant(nonNilStrings(s.Services)),
"ports": dbus.MakeVariant(nonNilPorts(s.Ports)),
"icmp_blocks": dbus.MakeVariant(nonNilStrings(s.ICMPBlocks)),
"masquerade": dbus.MakeVariant(s.Masquerade),
"forward_ports": dbus.MakeVariant(nonNilForwardPorts(s.ForwardPorts)),
"interfaces": dbus.MakeVariant(nonNilStrings(s.Interfaces)),
"sources": dbus.MakeVariant(nonNilStrings(s.Sources)),
"rules_str": dbus.MakeVariant(nonNilStrings(s.RichRules)),
"protocols": dbus.MakeVariant(nonNilStrings(s.Protocols)),
"source_ports": dbus.MakeVariant(nonNilPorts(s.SourcePorts)),
"icmp_block_inversion": dbus.MakeVariant(s.ICMPBlockInversion),
"forward": dbus.MakeVariant(s.Forward),
}
if s.EgressPriority != 0 {
d["egress_priority"] = dbus.MakeVariant(s.EgressPriority)
}
if s.IngressPriority != 0 {
d["ingress_priority"] = dbus.MakeVariant(s.IngressPriority)
}
return d
}
// zoneSettingsFromDict decodes an a{sv} dict into ZoneSettings. Keys firewalld
// omitted (unset fields) fall through to zero values.
func zoneSettingsFromDict(d map[string]dbus.Variant) ZoneSettings {
var s ZoneSettings
if v, ok := dictValue(d, "version"); ok {
s.Version = asString(v)
}
if v, ok := dictValue(d, "short"); ok {
s.Short = asString(v)
}
if v, ok := dictValue(d, "description"); ok {
s.Description = asString(v)
}
if v, ok := dictValue(d, "target"); ok {
s.Target = Target(asString(v))
}
if v, ok := dictValue(d, "services"); ok {
s.Services = asStrings(v)
}
if v, ok := dictValue(d, "ports"); ok {
s.Ports = asPorts(v)
}
if v, ok := dictValue(d, "icmp_blocks"); ok {
s.ICMPBlocks = asStrings(v)
}
if v, ok := dictValue(d, "masquerade"); ok {
s.Masquerade = asBool(v)
}
if v, ok := dictValue(d, "forward_ports"); ok {
s.ForwardPorts = asForwardPorts(v)
}
if v, ok := dictValue(d, "interfaces"); ok {
s.Interfaces = asStrings(v)
}
if v, ok := dictValue(d, "sources"); ok {
s.Sources = asStrings(v)
}
// firewalld names rich rules "rules_str" in the dict; accept "rich_rules" too
// for forward compatibility.
if v, ok := dictValue(d, "rules_str"); ok {
s.RichRules = asStrings(v)
} else if v, ok := dictValue(d, "rich_rules"); ok {
s.RichRules = asStrings(v)
}
if v, ok := dictValue(d, "protocols"); ok {
s.Protocols = asStrings(v)
}
if v, ok := dictValue(d, "source_ports"); ok {
s.SourcePorts = asPorts(v)
}
if v, ok := dictValue(d, "icmp_block_inversion"); ok {
s.ICMPBlockInversion = asBool(v)
}
if v, ok := dictValue(d, "forward"); ok {
s.Forward = asBool(v)
}
if v, ok := dictValue(d, "egress_priority"); ok {
s.EgressPriority = asInt32(v)
}
if v, ok := dictValue(d, "ingress_priority"); ok {
s.IngressPriority = asInt32(v)
}
return s
}
// nonNilStrings returns an empty, non-nil slice for a nil input so godbus emits an
// empty typed array (as) rather than a nil that could confuse the dict encoding.
func nonNilStrings(xs []string) []string {
if xs == nil {
return []string{}
}
return xs
}
func nonNilPorts(xs []Port) []Port {
if xs == nil {
return []Port{}
}
return xs
}
func nonNilForwardPorts(xs []ForwardPort) []ForwardPort {
if xs == nil {
return []ForwardPort{}
}
return xs
}