first commit

This commit is contained in:
James Coleman 2026-07-02 09:46:32 -05:00
commit 2b834e681f
27 changed files with 4378 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/

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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
}