commit 2b834e681ffd4c0d19114f87a0ae01f471fca8d6 Author: James Coleman Date: Thu Jul 2 09:46:32 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6cc24e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83d4a90 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..158964d --- /dev/null +++ b/README.md @@ -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**. diff --git a/caps.go b/caps.go new file mode 100644 index 0000000..b03173d --- /dev/null +++ b/caps.go @@ -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), + } +} diff --git a/caps_test.go b/caps_test.go new file mode 100644 index 0000000..73350d3 --- /dev/null +++ b/caps_test.go @@ -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]) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..cf5ac56 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/decode.go b/decode.go new file mode 100644 index 0000000..1c4967c --- /dev/null +++ b/decode.go @@ -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 +} diff --git a/direct.go b/direct.go new file mode 100644 index 0000000..7e44989 --- /dev/null +++ b/direct.go @@ -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()) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..411766b --- /dev/null +++ b/errors.go @@ -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 +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..de3de11 --- /dev/null +++ b/errors_test.go @@ -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) +} diff --git a/firewalld.go b/firewalld.go new file mode 100644 index 0000000..fed50bc --- /dev/null +++ b/firewalld.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d38fc79 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1531ce --- /dev/null +++ b/go.sum @@ -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= diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..1446d1b --- /dev/null +++ b/helper.go @@ -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) +} diff --git a/icmptype.go b/icmptype.go new file mode 100644 index 0000000..aa84622 --- /dev/null +++ b/icmptype.go @@ -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) +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..6108503 --- /dev/null +++ b/integration_test.go @@ -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@:/tmp/ && ssh root@ /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)) +} diff --git a/ipset.go b/ipset.go new file mode 100644 index 0000000..d8f8a93 --- /dev/null +++ b/ipset.go @@ -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) +} diff --git a/lockdown.go b/lockdown.go new file mode 100644 index 0000000..aebc4d6 --- /dev/null +++ b/lockdown.go @@ -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 +} diff --git a/permzone.go b/permzone.go new file mode 100644 index 0000000..7536db4 --- /dev/null +++ b/permzone.go @@ -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) +} diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..33f477e --- /dev/null +++ b/policy.go @@ -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) +} diff --git a/properties.go b/properties.go new file mode 100644 index 0000000..f5d0840 --- /dev/null +++ b/properties.go @@ -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) +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..87f8582 --- /dev/null +++ b/service.go @@ -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) +} diff --git a/signals.go b/signals.go new file mode 100644 index 0000000..39cc30e --- /dev/null +++ b/signals.go @@ -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:] +} diff --git a/signatures_test.go b/signatures_test.go new file mode 100644 index 0000000..7ad8fdb --- /dev/null +++ b/signatures_test.go @@ -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 +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..6fcc51a --- /dev/null +++ b/types.go @@ -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" +) diff --git a/zone.go b/zone.go new file mode 100644 index 0000000..18ccd3f --- /dev/null +++ b/zone.go @@ -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 +} diff --git a/zonesettings.go b/zonesettings.go new file mode 100644 index 0000000..d616908 --- /dev/null +++ b/zonesettings.go @@ -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 +}