first commit
This commit is contained in:
commit
2b834e681f
27 changed files with 4378 additions and 0 deletions
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Binaries / build output
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/dist/
|
||||
/bin/
|
||||
|
||||
# Test artifacts
|
||||
*.test
|
||||
*.out
|
||||
coverage.*
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
19
LICENSE
Normal file
19
LICENSE
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# go-firewalld
|
||||
|
||||
[](https://pkg.go.dev/github.com/grmrgecko/go-firewalld)
|
||||
|
||||
A comprehensive Go client for the [firewalld](https://firewalld.org) D-Bus API,
|
||||
built on [`github.com/godbus/dbus/v5`](https://github.com/godbus/dbus).
|
||||
|
||||
- **Runtime + permanent** operations, mirroring `firewall-cmd` (transient vs `--permanent`).
|
||||
- **Version/capability aware:** one binary serves both old (EL7, firewalld 0.6.x)
|
||||
and new (EL9+, firewalld 1.x/2.x) servers. The dict (`a{sv}`) settings path and
|
||||
zone-to-zone policies are used where supported and transparently fall back to
|
||||
the v1 tuple path where they are not.
|
||||
- **Typed, `errors.Is`-able errors** mapped from firewalld exceptions
|
||||
(`ErrAlreadyEnabled`, `ErrNotEnabled`, `ErrInvalidZone`, …).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
go get github.com/grmrgecko/go-firewalld
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
ctx := context.Background()
|
||||
conn, err := firewalld.Connect(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Println(conn.Version()) // e.g. "1.3.4"
|
||||
fmt.Println(conn.Supports(firewalld.Policies)) // false on EL7, true on EL9+
|
||||
|
||||
// Permanent edits (apply after a reload).
|
||||
zone := conn.Permanent().Zone("public")
|
||||
if err := zone.AddPort(ctx, firewalld.Port{Port: "4242", Protocol: "udp"}); err != nil {
|
||||
if errors.Is(err, firewalld.ErrAlreadyEnabled) {
|
||||
// idempotent no-op
|
||||
} else {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = zone.SetTarget(ctx, firewalld.TargetDROP)
|
||||
_ = conn.Reload(ctx)
|
||||
|
||||
// Read settings (transport chosen automatically by server capability).
|
||||
s, _ := conn.Permanent().Zone("public").Settings(ctx)
|
||||
fmt.Println(s.Services, s.Ports, s.Forward)
|
||||
|
||||
// Runtime (transient) edits, with an optional timeout.
|
||||
_ = conn.Runtime().Zone("public").AddRichRule(ctx,
|
||||
`rule family="ipv4" source address="10.0.0.0/8" reject`, 30*time.Second)
|
||||
|
||||
// Low-level escape hatch for anything not yet wrapped.
|
||||
var out []string
|
||||
_ = conn.Call(ctx, "/org/fedoraproject/FirewallD1",
|
||||
"org.fedoraproject.FirewallD1.zone", "getZones", []any{&out})
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
| Area | Types / handles |
|
||||
|---|---|
|
||||
| Connection & lifecycle | `Connect`, `Open`, `Version`, `Supports`, `Reload`, `CompleteReload`, `RuntimeToPermanent`, `ResetToDefaults`, `CheckPermanentConfig`, panic mode, log-denied, default zone |
|
||||
| Zones (permanent) | `Permanent().Zone(name)`: settings, update, add/remove/query for port/source/source-port/service/protocol/forward-port/masquerade/icmp-block/icmp-block-inversion/interface/rich-rule, target/short/description/version, rename, remove, load-defaults |
|
||||
| Zones (runtime) | `Runtime().Zone(name)`: same element set with timeouts; change-interface/change-source (move between zones); active zones, zone-of-interface/source; wholesale `Runtime().SetSettings` (dict servers) |
|
||||
| Config management | zone names/paths, add-zone, zone-of-interface/source |
|
||||
| Daemon properties | `RuntimeInfo` (state, IPv4/IPv6/IPSet/bridge support, ipset & icmp types), `DaemonConfig` (backend, rp-filter, cleanup, …) get/set |
|
||||
| IPSets | runtime + permanent: settings, entries, options, existence query |
|
||||
| Services | list, read (tuple/dict), permanent editor incl. includes |
|
||||
| ICMP types | list, read, permanent editor |
|
||||
| Helpers (conntrack) | list, read, permanent editor |
|
||||
| Policies (≥ 0.9) | runtime + permanent, capability-gated |
|
||||
| Direct interface | runtime chains/rules/passthroughs + permanent direct config blob |
|
||||
| Lockdown | enable/disable/query + whitelist (command/context/user/uid) |
|
||||
| Signals | `WatchSignals`, `WatchReloaded` |
|
||||
|
||||
Deliberately omitted as redundant with `Settings()`/`Update()`: the per-field bulk
|
||||
`setX` list-setters and scalar `getX` getters on config objects. Legacy/no-op
|
||||
methods (`authorizeAll`, `isImmutable`, `*AutomaticHelpers`, the `changeZone`
|
||||
alias) are also skipped.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests need no bus and cover encode/decode round-trips plus
|
||||
`dbus.SignatureOf` assertions for every compound type:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Integration tests run against a live firewalld and are gated behind a build tag.
|
||||
They operate only on throwaway zones/ipsets/policies they create and remove, so a
|
||||
failure cannot disturb the default zone or an SSH session. Build a test binary and
|
||||
run it on the target host (no Go toolchain needed there):
|
||||
|
||||
```sh
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go test -c -tags firewalld_integration -o firewalld.itest .
|
||||
scp firewalld.itest root@host:/tmp/
|
||||
ssh root@host '/tmp/firewalld.itest -test.v'
|
||||
```
|
||||
|
||||
Verified against firewalld **0.6.3** (CentOS 7, iptables backend) and **1.3.4**
|
||||
(Rocky 9, nftables backend); signature tests additionally cover **2.4.3**.
|
||||
88
caps.go
Normal file
88
caps.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Capability names a version-gated feature of the firewalld D-Bus surface.
|
||||
// Callers test support with Conn.Supports; internal code version-gates transports
|
||||
// (tuple vs dict) on these flags.
|
||||
type Capability int
|
||||
|
||||
const (
|
||||
// DictZoneSettings gates the a{sv} zone-settings path (getSettings2/update2 on
|
||||
// config.zone, getZoneSettings2/setZoneSettings2 on the runtime .zone). Added
|
||||
// in firewalld 0.9. Absent on EL7 0.6.3, which uses the v1 tuple only.
|
||||
DictZoneSettings Capability = iota
|
||||
// Policies gates zone-to-zone policy objects (config listPolicies/addPolicy,
|
||||
// runtime getPolicySettings/getActivePolicies). Added in 0.9. Absent on EL7.
|
||||
Policies
|
||||
// ServiceSettings2 gates getServiceSettings2/addService2 (a{sv} services).
|
||||
// Present on 1.3.4, absent on 0.6.3.
|
||||
ServiceSettings2
|
||||
// AddZone2 gates config.addZone2/addService2 (dict-based creation). Added 0.9.
|
||||
AddZone2
|
||||
// ResetToDefaults gates the main resetToDefaults method. Present on 1.x.
|
||||
ResetToDefaults
|
||||
)
|
||||
|
||||
// Version is a parsed firewalld version. Missing components read as zero, so a
|
||||
// server reporting "1" compares equal to "1.0.0".
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
Raw string
|
||||
}
|
||||
|
||||
// parseVersion parses a "X.Y.Z" firewalld version string. Trailing non-numeric
|
||||
// suffixes on a component (e.g. a distro tag) are tolerated by reading the leading
|
||||
// digits. Unparseable components read as zero rather than failing the connection.
|
||||
func parseVersion(raw string) Version {
|
||||
v := Version{Raw: raw}
|
||||
parts := strings.SplitN(raw, ".", 3)
|
||||
dst := []*int{&v.Major, &v.Minor, &v.Patch}
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
*dst[i] = leadingInt(parts[i])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// leadingInt reads the leading base-10 digits of s, ignoring any suffix.
|
||||
func leadingInt(s string) int {
|
||||
end := 0
|
||||
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s[:end])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// atLeast reports whether v is >= the given major.minor.
|
||||
func (v Version) atLeast(major, minor int) bool {
|
||||
if v.Major != major {
|
||||
return v.Major > major
|
||||
}
|
||||
return v.Minor >= minor
|
||||
}
|
||||
|
||||
// String returns the raw version string as reported by the server.
|
||||
func (v Version) String() string { return v.Raw }
|
||||
|
||||
// capsFor derives the capability set from a parsed version. Gates follow the
|
||||
// introspection findings: dict settings and policies land at 0.9; the *2 service
|
||||
// APIs and resetToDefaults are 1.x.
|
||||
func capsFor(v Version) map[Capability]bool {
|
||||
return map[Capability]bool{
|
||||
DictZoneSettings: v.atLeast(0, 9),
|
||||
Policies: v.atLeast(0, 9),
|
||||
AddZone2: v.atLeast(0, 9),
|
||||
ServiceSettings2: v.atLeast(1, 0),
|
||||
ResetToDefaults: v.atLeast(1, 0),
|
||||
}
|
||||
}
|
||||
61
caps_test.go
Normal file
61
caps_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
maj, min, patch int
|
||||
}{
|
||||
{"0.6.3", 0, 6, 3},
|
||||
{"1.3.4", 1, 3, 4},
|
||||
{"2.4.3", 2, 4, 3},
|
||||
{"1", 1, 0, 0},
|
||||
{"0.9", 0, 9, 0},
|
||||
{"1.2.3-4.el9", 1, 2, 3},
|
||||
{"", 0, 0, 0},
|
||||
{"garbage", 0, 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
v := parseVersion(tc.raw)
|
||||
assert.Equal(t, tc.maj, v.Major, "major of %q", tc.raw)
|
||||
assert.Equal(t, tc.min, v.Minor, "minor of %q", tc.raw)
|
||||
assert.Equal(t, tc.patch, v.Patch, "patch of %q", tc.raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionAtLeast(t *testing.T) {
|
||||
assert.True(t, parseVersion("1.3.4").atLeast(0, 9))
|
||||
assert.True(t, parseVersion("0.9.0").atLeast(0, 9))
|
||||
assert.False(t, parseVersion("0.6.3").atLeast(0, 9))
|
||||
assert.True(t, parseVersion("1.0.0").atLeast(1, 0))
|
||||
assert.False(t, parseVersion("0.9.9").atLeast(1, 0))
|
||||
assert.True(t, parseVersion("2.0.0").atLeast(1, 5))
|
||||
}
|
||||
|
||||
// TestCapsByVersion locks the capability gates to the three real server versions:
|
||||
// EL7 0.6.3 (no dict, no policies), EL9 1.3.4 and local 2.4.3 (full).
|
||||
func TestCapsByVersion(t *testing.T) {
|
||||
el7 := capsFor(parseVersion("0.6.3"))
|
||||
assert.False(t, el7[DictZoneSettings])
|
||||
assert.False(t, el7[Policies])
|
||||
assert.False(t, el7[ServiceSettings2])
|
||||
assert.False(t, el7[ResetToDefaults])
|
||||
|
||||
el9 := capsFor(parseVersion("1.3.4"))
|
||||
assert.True(t, el9[DictZoneSettings])
|
||||
assert.True(t, el9[Policies])
|
||||
assert.True(t, el9[ServiceSettings2])
|
||||
assert.True(t, el9[ResetToDefaults])
|
||||
assert.True(t, el9[AddZone2])
|
||||
|
||||
// The 0.9 boundary: dict + policies land, the 1.x-only flags do not.
|
||||
el09 := capsFor(parseVersion("0.9.0"))
|
||||
assert.True(t, el09[DictZoneSettings])
|
||||
assert.True(t, el09[Policies])
|
||||
assert.False(t, el09[ServiceSettings2])
|
||||
}
|
||||
68
config.go
Normal file
68
config.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// Permanent is the entry point for operations on firewalld's on-disk permanent
|
||||
// configuration, mirroring firewall-cmd --permanent. Changes take effect after a
|
||||
// Reload. It also exposes the config-object management methods (listing and
|
||||
// creating zones, ipsets, services, and so on).
|
||||
type Permanent struct{ c *Conn }
|
||||
|
||||
// Permanent returns the permanent configuration namespace.
|
||||
func (c *Conn) Permanent() *Permanent { return &Permanent{c: c} }
|
||||
|
||||
// Config is an alias for Permanent, matching the firewalld config object naming
|
||||
// used by callers that think in terms of "the config interface".
|
||||
func (c *Conn) Config() *Permanent { return &Permanent{c: c} }
|
||||
|
||||
// Zone returns a handle for permanent operations on the named zone. The object
|
||||
// path is resolved lazily on first use via getZoneByName.
|
||||
func (p *Permanent) Zone(name string) *PermZone {
|
||||
return &PermZone{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// ZoneNames lists the names of all permanently configured zones.
|
||||
func (p *Permanent) ZoneNames(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// ZonePaths lists the object paths of all permanent zones (listZones -> ao).
|
||||
func (p *Permanent) ZonePaths(ctx context.Context) ([]dbus.ObjectPath, error) {
|
||||
var paths []dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "listZones", []any{&paths})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// AddZone creates a new permanent zone from the given settings and returns its
|
||||
// object path. On dict-capable servers it uses addZone2; otherwise addZone with
|
||||
// the v1 tuple.
|
||||
func (p *Permanent) AddZone(ctx context.Context, name string, settings ZoneSettings) (dbus.ObjectPath, error) {
|
||||
var path dbus.ObjectPath
|
||||
if p.c.caps[AddZone2] {
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addZone2", []any{&path}, name, settings.toDict())
|
||||
return path, err
|
||||
}
|
||||
tuple := settings.toTuple()
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addZone", []any{&path}, name, tuple)
|
||||
return path, err
|
||||
}
|
||||
|
||||
// ZoneOfInterface returns the permanent zone bound to an interface, or "" if none.
|
||||
func (p *Permanent) ZoneOfInterface(ctx context.Context, iface string) (string, error) {
|
||||
var zone string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneOfInterface", []any{&zone}, iface)
|
||||
return zone, err
|
||||
}
|
||||
|
||||
// ZoneOfSource returns the permanent zone bound to a source, or "" if none.
|
||||
func (p *Permanent) ZoneOfSource(ctx context.Context, source string) (string, error) {
|
||||
var zone string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getZoneOfSource", []any{&zone}, source)
|
||||
return zone, err
|
||||
}
|
||||
195
decode.go
Normal file
195
decode.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// This file holds the length-tolerant decode helpers shared by every settings
|
||||
// decoder. Two facts make the coercion messy and force the tolerance here:
|
||||
//
|
||||
// 1. firewalld returns compound values either as a positional tuple (godbus hands
|
||||
// these back as []any) or as an a{sv} dict that OMITS unset keys.
|
||||
// 2. Inside the dict, firewalld's Python side serializes port/forward-port lists
|
||||
// as "aav" (array of array of variant), NOT the "a(ss)"/"a(ssss)" tuple it
|
||||
// accepts on the way in. So a decoded port element may be []any{Variant,
|
||||
// Variant} rather than a clean string pair.
|
||||
//
|
||||
// Every helper therefore unwraps variants and reads through reflection so it
|
||||
// tolerates []string, []any, [][]any, and []dbus.Variant interchangeably, and
|
||||
// never indexes a fixed tuple offset without a bounds check.
|
||||
|
||||
// unwrap peels dbus.Variant wrappers off a decoded value, recursively, so callers
|
||||
// see the underlying Go value regardless of how deeply firewalld boxed it.
|
||||
func unwrap(v any) any {
|
||||
for {
|
||||
vr, ok := v.(dbus.Variant)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
v = vr.Value()
|
||||
}
|
||||
}
|
||||
|
||||
// sliceElems returns the elements of any slice-shaped value as a []any, with each
|
||||
// element unwrapped from a variant. It handles the whole zoo of concrete slice
|
||||
// types godbus may produce (including nested [][]any) via reflection.
|
||||
func sliceElems(v any) []any {
|
||||
v = unwrap(v)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
|
||||
return nil
|
||||
}
|
||||
out := make([]any, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
out[i] = unwrap(rv.Index(i).Interface())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tupleField returns element i of a decoded D-Bus tuple, or nil if the tuple is
|
||||
// shorter than i+1. This is the guard that keeps a short EL7 tuple from panicking.
|
||||
func tupleField(raw []any, i int) any {
|
||||
if i < 0 || i >= len(raw) {
|
||||
return nil
|
||||
}
|
||||
return raw[i]
|
||||
}
|
||||
|
||||
// asString coerces a decoded value to a string, unwrapping variants first.
|
||||
func asString(v any) string {
|
||||
s, _ := unwrap(v).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// asBool coerces a decoded value to a bool, unwrapping variants first.
|
||||
func asBool(v any) bool {
|
||||
b, _ := unwrap(v).(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// asInt32 coerces a decoded integer value to int32, tolerating the several
|
||||
// integer widths godbus may hand back.
|
||||
func asInt32(v any) int32 {
|
||||
switch n := unwrap(v).(type) {
|
||||
case int32:
|
||||
return n
|
||||
case int:
|
||||
return int32(n)
|
||||
case int64:
|
||||
return int32(n)
|
||||
case uint32:
|
||||
return int32(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// asStrings coerces a decoded "as" value to []string.
|
||||
func asStrings(v any) []string {
|
||||
els := sliceElems(v)
|
||||
if els == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(els))
|
||||
for _, e := range els {
|
||||
out = append(out, asString(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pair extracts the first two string elements of a decoded "(ss)" tuple, however
|
||||
// it was serialized (string pair or variant pair).
|
||||
func pair(v any) (string, string) {
|
||||
els := sliceElems(v)
|
||||
var a, b string
|
||||
if len(els) > 0 {
|
||||
a = asString(els[0])
|
||||
}
|
||||
if len(els) > 1 {
|
||||
b = asString(els[1])
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
// quad extracts the four string elements of a decoded "(ssss)" tuple.
|
||||
func quad(v any) (string, string, string, string) {
|
||||
els := sliceElems(v)
|
||||
var a [4]string
|
||||
for i := 0; i < len(els) && i < 4; i++ {
|
||||
a[i] = asString(els[i])
|
||||
}
|
||||
return a[0], a[1], a[2], a[3]
|
||||
}
|
||||
|
||||
// asPorts coerces a decoded "a(ss)" (or its "aav" dict form) to []Port.
|
||||
func asPorts(v any) []Port {
|
||||
if ps, ok := v.([]Port); ok {
|
||||
return ps
|
||||
}
|
||||
els := sliceElems(v)
|
||||
if els == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Port, 0, len(els))
|
||||
for _, e := range els {
|
||||
p, proto := pair(e)
|
||||
out = append(out, Port{Port: p, Protocol: proto})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asForwardPorts coerces a decoded "a(ssss)" (or "aav") value to []ForwardPort.
|
||||
func asForwardPorts(v any) []ForwardPort {
|
||||
if fs, ok := v.([]ForwardPort); ok {
|
||||
return fs
|
||||
}
|
||||
els := sliceElems(v)
|
||||
if els == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ForwardPort, 0, len(els))
|
||||
for _, e := range els {
|
||||
p, proto, toport, toaddr := quad(e)
|
||||
out = append(out, ForwardPort{Port: p, Protocol: proto, ToPort: toport, ToAddr: toaddr})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asStringMap coerces a decoded "a{ss}" value to map[string]string, unwrapping
|
||||
// variant values (firewalld may hand back a{sv} in the dict forms).
|
||||
func asStringMap(v any) map[string]string {
|
||||
switch m := unwrap(v).(type) {
|
||||
case map[string]string:
|
||||
return m
|
||||
case map[string]any:
|
||||
out := make(map[string]string, len(m))
|
||||
for k, e := range m {
|
||||
out[k] = asString(e)
|
||||
}
|
||||
return out
|
||||
case map[string]dbus.Variant:
|
||||
out := make(map[string]string, len(m))
|
||||
for k, e := range m {
|
||||
out[k] = asString(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// dictValue reads key k from an a{sv} settings dict, returning the unwrapped value
|
||||
// and whether it was present. A missing key means firewalld left the field at its
|
||||
// default, so callers treat !ok as the zero value.
|
||||
func dictValue(d map[string]dbus.Variant, k string) (any, bool) {
|
||||
v, ok := d[k]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return v.Value(), true
|
||||
}
|
||||
322
direct.go
Normal file
322
direct.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package firewalld
|
||||
|
||||
import "context"
|
||||
|
||||
// Direct is the entry point for firewalld's direct interface, which passes rules
|
||||
// straight to the underlying iptables/nftables backend. It is a low-level escape
|
||||
// hatch: firewalld does not manage or reconcile direct rules the way it does
|
||||
// zones. All operations here are runtime (the direct interface has no per-element
|
||||
// permanent methods; the permanent form is a single config.direct blob).
|
||||
type Direct struct{ c *Conn }
|
||||
|
||||
// Direct returns the direct-interface namespace.
|
||||
func (c *Conn) Direct() *Direct { return &Direct{c: c} }
|
||||
|
||||
// DirectChain is a custom chain added through the direct interface.
|
||||
type DirectChain struct {
|
||||
IPV string // "ipv4", "ipv6", or "eb" (ebtables)
|
||||
Table string // e.g. "filter", "nat"
|
||||
Chain string
|
||||
}
|
||||
|
||||
// DirectRule is a rule added through the direct interface, including the chain it
|
||||
// belongs to, its priority, and the raw backend arguments.
|
||||
type DirectRule struct {
|
||||
IPV string
|
||||
Table string
|
||||
Chain string
|
||||
Priority int32
|
||||
Args []string
|
||||
}
|
||||
|
||||
// AddChain adds a custom chain (ipv, table, chain).
|
||||
func (d *Direct) AddChain(ctx context.Context, ch DirectChain) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "addChain", nil, ch.IPV, ch.Table, ch.Chain)
|
||||
}
|
||||
|
||||
// RemoveChain removes a custom chain.
|
||||
func (d *Direct) RemoveChain(ctx context.Context, ch DirectChain) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "removeChain", nil, ch.IPV, ch.Table, ch.Chain)
|
||||
}
|
||||
|
||||
// QueryChain reports whether a custom chain exists.
|
||||
func (d *Direct) QueryChain(ctx context.Context, ch DirectChain) (bool, error) {
|
||||
var ok bool
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "queryChain", []any{&ok}, ch.IPV, ch.Table, ch.Chain)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Chains lists the custom chains in a table for an IP family.
|
||||
func (d *Direct) Chains(ctx context.Context, ipv, table string) ([]string, error) {
|
||||
var chains []string
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "getChains", []any{&chains}, ipv, table)
|
||||
return chains, err
|
||||
}
|
||||
|
||||
// AllChains lists every custom chain across all families and tables.
|
||||
func (d *Direct) AllChains(ctx context.Context) ([]DirectChain, error) {
|
||||
var raw [][]any
|
||||
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllChains", []any{&raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DirectChain, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
ipv, table, chain, _ := quad(e)
|
||||
out = append(out, DirectChain{IPV: ipv, Table: table, Chain: chain})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddRule adds a direct rule at the given priority.
|
||||
func (d *Direct) AddRule(ctx context.Context, r DirectRule) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "addRule", nil, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
|
||||
}
|
||||
|
||||
// RemoveRule removes a direct rule matching the given fields exactly.
|
||||
func (d *Direct) RemoveRule(ctx context.Context, r DirectRule) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "removeRule", nil, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
|
||||
}
|
||||
|
||||
// RemoveRules removes all direct rules in a chain.
|
||||
func (d *Direct) RemoveRules(ctx context.Context, ipv, table, chain string) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "removeRules", nil, ipv, table, chain)
|
||||
}
|
||||
|
||||
// QueryRule reports whether a direct rule is present.
|
||||
func (d *Direct) QueryRule(ctx context.Context, r DirectRule) (bool, error) {
|
||||
var ok bool
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "queryRule", []any{&ok}, r.IPV, r.Table, r.Chain, r.Priority, r.Args)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Rules lists the rules in a chain as (priority, args) pairs. The IPV/Table/Chain
|
||||
// on each returned rule are filled in from the query arguments.
|
||||
func (d *Direct) Rules(ctx context.Context, ipv, table, chain string) ([]DirectRule, error) {
|
||||
var raw [][]any
|
||||
if err := d.c.call(ctx, basePath, ifaceDirect, "getRules", []any{&raw}, ipv, table, chain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DirectRule, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
r := DirectRule{IPV: ipv, Table: table, Chain: chain}
|
||||
if len(e) >= 1 {
|
||||
r.Priority = asInt32(e[0])
|
||||
}
|
||||
if len(e) >= 2 {
|
||||
r.Args = asStrings(e[1])
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AllRules lists every direct rule across all families, tables, and chains.
|
||||
func (d *Direct) AllRules(ctx context.Context) ([]DirectRule, error) {
|
||||
var raw [][]any
|
||||
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllRules", []any{&raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DirectRule, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
var r DirectRule
|
||||
if len(e) >= 1 {
|
||||
r.IPV = asString(e[0])
|
||||
}
|
||||
if len(e) >= 2 {
|
||||
r.Table = asString(e[1])
|
||||
}
|
||||
if len(e) >= 3 {
|
||||
r.Chain = asString(e[2])
|
||||
}
|
||||
if len(e) >= 4 {
|
||||
r.Priority = asInt32(e[3])
|
||||
}
|
||||
if len(e) >= 5 {
|
||||
r.Args = asStrings(e[4])
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Passthrough sends a raw command straight to the backend for an IP family and
|
||||
// returns the backend's output.
|
||||
func (d *Direct) Passthrough(ctx context.Context, ipv string, args []string) (string, error) {
|
||||
var out string
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "passthrough", []any{&out}, ipv, args)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AddPassthrough persists a passthrough rule for an IP family.
|
||||
func (d *Direct) AddPassthrough(ctx context.Context, ipv string, args []string) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "addPassthrough", nil, ipv, args)
|
||||
}
|
||||
|
||||
// RemovePassthrough removes a passthrough rule.
|
||||
func (d *Direct) RemovePassthrough(ctx context.Context, ipv string, args []string) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "removePassthrough", nil, ipv, args)
|
||||
}
|
||||
|
||||
// QueryPassthrough reports whether a passthrough rule is present.
|
||||
func (d *Direct) QueryPassthrough(ctx context.Context, ipv string, args []string) (bool, error) {
|
||||
var ok bool
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "queryPassthrough", []any{&ok}, ipv, args)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Passthroughs lists the passthrough rules for an IP family, each as an argv.
|
||||
func (d *Direct) Passthroughs(ctx context.Context, ipv string) ([][]string, error) {
|
||||
var raw [][]string
|
||||
err := d.c.call(ctx, basePath, ifaceDirect, "getPassthroughs", []any{&raw}, ipv)
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// DirectPassthrough is a passthrough rule tagged with its IP family, as returned
|
||||
// by AllPassthroughs.
|
||||
type DirectPassthrough struct {
|
||||
IPV string
|
||||
Args []string
|
||||
}
|
||||
|
||||
// AllPassthroughs lists every passthrough rule across all IP families.
|
||||
func (d *Direct) AllPassthroughs(ctx context.Context) ([]DirectPassthrough, error) {
|
||||
var raw [][]any
|
||||
if err := d.c.call(ctx, basePath, ifaceDirect, "getAllPassthroughs", []any{&raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DirectPassthrough, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
var pt DirectPassthrough
|
||||
if len(e) >= 1 {
|
||||
pt.IPV = asString(e[0])
|
||||
}
|
||||
if len(e) >= 2 {
|
||||
pt.Args = asStrings(e[1])
|
||||
}
|
||||
out = append(out, pt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoveAllPassthroughs clears all passthrough rules.
|
||||
func (d *Direct) RemoveAllPassthroughs(ctx context.Context) error {
|
||||
return d.c.call(ctx, basePath, ifaceDirect, "removeAllPassthroughs", nil)
|
||||
}
|
||||
|
||||
// DirectConfig is the complete permanent direct configuration: all custom chains,
|
||||
// rules, and passthroughs. It maps to the config.direct tuple
|
||||
// "(a(sss)a(sssias)a(sas))".
|
||||
type DirectConfig struct {
|
||||
Chains []DirectChain
|
||||
Rules []DirectRule
|
||||
Passthroughs []DirectPassthrough
|
||||
}
|
||||
|
||||
// The concrete encoder structs for the permanent direct tuple. Field order is
|
||||
// load-bearing and must match firewalld's signatures exactly.
|
||||
type directChainTuple struct {
|
||||
IPV, Table, Chain string // (sss)
|
||||
}
|
||||
type directRuleTuple struct {
|
||||
IPV, Table, Chain string
|
||||
Priority int32
|
||||
Args []string // (sssias)
|
||||
}
|
||||
type directPassthroughTuple struct {
|
||||
IPV string
|
||||
Args []string // (sas)
|
||||
}
|
||||
type directConfigTuple struct {
|
||||
Chains []directChainTuple
|
||||
Rules []directRuleTuple
|
||||
Passthroughs []directPassthroughTuple
|
||||
}
|
||||
|
||||
func (c DirectConfig) toTuple() directConfigTuple {
|
||||
t := directConfigTuple{
|
||||
Chains: make([]directChainTuple, 0, len(c.Chains)),
|
||||
Rules: make([]directRuleTuple, 0, len(c.Rules)),
|
||||
Passthroughs: make([]directPassthroughTuple, 0, len(c.Passthroughs)),
|
||||
}
|
||||
for _, ch := range c.Chains {
|
||||
t.Chains = append(t.Chains, directChainTuple{ch.IPV, ch.Table, ch.Chain})
|
||||
}
|
||||
for _, r := range c.Rules {
|
||||
args := r.Args
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
t.Rules = append(t.Rules, directRuleTuple{r.IPV, r.Table, r.Chain, r.Priority, args})
|
||||
}
|
||||
for _, pt := range c.Passthroughs {
|
||||
args := pt.Args
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
t.Passthroughs = append(t.Passthroughs, directPassthroughTuple{pt.IPV, args})
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// directConfigFromTuple decodes the raw config.direct tuple, length-tolerant.
|
||||
func directConfigFromTuple(raw []any) DirectConfig {
|
||||
var c DirectConfig
|
||||
for _, e := range sliceElems(tupleField(raw, 0)) {
|
||||
ipv, table, chain, _ := quad(e)
|
||||
c.Chains = append(c.Chains, DirectChain{IPV: ipv, Table: table, Chain: chain})
|
||||
}
|
||||
for _, e := range sliceElems(tupleField(raw, 1)) {
|
||||
els := sliceElems(e)
|
||||
var r DirectRule
|
||||
if len(els) >= 1 {
|
||||
r.IPV = asString(els[0])
|
||||
}
|
||||
if len(els) >= 2 {
|
||||
r.Table = asString(els[1])
|
||||
}
|
||||
if len(els) >= 3 {
|
||||
r.Chain = asString(els[2])
|
||||
}
|
||||
if len(els) >= 4 {
|
||||
r.Priority = asInt32(els[3])
|
||||
}
|
||||
if len(els) >= 5 {
|
||||
r.Args = asStrings(els[4])
|
||||
}
|
||||
c.Rules = append(c.Rules, r)
|
||||
}
|
||||
for _, e := range sliceElems(tupleField(raw, 2)) {
|
||||
els := sliceElems(e)
|
||||
var pt DirectPassthrough
|
||||
if len(els) >= 1 {
|
||||
pt.IPV = asString(els[0])
|
||||
}
|
||||
if len(els) >= 2 {
|
||||
pt.Args = asStrings(els[1])
|
||||
}
|
||||
c.Passthroughs = append(c.Passthroughs, pt)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Direct returns a handle for the permanent direct configuration.
|
||||
func (p *Permanent) Direct() *PermDirect { return &PermDirect{c: p.c} }
|
||||
|
||||
// PermDirect reads and writes firewalld's permanent direct configuration as a
|
||||
// single blob (the config.direct object), the permanent counterpart to the
|
||||
// runtime Direct interface.
|
||||
type PermDirect struct{ c *Conn }
|
||||
|
||||
// Settings reads the whole permanent direct configuration.
|
||||
func (p *PermDirect) Settings(ctx context.Context) (DirectConfig, error) {
|
||||
var raw []any
|
||||
if err := p.c.call(ctx, configPath, ifaceConfigDirect, "getSettings", []any{&raw}); err != nil {
|
||||
return DirectConfig{}, err
|
||||
}
|
||||
return directConfigFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the whole permanent direct configuration.
|
||||
func (p *PermDirect) Update(ctx context.Context, cfg DirectConfig) error {
|
||||
return p.c.call(ctx, configPath, ifaceConfigDirect, "update", nil, cfg.toTuple())
|
||||
}
|
||||
134
errors.go
Normal file
134
errors.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// exceptionName is the D-Bus error name firewalld raises for all of its domain
|
||||
// errors. The specific condition (ALREADY_ENABLED, INVALID_ZONE, ...) arrives as
|
||||
// the leading token of the error message, not as a distinct D-Bus name.
|
||||
const exceptionName = "org.fedoraproject.FirewallD1.Exception"
|
||||
|
||||
// Sentinel errors mapped from firewalld exception messages. Callers match with
|
||||
// errors.Is. ErrAlreadyEnabled/ErrNotEnabled are the idempotency signals: an
|
||||
// add of something already present, or a remove of something absent.
|
||||
var (
|
||||
ErrAlreadyEnabled = errors.New("firewalld: already enabled")
|
||||
ErrNotEnabled = errors.New("firewalld: not enabled")
|
||||
ErrInvalidZone = errors.New("firewalld: invalid zone")
|
||||
ErrInvalidPort = errors.New("firewalld: invalid port")
|
||||
ErrInvalidProtocol = errors.New("firewalld: invalid protocol")
|
||||
ErrInvalidService = errors.New("firewalld: invalid service")
|
||||
ErrInvalidICMPType = errors.New("firewalld: invalid icmptype")
|
||||
ErrInvalidIPSet = errors.New("firewalld: invalid ipset")
|
||||
ErrInvalidInterface = errors.New("firewalld: invalid interface")
|
||||
ErrInvalidSource = errors.New("firewalld: invalid source")
|
||||
ErrInvalidPolicy = errors.New("firewalld: invalid policy")
|
||||
ErrInvalidHelper = errors.New("firewalld: invalid helper")
|
||||
ErrInvalidCommand = errors.New("firewalld: invalid command")
|
||||
ErrNameConflict = errors.New("firewalld: name conflict")
|
||||
ErrZoneConflict = errors.New("firewalld: zone conflict")
|
||||
ErrBuiltinZone = errors.New("firewalld: builtin zone")
|
||||
ErrAlreadySet = errors.New("firewalld: already set")
|
||||
ErrMissingName = errors.New("firewalld: missing name")
|
||||
ErrNotRunning = errors.New("firewalld: not running")
|
||||
ErrNotApplicable = errors.New("firewalld: not applicable")
|
||||
// ErrUnsupported is returned by this library (not firewalld) when a caller
|
||||
// asks for a feature the connected server's version does not provide.
|
||||
ErrUnsupported = errors.New("firewalld: operation not supported by this server version")
|
||||
)
|
||||
|
||||
// exceptionMap keys the leading token of a firewalld exception message to a
|
||||
// sentinel. Tokens are the firewalld INVALID_*/ALREADY_*/... error codes.
|
||||
var exceptionMap = map[string]error{
|
||||
"ALREADY_ENABLED": ErrAlreadyEnabled,
|
||||
"NOT_ENABLED": ErrNotEnabled,
|
||||
"ZONE_ALREADY_SET": ErrAlreadySet,
|
||||
"ALREADY_SET": ErrAlreadySet,
|
||||
"INVALID_ZONE": ErrInvalidZone,
|
||||
"INVALID_PORT": ErrInvalidPort,
|
||||
"INVALID_PROTOCOL": ErrInvalidProtocol,
|
||||
"INVALID_SERVICE": ErrInvalidService,
|
||||
"INVALID_ICMPTYPE": ErrInvalidICMPType,
|
||||
"INVALID_IPSET": ErrInvalidIPSet,
|
||||
"INVALID_INTERFACE": ErrInvalidInterface,
|
||||
"INVALID_SOURCE": ErrInvalidSource,
|
||||
"INVALID_POLICY": ErrInvalidPolicy,
|
||||
"INVALID_HELPER": ErrInvalidHelper,
|
||||
"INVALID_COMMAND": ErrInvalidCommand,
|
||||
"NAME_CONFLICT": ErrNameConflict,
|
||||
"ZONE_CONFLICT": ErrZoneConflict,
|
||||
"BUILTIN_ZONE": ErrBuiltinZone,
|
||||
"MISSING_NAME": ErrMissingName,
|
||||
"NOT_RUNNING": ErrNotRunning,
|
||||
"NOT_APPLICABLE": ErrNotApplicable,
|
||||
}
|
||||
|
||||
// Error wraps a firewalld D-Bus exception, preserving the raw code and message
|
||||
// while chaining to a sentinel (via Unwrap) so errors.Is works. Errors that are
|
||||
// not firewalld exceptions are returned unchanged by mapError.
|
||||
type Error struct {
|
||||
Code string // firewalld token, e.g. "INVALID_ZONE"; empty if none parsed
|
||||
Message string // full exception message from firewalld
|
||||
sentinel error
|
||||
}
|
||||
|
||||
// Error renders the underlying firewalld message.
|
||||
func (e *Error) Error() string { return "firewalld: " + e.Message }
|
||||
|
||||
// Unwrap exposes the mapped sentinel so errors.Is matches (e.g. ErrInvalidZone).
|
||||
func (e *Error) Unwrap() error { return e.sentinel }
|
||||
|
||||
// mapError converts a raw error from a D-Bus call into a typed *Error when it is a
|
||||
// firewalld exception, chaining to the matching sentinel. Non-exception errors
|
||||
// (transport failures, unknown names) are returned unchanged.
|
||||
func mapError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var derr dbus.Error
|
||||
if !errors.As(err, &derr) || derr.Name != exceptionName {
|
||||
return err
|
||||
}
|
||||
msg := dbusErrorMessage(derr)
|
||||
code := leadingToken(msg)
|
||||
return &Error{Code: code, Message: msg, sentinel: exceptionMap[code]}
|
||||
}
|
||||
|
||||
// dbusErrorMessage extracts the string body of a firewalld D-Bus error, whose
|
||||
// first body element is the human-readable message.
|
||||
func dbusErrorMessage(derr dbus.Error) string {
|
||||
if len(derr.Body) > 0 {
|
||||
if s, ok := derr.Body[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return derr.Error()
|
||||
}
|
||||
|
||||
// leadingToken returns the leading UPPER_SNAKE code of a firewalld message, e.g.
|
||||
// "INVALID_ZONE" from "INVALID_ZONE: 'bogus'". Returns "" when the message does
|
||||
// not start with such a token.
|
||||
func leadingToken(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
end := 0
|
||||
for end < len(msg) {
|
||||
c := msg[end]
|
||||
if (c >= 'A' && c <= 'Z') || c == '_' || (c >= '0' && c <= '9') {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
token := msg[:end]
|
||||
// A real firewalld code is an all-caps run of two or more characters. This
|
||||
// rejects a stray leading capital from a plain-sentence error message; an
|
||||
// unmapped-but-well-formed token still round-trips harmlessly (sentinel nil).
|
||||
if len(token) < 2 {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
91
errors_test.go
Normal file
91
errors_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// makeException builds a dbus.Error shaped like a firewalld exception with the
|
||||
// given message body.
|
||||
func makeException(msg string) dbus.Error {
|
||||
return dbus.Error{Name: exceptionName, Body: []any{msg}}
|
||||
}
|
||||
|
||||
func TestMapErrorSentinels(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want error
|
||||
}{
|
||||
{"ALREADY_ENABLED", ErrAlreadyEnabled},
|
||||
{"NOT_ENABLED", ErrNotEnabled},
|
||||
{"INVALID_ZONE: 'bogus'", ErrInvalidZone},
|
||||
{"INVALID_PORT: '99999'", ErrInvalidPort},
|
||||
{"INVALID_ICMPTYPE: 'nope'", ErrInvalidICMPType},
|
||||
{"NAME_CONFLICT: 'dup'", ErrNameConflict},
|
||||
{"INVALID_IPSET: 'x'", ErrInvalidIPSet},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := mapError(makeException(tc.msg))
|
||||
assert.ErrorIs(t, err, tc.want, "message %q", tc.msg)
|
||||
|
||||
var fe *Error
|
||||
if assert.ErrorAs(t, err, &fe) {
|
||||
assert.Equal(t, tc.msg, fe.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapErrorPassthrough leaves non-firewalld errors untouched.
|
||||
func TestMapErrorPassthrough(t *testing.T) {
|
||||
assert.Nil(t, mapError(nil))
|
||||
|
||||
plain := errors.New("connection reset")
|
||||
assert.Equal(t, plain, mapError(plain))
|
||||
|
||||
// A D-Bus error that is not a firewalld exception passes through unchanged.
|
||||
other := dbus.Error{Name: "org.freedesktop.DBus.Error.NoReply", Body: []any{"timeout"}}
|
||||
assert.Equal(t, other, mapError(other))
|
||||
}
|
||||
|
||||
// TestMapErrorUnknownCode maps an unrecognised firewalld code to a typed *Error
|
||||
// with no sentinel, so errors.Is against a known sentinel is false but the code
|
||||
// and message are preserved.
|
||||
func TestMapErrorUnknownCode(t *testing.T) {
|
||||
err := mapError(makeException("SOMETHING_NEW: detail"))
|
||||
var fe *Error
|
||||
assert.ErrorAs(t, err, &fe)
|
||||
assert.Equal(t, "SOMETHING_NEW", fe.Code)
|
||||
assert.False(t, errors.Is(err, ErrInvalidZone))
|
||||
}
|
||||
|
||||
func TestLeadingToken(t *testing.T) {
|
||||
assert.Equal(t, "INVALID_ZONE", leadingToken("INVALID_ZONE: 'x'"))
|
||||
assert.Equal(t, "ALREADY_ENABLED", leadingToken("ALREADY_ENABLED"))
|
||||
assert.Equal(t, "", leadingToken("Firewall is not running"))
|
||||
assert.Equal(t, "", leadingToken(""))
|
||||
}
|
||||
|
||||
func TestPolicyDictSignature(t *testing.T) {
|
||||
s := PolicySettings{
|
||||
IngressZones: []string{"public"},
|
||||
EgressZones: []string{"HOST"},
|
||||
Ports: []Port{{Port: "80", Protocol: "tcp"}},
|
||||
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp"}},
|
||||
Priority: -1,
|
||||
}
|
||||
d := s.toDict()
|
||||
assert.Equal(t, "a{sv}", sigOf(d))
|
||||
assert.Equal(t, "a(ss)", d["ports"].Signature().String())
|
||||
assert.Equal(t, "a(ssss)", d["forward_ports"].Signature().String())
|
||||
assert.Equal(t, "i", d["priority"].Signature().String())
|
||||
|
||||
// Round-trip the dict back through the decoder.
|
||||
out := policySettingsFromDict(d)
|
||||
assert.Equal(t, s.IngressZones, out.IngressZones)
|
||||
assert.Equal(t, s.EgressZones, out.EgressZones)
|
||||
assert.Equal(t, s.Ports, out.Ports)
|
||||
assert.Equal(t, int32(-1), out.Priority)
|
||||
}
|
||||
215
firewalld.go
Normal file
215
firewalld.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// Package firewalld is a comprehensive Go client for the firewalld D-Bus API.
|
||||
//
|
||||
// It exposes both runtime (transient) and permanent configuration operations,
|
||||
// mirroring firewall-cmd's default and --permanent modes, and adapts to the
|
||||
// connected server's version so a single binary serves both old (EL7 0.6.x) and
|
||||
// new (EL9 1.x) firewalld. Compound values are encoded as concrete Go structs so
|
||||
// godbus emits the D-Bus tuples firewalld requires (see CLAUDE.md).
|
||||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// D-Bus names for the firewalld service and its well-known object paths.
|
||||
const (
|
||||
dest = "org.fedoraproject.FirewallD1"
|
||||
basePath = dbus.ObjectPath("/org/fedoraproject/FirewallD1")
|
||||
configPath = dbus.ObjectPath("/org/fedoraproject/FirewallD1/config")
|
||||
|
||||
// Interface names.
|
||||
ifaceMain = "org.fedoraproject.FirewallD1"
|
||||
ifaceZone = "org.fedoraproject.FirewallD1.zone"
|
||||
ifaceIPSet = "org.fedoraproject.FirewallD1.ipset"
|
||||
ifaceDirect = "org.fedoraproject.FirewallD1.direct"
|
||||
ifacePolicy = "org.fedoraproject.FirewallD1.policy"
|
||||
ifaceLockdown = "org.fedoraproject.FirewallD1.policies"
|
||||
ifaceConfig = "org.fedoraproject.FirewallD1.config"
|
||||
ifaceConfigZone = "org.fedoraproject.FirewallD1.config.zone"
|
||||
ifaceConfigIPSet = "org.fedoraproject.FirewallD1.config.ipset"
|
||||
ifaceConfigPolicy = "org.fedoraproject.FirewallD1.config.policy"
|
||||
ifaceConfigService = "org.fedoraproject.FirewallD1.config.service"
|
||||
ifaceConfigICMP = "org.fedoraproject.FirewallD1.config.icmptype"
|
||||
ifaceConfigHelper = "org.fedoraproject.FirewallD1.config.helper"
|
||||
ifaceConfigDirect = "org.fedoraproject.FirewallD1.config.direct"
|
||||
ifaceProperties = "org.freedesktop.DBus.Properties"
|
||||
)
|
||||
|
||||
// Conn is a connection to the firewalld service on the system bus. It caches the
|
||||
// server version and derived capabilities so callers can branch on Supports and
|
||||
// internal code can pick the tuple or dict transport. A Conn is safe for
|
||||
// concurrent use to the extent godbus's connection is.
|
||||
type Conn struct {
|
||||
conn *dbus.Conn
|
||||
version Version
|
||||
caps map[Capability]bool
|
||||
|
||||
// ownConn records whether we dialed the bus (and must close it) or adopted a
|
||||
// caller-provided connection (and must leave it open).
|
||||
ownConn bool
|
||||
}
|
||||
|
||||
// Connect dials the system bus, reads the firewalld version property, and derives
|
||||
// the capability set. The context bounds the connection and initial version read.
|
||||
func Connect(ctx context.Context) (*Conn, error) {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := newConn(ctx, conn, true)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Open builds a Conn on a caller-supplied bus connection. The caller retains
|
||||
// ownership: Close will not tear the bus down. Useful for sharing a bus or for
|
||||
// tests against a private connection.
|
||||
func Open(ctx context.Context, bus *dbus.Conn) (*Conn, error) {
|
||||
return newConn(ctx, bus, false)
|
||||
}
|
||||
|
||||
// newConn wires up the object handles and probes the version/caps.
|
||||
func newConn(ctx context.Context, bus *dbus.Conn, own bool) (*Conn, error) {
|
||||
c := &Conn{
|
||||
conn: bus,
|
||||
ownConn: own,
|
||||
}
|
||||
v, err := c.readVersion(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.version = v
|
||||
c.caps = capsFor(v)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// readVersion reads the "version" property on the main interface via a
|
||||
// context-bounded Properties.Get call.
|
||||
func (c *Conn) readVersion(ctx context.Context) (Version, error) {
|
||||
var variant dbus.Variant
|
||||
err := c.call(ctx, basePath, ifaceProperties, "Get", []any{&variant}, ifaceMain, "version")
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
s, _ := variant.Value().(string)
|
||||
return parseVersion(s), nil
|
||||
}
|
||||
|
||||
// Version returns the firewalld version reported by the server.
|
||||
func (c *Conn) Version() Version { return c.version }
|
||||
|
||||
// Supports reports whether the connected server provides a capability.
|
||||
func (c *Conn) Supports(cap Capability) bool { return c.caps[cap] }
|
||||
|
||||
// Close releases the connection. If the bus was dialed by Connect it is closed;
|
||||
// a bus adopted via Open is left open for its owner.
|
||||
func (c *Conn) Close() error {
|
||||
if c.ownConn && c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// call invokes a method on an arbitrary object path/interface and decodes the
|
||||
// return values into rets. It is the single choke point through which every typed
|
||||
// method flows, so error mapping happens in exactly one place.
|
||||
func (c *Conn) call(ctx context.Context, path dbus.ObjectPath, iface, method string, rets []any, args ...any) error {
|
||||
obj := c.conn.Object(dest, path)
|
||||
call := obj.CallWithContext(ctx, iface+"."+method, 0, args...)
|
||||
if call.Err != nil {
|
||||
return mapError(call.Err)
|
||||
}
|
||||
if len(rets) == 0 {
|
||||
return nil
|
||||
}
|
||||
return mapError(call.Store(rets...))
|
||||
}
|
||||
|
||||
// Call is the low-level escape hatch for any method not yet wrapped by a typed
|
||||
// helper. It targets the given object path and interface, passes args verbatim,
|
||||
// and stores results into rets (pointers). Errors are mapped to sentinels.
|
||||
func (c *Conn) Call(ctx context.Context, path dbus.ObjectPath, iface, method string, rets []any, args ...any) error {
|
||||
return c.call(ctx, path, iface, method, rets, args...)
|
||||
}
|
||||
|
||||
// Reload reloads firewalld's permanent configuration into the runtime, keeping
|
||||
// active bindings where possible.
|
||||
func (c *Conn) Reload(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "reload", nil)
|
||||
}
|
||||
|
||||
// CompleteReload reloads and also re-reads interface-to-zone bindings, dropping
|
||||
// active runtime state. Heavier than Reload; use when the permanent set changed
|
||||
// structurally.
|
||||
func (c *Conn) CompleteReload(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "completeReload", nil)
|
||||
}
|
||||
|
||||
// RuntimeToPermanent persists the current runtime configuration as permanent.
|
||||
func (c *Conn) RuntimeToPermanent(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "runtimeToPermanent", nil)
|
||||
}
|
||||
|
||||
// ResetToDefaults resets the permanent configuration to firewalld's defaults.
|
||||
// Requires firewalld >= 1.0; returns ErrUnsupported on older servers.
|
||||
func (c *Conn) ResetToDefaults(ctx context.Context) error {
|
||||
if !c.caps[ResetToDefaults] {
|
||||
return ErrUnsupported
|
||||
}
|
||||
return c.call(ctx, basePath, ifaceMain, "resetToDefaults", nil)
|
||||
}
|
||||
|
||||
// CheckPermanentConfig validates the on-disk permanent configuration, returning a
|
||||
// firewalld error if it is inconsistent.
|
||||
func (c *Conn) CheckPermanentConfig(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "checkPermanentConfig", nil)
|
||||
}
|
||||
|
||||
// Panic-mode controls. Panic mode drops all traffic; callers on a remote bus must
|
||||
// take care not to lock themselves out.
|
||||
|
||||
// EnablePanicMode drops all inbound and outbound packets.
|
||||
func (c *Conn) EnablePanicMode(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "enablePanicMode", nil)
|
||||
}
|
||||
|
||||
// DisablePanicMode restores normal packet processing.
|
||||
func (c *Conn) DisablePanicMode(ctx context.Context) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "disablePanicMode", nil)
|
||||
}
|
||||
|
||||
// QueryPanicMode reports whether panic mode is active.
|
||||
func (c *Conn) QueryPanicMode(ctx context.Context) (bool, error) {
|
||||
var on bool
|
||||
err := c.call(ctx, basePath, ifaceMain, "queryPanicMode", []any{&on})
|
||||
return on, err
|
||||
}
|
||||
|
||||
// LogDenied returns the current LogDenied setting ("off", "all", "unicast", ...).
|
||||
func (c *Conn) LogDenied(ctx context.Context) (string, error) {
|
||||
var s string
|
||||
err := c.call(ctx, basePath, ifaceMain, "getLogDenied", []any{&s})
|
||||
return s, err
|
||||
}
|
||||
|
||||
// SetLogDenied sets the LogDenied value.
|
||||
func (c *Conn) SetLogDenied(ctx context.Context, value string) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "setLogDenied", nil, value)
|
||||
}
|
||||
|
||||
// DefaultZone returns the name of the default zone.
|
||||
func (c *Conn) DefaultZone(ctx context.Context) (string, error) {
|
||||
var s string
|
||||
err := c.call(ctx, basePath, ifaceMain, "getDefaultZone", []any{&s})
|
||||
return s, err
|
||||
}
|
||||
|
||||
// SetDefaultZone sets the default zone by name.
|
||||
func (c *Conn) SetDefaultZone(ctx context.Context, zone string) error {
|
||||
return c.call(ctx, basePath, ifaceMain, "setDefaultZone", nil, zone)
|
||||
}
|
||||
15
go.mod
Normal file
15
go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module github.com/grmrgecko/go-firewalld
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
14
go.sum
Normal file
14
go.sum
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
165
helper.go
Normal file
165
helper.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// HelperSettings is the transport-neutral representation of a conntrack helper. It
|
||||
// maps to the tuple "(sssssa(ss))": version, short, description, family, module,
|
||||
// ports.
|
||||
type HelperSettings struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Family string // "", "ipv4", or "ipv6"
|
||||
Module string // e.g. "nf_conntrack_ftp"
|
||||
Ports []Port
|
||||
}
|
||||
|
||||
// helperTuple is the concrete struct godbus encodes to "(sssssa(ss))".
|
||||
type helperTuple struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Family string
|
||||
Module string
|
||||
Ports []Port
|
||||
}
|
||||
|
||||
func (s *HelperSettings) toTuple() helperTuple {
|
||||
return helperTuple{
|
||||
Version: s.Version,
|
||||
Short: s.Short,
|
||||
Description: s.Description,
|
||||
Family: s.Family,
|
||||
Module: s.Module,
|
||||
Ports: s.Ports,
|
||||
}
|
||||
}
|
||||
|
||||
func helperSettingsFromTuple(raw []any) HelperSettings {
|
||||
return HelperSettings{
|
||||
Version: asString(tupleField(raw, 0)),
|
||||
Short: asString(tupleField(raw, 1)),
|
||||
Description: asString(tupleField(raw, 2)),
|
||||
Family: asString(tupleField(raw, 3)),
|
||||
Module: asString(tupleField(raw, 4)),
|
||||
Ports: asPorts(tupleField(raw, 5)),
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers lists the names of all conntrack helpers known to the runtime.
|
||||
func (c *Conn) Helpers(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := c.call(ctx, basePath, ifaceMain, "getHelpers", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// HelperSettings reads a conntrack helper definition from the running firewall.
|
||||
func (c *Conn) HelperSettings(ctx context.Context, helper string) (HelperSettings, error) {
|
||||
var raw []any
|
||||
if err := c.call(ctx, basePath, ifaceMain, "getHelperSettings", []any{&raw}, helper); err != nil {
|
||||
return HelperSettings{}, err
|
||||
}
|
||||
return helperSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// HelperNames lists the names of all permanent conntrack helpers.
|
||||
func (p *Permanent) HelperNames(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getHelperNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// HelperPaths lists the config object paths of all permanent helpers.
|
||||
func (p *Permanent) HelperPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
|
||||
var paths []dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "listHelpers", []any{&paths})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// AddHelper creates a new permanent conntrack helper and returns its config path.
|
||||
func (p *Permanent) AddHelper(ctx context.Context, name string, settings HelperSettings) (dbus.ObjectPath, error) {
|
||||
var path dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addHelper", []any{&path}, name, settings.toTuple())
|
||||
return path, err
|
||||
}
|
||||
|
||||
// Helper returns a handle for permanent operations on the named helper.
|
||||
func (p *Permanent) Helper(name string) *PermHelper {
|
||||
return &PermHelper{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// PermHelper is a handle for permanent edits to a single conntrack helper,
|
||||
// operating on the helper's config child object (/config/helper/N).
|
||||
type PermHelper struct {
|
||||
c *Conn
|
||||
name string
|
||||
path dbus.ObjectPath
|
||||
}
|
||||
|
||||
func (s *PermHelper) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
if s.path != "" {
|
||||
return s.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := s.c.call(ctx, configPath, ifaceConfig, "getHelperByName", []any{&path}, s.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PermHelper) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := s.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.c.call(ctx, path, ifaceConfigHelper, method, rets, args...)
|
||||
}
|
||||
|
||||
// Settings reads the helper's permanent settings.
|
||||
func (s *PermHelper) Settings(ctx context.Context) (HelperSettings, error) {
|
||||
var raw []any
|
||||
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
|
||||
return HelperSettings{}, err
|
||||
}
|
||||
return helperSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the helper's permanent settings.
|
||||
func (s *PermHelper) Update(ctx context.Context, settings HelperSettings) error {
|
||||
return s.callOn(ctx, "update", nil, settings.toTuple())
|
||||
}
|
||||
|
||||
// SetFamily sets the helper's IP family ("", "ipv4", "ipv6").
|
||||
func (s *PermHelper) SetFamily(ctx context.Context, family string) error {
|
||||
return s.callOn(ctx, "setFamily", nil, family)
|
||||
}
|
||||
|
||||
// SetModule sets the helper's netfilter module name.
|
||||
func (s *PermHelper) SetModule(ctx context.Context, module string) error {
|
||||
return s.callOn(ctx, "setModule", nil, module)
|
||||
}
|
||||
|
||||
// AddPort adds a port to the permanent helper.
|
||||
func (s *PermHelper) AddPort(ctx context.Context, p Port) error {
|
||||
return s.callOn(ctx, "addPort", nil, p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// RemovePort removes a port from the permanent helper.
|
||||
func (s *PermHelper) RemovePort(ctx context.Context, p Port) error {
|
||||
return s.callOn(ctx, "removePort", nil, p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// SetDescription sets the permanent helper description.
|
||||
func (s *PermHelper) SetDescription(ctx context.Context, description string) error {
|
||||
return s.callOn(ctx, "setDescription", nil, description)
|
||||
}
|
||||
|
||||
// Remove deletes the helper from the permanent configuration.
|
||||
func (s *PermHelper) Remove(ctx context.Context) error {
|
||||
return s.callOn(ctx, "remove", nil)
|
||||
}
|
||||
151
icmptype.go
Normal file
151
icmptype.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// ICMPTypeSettings is the transport-neutral representation of an ICMP type. It
|
||||
// maps to the tuple "(sssas)": version, short, description, destinations. The
|
||||
// destinations list holds the IP families the type applies to ("ipv4", "ipv6").
|
||||
type ICMPTypeSettings struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Destinations []string
|
||||
}
|
||||
|
||||
// icmpTuple is the concrete struct godbus encodes to "(sssas)".
|
||||
type icmpTuple struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Destinations []string
|
||||
}
|
||||
|
||||
func (s *ICMPTypeSettings) toTuple() icmpTuple {
|
||||
dst := s.Destinations
|
||||
if dst == nil {
|
||||
dst = []string{}
|
||||
}
|
||||
return icmpTuple{
|
||||
Version: s.Version,
|
||||
Short: s.Short,
|
||||
Description: s.Description,
|
||||
Destinations: dst,
|
||||
}
|
||||
}
|
||||
|
||||
func icmpSettingsFromTuple(raw []any) ICMPTypeSettings {
|
||||
return ICMPTypeSettings{
|
||||
Version: asString(tupleField(raw, 0)),
|
||||
Short: asString(tupleField(raw, 1)),
|
||||
Description: asString(tupleField(raw, 2)),
|
||||
Destinations: asStrings(tupleField(raw, 3)),
|
||||
}
|
||||
}
|
||||
|
||||
// ICMPTypes lists the names of all ICMP types known to the runtime.
|
||||
func (c *Conn) ICMPTypes(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := c.call(ctx, basePath, ifaceMain, "listIcmpTypes", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// ICMPTypeSettings reads an ICMP type definition from the running firewall.
|
||||
func (c *Conn) ICMPTypeSettings(ctx context.Context, icmptype string) (ICMPTypeSettings, error) {
|
||||
var raw []any
|
||||
if err := c.call(ctx, basePath, ifaceMain, "getIcmpTypeSettings", []any{&raw}, icmptype); err != nil {
|
||||
return ICMPTypeSettings{}, err
|
||||
}
|
||||
return icmpSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// ICMPTypeNames lists the names of all permanent ICMP types.
|
||||
func (p *Permanent) ICMPTypeNames(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getIcmpTypeNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// AddICMPType creates a new permanent ICMP type and returns its config path.
|
||||
func (p *Permanent) AddICMPType(ctx context.Context, name string, settings ICMPTypeSettings) (dbus.ObjectPath, error) {
|
||||
var path dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addIcmpType", []any{&path}, name, settings.toTuple())
|
||||
return path, err
|
||||
}
|
||||
|
||||
// ICMPType returns a handle for permanent operations on the named ICMP type.
|
||||
func (p *Permanent) ICMPType(name string) *PermICMPType {
|
||||
return &PermICMPType{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// PermICMPType is a handle for permanent edits to a single ICMP type, operating
|
||||
// on the icmptype's config child object (/config/icmptype/N).
|
||||
type PermICMPType struct {
|
||||
c *Conn
|
||||
name string
|
||||
path dbus.ObjectPath
|
||||
}
|
||||
|
||||
func (s *PermICMPType) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
if s.path != "" {
|
||||
return s.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := s.c.call(ctx, configPath, ifaceConfig, "getIcmpTypeByName", []any{&path}, s.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PermICMPType) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := s.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.c.call(ctx, path, ifaceConfigICMP, method, rets, args...)
|
||||
}
|
||||
|
||||
// Settings reads the ICMP type's permanent settings.
|
||||
func (s *PermICMPType) Settings(ctx context.Context) (ICMPTypeSettings, error) {
|
||||
var raw []any
|
||||
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
|
||||
return ICMPTypeSettings{}, err
|
||||
}
|
||||
return icmpSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the ICMP type's permanent settings.
|
||||
func (s *PermICMPType) Update(ctx context.Context, settings ICMPTypeSettings) error {
|
||||
return s.callOn(ctx, "update", nil, settings.toTuple())
|
||||
}
|
||||
|
||||
// AddDestination adds an IP family ("ipv4"/"ipv6") the type applies to.
|
||||
func (s *PermICMPType) AddDestination(ctx context.Context, family string) error {
|
||||
return s.callOn(ctx, "addDestination", nil, family)
|
||||
}
|
||||
|
||||
// RemoveDestination removes an IP family from the type.
|
||||
func (s *PermICMPType) RemoveDestination(ctx context.Context, family string) error {
|
||||
return s.callOn(ctx, "removeDestination", nil, family)
|
||||
}
|
||||
|
||||
// QueryDestination reports whether the type applies to an IP family.
|
||||
func (s *PermICMPType) QueryDestination(ctx context.Context, family string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.callOn(ctx, "queryDestination", []any{&ok}, family)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// SetDescription sets the permanent ICMP type description.
|
||||
func (s *PermICMPType) SetDescription(ctx context.Context, description string) error {
|
||||
return s.callOn(ctx, "setDescription", nil, description)
|
||||
}
|
||||
|
||||
// Remove deletes the ICMP type from the permanent configuration.
|
||||
func (s *PermICMPType) Remove(ctx context.Context) error {
|
||||
return s.callOn(ctx, "remove", nil)
|
||||
}
|
||||
519
integration_test.go
Normal file
519
integration_test.go
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
//go:build firewalld_integration
|
||||
|
||||
// Integration tests exercised against a live firewalld over the system bus. They
|
||||
// are gated behind the firewalld_integration build tag so the default `go test`
|
||||
// stays bus-free. Run them on each target server (they need firewalld and root):
|
||||
//
|
||||
// go test -c -tags firewalld_integration -o firewalld.itest .
|
||||
// scp firewalld.itest root@<host>:/tmp/ && ssh root@<host> /tmp/firewalld.itest -test.v
|
||||
//
|
||||
// Safety: every test operates on a throwaway zone/ipset/policy it creates and
|
||||
// removes. Nothing here touches the default zone, real interfaces, panic mode, or
|
||||
// lockdown, so a failure cannot cut the SSH session.
|
||||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testCtx returns a context bounded to keep a hung call from stalling the suite.
|
||||
func testCtx(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// dial connects to the live firewalld or skips the test if unavailable.
|
||||
func dial(t *testing.T) *Conn {
|
||||
t.Helper()
|
||||
c, err := Connect(testCtx(t))
|
||||
if err != nil {
|
||||
t.Skipf("no firewalld on the system bus: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
// uniqueName derives a collision-resistant throwaway name.
|
||||
func uniqueName(prefix string) string {
|
||||
return fmt.Sprintf("%s%d", prefix, os.Getpid())
|
||||
}
|
||||
|
||||
func TestIntegrationConnect(t *testing.T) {
|
||||
c := dial(t)
|
||||
v := c.Version()
|
||||
t.Logf("firewalld version %s (major=%d minor=%d)", v.Raw, v.Major, v.Minor)
|
||||
assert.NotEmpty(t, v.Raw)
|
||||
t.Logf("caps: dict=%v policies=%v svc2=%v reset=%v",
|
||||
c.Supports(DictZoneSettings), c.Supports(Policies),
|
||||
c.Supports(ServiceSettings2), c.Supports(ResetToDefaults))
|
||||
}
|
||||
|
||||
func TestIntegrationDefaultZoneAndList(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
dz, err := c.DefaultZone(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dz)
|
||||
t.Logf("default zone: %s", dz)
|
||||
|
||||
names, err := c.Permanent().ZoneNames(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, names, "public")
|
||||
}
|
||||
|
||||
// TestIntegrationZoneSettingsTransport reads the public zone through whichever
|
||||
// transport the server supports (tuple on EL7, dict on EL9+) and sanity-checks it.
|
||||
func TestIntegrationZoneSettingsTransport(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
s, err := c.Permanent().Zone("public").Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Public", s.Short)
|
||||
assert.Equal(t, TargetDefault, s.Target)
|
||||
t.Logf("public services=%v ports=%v forward=%v", s.Services, s.Ports, s.Forward)
|
||||
|
||||
// The runtime read path too.
|
||||
rs, err := c.Runtime().Settings(ctx, "public")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Public", rs.Short)
|
||||
}
|
||||
|
||||
// TestIntegrationPermanentZoneLifecycle is the core round-trip: create a zone,
|
||||
// mutate every element kind, read it back through Settings, then remove it.
|
||||
func TestIntegrationPermanentZoneLifecycle(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwz")
|
||||
|
||||
// Clean any leftover from a previous crashed run, then create fresh.
|
||||
_ = perm.Zone(name).Remove(ctx)
|
||||
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name, Target: TargetDefault})
|
||||
require.NoError(t, err, "addZone")
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Zone(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
z := perm.Zone(name)
|
||||
require.NoError(t, z.SetTarget(ctx, TargetDROP))
|
||||
require.NoError(t, z.AddPort(ctx, Port{Port: "4242", Protocol: "udp"}))
|
||||
require.NoError(t, z.AddPort(ctx, Port{Port: "8080", Protocol: "tcp"}))
|
||||
require.NoError(t, z.AddSourcePort(ctx, Port{Port: "1024", Protocol: "tcp"}))
|
||||
require.NoError(t, z.AddSource(ctx, "10.9.8.0/24"))
|
||||
require.NoError(t, z.AddService(ctx, "ssh"))
|
||||
require.NoError(t, z.AddProtocol(ctx, "gre"))
|
||||
require.NoError(t, z.AddMasquerade(ctx))
|
||||
require.NoError(t, z.AddForwardPort(ctx, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"}))
|
||||
require.NoError(t, z.AddRichRule(ctx, `rule family="ipv4" source address="10.9.8.0/24" reject`))
|
||||
|
||||
// Read everything back through the settings decoder.
|
||||
s, err := z.Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TargetDROP, s.Target)
|
||||
assert.Contains(t, s.Ports, Port{Port: "4242", Protocol: "udp"})
|
||||
assert.Contains(t, s.Ports, Port{Port: "8080", Protocol: "tcp"})
|
||||
assert.Contains(t, s.SourcePorts, Port{Port: "1024", Protocol: "tcp"})
|
||||
assert.Contains(t, s.Sources, "10.9.8.0/24")
|
||||
assert.Contains(t, s.Services, "ssh")
|
||||
assert.Contains(t, s.Protocols, "gre")
|
||||
assert.True(t, s.Masquerade)
|
||||
assert.Contains(t, s.ForwardPorts, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"})
|
||||
assert.Len(t, s.RichRules, 1)
|
||||
|
||||
// Query helpers agree with the settings snapshot.
|
||||
ok, err := z.QueryPort(ctx, Port{Port: "4242", Protocol: "udp"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
// Remove a port and confirm it is gone.
|
||||
require.NoError(t, z.RemovePort(ctx, Port{Port: "8080", Protocol: "tcp"}))
|
||||
ok, err = z.QueryPort(ctx, Port{Port: "8080", Protocol: "tcp"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// TestIntegrationUpdateWholesale verifies the Update path (tuple or dict) writes a
|
||||
// full settings struct that reads back intact.
|
||||
func TestIntegrationUpdateWholesale(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwu")
|
||||
|
||||
_ = perm.Zone(name).Remove(ctx)
|
||||
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Zone(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
want := ZoneSettings{
|
||||
Short: name,
|
||||
Description: "wholesale update",
|
||||
Target: TargetACCEPT,
|
||||
Services: []string{"ssh", "http"},
|
||||
Ports: []Port{{Port: "5353", Protocol: "udp"}},
|
||||
Masquerade: true,
|
||||
}
|
||||
require.NoError(t, perm.Zone(name).Update(ctx, want))
|
||||
|
||||
got, err := perm.Zone(name).Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TargetACCEPT, got.Target)
|
||||
assert.Equal(t, "wholesale update", got.Description)
|
||||
assert.ElementsMatch(t, want.Services, got.Services)
|
||||
assert.Contains(t, got.Ports, Port{Port: "5353", Protocol: "udp"})
|
||||
assert.True(t, got.Masquerade)
|
||||
}
|
||||
|
||||
// TestIntegrationRuntimeZone exercises transient edits with a timeout against a
|
||||
// throwaway zone that has been reloaded into the runtime.
|
||||
func TestIntegrationRuntimeZone(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwr")
|
||||
|
||||
_ = perm.Zone(name).Remove(ctx)
|
||||
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Reload(ctx)) // materialise the zone at runtime
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Zone(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
rz := c.Runtime().Zone(name)
|
||||
require.NoError(t, rz.AddPort(ctx, Port{Port: "7000", Protocol: "tcp"}, 0))
|
||||
ports, err := rz.Ports(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, ports, Port{Port: "7000", Protocol: "tcp"})
|
||||
|
||||
ok, err := rz.QueryPort(ctx, Port{Port: "7000", Protocol: "tcp"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
require.NoError(t, rz.RemovePort(ctx, Port{Port: "7000", Protocol: "tcp"}))
|
||||
}
|
||||
|
||||
// TestIntegrationIdempotency confirms firewalld's idempotency signals map to the
|
||||
// sentinels callers rely on.
|
||||
func TestIntegrationIdempotency(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwi")
|
||||
|
||||
_ = perm.Zone(name).Remove(ctx)
|
||||
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Zone(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
z := perm.Zone(name)
|
||||
require.NoError(t, z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"}))
|
||||
|
||||
// Adding the same port again is ALREADY_ENABLED.
|
||||
err = z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"})
|
||||
assert.ErrorIs(t, err, ErrAlreadyEnabled)
|
||||
|
||||
// Removing a port that is not present is NOT_ENABLED.
|
||||
err = z.RemovePort(ctx, Port{Port: "1234", Protocol: "tcp"})
|
||||
assert.ErrorIs(t, err, ErrNotEnabled)
|
||||
}
|
||||
|
||||
// TestIntegrationErrorMapping checks a precise INVALID_* mapping.
|
||||
func TestIntegrationErrorMapping(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
_, err := c.Permanent().Zone("definitely-not-a-zone").Settings(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInvalidZone)
|
||||
}
|
||||
|
||||
func TestIntegrationIPSetLifecycle(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwset")
|
||||
|
||||
_ = perm.IPSet(name).Remove(ctx)
|
||||
_, err := perm.AddIPSet(ctx, name, IPSetSettings{
|
||||
Type: "hash:ip",
|
||||
Options: map[string]string{"family": "inet"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = perm.IPSet(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
set := perm.IPSet(name)
|
||||
require.NoError(t, set.AddEntry(ctx, "10.7.7.1"))
|
||||
require.NoError(t, set.AddEntry(ctx, "10.7.7.2"))
|
||||
|
||||
entries, err := set.Entries(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"10.7.7.1", "10.7.7.2"}, entries)
|
||||
|
||||
s, err := set.Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hash:ip", s.Type)
|
||||
assert.Equal(t, "inet", s.Options["family"])
|
||||
|
||||
require.NoError(t, set.RemoveEntry(ctx, "10.7.7.1"))
|
||||
ok, err := set.QueryEntry(ctx, "10.7.7.1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestIntegrationServiceRead(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
s, err := c.ServiceSettings(ctx, "ssh")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, s.Ports, Port{Port: "22", Protocol: "tcp"})
|
||||
}
|
||||
|
||||
func TestIntegrationICMPTypeRead(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
names, err := c.ICMPTypes(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, names)
|
||||
|
||||
s, err := c.ICMPTypeSettings(ctx, "echo-request")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, s.Short)
|
||||
}
|
||||
|
||||
// TestIntegrationPolicyLifecycle is skipped on servers without policy support.
|
||||
func TestIntegrationPolicyLifecycle(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
if !c.Supports(Policies) {
|
||||
t.Skip("server predates zone-to-zone policies")
|
||||
}
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwp")
|
||||
|
||||
_ = perm.Policy(name).Remove(ctx)
|
||||
_, err := perm.AddPolicy(ctx, name, PolicySettings{
|
||||
Target: "CONTINUE",
|
||||
Priority: -1,
|
||||
IngressZones: []string{"public"},
|
||||
EgressZones: []string{"HOST"},
|
||||
Ports: []Port{{Port: "6000", Protocol: "tcp"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Policy(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
s, err := perm.Policy(name).Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "CONTINUE", s.Target)
|
||||
assert.Equal(t, int32(-1), s.Priority)
|
||||
assert.Contains(t, s.IngressZones, "public")
|
||||
assert.Contains(t, s.Ports, Port{Port: "6000", Protocol: "tcp"})
|
||||
|
||||
// Reload so the permanent policy appears in the runtime, then exercise the
|
||||
// runtime .policy interface. These calls target the .policy interface (not
|
||||
// .zone); a regression to the wrong interface would fault here with
|
||||
// UnknownMethod rather than returning the policy.
|
||||
require.NoError(t, c.Reload(ctx))
|
||||
names, err := c.Runtime().Policies(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, names, name)
|
||||
|
||||
rs, err := c.Runtime().PolicySettings(ctx, name)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "CONTINUE", rs.Target)
|
||||
assert.Contains(t, rs.IngressZones, "public")
|
||||
|
||||
_, err = c.Runtime().ActivePolicies(ctx)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestIntegrationDirect exercises the direct interface with a throwaway custom
|
||||
// chain and rule, cleaning up after itself.
|
||||
func TestIntegrationDirect(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
d := c.Direct()
|
||||
chain := DirectChain{IPV: "ipv4", Table: "filter", Chain: uniqueName("GOFW")}
|
||||
|
||||
_ = d.RemoveChain(ctx, chain)
|
||||
require.NoError(t, d.AddChain(ctx, chain))
|
||||
t.Cleanup(func() {
|
||||
_ = d.RemoveRules(context.Background(), chain.IPV, chain.Table, chain.Chain)
|
||||
_ = d.RemoveChain(context.Background(), chain)
|
||||
})
|
||||
|
||||
ok, err := d.QueryChain(ctx, chain)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
chains, err := d.AllChains(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, chains, chain)
|
||||
|
||||
rule := DirectRule{IPV: "ipv4", Table: "filter", Chain: chain.Chain, Priority: 0, Args: []string{"-j", "ACCEPT"}}
|
||||
require.NoError(t, d.AddRule(ctx, rule))
|
||||
|
||||
ok, err = d.QueryRule(ctx, rule)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
rules, err := d.Rules(ctx, chain.IPV, chain.Table, chain.Chain)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 1)
|
||||
assert.Equal(t, []string{"-j", "ACCEPT"}, rules[0].Args)
|
||||
|
||||
require.NoError(t, d.RemoveRule(ctx, rule))
|
||||
}
|
||||
|
||||
// TestIntegrationSignals subscribes to firewalld signals and confirms a Reloaded
|
||||
// event arrives after an explicit reload.
|
||||
func TestIntegrationSignals(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
reloaded, err := c.WatchReloaded(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Give the match rule a moment to register before triggering.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
require.NoError(t, c.Reload(ctx))
|
||||
|
||||
select {
|
||||
case <-reloaded:
|
||||
// success
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("did not receive Reloaded signal within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationProperties reads the runtime state and daemon config properties.
|
||||
func TestIntegrationProperties(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
state, err := c.State(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "RUNNING", state)
|
||||
|
||||
info, err := c.RuntimeInfo(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "RUNNING", info.State)
|
||||
t.Logf("runtime: ipv4=%v ipv6=%v ipset=%v bridge=%v ipsetTypes=%d",
|
||||
info.IPv4, info.IPv6, info.IPSet, info.Bridge, len(info.IPSetTypes))
|
||||
|
||||
dc, err := c.Permanent().DaemonConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dc.DefaultZone)
|
||||
t.Logf("daemon: backend=%q defaultZone=%q ipv6rpfilter=%q cleanup=%q",
|
||||
dc.FirewallBackend, dc.DefaultZone, dc.IPv6RPFilter, dc.CleanupOnExit)
|
||||
t.Logf("daemon 2.x knobs: nftCounters=%q nftFlowtable=%q strictForwardPorts=%q",
|
||||
dc.NftablesCounters, dc.NftablesFlowtable, dc.StrictForwardPorts)
|
||||
}
|
||||
|
||||
// TestIntegrationServiceIncludes exercises the service includes editor on a
|
||||
// throwaway service (firewalld >= 1.0).
|
||||
func TestIntegrationServiceIncludes(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
if !c.Supports(ServiceSettings2) {
|
||||
t.Skip("server predates service includes")
|
||||
}
|
||||
perm := c.Permanent()
|
||||
name := uniqueName("gofwsvc")
|
||||
|
||||
_ = perm.Service(name).Remove(ctx)
|
||||
_, err := perm.AddService(ctx, name, ServiceSettings{Short: name})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = perm.Service(name).Remove(context.Background())
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
svc := perm.Service(name)
|
||||
require.NoError(t, svc.AddPort(ctx, Port{Port: "1717", Protocol: "tcp"}))
|
||||
require.NoError(t, svc.AddInclude(ctx, "ssh"))
|
||||
|
||||
ok, err := svc.QueryInclude(ctx, "ssh")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
incs, err := svc.Includes(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, incs, "ssh")
|
||||
|
||||
got, err := svc.Settings(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, got.Ports, Port{Port: "1717", Protocol: "tcp"})
|
||||
}
|
||||
|
||||
// TestIntegrationChangeZoneOfSource confirms a source can be moved between zones
|
||||
// at runtime without an ALREADY-bound error.
|
||||
func TestIntegrationChangeZoneOfSource(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
perm := c.Permanent()
|
||||
za, zb := uniqueName("gofwca"), uniqueName("gofwcb")
|
||||
source := "10.44.44.0/24"
|
||||
|
||||
for _, n := range []string{za, zb} {
|
||||
_ = perm.Zone(n).Remove(ctx)
|
||||
_, err := perm.AddZone(ctx, n, ZoneSettings{Short: n})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, c.Reload(ctx))
|
||||
t.Cleanup(func() {
|
||||
for _, n := range []string{za, zb} {
|
||||
_ = perm.Zone(n).Remove(context.Background())
|
||||
}
|
||||
_ = c.Reload(context.Background())
|
||||
})
|
||||
|
||||
require.NoError(t, c.Runtime().Zone(za).AddSource(ctx, source))
|
||||
// Move it to zb; ChangeSource must succeed even though it is bound to za.
|
||||
require.NoError(t, c.Runtime().Zone(zb).ChangeSource(ctx, source))
|
||||
|
||||
zone, err := c.Runtime().ZoneOfSource(ctx, source)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, zb, zone)
|
||||
}
|
||||
|
||||
// TestIntegrationUnsupportedGuards ensures the library refuses policy calls on old
|
||||
// servers rather than emitting an UnknownMethod fault.
|
||||
func TestIntegrationUnsupportedGuards(t *testing.T) {
|
||||
c := dial(t)
|
||||
ctx := testCtx(t)
|
||||
if c.Supports(Policies) {
|
||||
t.Skip("server supports policies; nothing to guard")
|
||||
}
|
||||
_, err := c.Runtime().Policies(ctx)
|
||||
assert.True(t, errors.Is(err, ErrUnsupported))
|
||||
}
|
||||
252
ipset.go
Normal file
252
ipset.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// IPSetSettings is the transport-neutral representation of an ipset. It maps to
|
||||
// the D-Bus tuple "(ssssa{ss}as)": version, name, description, type, options,
|
||||
// entries. ipsets have no dict form; the tuple is used on every server.
|
||||
type IPSetSettings struct {
|
||||
Version string
|
||||
Name string
|
||||
Description string
|
||||
Type string // e.g. "hash:ip", "hash:net"
|
||||
Options map[string]string // e.g. {"family": "inet"}
|
||||
Entries []string
|
||||
}
|
||||
|
||||
// ipsetTuple is the concrete struct godbus encodes to "(ssssa{ss}as)". Field
|
||||
// order matches firewalld's ipset settings tuple exactly.
|
||||
type ipsetTuple struct {
|
||||
Version string
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
Options map[string]string
|
||||
Entries []string
|
||||
}
|
||||
|
||||
// toTuple builds the wire tuple, defaulting nil maps/slices to empty.
|
||||
func (s *IPSetSettings) toTuple() ipsetTuple {
|
||||
opts := s.Options
|
||||
if opts == nil {
|
||||
opts = map[string]string{}
|
||||
}
|
||||
entries := s.Entries
|
||||
if entries == nil {
|
||||
entries = []string{}
|
||||
}
|
||||
return ipsetTuple{
|
||||
Version: s.Version,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Type: s.Type,
|
||||
Options: opts,
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
// ipsetSettingsFromTuple decodes a raw ipset tuple, length-tolerant throughout.
|
||||
func ipsetSettingsFromTuple(raw []any) IPSetSettings {
|
||||
return IPSetSettings{
|
||||
Version: asString(tupleField(raw, 0)),
|
||||
Name: asString(tupleField(raw, 1)),
|
||||
Description: asString(tupleField(raw, 2)),
|
||||
Type: asString(tupleField(raw, 3)),
|
||||
Options: asStringMap(tupleField(raw, 4)),
|
||||
Entries: asStrings(tupleField(raw, 5)),
|
||||
}
|
||||
}
|
||||
|
||||
// IPSetNames lists the names of all permanent ipsets.
|
||||
func (p *Permanent) IPSetNames(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getIPSetNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// AddIPSet creates a new permanent ipset from the given settings and returns its
|
||||
// config object path.
|
||||
func (p *Permanent) AddIPSet(ctx context.Context, name string, settings IPSetSettings) (dbus.ObjectPath, error) {
|
||||
var path dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addIPSet", []any{&path}, name, settings.toTuple())
|
||||
return path, err
|
||||
}
|
||||
|
||||
// IPSet returns a handle for permanent operations on the named ipset.
|
||||
func (p *Permanent) IPSet(name string) *PermIPSet {
|
||||
return &PermIPSet{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// IPSets lists the names of ipsets known to the runtime.
|
||||
func (r *Runtime) IPSets(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := r.c.call(ctx, basePath, ifaceIPSet, "getIPSets", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// IPSetExists reports whether an ipset of the given name exists at runtime.
|
||||
func (r *Runtime) IPSetExists(ctx context.Context, name string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.c.call(ctx, basePath, ifaceIPSet, "queryIPSet", []any{&ok}, name)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// IPSetPaths lists the config object paths of all permanent ipsets (listIPSets).
|
||||
func (p *Permanent) IPSetPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
|
||||
var paths []dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "listIPSets", []any{&paths})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// IPSet returns a handle for runtime operations on the named ipset.
|
||||
func (r *Runtime) IPSet(name string) *RuntimeIPSet {
|
||||
return &RuntimeIPSet{c: r.c, name: name}
|
||||
}
|
||||
|
||||
// RuntimeIPSet is a handle for transient ipset entry operations on the running
|
||||
// firewall. ipset runtime methods live on the main object's .ipset interface.
|
||||
type RuntimeIPSet struct {
|
||||
c *Conn
|
||||
name string
|
||||
}
|
||||
|
||||
// Settings reads the runtime settings of the ipset.
|
||||
func (s *RuntimeIPSet) Settings(ctx context.Context) (IPSetSettings, error) {
|
||||
var raw []any
|
||||
if err := s.c.call(ctx, basePath, ifaceIPSet, "getIPSetSettings", []any{&raw}, s.name); err != nil {
|
||||
return IPSetSettings{}, err
|
||||
}
|
||||
return ipsetSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// AddEntry adds a single entry to the ipset at runtime.
|
||||
func (s *RuntimeIPSet) AddEntry(ctx context.Context, entry string) error {
|
||||
return s.c.call(ctx, basePath, ifaceIPSet, "addEntry", nil, s.name, entry)
|
||||
}
|
||||
|
||||
// RemoveEntry removes a single entry from the ipset at runtime.
|
||||
func (s *RuntimeIPSet) RemoveEntry(ctx context.Context, entry string) error {
|
||||
return s.c.call(ctx, basePath, ifaceIPSet, "removeEntry", nil, s.name, entry)
|
||||
}
|
||||
|
||||
// QueryEntry reports whether an entry is present at runtime.
|
||||
func (s *RuntimeIPSet) QueryEntry(ctx context.Context, entry string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.c.call(ctx, basePath, ifaceIPSet, "queryEntry", []any{&ok}, s.name, entry)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Entries returns all entries of the ipset at runtime.
|
||||
func (s *RuntimeIPSet) Entries(ctx context.Context) ([]string, error) {
|
||||
var entries []string
|
||||
err := s.c.call(ctx, basePath, ifaceIPSet, "getEntries", []any{&entries}, s.name)
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// SetEntries replaces all entries of the ipset at runtime.
|
||||
func (s *RuntimeIPSet) SetEntries(ctx context.Context, entries []string) error {
|
||||
if entries == nil {
|
||||
entries = []string{}
|
||||
}
|
||||
return s.c.call(ctx, basePath, ifaceIPSet, "setEntries", nil, s.name, entries)
|
||||
}
|
||||
|
||||
// PermIPSet is a handle for permanent edits to a single ipset, operating on the
|
||||
// ipset's config child object (/config/ipset/N).
|
||||
type PermIPSet struct {
|
||||
c *Conn
|
||||
name string
|
||||
path dbus.ObjectPath
|
||||
}
|
||||
|
||||
// resolve looks up and caches the config object path for the ipset.
|
||||
func (s *PermIPSet) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
if s.path != "" {
|
||||
return s.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := s.c.call(ctx, configPath, ifaceConfig, "getIPSetByName", []any{&path}, s.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// callOn resolves the ipset path and invokes a config.ipset method.
|
||||
func (s *PermIPSet) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := s.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.c.call(ctx, path, ifaceConfigIPSet, method, rets, args...)
|
||||
}
|
||||
|
||||
// Settings reads the ipset's permanent settings.
|
||||
func (s *PermIPSet) Settings(ctx context.Context) (IPSetSettings, error) {
|
||||
var raw []any
|
||||
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
|
||||
return IPSetSettings{}, err
|
||||
}
|
||||
return ipsetSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the ipset's permanent settings wholesale.
|
||||
func (s *PermIPSet) Update(ctx context.Context, settings IPSetSettings) error {
|
||||
return s.callOn(ctx, "update", nil, settings.toTuple())
|
||||
}
|
||||
|
||||
// AddEntry adds an entry to the permanent ipset.
|
||||
func (s *PermIPSet) AddEntry(ctx context.Context, entry string) error {
|
||||
return s.callOn(ctx, "addEntry", nil, entry)
|
||||
}
|
||||
|
||||
// RemoveEntry removes an entry from the permanent ipset.
|
||||
func (s *PermIPSet) RemoveEntry(ctx context.Context, entry string) error {
|
||||
return s.callOn(ctx, "removeEntry", nil, entry)
|
||||
}
|
||||
|
||||
// QueryEntry reports whether an entry is present in the permanent ipset.
|
||||
func (s *PermIPSet) QueryEntry(ctx context.Context, entry string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.callOn(ctx, "queryEntry", []any{&ok}, entry)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Entries returns all entries of the permanent ipset.
|
||||
func (s *PermIPSet) Entries(ctx context.Context) ([]string, error) {
|
||||
var entries []string
|
||||
err := s.callOn(ctx, "getEntries", []any{&entries})
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// SetEntries replaces all entries of the permanent ipset.
|
||||
func (s *PermIPSet) SetEntries(ctx context.Context, entries []string) error {
|
||||
if entries == nil {
|
||||
entries = []string{}
|
||||
}
|
||||
return s.callOn(ctx, "setEntries", nil, entries)
|
||||
}
|
||||
|
||||
// AddOption sets an option (key/value) on the permanent ipset.
|
||||
func (s *PermIPSet) AddOption(ctx context.Context, key, value string) error {
|
||||
return s.callOn(ctx, "addOption", nil, key, value)
|
||||
}
|
||||
|
||||
// RemoveOption removes an option from the permanent ipset.
|
||||
func (s *PermIPSet) RemoveOption(ctx context.Context, key string) error {
|
||||
return s.callOn(ctx, "removeOption", nil, key)
|
||||
}
|
||||
|
||||
// SetDescription sets the permanent ipset description.
|
||||
func (s *PermIPSet) SetDescription(ctx context.Context, description string) error {
|
||||
return s.callOn(ctx, "setDescription", nil, description)
|
||||
}
|
||||
|
||||
// Remove deletes the ipset from the permanent configuration.
|
||||
func (s *PermIPSet) Remove(ctx context.Context) error {
|
||||
return s.callOn(ctx, "remove", nil)
|
||||
}
|
||||
101
lockdown.go
Normal file
101
lockdown.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package firewalld
|
||||
|
||||
import "context"
|
||||
|
||||
// Lockdown controls firewalld's lockdown feature and its whitelist. Lockdown, when
|
||||
// enabled, restricts which applications may change the firewall; the whitelist
|
||||
// names the commands, contexts, users, and uids that remain allowed. These live on
|
||||
// the main object's .policies interface (distinct from zone-to-zone policies).
|
||||
type Lockdown struct{ c *Conn }
|
||||
|
||||
// Lockdown returns the lockdown namespace.
|
||||
func (c *Conn) Lockdown() *Lockdown { return &Lockdown{c: c} }
|
||||
|
||||
// Enable turns lockdown on.
|
||||
func (l *Lockdown) Enable(ctx context.Context) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "enableLockdown", nil)
|
||||
}
|
||||
|
||||
// Disable turns lockdown off.
|
||||
func (l *Lockdown) Disable(ctx context.Context) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "disableLockdown", nil)
|
||||
}
|
||||
|
||||
// Query reports whether lockdown is currently enabled.
|
||||
func (l *Lockdown) Query(ctx context.Context) (bool, error) {
|
||||
var on bool
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdown", []any{&on})
|
||||
return on, err
|
||||
}
|
||||
|
||||
// Whitelist command operations. A command entry may end in "*" to match a prefix.
|
||||
func (l *Lockdown) AddCommand(ctx context.Context, command string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistCommand", nil, command)
|
||||
}
|
||||
func (l *Lockdown) RemoveCommand(ctx context.Context, command string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistCommand", nil, command)
|
||||
}
|
||||
func (l *Lockdown) QueryCommand(ctx context.Context, command string) (bool, error) {
|
||||
var ok bool
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistCommand", []any{&ok}, command)
|
||||
return ok, err
|
||||
}
|
||||
func (l *Lockdown) Commands(ctx context.Context) ([]string, error) {
|
||||
var xs []string
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistCommands", []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// Whitelist context (SELinux) operations.
|
||||
func (l *Lockdown) AddContext(ctx context.Context, selinuxContext string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistContext", nil, selinuxContext)
|
||||
}
|
||||
func (l *Lockdown) RemoveContext(ctx context.Context, selinuxContext string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistContext", nil, selinuxContext)
|
||||
}
|
||||
func (l *Lockdown) QueryContext(ctx context.Context, selinuxContext string) (bool, error) {
|
||||
var ok bool
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistContext", []any{&ok}, selinuxContext)
|
||||
return ok, err
|
||||
}
|
||||
func (l *Lockdown) Contexts(ctx context.Context) ([]string, error) {
|
||||
var xs []string
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistContexts", []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// Whitelist user operations (by user name).
|
||||
func (l *Lockdown) AddUser(ctx context.Context, user string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistUser", nil, user)
|
||||
}
|
||||
func (l *Lockdown) RemoveUser(ctx context.Context, user string) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistUser", nil, user)
|
||||
}
|
||||
func (l *Lockdown) QueryUser(ctx context.Context, user string) (bool, error) {
|
||||
var ok bool
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistUser", []any{&ok}, user)
|
||||
return ok, err
|
||||
}
|
||||
func (l *Lockdown) Users(ctx context.Context) ([]string, error) {
|
||||
var xs []string
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistUsers", []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// Whitelist uid operations (by numeric uid).
|
||||
func (l *Lockdown) AddUID(ctx context.Context, uid int32) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "addLockdownWhitelistUid", nil, uid)
|
||||
}
|
||||
func (l *Lockdown) RemoveUID(ctx context.Context, uid int32) error {
|
||||
return l.c.call(ctx, basePath, ifaceLockdown, "removeLockdownWhitelistUid", nil, uid)
|
||||
}
|
||||
func (l *Lockdown) QueryUID(ctx context.Context, uid int32) (bool, error) {
|
||||
var ok bool
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "queryLockdownWhitelistUid", []any{&ok}, uid)
|
||||
return ok, err
|
||||
}
|
||||
func (l *Lockdown) UIDs(ctx context.Context) ([]int32, error) {
|
||||
var xs []int32
|
||||
err := l.c.call(ctx, basePath, ifaceLockdown, "getLockdownWhitelistUids", []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
269
permzone.go
Normal file
269
permzone.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// PermZone is a handle for permanent edits to a single zone, operating on the
|
||||
// zone's config child object (/config/zone/N). The object path is resolved once
|
||||
// from the zone name and cached. Permanent element methods take no timeout and
|
||||
// return nothing; changes apply after a Reload.
|
||||
type PermZone struct {
|
||||
c *Conn
|
||||
name string
|
||||
|
||||
mu sync.Mutex
|
||||
path dbus.ObjectPath // cached; resolved lazily via getZoneByName
|
||||
}
|
||||
|
||||
// Name returns the zone name this handle targets.
|
||||
func (z *PermZone) Name() string { return z.name }
|
||||
|
||||
// resolve returns the cached config object path, looking it up on first use.
|
||||
func (z *PermZone) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
if z.path != "" {
|
||||
return z.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := z.c.call(ctx, configPath, ifaceConfig, "getZoneByName", []any{&path}, z.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
z.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// callOn resolves the zone path and invokes a config.zone method against it.
|
||||
func (z *PermZone) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := z.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return z.c.call(ctx, path, ifaceConfigZone, method, rets, args...)
|
||||
}
|
||||
|
||||
func (z *PermZone) add(ctx context.Context, method string, args ...any) error {
|
||||
return z.callOn(ctx, method, nil, args...)
|
||||
}
|
||||
|
||||
func (z *PermZone) query(ctx context.Context, method string, args ...any) (bool, error) {
|
||||
var ok bool
|
||||
err := z.callOn(ctx, method, []any{&ok}, args...)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (z *PermZone) getStrings(ctx context.Context, method string) ([]string, error) {
|
||||
var xs []string
|
||||
err := z.callOn(ctx, method, []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// Settings reads the zone's permanent settings, choosing the dict transport
|
||||
// (getSettings2) when supported and falling back to the v1 tuple (getSettings).
|
||||
func (z *PermZone) Settings(ctx context.Context) (ZoneSettings, error) {
|
||||
if z.c.caps[DictZoneSettings] {
|
||||
var d map[string]dbus.Variant
|
||||
if err := z.callOn(ctx, "getSettings2", []any{&d}); err != nil {
|
||||
return ZoneSettings{}, err
|
||||
}
|
||||
return zoneSettingsFromDict(d), nil
|
||||
}
|
||||
var raw []any
|
||||
if err := z.callOn(ctx, "getSettings", []any{&raw}); err != nil {
|
||||
return ZoneSettings{}, err
|
||||
}
|
||||
return zoneSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the zone's permanent settings wholesale, using update2 (dict)
|
||||
// when supported and update (v1 tuple) otherwise.
|
||||
func (z *PermZone) Update(ctx context.Context, settings ZoneSettings) error {
|
||||
if z.c.caps[DictZoneSettings] {
|
||||
return z.callOn(ctx, "update2", nil, settings.toDict())
|
||||
}
|
||||
return z.callOn(ctx, "update", nil, settings.toTuple())
|
||||
}
|
||||
|
||||
// Port operations.
|
||||
func (z *PermZone) AddPort(ctx context.Context, p Port) error {
|
||||
return z.add(ctx, "addPort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *PermZone) RemovePort(ctx context.Context, p Port) error {
|
||||
return z.add(ctx, "removePort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *PermZone) QueryPort(ctx context.Context, p Port) (bool, error) {
|
||||
return z.query(ctx, "queryPort", p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// Protocol operations.
|
||||
func (z *PermZone) AddProtocol(ctx context.Context, proto string) error {
|
||||
return z.add(ctx, "addProtocol", proto)
|
||||
}
|
||||
func (z *PermZone) RemoveProtocol(ctx context.Context, proto string) error {
|
||||
return z.add(ctx, "removeProtocol", proto)
|
||||
}
|
||||
func (z *PermZone) QueryProtocol(ctx context.Context, proto string) (bool, error) {
|
||||
return z.query(ctx, "queryProtocol", proto)
|
||||
}
|
||||
|
||||
// SourcePort operations.
|
||||
func (z *PermZone) AddSourcePort(ctx context.Context, p Port) error {
|
||||
return z.add(ctx, "addSourcePort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *PermZone) RemoveSourcePort(ctx context.Context, p Port) error {
|
||||
return z.add(ctx, "removeSourcePort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *PermZone) QuerySourcePort(ctx context.Context, p Port) (bool, error) {
|
||||
return z.query(ctx, "querySourcePort", p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// Service operations.
|
||||
func (z *PermZone) AddService(ctx context.Context, service string) error {
|
||||
return z.add(ctx, "addService", service)
|
||||
}
|
||||
func (z *PermZone) RemoveService(ctx context.Context, service string) error {
|
||||
return z.add(ctx, "removeService", service)
|
||||
}
|
||||
func (z *PermZone) QueryService(ctx context.Context, service string) (bool, error) {
|
||||
return z.query(ctx, "queryService", service)
|
||||
}
|
||||
|
||||
// Source operations.
|
||||
func (z *PermZone) AddSource(ctx context.Context, source string) error {
|
||||
return z.add(ctx, "addSource", source)
|
||||
}
|
||||
func (z *PermZone) RemoveSource(ctx context.Context, source string) error {
|
||||
return z.add(ctx, "removeSource", source)
|
||||
}
|
||||
func (z *PermZone) QuerySource(ctx context.Context, source string) (bool, error) {
|
||||
return z.query(ctx, "querySource", source)
|
||||
}
|
||||
|
||||
// RichRule operations.
|
||||
func (z *PermZone) AddRichRule(ctx context.Context, rule string) error {
|
||||
return z.add(ctx, "addRichRule", rule)
|
||||
}
|
||||
func (z *PermZone) RemoveRichRule(ctx context.Context, rule string) error {
|
||||
return z.add(ctx, "removeRichRule", rule)
|
||||
}
|
||||
func (z *PermZone) QueryRichRule(ctx context.Context, rule string) (bool, error) {
|
||||
return z.query(ctx, "queryRichRule", rule)
|
||||
}
|
||||
|
||||
// ForwardPort operations.
|
||||
func (z *PermZone) AddForwardPort(ctx context.Context, fp ForwardPort) error {
|
||||
return z.add(ctx, "addForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
func (z *PermZone) RemoveForwardPort(ctx context.Context, fp ForwardPort) error {
|
||||
return z.add(ctx, "removeForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
func (z *PermZone) QueryForwardPort(ctx context.Context, fp ForwardPort) (bool, error) {
|
||||
return z.query(ctx, "queryForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
|
||||
// Masquerade operations.
|
||||
func (z *PermZone) AddMasquerade(ctx context.Context) error {
|
||||
return z.add(ctx, "addMasquerade")
|
||||
}
|
||||
func (z *PermZone) RemoveMasquerade(ctx context.Context) error {
|
||||
return z.add(ctx, "removeMasquerade")
|
||||
}
|
||||
func (z *PermZone) QueryMasquerade(ctx context.Context) (bool, error) {
|
||||
return z.query(ctx, "queryMasquerade")
|
||||
}
|
||||
|
||||
// IcmpBlock operations.
|
||||
func (z *PermZone) AddIcmpBlock(ctx context.Context, icmptype string) error {
|
||||
return z.add(ctx, "addIcmpBlock", icmptype)
|
||||
}
|
||||
func (z *PermZone) RemoveIcmpBlock(ctx context.Context, icmptype string) error {
|
||||
return z.add(ctx, "removeIcmpBlock", icmptype)
|
||||
}
|
||||
func (z *PermZone) QueryIcmpBlock(ctx context.Context, icmptype string) (bool, error) {
|
||||
return z.query(ctx, "queryIcmpBlock", icmptype)
|
||||
}
|
||||
|
||||
// IcmpBlockInversion operations.
|
||||
func (z *PermZone) AddIcmpBlockInversion(ctx context.Context) error {
|
||||
return z.add(ctx, "addIcmpBlockInversion")
|
||||
}
|
||||
func (z *PermZone) RemoveIcmpBlockInversion(ctx context.Context) error {
|
||||
return z.add(ctx, "removeIcmpBlockInversion")
|
||||
}
|
||||
func (z *PermZone) QueryIcmpBlockInversion(ctx context.Context) (bool, error) {
|
||||
return z.query(ctx, "queryIcmpBlockInversion")
|
||||
}
|
||||
|
||||
// Interface operations.
|
||||
func (z *PermZone) AddInterface(ctx context.Context, iface string) error {
|
||||
return z.add(ctx, "addInterface", iface)
|
||||
}
|
||||
func (z *PermZone) RemoveInterface(ctx context.Context, iface string) error {
|
||||
return z.add(ctx, "removeInterface", iface)
|
||||
}
|
||||
func (z *PermZone) QueryInterface(ctx context.Context, iface string) (bool, error) {
|
||||
return z.query(ctx, "queryInterface", iface)
|
||||
}
|
||||
|
||||
// Scalar setters for zone metadata and default policy.
|
||||
func (z *PermZone) SetTarget(ctx context.Context, target Target) error {
|
||||
return z.callOn(ctx, "setTarget", nil, string(target))
|
||||
}
|
||||
func (z *PermZone) SetShort(ctx context.Context, short string) error {
|
||||
return z.callOn(ctx, "setShort", nil, short)
|
||||
}
|
||||
func (z *PermZone) SetDescription(ctx context.Context, description string) error {
|
||||
return z.callOn(ctx, "setDescription", nil, description)
|
||||
}
|
||||
func (z *PermZone) SetVersion(ctx context.Context, version string) error {
|
||||
return z.callOn(ctx, "setVersion", nil, version)
|
||||
}
|
||||
|
||||
// Getters for the current permanent element lists.
|
||||
func (z *PermZone) Services(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getServices")
|
||||
}
|
||||
func (z *PermZone) Interfaces(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getInterfaces")
|
||||
}
|
||||
func (z *PermZone) Sources(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getSources")
|
||||
}
|
||||
func (z *PermZone) Protocols(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getProtocols")
|
||||
}
|
||||
func (z *PermZone) RichRules(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getRichRules")
|
||||
}
|
||||
func (z *PermZone) Target(ctx context.Context) (Target, error) {
|
||||
var t string
|
||||
err := z.callOn(ctx, "getTarget", []any{&t})
|
||||
return Target(t), err
|
||||
}
|
||||
|
||||
// LoadDefaults resets the zone to its built-in defaults.
|
||||
func (z *PermZone) LoadDefaults(ctx context.Context) error {
|
||||
return z.callOn(ctx, "loadDefaults", nil)
|
||||
}
|
||||
|
||||
// Rename changes the zone's name. The cached path is invalidated so subsequent
|
||||
// calls re-resolve under the new name.
|
||||
func (z *PermZone) Rename(ctx context.Context, newName string) error {
|
||||
if err := z.callOn(ctx, "rename", nil, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
z.mu.Lock()
|
||||
z.name = newName
|
||||
z.path = ""
|
||||
z.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes the zone from the permanent configuration.
|
||||
func (z *PermZone) Remove(ctx context.Context) error {
|
||||
return z.callOn(ctx, "remove", nil)
|
||||
}
|
||||
252
policy.go
Normal file
252
policy.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// PolicySettings is the transport-neutral representation of a zone-to-zone policy
|
||||
// (firewalld >= 0.9). Policies have no v1 tuple form; they are exchanged only as
|
||||
// an a{sv} dict. Ingress and egress zones define the traffic the policy governs;
|
||||
// the remaining fields mirror a zone's filtering elements.
|
||||
type PolicySettings struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Target string // CONTINUE, ACCEPT, DROP, REJECT
|
||||
Priority int32
|
||||
IngressZones []string
|
||||
EgressZones []string
|
||||
Services []string
|
||||
Ports []Port
|
||||
SourcePorts []Port
|
||||
ICMPBlocks []string
|
||||
Masquerade bool
|
||||
ForwardPorts []ForwardPort
|
||||
RichRules []string
|
||||
Protocols []string
|
||||
}
|
||||
|
||||
// toDict builds the a{sv} policy dict with concrete typed values so godbus emits
|
||||
// the tuple signatures firewalld requires.
|
||||
func (s *PolicySettings) toDict() map[string]dbus.Variant {
|
||||
target := s.Target
|
||||
if target == "" {
|
||||
target = "CONTINUE"
|
||||
}
|
||||
return map[string]dbus.Variant{
|
||||
"version": dbus.MakeVariant(s.Version),
|
||||
"short": dbus.MakeVariant(s.Short),
|
||||
"description": dbus.MakeVariant(s.Description),
|
||||
"target": dbus.MakeVariant(target),
|
||||
"priority": dbus.MakeVariant(s.Priority),
|
||||
"ingress_zones": dbus.MakeVariant(nonNilStrings(s.IngressZones)),
|
||||
"egress_zones": dbus.MakeVariant(nonNilStrings(s.EgressZones)),
|
||||
"services": dbus.MakeVariant(nonNilStrings(s.Services)),
|
||||
"ports": dbus.MakeVariant(nonNilPorts(s.Ports)),
|
||||
"source_ports": dbus.MakeVariant(nonNilPorts(s.SourcePorts)),
|
||||
"icmp_blocks": dbus.MakeVariant(nonNilStrings(s.ICMPBlocks)),
|
||||
"masquerade": dbus.MakeVariant(s.Masquerade),
|
||||
"forward_ports": dbus.MakeVariant(nonNilForwardPorts(s.ForwardPorts)),
|
||||
// Policies name rich rules "rich_rules" in their dict (zones use
|
||||
// "rules_str"); firewalld rejects the wrong key with INVALID_OPTION.
|
||||
"rich_rules": dbus.MakeVariant(nonNilStrings(s.RichRules)),
|
||||
"protocols": dbus.MakeVariant(nonNilStrings(s.Protocols)),
|
||||
}
|
||||
}
|
||||
|
||||
// policySettingsFromDict decodes an a{sv} policy dict, tolerating omitted keys.
|
||||
func policySettingsFromDict(d map[string]dbus.Variant) PolicySettings {
|
||||
var s PolicySettings
|
||||
if v, ok := dictValue(d, "version"); ok {
|
||||
s.Version = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "short"); ok {
|
||||
s.Short = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "description"); ok {
|
||||
s.Description = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "target"); ok {
|
||||
s.Target = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "priority"); ok {
|
||||
s.Priority = asInt32(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "ingress_zones"); ok {
|
||||
s.IngressZones = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "egress_zones"); ok {
|
||||
s.EgressZones = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "services"); ok {
|
||||
s.Services = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "ports"); ok {
|
||||
s.Ports = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "source_ports"); ok {
|
||||
s.SourcePorts = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "icmp_blocks"); ok {
|
||||
s.ICMPBlocks = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "masquerade"); ok {
|
||||
s.Masquerade = asBool(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "forward_ports"); ok {
|
||||
s.ForwardPorts = asForwardPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "rules_str"); ok {
|
||||
s.RichRules = asStrings(v)
|
||||
} else if v, ok := dictValue(d, "rich_rules"); ok {
|
||||
s.RichRules = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "protocols"); ok {
|
||||
s.Protocols = asStrings(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Policies lists the names of all policies known to the runtime. Returns
|
||||
// ErrUnsupported on servers that predate policy support (firewalld < 0.9).
|
||||
func (r *Runtime) Policies(ctx context.Context) ([]string, error) {
|
||||
if !r.c.caps[Policies] {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
var names []string
|
||||
err := r.c.call(ctx, basePath, ifacePolicy, "getPolicies", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// ActivePolicies returns the policies that currently have zones bound.
|
||||
func (r *Runtime) ActivePolicies(ctx context.Context) ([]string, error) {
|
||||
if !r.c.caps[Policies] {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
var active map[string]map[string][]string
|
||||
if err := r.c.call(ctx, basePath, ifacePolicy, "getActivePolicies", []any{&active}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(active))
|
||||
for name := range active {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// PolicySettings reads a policy definition from the running firewall.
|
||||
func (r *Runtime) PolicySettings(ctx context.Context, policy string) (PolicySettings, error) {
|
||||
if !r.c.caps[Policies] {
|
||||
return PolicySettings{}, ErrUnsupported
|
||||
}
|
||||
var d map[string]dbus.Variant
|
||||
if err := r.c.call(ctx, basePath, ifacePolicy, "getPolicySettings", []any{&d}, policy); err != nil {
|
||||
return PolicySettings{}, err
|
||||
}
|
||||
return policySettingsFromDict(d), nil
|
||||
}
|
||||
|
||||
// SetPolicySettings replaces a policy's runtime settings wholesale.
|
||||
func (r *Runtime) SetPolicySettings(ctx context.Context, policy string, settings PolicySettings) error {
|
||||
if !r.c.caps[Policies] {
|
||||
return ErrUnsupported
|
||||
}
|
||||
return r.c.call(ctx, basePath, ifacePolicy, "setPolicySettings", nil, policy, settings.toDict())
|
||||
}
|
||||
|
||||
// PolicyNames lists the names of all permanent policies.
|
||||
func (p *Permanent) PolicyNames(ctx context.Context) ([]string, error) {
|
||||
if !p.c.caps[Policies] {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getPolicyNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// PolicyPaths lists the config object paths of all permanent policies.
|
||||
func (p *Permanent) PolicyPaths(ctx context.Context) ([]dbus.ObjectPath, error) {
|
||||
if !p.c.caps[Policies] {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
var paths []dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "listPolicies", []any{&paths})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// AddPolicy creates a new permanent policy and returns its config object path.
|
||||
func (p *Permanent) AddPolicy(ctx context.Context, name string, settings PolicySettings) (dbus.ObjectPath, error) {
|
||||
if !p.c.caps[Policies] {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addPolicy", []any{&path}, name, settings.toDict())
|
||||
return path, err
|
||||
}
|
||||
|
||||
// Policy returns a handle for permanent operations on the named policy.
|
||||
func (p *Permanent) Policy(name string) *PermPolicy {
|
||||
return &PermPolicy{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// PermPolicy is a handle for permanent edits to a single policy, operating on the
|
||||
// policy's config child object (/config/policy/N).
|
||||
type PermPolicy struct {
|
||||
c *Conn
|
||||
name string
|
||||
path dbus.ObjectPath
|
||||
}
|
||||
|
||||
func (s *PermPolicy) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
if !s.c.caps[Policies] {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
if s.path != "" {
|
||||
return s.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := s.c.call(ctx, configPath, ifaceConfig, "getPolicyByName", []any{&path}, s.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PermPolicy) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := s.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.c.call(ctx, path, ifaceConfigPolicy, method, rets, args...)
|
||||
}
|
||||
|
||||
// Settings reads the policy's permanent settings.
|
||||
func (s *PermPolicy) Settings(ctx context.Context) (PolicySettings, error) {
|
||||
var d map[string]dbus.Variant
|
||||
if err := s.callOn(ctx, "getSettings", []any{&d}); err != nil {
|
||||
return PolicySettings{}, err
|
||||
}
|
||||
return policySettingsFromDict(d), nil
|
||||
}
|
||||
|
||||
// Update replaces the policy's permanent settings wholesale.
|
||||
func (s *PermPolicy) Update(ctx context.Context, settings PolicySettings) error {
|
||||
return s.callOn(ctx, "update", nil, settings.toDict())
|
||||
}
|
||||
|
||||
// Rename changes the policy's name and invalidates the cached path.
|
||||
func (s *PermPolicy) Rename(ctx context.Context, newName string) error {
|
||||
if err := s.callOn(ctx, "rename", nil, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
s.name = newName
|
||||
s.path = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes the policy from the permanent configuration.
|
||||
func (s *PermPolicy) Remove(ctx context.Context) error {
|
||||
return s.callOn(ctx, "remove", nil)
|
||||
}
|
||||
163
properties.go
Normal file
163
properties.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// This file exposes firewalld's D-Bus properties: read-only runtime state on the
|
||||
// main object, and the read/write daemon settings (firewalld.conf knobs) on the
|
||||
// config object. Both are read through org.freedesktop.DBus.Properties.
|
||||
|
||||
// getProperty reads a single property via Properties.Get.
|
||||
func (c *Conn) getProperty(ctx context.Context, path dbus.ObjectPath, iface, name string) (dbus.Variant, error) {
|
||||
var v dbus.Variant
|
||||
err := c.call(ctx, path, ifaceProperties, "Get", []any{&v}, iface, name)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// getAllProperties reads every property of an interface via Properties.GetAll.
|
||||
func (c *Conn) getAllProperties(ctx context.Context, path dbus.ObjectPath, iface string) (map[string]dbus.Variant, error) {
|
||||
var m map[string]dbus.Variant
|
||||
err := c.call(ctx, path, ifaceProperties, "GetAll", []any{&m}, iface)
|
||||
return m, err
|
||||
}
|
||||
|
||||
// setProperty writes a property via Properties.Set.
|
||||
func (c *Conn) setProperty(ctx context.Context, path dbus.ObjectPath, iface, name string, value any) error {
|
||||
return c.call(ctx, path, ifaceProperties, "Set", nil, iface, name, dbus.MakeVariant(value))
|
||||
}
|
||||
|
||||
// State returns firewalld's runtime state, e.g. "RUNNING".
|
||||
func (c *Conn) State(ctx context.Context) (string, error) {
|
||||
v, err := c.getProperty(ctx, basePath, ifaceMain, "state")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return asString(v.Value()), nil
|
||||
}
|
||||
|
||||
// MainProperty reads a single read-only property from the main object (e.g.
|
||||
// "IPv4", "IPSetTypes", "nf_conntrack_helpers") for values not covered by a typed
|
||||
// accessor.
|
||||
func (c *Conn) MainProperty(ctx context.Context, name string) (dbus.Variant, error) {
|
||||
return c.getProperty(ctx, basePath, ifaceMain, name)
|
||||
}
|
||||
|
||||
// RuntimeInfo captures the main object's read-only capability and state
|
||||
// properties, describing what the running firewalld supports.
|
||||
type RuntimeInfo struct {
|
||||
State string
|
||||
InterfaceVer string
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
IPv6RPFilter bool
|
||||
Bridge bool
|
||||
IPSet bool
|
||||
IPSetTypes []string
|
||||
IPv4ICMPTypes []string
|
||||
IPv6ICMPTypes []string
|
||||
}
|
||||
|
||||
// RuntimeInfo reads the main object's runtime properties in one round-trip.
|
||||
func (c *Conn) RuntimeInfo(ctx context.Context) (RuntimeInfo, error) {
|
||||
m, err := c.getAllProperties(ctx, basePath, ifaceMain)
|
||||
if err != nil {
|
||||
return RuntimeInfo{}, err
|
||||
}
|
||||
get := func(k string) any {
|
||||
if v, ok := m[k]; ok {
|
||||
return v.Value()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return RuntimeInfo{
|
||||
State: asString(get("state")),
|
||||
InterfaceVer: asString(get("interface_version")),
|
||||
IPv4: asBool(get("IPv4")),
|
||||
IPv6: asBool(get("IPv6")),
|
||||
IPv6RPFilter: asBool(get("IPv6_rpfilter")),
|
||||
Bridge: asBool(get("BRIDGE")),
|
||||
IPSet: asBool(get("IPSet")),
|
||||
IPSetTypes: asStrings(get("IPSetTypes")),
|
||||
IPv4ICMPTypes: asStrings(get("IPv4ICMPTypes")),
|
||||
IPv6ICMPTypes: asStrings(get("IPv6ICMPTypes")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DaemonConfig holds firewalld's permanent daemon settings (the firewalld.conf
|
||||
// knobs). Fields absent on the connected version read as empty. Most values are
|
||||
// firewalld's "yes"/"no" strings or an enum ("iptables"/"nftables"); they are
|
||||
// exposed verbatim rather than coerced, since firewalld's own semantics vary.
|
||||
type DaemonConfig struct {
|
||||
DefaultZone string
|
||||
MinimalMark int32
|
||||
CleanupOnExit string
|
||||
CleanupModulesOnExit string
|
||||
Lockdown string
|
||||
IPv6RPFilter string
|
||||
IPv6RPFilter2 string // finer-grained rp_filter (newer firewalld)
|
||||
IndividualCalls string
|
||||
LogDenied string
|
||||
AutomaticHelpers string
|
||||
FirewallBackend string
|
||||
FlushAllOnReload string
|
||||
RFC3964IPv4 string
|
||||
AllowZoneDrifting string
|
||||
NftablesTableOwner string
|
||||
NftablesCounters string // nftables rule counters (firewalld >= 2.0)
|
||||
NftablesFlowtable string // nftables flowtable offload (firewalld >= 2.0)
|
||||
StrictForwardPorts string // strict forward-port handling (firewalld >= 2.0)
|
||||
}
|
||||
|
||||
// DaemonConfig reads the config object's daemon-settings properties.
|
||||
func (p *Permanent) DaemonConfig(ctx context.Context) (DaemonConfig, error) {
|
||||
m, err := p.c.getAllProperties(ctx, configPath, ifaceConfig)
|
||||
if err != nil {
|
||||
return DaemonConfig{}, err
|
||||
}
|
||||
get := func(k string) any {
|
||||
if v, ok := m[k]; ok {
|
||||
return v.Value()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return DaemonConfig{
|
||||
DefaultZone: asString(get("DefaultZone")),
|
||||
MinimalMark: asInt32(get("MinimalMark")),
|
||||
CleanupOnExit: asString(get("CleanupOnExit")),
|
||||
CleanupModulesOnExit: asString(get("CleanupModulesOnExit")),
|
||||
Lockdown: asString(get("Lockdown")),
|
||||
IPv6RPFilter: asString(get("IPv6_rpfilter")),
|
||||
IPv6RPFilter2: asString(get("IPv6_rpfilter2")),
|
||||
IndividualCalls: asString(get("IndividualCalls")),
|
||||
LogDenied: asString(get("LogDenied")),
|
||||
AutomaticHelpers: asString(get("AutomaticHelpers")),
|
||||
FirewallBackend: asString(get("FirewallBackend")),
|
||||
FlushAllOnReload: asString(get("FlushAllOnReload")),
|
||||
RFC3964IPv4: asString(get("RFC3964_IPv4")),
|
||||
AllowZoneDrifting: asString(get("AllowZoneDrifting")),
|
||||
NftablesTableOwner: asString(get("NftablesTableOwner")),
|
||||
NftablesCounters: asString(get("NftablesCounters")),
|
||||
NftablesFlowtable: asString(get("NftablesFlowtable")),
|
||||
StrictForwardPorts: asString(get("StrictForwardPorts")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfigProperty reads a single daemon-config property by its firewalld name
|
||||
// (e.g. "FirewallBackend", "IPv6_rpfilter").
|
||||
func (p *Permanent) ConfigProperty(ctx context.Context, name string) (dbus.Variant, error) {
|
||||
return p.c.getProperty(ctx, configPath, ifaceConfig, name)
|
||||
}
|
||||
|
||||
// SetConfigProperty writes a daemon-config property. Changes are permanent and
|
||||
// take effect after a reload; most require privilege.
|
||||
func (p *Permanent) SetConfigProperty(ctx context.Context, name string, value any) error {
|
||||
return p.c.setProperty(ctx, configPath, ifaceConfig, name, value)
|
||||
}
|
||||
|
||||
// SetFirewallBackend selects the packet backend ("nftables" or "iptables").
|
||||
func (p *Permanent) SetFirewallBackend(ctx context.Context, backend string) error {
|
||||
return p.SetConfigProperty(ctx, "FirewallBackend", backend)
|
||||
}
|
||||
257
service.go
Normal file
257
service.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// ServiceSettings is the transport-neutral representation of a firewalld service.
|
||||
// It maps to the v1 tuple "(sssa(ss)asa{ss}asa(ss))": version, short,
|
||||
// description, ports, modules, destinations, protocols, source_ports. Newer
|
||||
// firewalld also exposes includes/helpers via the dict form (getSettings2), which
|
||||
// are modelled here and populated when the dict transport is available.
|
||||
type ServiceSettings struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Ports []Port
|
||||
Modules []string // netfilter helper modules
|
||||
Destinations map[string]string // family -> address, e.g. {"ipv4": "224.0.0.0/8"}
|
||||
Protocols []string
|
||||
SourcePorts []Port
|
||||
Includes []string // dict-only (firewalld >= 1.0)
|
||||
Helpers []string // dict-only (firewalld >= 1.0)
|
||||
}
|
||||
|
||||
// serviceTuple is the concrete struct godbus encodes to the v1 service signature.
|
||||
type serviceTuple struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Ports []Port
|
||||
Modules []string
|
||||
Destinations map[string]string
|
||||
Protocols []string
|
||||
SourcePorts []Port
|
||||
}
|
||||
|
||||
func (s *ServiceSettings) toTuple() serviceTuple {
|
||||
dst := s.Destinations
|
||||
if dst == nil {
|
||||
dst = map[string]string{}
|
||||
}
|
||||
return serviceTuple{
|
||||
Version: s.Version,
|
||||
Short: s.Short,
|
||||
Description: s.Description,
|
||||
Ports: s.Ports,
|
||||
Modules: s.Modules,
|
||||
Destinations: dst,
|
||||
Protocols: s.Protocols,
|
||||
SourcePorts: s.SourcePorts,
|
||||
}
|
||||
}
|
||||
|
||||
// serviceSettingsFromTuple decodes a raw v1 service tuple, length-tolerant.
|
||||
func serviceSettingsFromTuple(raw []any) ServiceSettings {
|
||||
return ServiceSettings{
|
||||
Version: asString(tupleField(raw, 0)),
|
||||
Short: asString(tupleField(raw, 1)),
|
||||
Description: asString(tupleField(raw, 2)),
|
||||
Ports: asPorts(tupleField(raw, 3)),
|
||||
Modules: asStrings(tupleField(raw, 4)),
|
||||
Destinations: asStringMap(tupleField(raw, 5)),
|
||||
Protocols: asStrings(tupleField(raw, 6)),
|
||||
SourcePorts: asPorts(tupleField(raw, 7)),
|
||||
}
|
||||
}
|
||||
|
||||
// serviceSettingsFromDict decodes the a{sv} service form, including the
|
||||
// dict-only includes/helpers keys. Missing keys read as zero values.
|
||||
func serviceSettingsFromDict(d map[string]dbus.Variant) ServiceSettings {
|
||||
var s ServiceSettings
|
||||
if v, ok := dictValue(d, "version"); ok {
|
||||
s.Version = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "short"); ok {
|
||||
s.Short = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "description"); ok {
|
||||
s.Description = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "ports"); ok {
|
||||
s.Ports = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "module"); ok {
|
||||
s.Modules = asStrings(v)
|
||||
} else if v, ok := dictValue(d, "modules"); ok {
|
||||
s.Modules = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "destination"); ok {
|
||||
s.Destinations = asStringMap(v)
|
||||
} else if v, ok := dictValue(d, "destinations"); ok {
|
||||
s.Destinations = asStringMap(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "protocols"); ok {
|
||||
s.Protocols = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "source_ports"); ok {
|
||||
s.SourcePorts = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "includes"); ok {
|
||||
s.Includes = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "helpers"); ok {
|
||||
s.Helpers = asStrings(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Services lists the names of all services known to the runtime.
|
||||
func (c *Conn) Services(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := c.call(ctx, basePath, ifaceMain, "listServices", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// ServiceSettings reads a service definition from the running firewall, choosing
|
||||
// the dict transport (getServiceSettings2) when available so includes/helpers are
|
||||
// populated, and falling back to the v1 tuple otherwise.
|
||||
func (c *Conn) ServiceSettings(ctx context.Context, service string) (ServiceSettings, error) {
|
||||
if c.caps[ServiceSettings2] {
|
||||
var d map[string]dbus.Variant
|
||||
if err := c.call(ctx, basePath, ifaceMain, "getServiceSettings2", []any{&d}, service); err != nil {
|
||||
return ServiceSettings{}, err
|
||||
}
|
||||
return serviceSettingsFromDict(d), nil
|
||||
}
|
||||
var raw []any
|
||||
if err := c.call(ctx, basePath, ifaceMain, "getServiceSettings", []any{&raw}, service); err != nil {
|
||||
return ServiceSettings{}, err
|
||||
}
|
||||
return serviceSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// ServiceNames lists the names of all permanent services.
|
||||
func (p *Permanent) ServiceNames(ctx context.Context) ([]string, error) {
|
||||
var names []string
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "getServiceNames", []any{&names})
|
||||
return names, err
|
||||
}
|
||||
|
||||
// AddService creates a new permanent service and returns its config object path.
|
||||
func (p *Permanent) AddService(ctx context.Context, name string, settings ServiceSettings) (dbus.ObjectPath, error) {
|
||||
var path dbus.ObjectPath
|
||||
err := p.c.call(ctx, configPath, ifaceConfig, "addService", []any{&path}, name, settings.toTuple())
|
||||
return path, err
|
||||
}
|
||||
|
||||
// Service returns a handle for permanent operations on the named service.
|
||||
func (p *Permanent) Service(name string) *PermService {
|
||||
return &PermService{c: p.c, name: name}
|
||||
}
|
||||
|
||||
// PermService is a handle for permanent edits to a single service definition,
|
||||
// operating on the service's config child object (/config/service/N).
|
||||
type PermService struct {
|
||||
c *Conn
|
||||
name string
|
||||
path dbus.ObjectPath
|
||||
}
|
||||
|
||||
func (s *PermService) resolve(ctx context.Context) (dbus.ObjectPath, error) {
|
||||
if s.path != "" {
|
||||
return s.path, nil
|
||||
}
|
||||
var path dbus.ObjectPath
|
||||
if err := s.c.call(ctx, configPath, ifaceConfig, "getServiceByName", []any{&path}, s.name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.path = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PermService) callOn(ctx context.Context, method string, rets []any, args ...any) error {
|
||||
path, err := s.resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.c.call(ctx, path, ifaceConfigService, method, rets, args...)
|
||||
}
|
||||
|
||||
// Settings reads the service's permanent settings via the v1 tuple.
|
||||
func (s *PermService) Settings(ctx context.Context) (ServiceSettings, error) {
|
||||
var raw []any
|
||||
if err := s.callOn(ctx, "getSettings", []any{&raw}); err != nil {
|
||||
return ServiceSettings{}, err
|
||||
}
|
||||
return serviceSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// Update replaces the service's permanent settings via the v1 tuple.
|
||||
func (s *PermService) Update(ctx context.Context, settings ServiceSettings) error {
|
||||
return s.callOn(ctx, "update", nil, settings.toTuple())
|
||||
}
|
||||
|
||||
// Port operations.
|
||||
func (s *PermService) AddPort(ctx context.Context, p Port) error {
|
||||
return s.callOn(ctx, "addPort", nil, p.Port, p.Protocol)
|
||||
}
|
||||
func (s *PermService) RemovePort(ctx context.Context, p Port) error {
|
||||
return s.callOn(ctx, "removePort", nil, p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// Protocol operations.
|
||||
func (s *PermService) AddProtocol(ctx context.Context, proto string) error {
|
||||
return s.callOn(ctx, "addProtocol", nil, proto)
|
||||
}
|
||||
func (s *PermService) RemoveProtocol(ctx context.Context, proto string) error {
|
||||
return s.callOn(ctx, "removeProtocol", nil, proto)
|
||||
}
|
||||
|
||||
// Module (netfilter helper) operations.
|
||||
func (s *PermService) AddModule(ctx context.Context, module string) error {
|
||||
return s.callOn(ctx, "addModule", nil, module)
|
||||
}
|
||||
func (s *PermService) RemoveModule(ctx context.Context, module string) error {
|
||||
return s.callOn(ctx, "removeModule", nil, module)
|
||||
}
|
||||
|
||||
// SetDestination sets the destination address for an IP family ("ipv4"/"ipv6").
|
||||
func (s *PermService) SetDestination(ctx context.Context, family, address string) error {
|
||||
return s.callOn(ctx, "setDestination", nil, family, address)
|
||||
}
|
||||
|
||||
// RemoveDestination clears the destination for an IP family.
|
||||
func (s *PermService) RemoveDestination(ctx context.Context, family string) error {
|
||||
return s.callOn(ctx, "removeDestination", nil, family)
|
||||
}
|
||||
|
||||
// Include operations compose other services into this one (firewalld >= 1.0).
|
||||
func (s *PermService) AddInclude(ctx context.Context, service string) error {
|
||||
return s.callOn(ctx, "addInclude", nil, service)
|
||||
}
|
||||
func (s *PermService) RemoveInclude(ctx context.Context, service string) error {
|
||||
return s.callOn(ctx, "removeInclude", nil, service)
|
||||
}
|
||||
func (s *PermService) QueryInclude(ctx context.Context, service string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.callOn(ctx, "queryInclude", []any{&ok}, service)
|
||||
return ok, err
|
||||
}
|
||||
func (s *PermService) Includes(ctx context.Context) ([]string, error) {
|
||||
var xs []string
|
||||
err := s.callOn(ctx, "getIncludes", []any{&xs})
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// SetDescription sets the permanent service description.
|
||||
func (s *PermService) SetDescription(ctx context.Context, description string) error {
|
||||
return s.callOn(ctx, "setDescription", nil, description)
|
||||
}
|
||||
|
||||
// Remove deletes the service from the permanent configuration.
|
||||
func (s *PermService) Remove(ctx context.Context) error {
|
||||
return s.callOn(ctx, "remove", nil)
|
||||
}
|
||||
112
signals.go
Normal file
112
signals.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// Signal is a firewalld D-Bus signal delivered to a watcher. Member is the short
|
||||
// signal name (e.g. "Reloaded", "PortAdded"); Interface is the emitting interface;
|
||||
// Body carries the signal's arguments in firewalld's declared order.
|
||||
type Signal struct {
|
||||
Member string
|
||||
Interface string
|
||||
Path dbus.ObjectPath
|
||||
Body []any
|
||||
}
|
||||
|
||||
// signalInterfaces are the firewalld interfaces whose signals a watcher subscribes
|
||||
// to. Matching by interface keeps unrelated bus traffic out of the channel.
|
||||
var signalInterfaces = []string{
|
||||
ifaceMain, ifaceZone, ifaceIPSet, ifaceDirect, ifacePolicy, ifaceLockdown,
|
||||
ifaceConfig, ifaceConfigZone, ifaceConfigIPSet, ifaceConfigPolicy,
|
||||
ifaceConfigService, ifaceConfigICMP, ifaceConfigHelper, ifaceConfigDirect,
|
||||
}
|
||||
|
||||
// WatchSignals subscribes to firewalld's signals and returns a channel of typed
|
||||
// events. The subscription and channel are torn down when ctx is cancelled. The
|
||||
// channel is buffered; a slow consumer that lets it fill will drop no earlier
|
||||
// events but may block delivery of later ones until drained.
|
||||
func (c *Conn) WatchSignals(ctx context.Context) (<-chan Signal, error) {
|
||||
for _, iface := range signalInterfaces {
|
||||
if err := c.conn.AddMatchSignal(dbus.WithMatchInterface(iface)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
raw := make(chan *dbus.Signal, 64)
|
||||
c.conn.Signal(raw)
|
||||
|
||||
out := make(chan Signal, 64)
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer c.removeSignalMatches(raw)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sig, ok := <-raw:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
iface, member := splitSignalName(sig.Name)
|
||||
// Ignore anything outside firewalld's namespace.
|
||||
if !strings.HasPrefix(iface, ifaceMain) {
|
||||
continue
|
||||
}
|
||||
ev := Signal{Member: member, Interface: iface, Path: sig.Path, Body: sig.Body}
|
||||
select {
|
||||
case out <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WatchReloaded returns a channel that receives an empty struct each time
|
||||
// firewalld emits its Reloaded signal, a convenience for callers that only need to
|
||||
// re-read state after a reload.
|
||||
func (c *Conn) WatchReloaded(ctx context.Context) (<-chan struct{}, error) {
|
||||
signals, err := c.WatchSignals(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(chan struct{}, 4)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for sig := range signals {
|
||||
if sig.Member == "Reloaded" {
|
||||
select {
|
||||
case out <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removeSignalMatches unregisters the channel and drops the match rules added by
|
||||
// WatchSignals. Best-effort: match removal errors are ignored during teardown.
|
||||
func (c *Conn) removeSignalMatches(raw chan *dbus.Signal) {
|
||||
c.conn.RemoveSignal(raw)
|
||||
for _, iface := range signalInterfaces {
|
||||
_ = c.conn.RemoveMatchSignal(dbus.WithMatchInterface(iface))
|
||||
}
|
||||
}
|
||||
|
||||
// splitSignalName splits a fully-qualified signal name "iface.Member" into its
|
||||
// interface and member parts.
|
||||
func splitSignalName(full string) (iface, member string) {
|
||||
i := strings.LastIndex(full, ".")
|
||||
if i < 0 {
|
||||
return "", full
|
||||
}
|
||||
return full[:i], full[i+1:]
|
||||
}
|
||||
183
signatures_test.go
Normal file
183
signatures_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// sigOf returns the D-Bus signature godbus would emit for v. This is the exact
|
||||
// check that catches the []interface{}->"av" regression: a compound value that
|
||||
// does not encode to firewalld's expected tuple signature would fail here before
|
||||
// it ever reaches a bus.
|
||||
func sigOf(v any) string { return dbus.SignatureOf(v).String() }
|
||||
|
||||
// TestCompoundSignatures locks every compound type to the signature firewalld
|
||||
// requires. These are pure encode checks; no bus is involved.
|
||||
func TestCompoundSignatures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{"Port", Port{}, "(ss)"},
|
||||
{"ForwardPort", ForwardPort{}, "(ssss)"},
|
||||
{"[]Port", []Port{}, "a(ss)"},
|
||||
{"[]ForwardPort", []ForwardPort{}, "a(ssss)"},
|
||||
{"zoneTuple", zoneTuple{}, "(sssbsasa(ss)asba(ssss)asasasasa(ss)b)"},
|
||||
{"ipsetTuple", ipsetTuple{}, "(ssssa{ss}as)"},
|
||||
{"serviceTuple", serviceTuple{}, "(sssa(ss)asa{ss}asa(ss))"},
|
||||
{"icmpTuple", icmpTuple{}, "(sssas)"},
|
||||
{"helperTuple", helperTuple{}, "(sssssa(ss))"},
|
||||
{"directChainTuple", directChainTuple{}, "(sss)"},
|
||||
{"directRuleTuple", directRuleTuple{}, "(sssias)"},
|
||||
{"directPassthroughTuple", directPassthroughTuple{}, "(sas)"},
|
||||
{"directConfigTuple", directConfigTuple{}, "(a(sss)a(sssias)a(sas))"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, sigOf(tc.val), "signature mismatch for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneDictSignatures verifies the a{sv} dict encodes as a{sv} and that each
|
||||
// compound value wrapped in a variant carries the tuple signature, not "av".
|
||||
func TestZoneDictSignatures(t *testing.T) {
|
||||
s := ZoneSettings{
|
||||
Ports: []Port{{Port: "80", Protocol: "tcp"}},
|
||||
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp", ToPort: "2222"}},
|
||||
Services: []string{"ssh"},
|
||||
}
|
||||
d := s.toDict()
|
||||
assert.Equal(t, "a{sv}", sigOf(d))
|
||||
|
||||
// The variants must carry concrete tuple signatures.
|
||||
assert.Equal(t, "a(ss)", d["ports"].Signature().String())
|
||||
assert.Equal(t, "a(ssss)", d["forward_ports"].Signature().String())
|
||||
assert.Equal(t, "as", d["services"].Signature().String())
|
||||
assert.Equal(t, "b", d["masquerade"].Signature().String())
|
||||
}
|
||||
|
||||
// TestZoneTupleRoundTrip encodes settings to the tuple form, then decodes the
|
||||
// same struct back through the length-tolerant tuple decoder to confirm field
|
||||
// alignment and value fidelity.
|
||||
func TestZoneTupleRoundTrip(t *testing.T) {
|
||||
in := ZoneSettings{
|
||||
Version: "1",
|
||||
Short: "Test",
|
||||
Description: "a test zone",
|
||||
Target: TargetDROP,
|
||||
Services: []string{"ssh", "http"},
|
||||
Ports: []Port{{Port: "80", Protocol: "tcp"}, {Port: "53", Protocol: "udp"}},
|
||||
ICMPBlocks: []string{"echo-request"},
|
||||
Masquerade: true,
|
||||
ForwardPorts: []ForwardPort{{Port: "22", Protocol: "tcp", ToPort: "2222", ToAddr: "10.0.0.5"}},
|
||||
Interfaces: []string{"eth0"},
|
||||
Sources: []string{"10.0.0.0/8"},
|
||||
RichRules: []string{`rule family="ipv4" reject`},
|
||||
Protocols: []string{"gre"},
|
||||
SourcePorts: []Port{{Port: "1024", Protocol: "udp"}},
|
||||
ICMPBlockInversion: true,
|
||||
}
|
||||
tuple := in.toTuple()
|
||||
|
||||
// Simulate the wire decode: godbus hands a tuple back as []any positionally.
|
||||
raw := []any{
|
||||
tuple.Version, tuple.Short, tuple.Description, tuple.Unused, tuple.Target,
|
||||
tuple.Services, portsToAny(tuple.Ports), tuple.ICMPBlocks, tuple.Masquerade,
|
||||
forwardsToAny(tuple.ForwardPorts), tuple.Interfaces, tuple.Sources,
|
||||
tuple.RichRules, tuple.Protocols, portsToAny(tuple.SourcePorts),
|
||||
tuple.ICMPBlockInversion,
|
||||
}
|
||||
out := zoneSettingsFromTuple(raw)
|
||||
|
||||
assert.Equal(t, in.Version, out.Version)
|
||||
assert.Equal(t, in.Short, out.Short)
|
||||
assert.Equal(t, in.Description, out.Description)
|
||||
assert.Equal(t, in.Target, out.Target)
|
||||
assert.Equal(t, in.Services, out.Services)
|
||||
assert.Equal(t, in.Ports, out.Ports)
|
||||
assert.Equal(t, in.ICMPBlocks, out.ICMPBlocks)
|
||||
assert.Equal(t, in.Masquerade, out.Masquerade)
|
||||
assert.Equal(t, in.ForwardPorts, out.ForwardPorts)
|
||||
assert.Equal(t, in.Interfaces, out.Interfaces)
|
||||
assert.Equal(t, in.Sources, out.Sources)
|
||||
assert.Equal(t, in.RichRules, out.RichRules)
|
||||
assert.Equal(t, in.Protocols, out.Protocols)
|
||||
assert.Equal(t, in.SourcePorts, out.SourcePorts)
|
||||
assert.Equal(t, in.ICMPBlockInversion, out.ICMPBlockInversion)
|
||||
}
|
||||
|
||||
// TestZoneTupleShortTolerant proves the decoder never panics or misaligns on a
|
||||
// truncated tuple from an old firewalld: trailing fields simply read as zero.
|
||||
func TestZoneTupleShortTolerant(t *testing.T) {
|
||||
// Only the first five fields present (version..target).
|
||||
raw := []any{"1", "Short", "desc", false, "ACCEPT"}
|
||||
require.NotPanics(t, func() {
|
||||
out := zoneSettingsFromTuple(raw)
|
||||
assert.Equal(t, "1", out.Version)
|
||||
assert.Equal(t, Target("ACCEPT"), out.Target)
|
||||
assert.Nil(t, out.Services)
|
||||
assert.False(t, out.Masquerade)
|
||||
assert.False(t, out.ICMPBlockInversion)
|
||||
})
|
||||
|
||||
// Empty tuple: everything zero, still no panic.
|
||||
require.NotPanics(t, func() {
|
||||
out := zoneSettingsFromTuple(nil)
|
||||
assert.Equal(t, ZoneSettings{}, out)
|
||||
})
|
||||
}
|
||||
|
||||
// TestZoneDictRoundTrip confirms dict encode/decode is lossless for the modelled
|
||||
// fields, including the v2-only forward flag.
|
||||
func TestZoneDictRoundTrip(t *testing.T) {
|
||||
in := ZoneSettings{
|
||||
Short: "Test",
|
||||
Target: TargetACCEPT,
|
||||
Services: []string{"ssh"},
|
||||
Ports: []Port{{Port: "443", Protocol: "tcp"}},
|
||||
Masquerade: true,
|
||||
Forward: true,
|
||||
SourcePorts: []Port{{Port: "68", Protocol: "udp"}},
|
||||
ForwardPorts: []ForwardPort{{Port: "80", Protocol: "tcp", ToPort: "8080"}},
|
||||
}
|
||||
d := in.toDict()
|
||||
|
||||
// Re-wrap variants the way getSettings2 hands them back (plain values).
|
||||
back := make(map[string]dbus.Variant, len(d))
|
||||
for k, v := range d {
|
||||
back[k] = v
|
||||
}
|
||||
out := zoneSettingsFromDict(back)
|
||||
|
||||
assert.Equal(t, in.Short, out.Short)
|
||||
assert.Equal(t, in.Target, out.Target)
|
||||
assert.Equal(t, in.Services, out.Services)
|
||||
assert.Equal(t, in.Ports, out.Ports)
|
||||
assert.Equal(t, in.Masquerade, out.Masquerade)
|
||||
assert.Equal(t, in.Forward, out.Forward)
|
||||
assert.Equal(t, in.SourcePorts, out.SourcePorts)
|
||||
assert.Equal(t, in.ForwardPorts, out.ForwardPorts)
|
||||
}
|
||||
|
||||
// portsToAny mirrors how godbus decodes a(ss) in raw mode: each element becomes a
|
||||
// []any pair. Used to feed the tuple decoder realistic input in tests.
|
||||
func portsToAny(ports []Port) []any {
|
||||
out := make([]any, len(ports))
|
||||
for i, p := range ports {
|
||||
out[i] = []any{p.Port, p.Protocol}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func forwardsToAny(fps []ForwardPort) []any {
|
||||
out := make([]any, len(fps))
|
||||
for i, f := range fps {
|
||||
out[i] = []any{f.Port, f.Protocol, f.ToPort, f.ToAddr}
|
||||
}
|
||||
return out
|
||||
}
|
||||
40
types.go
Normal file
40
types.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package firewalld
|
||||
|
||||
// This file defines the concrete Go structs used for every compound D-Bus value
|
||||
// firewalld exchanges. Encoding compound values as Go structs (not []any)
|
||||
// is mandatory: godbus encodes a struct as a D-Bus struct/tuple "(...)", which is
|
||||
// what firewalld's Python side requires. A []any would encode as "av" and
|
||||
// firewalld rejects it with INVALID_TYPE. See CLAUDE.md for the full rationale.
|
||||
|
||||
// Port is a port-or-range with its protocol. Encodes to the D-Bus tuple "(ss)".
|
||||
type Port struct {
|
||||
Port string // single port "80" or range "1000-2000"
|
||||
Protocol string // "tcp" | "udp" | "sctp" | "dccp"
|
||||
}
|
||||
|
||||
// ForwardPort describes a port-forward rule. Encodes to the tuple "(ssss)" in the
|
||||
// field order firewalld expects: port, protocol, toport, toaddr.
|
||||
type ForwardPort struct {
|
||||
Port string // incoming port or range
|
||||
Protocol string // "tcp" | "udp"
|
||||
ToPort string // destination port; empty keeps the same port
|
||||
ToAddr string // destination address; empty forwards locally
|
||||
}
|
||||
|
||||
// Target is a zone's default packet policy for unmatched traffic.
|
||||
type Target string
|
||||
|
||||
const (
|
||||
TargetDefault Target = "default"
|
||||
TargetACCEPT Target = "ACCEPT"
|
||||
TargetDROP Target = "DROP"
|
||||
TargetReject Target = "%%REJECT%%"
|
||||
)
|
||||
|
||||
// Family selects the IP family for ipsets, rich rules, and helpers.
|
||||
type Family string
|
||||
|
||||
const (
|
||||
FamilyIPv4 Family = "inet"
|
||||
FamilyIPv6 Family = "inet6"
|
||||
)
|
||||
338
zone.go
Normal file
338
zone.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
package firewalld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// timeoutSeconds converts a Go duration to firewalld's int32 timeout in seconds.
|
||||
// A zero or negative duration means "no timeout" (the runtime edit persists until
|
||||
// reload), which firewalld encodes as 0.
|
||||
func timeoutSeconds(d time.Duration) int32 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int32(d / time.Second)
|
||||
}
|
||||
|
||||
// Runtime is the entry point for transient operations on the running firewall.
|
||||
// Edits made here last until the next reload unless given a timeout, mirroring
|
||||
// firewall-cmd without --permanent.
|
||||
type Runtime struct{ c *Conn }
|
||||
|
||||
// Runtime returns the runtime operation namespace.
|
||||
func (c *Conn) Runtime() *Runtime { return &Runtime{c: c} }
|
||||
|
||||
// Zone returns a handle for runtime operations on the named zone.
|
||||
func (r *Runtime) Zone(name string) *RuntimeZone {
|
||||
return &RuntimeZone{c: r.c, name: name}
|
||||
}
|
||||
|
||||
// Zones lists the names of all defined zones (runtime view).
|
||||
func (r *Runtime) Zones(ctx context.Context) ([]string, error) {
|
||||
var zones []string
|
||||
err := r.c.call(ctx, basePath, ifaceZone, "getZones", []any{&zones})
|
||||
return zones, err
|
||||
}
|
||||
|
||||
// ActiveZone pairs a zone name with the interfaces and sources currently bound to
|
||||
// it, as reported by getActiveZones.
|
||||
type ActiveZone struct {
|
||||
Name string
|
||||
Interfaces []string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// ActiveZones returns the zones that currently have interfaces or sources bound.
|
||||
// getActiveZones returns a{sa{sas}}: zone -> {"interfaces": [...], "sources": [...]}.
|
||||
func (r *Runtime) ActiveZones(ctx context.Context) ([]ActiveZone, error) {
|
||||
var raw map[string]map[string][]string
|
||||
if err := r.c.call(ctx, basePath, ifaceZone, "getActiveZones", []any{&raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ActiveZone, 0, len(raw))
|
||||
for name, binding := range raw {
|
||||
out = append(out, ActiveZone{
|
||||
Name: name,
|
||||
Interfaces: binding["interfaces"],
|
||||
Sources: binding["sources"],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ZoneOfInterface returns the zone an interface is bound to at runtime, or "" if
|
||||
// none.
|
||||
func (r *Runtime) ZoneOfInterface(ctx context.Context, iface string) (string, error) {
|
||||
var zone string
|
||||
err := r.c.call(ctx, basePath, ifaceZone, "getZoneOfInterface", []any{&zone}, iface)
|
||||
return zone, err
|
||||
}
|
||||
|
||||
// ZoneOfSource returns the zone a source is bound to at runtime, or "" if none.
|
||||
func (r *Runtime) ZoneOfSource(ctx context.Context, source string) (string, error) {
|
||||
var zone string
|
||||
err := r.c.call(ctx, basePath, ifaceZone, "getZoneOfSource", []any{&zone}, source)
|
||||
return zone, err
|
||||
}
|
||||
|
||||
// Settings reads the runtime settings of a zone. On dict-capable servers it uses
|
||||
// getZoneSettings2; otherwise it falls back to the main getZoneSettings tuple.
|
||||
func (r *Runtime) Settings(ctx context.Context, zone string) (ZoneSettings, error) {
|
||||
if r.c.caps[DictZoneSettings] {
|
||||
var d map[string]dbus.Variant
|
||||
if err := r.c.call(ctx, basePath, ifaceZone, "getZoneSettings2", []any{&d}, zone); err != nil {
|
||||
return ZoneSettings{}, err
|
||||
}
|
||||
return zoneSettingsFromDict(d), nil
|
||||
}
|
||||
var raw []any
|
||||
if err := r.c.call(ctx, basePath, ifaceMain, "getZoneSettings", []any{&raw}, zone); err != nil {
|
||||
return ZoneSettings{}, err
|
||||
}
|
||||
return zoneSettingsFromTuple(raw), nil
|
||||
}
|
||||
|
||||
// SetSettings replaces a zone's runtime settings wholesale via setZoneSettings2.
|
||||
// This is a dict-only path (firewalld >= 0.9); older servers expose no runtime
|
||||
// settings writer, so it returns ErrUnsupported there.
|
||||
func (r *Runtime) SetSettings(ctx context.Context, zone string, settings ZoneSettings) error {
|
||||
if !r.c.caps[DictZoneSettings] {
|
||||
return ErrUnsupported
|
||||
}
|
||||
return r.c.call(ctx, basePath, ifaceZone, "setZoneSettings2", nil, zone, settings.toDict())
|
||||
}
|
||||
|
||||
// RuntimeZone is a handle for transient edits to a single zone. Add methods take a
|
||||
// timeout (zero for none) and return the affected zone name from firewalld.
|
||||
type RuntimeZone struct {
|
||||
c *Conn
|
||||
name string
|
||||
}
|
||||
|
||||
// Name returns the zone name this handle targets.
|
||||
func (z *RuntimeZone) Name() string { return z.name }
|
||||
|
||||
// addElement invokes a runtime add* method: (zone, args..., timeout) -> zone.
|
||||
func (z *RuntimeZone) addElement(ctx context.Context, method string, timeout time.Duration, args ...any) error {
|
||||
full := append([]any{z.name}, args...)
|
||||
full = append(full, timeoutSeconds(timeout))
|
||||
return z.c.call(ctx, basePath, ifaceZone, method, nil, full...)
|
||||
}
|
||||
|
||||
// removeElement invokes a runtime remove* method: (zone, args...) -> zone.
|
||||
func (z *RuntimeZone) removeElement(ctx context.Context, method string, args ...any) error {
|
||||
full := append([]any{z.name}, args...)
|
||||
return z.c.call(ctx, basePath, ifaceZone, method, nil, full...)
|
||||
}
|
||||
|
||||
// queryElement invokes a runtime query* method: (zone, args...) -> bool.
|
||||
func (z *RuntimeZone) queryElement(ctx context.Context, method string, args ...any) (bool, error) {
|
||||
var ok bool
|
||||
full := append([]any{z.name}, args...)
|
||||
err := z.c.call(ctx, basePath, ifaceZone, method, []any{&ok}, full...)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// getStrings invokes a runtime get* method returning "as": (zone) -> []string.
|
||||
func (z *RuntimeZone) getStrings(ctx context.Context, method string) ([]string, error) {
|
||||
var xs []string
|
||||
err := z.c.call(ctx, basePath, ifaceZone, method, []any{&xs}, z.name)
|
||||
return xs, err
|
||||
}
|
||||
|
||||
// Port operations.
|
||||
func (z *RuntimeZone) AddPort(ctx context.Context, p Port, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addPort", timeout, p.Port, p.Protocol)
|
||||
}
|
||||
func (z *RuntimeZone) RemovePort(ctx context.Context, p Port) error {
|
||||
return z.removeElement(ctx, "removePort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *RuntimeZone) QueryPort(ctx context.Context, p Port) (bool, error) {
|
||||
return z.queryElement(ctx, "queryPort", p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// Protocol operations.
|
||||
func (z *RuntimeZone) AddProtocol(ctx context.Context, proto string, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addProtocol", timeout, proto)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveProtocol(ctx context.Context, proto string) error {
|
||||
return z.removeElement(ctx, "removeProtocol", proto)
|
||||
}
|
||||
func (z *RuntimeZone) QueryProtocol(ctx context.Context, proto string) (bool, error) {
|
||||
return z.queryElement(ctx, "queryProtocol", proto)
|
||||
}
|
||||
|
||||
// SourcePort operations.
|
||||
func (z *RuntimeZone) AddSourcePort(ctx context.Context, p Port, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addSourcePort", timeout, p.Port, p.Protocol)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveSourcePort(ctx context.Context, p Port) error {
|
||||
return z.removeElement(ctx, "removeSourcePort", p.Port, p.Protocol)
|
||||
}
|
||||
func (z *RuntimeZone) QuerySourcePort(ctx context.Context, p Port) (bool, error) {
|
||||
return z.queryElement(ctx, "querySourcePort", p.Port, p.Protocol)
|
||||
}
|
||||
|
||||
// Service operations.
|
||||
func (z *RuntimeZone) AddService(ctx context.Context, service string, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addService", timeout, service)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveService(ctx context.Context, service string) error {
|
||||
return z.removeElement(ctx, "removeService", service)
|
||||
}
|
||||
func (z *RuntimeZone) QueryService(ctx context.Context, service string) (bool, error) {
|
||||
return z.queryElement(ctx, "queryService", service)
|
||||
}
|
||||
|
||||
// Source operations. Unlike ports/services, a source binding is not timed, so
|
||||
// AddSource takes no timeout (firewalld's addSource has no timeout argument).
|
||||
func (z *RuntimeZone) AddSource(ctx context.Context, source string) error {
|
||||
return z.removeElement(ctx, "addSource", source)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveSource(ctx context.Context, source string) error {
|
||||
return z.removeElement(ctx, "removeSource", source)
|
||||
}
|
||||
func (z *RuntimeZone) QuerySource(ctx context.Context, source string) (bool, error) {
|
||||
return z.queryElement(ctx, "querySource", source)
|
||||
}
|
||||
|
||||
// RichRule operations.
|
||||
func (z *RuntimeZone) AddRichRule(ctx context.Context, rule string, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addRichRule", timeout, rule)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveRichRule(ctx context.Context, rule string) error {
|
||||
return z.removeElement(ctx, "removeRichRule", rule)
|
||||
}
|
||||
func (z *RuntimeZone) QueryRichRule(ctx context.Context, rule string) (bool, error) {
|
||||
return z.queryElement(ctx, "queryRichRule", rule)
|
||||
}
|
||||
|
||||
// ForwardPort operations.
|
||||
func (z *RuntimeZone) AddForwardPort(ctx context.Context, fp ForwardPort, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addForwardPort", timeout, fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveForwardPort(ctx context.Context, fp ForwardPort) error {
|
||||
return z.removeElement(ctx, "removeForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
func (z *RuntimeZone) QueryForwardPort(ctx context.Context, fp ForwardPort) (bool, error) {
|
||||
return z.queryElement(ctx, "queryForwardPort", fp.Port, fp.Protocol, fp.ToPort, fp.ToAddr)
|
||||
}
|
||||
|
||||
// Masquerade operations. Add takes a timeout; the others do not.
|
||||
func (z *RuntimeZone) AddMasquerade(ctx context.Context, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addMasquerade", timeout)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveMasquerade(ctx context.Context) error {
|
||||
return z.removeElement(ctx, "removeMasquerade")
|
||||
}
|
||||
func (z *RuntimeZone) QueryMasquerade(ctx context.Context) (bool, error) {
|
||||
return z.queryElement(ctx, "queryMasquerade")
|
||||
}
|
||||
|
||||
// IcmpBlock operations.
|
||||
func (z *RuntimeZone) AddIcmpBlock(ctx context.Context, icmptype string, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addIcmpBlock", timeout, icmptype)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveIcmpBlock(ctx context.Context, icmptype string) error {
|
||||
return z.removeElement(ctx, "removeIcmpBlock", icmptype)
|
||||
}
|
||||
func (z *RuntimeZone) QueryIcmpBlock(ctx context.Context, icmptype string) (bool, error) {
|
||||
return z.queryElement(ctx, "queryIcmpBlock", icmptype)
|
||||
}
|
||||
|
||||
// IcmpBlockInversion operations. Add takes a timeout; the others do not.
|
||||
func (z *RuntimeZone) AddIcmpBlockInversion(ctx context.Context, timeout time.Duration) error {
|
||||
return z.addElement(ctx, "addIcmpBlockInversion", timeout)
|
||||
}
|
||||
func (z *RuntimeZone) RemoveIcmpBlockInversion(ctx context.Context) error {
|
||||
return z.removeElement(ctx, "removeIcmpBlockInversion")
|
||||
}
|
||||
func (z *RuntimeZone) QueryIcmpBlockInversion(ctx context.Context) (bool, error) {
|
||||
return z.queryElement(ctx, "queryIcmpBlockInversion")
|
||||
}
|
||||
|
||||
// Interface operations bind and unbind network interfaces to the zone at runtime.
|
||||
func (z *RuntimeZone) AddInterface(ctx context.Context, iface string) error {
|
||||
return z.removeElement(ctx, "addInterface", iface) // no timeout arg on interface add
|
||||
}
|
||||
func (z *RuntimeZone) RemoveInterface(ctx context.Context, iface string) error {
|
||||
return z.removeElement(ctx, "removeInterface", iface)
|
||||
}
|
||||
func (z *RuntimeZone) QueryInterface(ctx context.Context, iface string) (bool, error) {
|
||||
return z.queryElement(ctx, "queryInterface", iface)
|
||||
}
|
||||
|
||||
// ChangeInterface moves an interface into this zone, detaching it from whatever
|
||||
// zone currently owns it. Unlike AddInterface, it does not fail if the interface
|
||||
// is already bound elsewhere (firewall-cmd --change-interface).
|
||||
func (z *RuntimeZone) ChangeInterface(ctx context.Context, iface string) error {
|
||||
return z.removeElement(ctx, "changeZoneOfInterface", iface)
|
||||
}
|
||||
|
||||
// ChangeSource moves a source into this zone, detaching it from its current zone.
|
||||
func (z *RuntimeZone) ChangeSource(ctx context.Context, source string) error {
|
||||
return z.removeElement(ctx, "changeZoneOfSource", source)
|
||||
}
|
||||
|
||||
// Getters for the current runtime element lists.
|
||||
func (z *RuntimeZone) Services(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getServices")
|
||||
}
|
||||
func (z *RuntimeZone) Interfaces(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getInterfaces")
|
||||
}
|
||||
func (z *RuntimeZone) Sources(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getSources")
|
||||
}
|
||||
func (z *RuntimeZone) Protocols(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getProtocols")
|
||||
}
|
||||
func (z *RuntimeZone) RichRules(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getRichRules")
|
||||
}
|
||||
func (z *RuntimeZone) ICMPBlocks(ctx context.Context) ([]string, error) {
|
||||
return z.getStrings(ctx, "getIcmpBlocks")
|
||||
}
|
||||
|
||||
// Ports returns the runtime ports as typed pairs.
|
||||
func (z *RuntimeZone) Ports(ctx context.Context) ([]Port, error) {
|
||||
var raw [][]any
|
||||
err := z.c.call(ctx, basePath, ifaceZone, "getPorts", []any{&raw}, z.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asPorts(toAnySlice(raw)), nil
|
||||
}
|
||||
|
||||
// SourcePorts returns the runtime source ports as typed pairs.
|
||||
func (z *RuntimeZone) SourcePorts(ctx context.Context) ([]Port, error) {
|
||||
var raw [][]any
|
||||
err := z.c.call(ctx, basePath, ifaceZone, "getSourcePorts", []any{&raw}, z.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asPorts(toAnySlice(raw)), nil
|
||||
}
|
||||
|
||||
// ForwardPorts returns the runtime forward ports as typed values.
|
||||
func (z *RuntimeZone) ForwardPorts(ctx context.Context) ([]ForwardPort, error) {
|
||||
var raw [][]any
|
||||
err := z.c.call(ctx, basePath, ifaceZone, "getForwardPorts", []any{&raw}, z.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asForwardPorts(toAnySlice(raw)), nil
|
||||
}
|
||||
|
||||
// toAnySlice re-wraps a [][]any as []any so the shared tuple
|
||||
// helpers (asPorts/asForwardPorts) can consume it.
|
||||
func toAnySlice(raw [][]any) []any {
|
||||
out := make([]any, len(raw))
|
||||
for i := range raw {
|
||||
out[i] = raw[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
229
zonesettings.go
Normal file
229
zonesettings.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package firewalld
|
||||
|
||||
import "github.com/godbus/dbus/v5"
|
||||
|
||||
// ZoneSettings is the complete, transport-neutral representation of a zone's
|
||||
// configuration. It is populated from either the v1 tuple (EL7 and up) or the v2
|
||||
// dict (firewalld >= 0.9) and encodes back through whichever transport the server
|
||||
// supports. The final three fields exist only in the v2 dict; on the tuple path
|
||||
// they are ignored on write and left zero on read.
|
||||
type ZoneSettings struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Target Target
|
||||
Services []string
|
||||
Ports []Port
|
||||
ICMPBlocks []string
|
||||
Masquerade bool
|
||||
ForwardPorts []ForwardPort
|
||||
Interfaces []string
|
||||
Sources []string
|
||||
RichRules []string
|
||||
Protocols []string
|
||||
SourcePorts []Port
|
||||
ICMPBlockInversion bool
|
||||
|
||||
// v2-only (dict) fields.
|
||||
Forward bool // intra-zone forwarding (firewalld >= 0.9)
|
||||
EgressPriority int32 // zone egress priority (newer firewalld)
|
||||
IngressPriority int32 // zone ingress priority (newer firewalld)
|
||||
}
|
||||
|
||||
// zoneTuple is the concrete Go struct godbus encodes to the v1 zone-settings
|
||||
// signature "(sssbsasa(ss)asba(ssss)asasasasa(ss)b)". Field order is load-bearing:
|
||||
// it must match firewalld's tuple exactly. The Unused bool is firewalld's dead
|
||||
// field 3 (historically "immutable").
|
||||
type zoneTuple struct {
|
||||
Version string
|
||||
Short string
|
||||
Description string
|
||||
Unused bool
|
||||
Target string
|
||||
Services []string
|
||||
Ports []Port
|
||||
ICMPBlocks []string
|
||||
Masquerade bool
|
||||
ForwardPorts []ForwardPort
|
||||
Interfaces []string
|
||||
Sources []string
|
||||
RichRules []string
|
||||
Protocols []string
|
||||
SourcePorts []Port
|
||||
ICMPBlockInversion bool
|
||||
}
|
||||
|
||||
// toTuple builds the v1 tuple struct from settings. Nil slices encode as empty
|
||||
// D-Bus arrays, which is what firewalld expects for "no entries".
|
||||
func (s *ZoneSettings) toTuple() zoneTuple {
|
||||
target := string(s.Target)
|
||||
if target == "" {
|
||||
target = string(TargetDefault)
|
||||
}
|
||||
return zoneTuple{
|
||||
Version: s.Version,
|
||||
Short: s.Short,
|
||||
Description: s.Description,
|
||||
Target: target,
|
||||
Services: s.Services,
|
||||
Ports: s.Ports,
|
||||
ICMPBlocks: s.ICMPBlocks,
|
||||
Masquerade: s.Masquerade,
|
||||
ForwardPorts: s.ForwardPorts,
|
||||
Interfaces: s.Interfaces,
|
||||
Sources: s.Sources,
|
||||
RichRules: s.RichRules,
|
||||
Protocols: s.Protocols,
|
||||
SourcePorts: s.SourcePorts,
|
||||
ICMPBlockInversion: s.ICMPBlockInversion,
|
||||
}
|
||||
}
|
||||
|
||||
// zoneSettingsFromTuple decodes a raw v1 tuple (godbus []any) into
|
||||
// ZoneSettings. Every field is read through the length-tolerant helpers so a
|
||||
// short tuple from an old firewalld yields zero values instead of a panic.
|
||||
func zoneSettingsFromTuple(raw []any) ZoneSettings {
|
||||
return ZoneSettings{
|
||||
Version: asString(tupleField(raw, 0)),
|
||||
Short: asString(tupleField(raw, 1)),
|
||||
Description: asString(tupleField(raw, 2)),
|
||||
// field 3 is the unused bool
|
||||
Target: Target(asString(tupleField(raw, 4))),
|
||||
Services: asStrings(tupleField(raw, 5)),
|
||||
Ports: asPorts(tupleField(raw, 6)),
|
||||
ICMPBlocks: asStrings(tupleField(raw, 7)),
|
||||
Masquerade: asBool(tupleField(raw, 8)),
|
||||
ForwardPorts: asForwardPorts(tupleField(raw, 9)),
|
||||
Interfaces: asStrings(tupleField(raw, 10)),
|
||||
Sources: asStrings(tupleField(raw, 11)),
|
||||
RichRules: asStrings(tupleField(raw, 12)),
|
||||
Protocols: asStrings(tupleField(raw, 13)),
|
||||
SourcePorts: asPorts(tupleField(raw, 14)),
|
||||
ICMPBlockInversion: asBool(tupleField(raw, 15)),
|
||||
}
|
||||
}
|
||||
|
||||
// toDict builds the a{sv} dict for the v2 transport. Every modelled field is sent
|
||||
// with a concrete typed value so godbus emits the tuple signatures firewalld
|
||||
// requires (ports as a(ss), forward_ports as a(ssss)). The egress/ingress
|
||||
// priority keys are only included when non-zero, so we never send a key an
|
||||
// intermediate 0.9.x server would reject.
|
||||
func (s *ZoneSettings) toDict() map[string]dbus.Variant {
|
||||
target := string(s.Target)
|
||||
if target == "" {
|
||||
target = string(TargetDefault)
|
||||
}
|
||||
d := map[string]dbus.Variant{
|
||||
"version": dbus.MakeVariant(s.Version),
|
||||
"short": dbus.MakeVariant(s.Short),
|
||||
"description": dbus.MakeVariant(s.Description),
|
||||
"target": dbus.MakeVariant(target),
|
||||
"services": dbus.MakeVariant(nonNilStrings(s.Services)),
|
||||
"ports": dbus.MakeVariant(nonNilPorts(s.Ports)),
|
||||
"icmp_blocks": dbus.MakeVariant(nonNilStrings(s.ICMPBlocks)),
|
||||
"masquerade": dbus.MakeVariant(s.Masquerade),
|
||||
"forward_ports": dbus.MakeVariant(nonNilForwardPorts(s.ForwardPorts)),
|
||||
"interfaces": dbus.MakeVariant(nonNilStrings(s.Interfaces)),
|
||||
"sources": dbus.MakeVariant(nonNilStrings(s.Sources)),
|
||||
"rules_str": dbus.MakeVariant(nonNilStrings(s.RichRules)),
|
||||
"protocols": dbus.MakeVariant(nonNilStrings(s.Protocols)),
|
||||
"source_ports": dbus.MakeVariant(nonNilPorts(s.SourcePorts)),
|
||||
"icmp_block_inversion": dbus.MakeVariant(s.ICMPBlockInversion),
|
||||
"forward": dbus.MakeVariant(s.Forward),
|
||||
}
|
||||
if s.EgressPriority != 0 {
|
||||
d["egress_priority"] = dbus.MakeVariant(s.EgressPriority)
|
||||
}
|
||||
if s.IngressPriority != 0 {
|
||||
d["ingress_priority"] = dbus.MakeVariant(s.IngressPriority)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// zoneSettingsFromDict decodes an a{sv} dict into ZoneSettings. Keys firewalld
|
||||
// omitted (unset fields) fall through to zero values.
|
||||
func zoneSettingsFromDict(d map[string]dbus.Variant) ZoneSettings {
|
||||
var s ZoneSettings
|
||||
if v, ok := dictValue(d, "version"); ok {
|
||||
s.Version = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "short"); ok {
|
||||
s.Short = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "description"); ok {
|
||||
s.Description = asString(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "target"); ok {
|
||||
s.Target = Target(asString(v))
|
||||
}
|
||||
if v, ok := dictValue(d, "services"); ok {
|
||||
s.Services = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "ports"); ok {
|
||||
s.Ports = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "icmp_blocks"); ok {
|
||||
s.ICMPBlocks = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "masquerade"); ok {
|
||||
s.Masquerade = asBool(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "forward_ports"); ok {
|
||||
s.ForwardPorts = asForwardPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "interfaces"); ok {
|
||||
s.Interfaces = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "sources"); ok {
|
||||
s.Sources = asStrings(v)
|
||||
}
|
||||
// firewalld names rich rules "rules_str" in the dict; accept "rich_rules" too
|
||||
// for forward compatibility.
|
||||
if v, ok := dictValue(d, "rules_str"); ok {
|
||||
s.RichRules = asStrings(v)
|
||||
} else if v, ok := dictValue(d, "rich_rules"); ok {
|
||||
s.RichRules = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "protocols"); ok {
|
||||
s.Protocols = asStrings(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "source_ports"); ok {
|
||||
s.SourcePorts = asPorts(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "icmp_block_inversion"); ok {
|
||||
s.ICMPBlockInversion = asBool(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "forward"); ok {
|
||||
s.Forward = asBool(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "egress_priority"); ok {
|
||||
s.EgressPriority = asInt32(v)
|
||||
}
|
||||
if v, ok := dictValue(d, "ingress_priority"); ok {
|
||||
s.IngressPriority = asInt32(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// nonNilStrings returns an empty, non-nil slice for a nil input so godbus emits an
|
||||
// empty typed array (as) rather than a nil that could confuse the dict encoding.
|
||||
func nonNilStrings(xs []string) []string {
|
||||
if xs == nil {
|
||||
return []string{}
|
||||
}
|
||||
return xs
|
||||
}
|
||||
|
||||
func nonNilPorts(xs []Port) []Port {
|
||||
if xs == nil {
|
||||
return []Port{}
|
||||
}
|
||||
return xs
|
||||
}
|
||||
|
||||
func nonNilForwardPorts(xs []ForwardPort) []ForwardPort {
|
||||
if xs == nil {
|
||||
return []ForwardPort{}
|
||||
}
|
||||
return xs
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue