feat(backend): add OPNsense backend (#743)

Manages interfaces and peers on OPNsense through the WireGuard API in
OPNsense core, so a stock appliance needs nothing installed. OPNsense
calls a tunnel a "server" and a peer on it a "client"; those map to
PhysicalInterface and PhysicalPeer.

Reads go through searchXxx because getXxx returns select fields as
{value, selected} maps that cannot be posted back to a write. Validation
failures arrive as HTTP 200 with a "result": "failed" body, so the body
is checked and not the status code.

Firewall rules are not managed, matching the pfSense backend: a new
tunnel handshakes but carries no traffic until a pass rule exists.

Alpha, and documented as such.

Signed-off-by: clark-ja <37738506+clark-ja@users.noreply.github.com>
This commit is contained in:
Jacopo Clark
2026-09-10 22:27:17 +02:00
committed by GitHub
parent 32ef6048fb
commit 7f5786f40f
14 changed files with 2855 additions and 14 deletions
+53 -1
View File
@@ -219,7 +219,7 @@ The current MikroTik backend is in **BETA** and may not support all features.
### `default`
- **Default:** `local`
- **Description:** The default backend to use for managing WireGuard interfaces.
Valid options are: `local`, or other backend id's configured in the `mikrotik` section.
Valid options are: `local`, or other backend id's configured in the `mikrotik`, `pfsense` or `opnsense` sections.
### `rekey_timeout_interval`
- **Default:** `180s`
@@ -293,6 +293,58 @@ Below are the properties for each entry inside `backend.mikrotik`:
For more details on configuring the MikroTik backend, see the [Backends](../usage/backends.md) documentation.
### OPNsense
The `opnsense` array contains a list of OPNsense backend definitions. Each entry describes how to connect to an OPNsense firewall that hosts WireGuard interfaces.
The WireGuard API used here is part of OPNsense core, so no add-on package needs to be installed on the firewall.
Below are the properties for each entry inside `backend.opnsense`:
#### `id`
- **Default:** *(empty)*
- **Description:** A unique identifier for this backend.
This value can be referenced by `backend.default` to use this backend as default.
The identifier must be unique across all backends and must not use the reserved keyword `local`.
#### `display_name`
- **Default:** *(empty)*
- **Description:** A human-friendly display name for this backend. If omitted, the `id` will be used as the display name.
#### `api_url`
- **Default:** *(empty)*
- **Description:** Base URL of the OPNsense appliance, including scheme, e.g., `https://opnsense.example.com`.
Do not append `/api`; the backend adds the API paths itself.
#### `api_key`
- **Default:** *(empty)*
- **Description:** API key, created under `System -> Access -> Users -> <user> -> API keys`.
#### `api_secret`
- **Default:** *(empty)*
- **Description:** The secret belonging to `api_key`. OPNsense authenticates the pair as HTTP Basic credentials.
The secret is shown only once, when the key is created.
#### `api_verify_tls`
- **Default:** `false`
- **Description:** Whether to verify the TLS certificate of the OPNsense API endpoint. Set to `false` to allow self-signed certificates (not recommended for production).
#### `api_timeout`
- **Default:** `30s`
- **Description:** Timeout for API requests to the OPNsense firewall. Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 30 seconds is used.
#### `ignored_interfaces`
- **Default:** *(empty)*
- **Description:** A list of interface names to exclude during interface enumeration.
This is useful if you want to prevent specific interfaces from being imported from the OPNsense firewall.
#### `debug`
- **Default:** `false`
- **Description:** Enable verbose debug logging for the OPNsense backend.
> There is deliberately no `concurrency` option for this backend. The MikroTik and pfSense backends issue one request per interface when enumerating details; the OPNsense search endpoints return every record with its fields already populated, so enumeration costs a fixed number of requests regardless of how many tunnels exist.
For more details on configuring the OPNsense backend, see the [Backends](../usage/backends.md) documentation.
---
## Advanced
+46 -1
View File
@@ -9,9 +9,10 @@ A global default backend determines where newly created interfaces go (unless yo
- **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server.
- **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+.
- **pfSense** (_alpha_): Manages interfaces and peers on pfSense firewalls via the pfSense REST API.
- **OPNsense** (_alpha_): Manages interfaces and peers on OPNsense firewalls via the WireGuard API built into OPNsense core. Unlike the pfSense backend, no add-on package is required on the firewall.
How backend selection works:
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend).
- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik, pfSense or OPNsense backend).
New interfaces created in the UI will use this backend by default.
- Each interface stores its backend. You can select a different backend when creating a new interface.
@@ -89,3 +90,47 @@ backend:
### Known limitations:
- Alpha quality: behavior and API coverage may change.
- Statistics (rx/tx bytes, last handshake) are not available from the pfSense REST API today.
## Configuring OPNsense backends
> :warning: The OPNsense backend is currently **alpha**. Interface and peer CRUD are supported, as are **per-peer** traffic statistics (rx/tx bytes and last handshake), which the pfSense backend cannot provide. Interface-level byte counters are not reported, because OPNsense exposes counters per peer only. Interface hooks, DNS push and ping are not supported.
The OPNsense backend talks to the WireGuard API that is part of **OPNsense core**. Unlike the pfSense backend, no add-on package needs to be installed — a stock OPNsense install already answers `/api/wireguard/*`.
Point `api_url` at the appliance root (for example `https://opnsense.example.com`); the portal appends the API paths itself.
### Prerequisites on OPNsense:
- OPNsense 24.1 or newer, where WireGuard is part of the base system. Developed and tested against 26.7.
- An API key/secret pair, created under `System -> Access -> Users -> <user> -> API keys`. OPNsense shows the secret only once, at creation time.
- The user needs permission for the WireGuard and firewall pages it should manage.
- HTTPS recommended; set `api_verify_tls: false` only for lab/self-signed setups.
Example WireGuard Portal configuration:
```yaml
backend:
# default backend decides where new interfaces are created
default: opnsense1
opnsense:
- id: opnsense1 # unique id, not "local"
display_name: Edge firewall # optional nice name
api_url: https://opnsense.example.com # appliance root, no /api suffix
api_key: your-api-key
api_secret: your-api-secret
api_verify_tls: true
api_timeout: 30s
debug: false
```
### Behaviour worth knowing:
- **Interface naming.** An OPNsense tunnel has both an instance number and a free-text name. WireGuard Portal uses the resulting device name (`wg0`, `wg1`, ...) as the interface identifier, so an interface imported from OPNsense looks the same as one managed by the `local` backend. The free-text name becomes the display name.
- **Changes are staged.** OPNsense applies WireGuard configuration only when the service is reconfigured, which the backend does after every change. Reconfiguring does **not** drop or rekey sessions of peers that are already connected, so adding a peer is safe against a live VPN.
- **The service is enabled automatically.** Bringing an interface up switches on the global WireGuard service if it is off, because a tunnel configured while the service is disabled stays down without reporting an error anywhere. The backend never disables the service.
- **Firewall rules are not managed.** A newly created tunnel has no pass rule, so peers will complete a handshake but carry no traffic until you add a rule on the `WireGuard (Group)` interface. This matches the pfSense backend, which also leaves firewall rules to the administrator.
### Known limitations:
- Alpha quality: behavior and API coverage may change.
- Interface hooks (`PreUp`/`PostUp`/...) are not supported; OPNsense has no API equivalent.
- DNS settings pushed to clients are a property of the tunnel in OPNsense and are not managed by the portal.
- `PingAddresses` is not implemented.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,488 @@
//go:build integration
package wgcontroller
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// These tests run against a real OPNsense instance. They are skipped unless the
// connection details are supplied:
//
// WG_PORTAL_OPNSENSE_URL=https://10.177.0.1 \
// WG_PORTAL_OPNSENSE_KEY=... \
// WG_PORTAL_OPNSENSE_SECRET=... \
// WG_PORTAL_OPNSENSE_INTERFACE=wg0 \
// go test -tags integration ./internal/adapters/wgcontroller/ -run Opnsense -v
//
// The tests create and remove their own peer; they do not modify the tunnel or
// any peer they did not create.
func opnsenseTestController(t *testing.T) (*OpnsenseController, domain.InterfaceIdentifier) {
t.Helper()
apiUrl := os.Getenv("WG_PORTAL_OPNSENSE_URL")
apiKey := os.Getenv("WG_PORTAL_OPNSENSE_KEY")
apiSecret := os.Getenv("WG_PORTAL_OPNSENSE_SECRET")
iface := os.Getenv("WG_PORTAL_OPNSENSE_INTERFACE")
if apiUrl == "" || apiKey == "" || apiSecret == "" {
t.Skip("set WG_PORTAL_OPNSENSE_URL/KEY/SECRET to run OPNsense integration tests")
}
if iface == "" {
iface = "wg0"
}
controller, err := NewOpnsenseController(&config.Config{}, &config.BackendOpnsense{
BackendBase: config.BackendBase{Id: "opnsense-test"},
ApiUrl: apiUrl,
ApiKey: apiKey,
ApiSecret: apiSecret,
ApiVerifyTls: false,
ApiTimeout: 30 * time.Second,
})
require.NoError(t, err)
return controller, domain.InterfaceIdentifier(iface)
}
func TestOpnsenseGetInterfaces(t *testing.T) {
controller, iface := opnsenseTestController(t)
ctx := context.Background()
interfaces, err := controller.GetInterfaces(ctx)
require.NoError(t, err)
require.NotEmpty(t, interfaces, "expected at least one WireGuard tunnel on the firewall")
var found *domain.PhysicalInterface
for i := range interfaces {
if interfaces[i].Identifier == iface {
found = &interfaces[i]
break
}
}
require.NotNil(t, found, "tunnel %s not found in %v", iface, interfaces)
// The identifier must be the device name so that an imported interface
// looks the same as one managed by the local backend.
assert.Equal(t, iface, found.Identifier)
assert.NotEmpty(t, found.KeyPair.PublicKey, "public key should be populated")
assert.NotEmpty(t, found.Addresses, "tunnel addresses should be parsed")
assert.Greater(t, found.ListenPort, 0, "listen port should be populated")
assert.Equal(t, domain.ControllerTypeOpnsense, found.ImportSource)
extras, ok := found.GetExtras().(domain.OpnsenseInterfaceExtras)
require.True(t, ok, "extras should be OpnsenseInterfaceExtras")
assert.NotEmpty(t, extras.Uuid, "the OPNsense UUID must be carried in extras")
// GetInterface must agree with GetInterfaces.
single, err := controller.GetInterface(ctx, iface)
require.NoError(t, err)
assert.Equal(t, found.Identifier, single.Identifier)
assert.Equal(t, found.KeyPair.PublicKey, single.KeyPair.PublicKey)
}
func TestOpnsenseGetPeers(t *testing.T) {
controller, iface := opnsenseTestController(t)
ctx := context.Background()
peers, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
require.NotEmpty(t, peers, "expected at least one peer on %s", iface)
for _, peer := range peers {
assert.NotEmpty(t, peer.Identifier, "peer identifier (public key) must be set")
assert.Equal(t, string(peer.Identifier), peer.KeyPair.PublicKey)
assert.Equal(t, domain.ControllerTypeOpnsense, peer.ImportSource)
extras, ok := peer.GetExtras().(domain.OpnsensePeerExtras)
require.True(t, ok)
assert.NotEmpty(t, extras.Uuid)
}
}
// A connected peer should report handshake and transfer counters, which the
// pfSense backend cannot supply. This asserts the service/show plumbing works;
// it tolerates the case where no peer is currently connected.
func TestOpnsensePeerStatistics(t *testing.T) {
controller, iface := opnsenseTestController(t)
ctx := context.Background()
peers, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
var withHandshake int
for _, peer := range peers {
if !peer.LastHandshake.IsZero() {
withHandshake++
assert.Greater(t, peer.BytesDownload+peer.BytesUpload, uint64(0),
"a peer that has handshaken should have moved some bytes")
}
}
t.Logf("%d of %d peers have completed a handshake", withHandshake, len(peers))
}
// Full create -> read -> update -> delete cycle for a peer, which is the path
// wg-portal exercises when an LDAP user gains or loses VPN access.
func TestOpnsenseSaveAndDeletePeer(t *testing.T) {
controller, iface := opnsenseTestController(t)
ctx := context.Background()
// A syntactically valid, deterministic key that will not collide with the
// testbed's real peers.
const testKey = "TEsT0000integrationTESTkey0000000000000000k="
peerId := domain.PeerIdentifier(testKey)
peersBefore, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
t.Cleanup(func() {
if err := controller.DeletePeer(context.Background(), iface, peerId); err != nil {
t.Logf("cleanup: failed to delete test peer: %v", err)
}
})
allowed, err := domain.CidrsFromString("10.99.0.240/32")
require.NoError(t, err)
err = controller.SavePeer(ctx, iface, peerId, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
pp.AllowedIPs = allowed
pp.PersistentKeepalive = 25
pp.SetExtras(domain.OpnsensePeerExtras{
Name: "wg-portal-integration-test",
Disabled: false,
})
return pp, nil
})
require.NoError(t, err, "creating a peer should succeed")
// Read it back.
peersAfter, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
assert.Len(t, peersAfter, len(peersBefore)+1, "exactly one peer should have been added")
var created *domain.PhysicalPeer
for i := range peersAfter {
if peersAfter[i].Identifier == peerId {
created = &peersAfter[i]
break
}
}
require.NotNil(t, created, "the created peer should be readable back")
assert.Equal(t, "10.99.0.240/32", domain.CidrsToString(created.AllowedIPs))
assert.Equal(t, 25, created.PersistentKeepalive)
createdExtras := created.GetExtras().(domain.OpnsensePeerExtras)
assert.Equal(t, "wg-portal-integration-test", createdExtras.Name)
assert.False(t, createdExtras.Disabled)
// Update it: a second SavePeer must modify in place, not create a duplicate.
updatedAllowed, err := domain.CidrsFromString("10.99.0.241/32")
require.NoError(t, err)
err = controller.SavePeer(ctx, iface, peerId, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
pp.AllowedIPs = updatedAllowed
extras := pp.GetExtras().(domain.OpnsensePeerExtras)
extras.Disabled = true
pp.SetExtras(extras)
return pp, nil
})
require.NoError(t, err)
peersUpdated, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
assert.Len(t, peersUpdated, len(peersBefore)+1, "update must not create a duplicate peer")
// Look the peer up explicitly rather than asserting inside a filter loop:
// a loop that matches nothing would run zero assertions and pass.
var updated *domain.PhysicalPeer
for i := range peersUpdated {
if peersUpdated[i].Identifier == peerId {
updated = &peersUpdated[i]
break
}
}
require.NotNil(t, updated, "the updated peer must still be present")
assert.Equal(t, "10.99.0.241/32", domain.CidrsToString(updated.AllowedIPs))
assert.True(t, updated.GetExtras().(domain.OpnsensePeerExtras).Disabled,
"the peer should now be disabled")
// Delete it.
require.NoError(t, controller.DeletePeer(ctx, iface, peerId))
peersFinal, err := controller.GetPeers(ctx, iface)
require.NoError(t, err)
assert.Len(t, peersFinal, len(peersBefore), "peer count should return to its original value")
for _, peer := range peersFinal {
assert.NotEqual(t, peerId, peer.Identifier, "the test peer should be gone")
}
}
// Deleting a peer that does not exist must be a no-op rather than an error, so
// that a sync which runs twice does not fail the second time.
func TestOpnsenseDeleteUnknownPeerIsNoOp(t *testing.T) {
controller, iface := opnsenseTestController(t)
err := controller.DeletePeer(context.Background(), iface,
domain.PeerIdentifier("d2dwb3J0YWwtaW50ZWdyYXRpb24tdGVzdC1ub2tleSE="))
assert.NoError(t, err)
}
// Full lifecycle for a tunnel. This creates a *new* tunnel on a spare instance
// so the testbed's primary tunnel and its live peer are left untouched.
func TestOpnsenseSaveAndDeleteInterface(t *testing.T) {
controller, _ := opnsenseTestController(t)
ctx := context.Background()
spare := domain.InterfaceIdentifier(os.Getenv("WG_PORTAL_OPNSENSE_SPARE_INTERFACE"))
if spare == "" {
spare = "wg9"
}
// This test creates a tunnel and then deletes it. If a tunnel of that name
// already exists it belongs to someone else: SaveInterface would silently
// adopt and reconfigure it, and the cleanup below would then delete it.
// Refuse rather than destroy a tunnel we did not create.
if existing, err := controller.GetInterface(ctx, spare); err == nil && existing != nil {
t.Skipf("refusing to run: %s already exists on this firewall; "+
"set WG_PORTAL_OPNSENSE_SPARE_INTERFACE to an unused wgN name", spare)
}
t.Cleanup(func() {
if err := controller.DeleteInterface(context.Background(), spare); err != nil {
t.Logf("cleanup: failed to delete test interface: %v", err)
}
})
before, err := controller.GetInterfaces(ctx)
require.NoError(t, err)
addresses, err := domain.CidrsFromString("10.98.0.1/24")
require.NoError(t, err)
err = controller.SaveInterface(ctx, spare, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = addresses
pi.ListenPort = 51899
pi.Mtu = 1420
pi.KeyPair = domain.KeyPair{
// Placeholders, not a real keypair: OPNsense only checks that these
// are 32-byte base64, and the tunnel is deleted without ever being
// brought up, so there is nothing for real key material to protect.
PrivateKey: "d2dwb3J0YWwtdGVzdGJlZC1wcml2a2V5LUVYQU1QTCE=",
PublicKey: "d2dwb3J0YWwtdGVzdGJlZC1wdWJrZXktRVhBTVBMRSE=",
}
extras := pi.GetExtras().(domain.OpnsenseInterfaceExtras)
extras.Comment = "wg-portal-integration-test"
pi.SetExtras(extras)
return pi, nil
})
require.NoError(t, err, "creating a tunnel should succeed")
created, err := controller.GetInterface(ctx, spare)
require.NoError(t, err, "the created tunnel should be readable back")
assert.Equal(t, spare, created.Identifier,
"the identifier must be the derived device name, not the free-text name")
assert.Equal(t, 51899, created.ListenPort)
assert.Equal(t, "10.98.0.1/24", domain.CidrsToString(created.Addresses))
createdExtras := created.GetExtras().(domain.OpnsenseInterfaceExtras)
assert.NotEmpty(t, createdExtras.Uuid)
assert.Equal(t, "wg-portal-integration-test", createdExtras.Comment)
after, err := controller.GetInterfaces(ctx)
require.NoError(t, err)
assert.Len(t, after, len(before)+1, "exactly one tunnel should have been added")
// Update in place: a second SaveInterface must not create a duplicate.
updatedAddresses, err := domain.CidrsFromString("10.98.1.1/24")
require.NoError(t, err)
err = controller.SaveInterface(ctx, spare, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = updatedAddresses
pi.ListenPort = 51898
return pi, nil
})
require.NoError(t, err)
updated, err := controller.GetInterface(ctx, spare)
require.NoError(t, err)
assert.Equal(t, "10.98.1.1/24", domain.CidrsToString(updated.Addresses))
assert.Equal(t, 51898, updated.ListenPort)
assert.Equal(t, createdExtras.Uuid, updated.GetExtras().(domain.OpnsenseInterfaceExtras).Uuid,
"update must reuse the existing UUID rather than creating a second tunnel")
afterUpdate, err := controller.GetInterfaces(ctx)
require.NoError(t, err)
assert.Len(t, afterUpdate, len(before)+1, "update must not create a duplicate tunnel")
require.NoError(t, controller.DeleteInterface(ctx, spare))
final, err := controller.GetInterfaces(ctx)
require.NoError(t, err)
assert.Len(t, final, len(before), "tunnel count should return to its original value")
}
// Deleting a tunnel that does not exist must be a no-op.
func TestOpnsenseDeleteUnknownInterfaceIsNoOp(t *testing.T) {
controller, _ := opnsenseTestController(t)
assert.NoError(t, controller.DeleteInterface(context.Background(), "wg99"))
}
// An OPNsense "client" is a single record that can be attached to several
// tunnels at once. Removing a peer from one tunnel must detach it from that
// tunnel only -- deleting the record would silently remove it from every other
// tunnel too. This is the branch that only runs for multi-tunnel peers, so it
// needs a peer deliberately attached to two.
func TestOpnsenseDeletePeerDetachesRatherThanDeleting(t *testing.T) {
controller, primary := opnsenseTestController(t)
ctx := context.Background()
second := domain.InterfaceIdentifier(os.Getenv("WG_PORTAL_OPNSENSE_SECOND_INTERFACE"))
if second == "" {
t.Skip("set WG_PORTAL_OPNSENSE_SECOND_INTERFACE to a second existing tunnel to run this test")
}
const testKey = "bXVsdGl0dW5uZWwtZGV0YWNoLXRlc3Qta2V5ISEhISE="
peerId := domain.PeerIdentifier(testKey)
t.Cleanup(func() {
_ = controller.DeletePeer(context.Background(), primary, peerId)
_ = controller.DeletePeer(context.Background(), second, peerId)
})
allowed, err := domain.CidrsFromString("10.99.0.250/32")
require.NoError(t, err)
// Attach to both tunnels. SavePeer merges the server list, so saving twice
// leaves the peer on both.
for _, iface := range []domain.InterfaceIdentifier{primary, second} {
err = controller.SavePeer(ctx, iface, peerId, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
pp.AllowedIPs = allowed
extras, _ := pp.GetExtras().(domain.OpnsensePeerExtras)
extras.Name = "wg-portal-multitunnel-test"
pp.SetExtras(extras)
return pp, nil
})
require.NoError(t, err, "attaching to %s should succeed", iface)
}
onPrimary, err := controller.GetPeers(ctx, primary)
require.NoError(t, err)
onSecond, err := controller.GetPeers(ctx, second)
require.NoError(t, err)
require.True(t, containsPeer(onPrimary, peerId), "peer should be on %s", primary)
require.True(t, containsPeer(onSecond, peerId), "peer should be on %s", second)
// Remove from the primary only.
require.NoError(t, controller.DeletePeer(ctx, primary, peerId))
onPrimary, err = controller.GetPeers(ctx, primary)
require.NoError(t, err)
onSecond, err = controller.GetPeers(ctx, second)
require.NoError(t, err)
assert.False(t, containsPeer(onPrimary, peerId), "peer must be gone from %s", primary)
assert.True(t, containsPeer(onSecond, peerId),
"peer must SURVIVE on %s: deleting from one tunnel must not detach it from others", second)
// Removing it from the last tunnel deletes the record outright.
require.NoError(t, controller.DeletePeer(ctx, second, peerId))
onSecond, err = controller.GetPeers(ctx, second)
require.NoError(t, err)
assert.False(t, containsPeer(onSecond, peerId), "peer must be gone from %s once unreferenced", second)
}
func containsPeer(peers []domain.PhysicalPeer, id domain.PeerIdentifier) bool {
for _, p := range peers {
if p.Identifier == id {
return true
}
}
return false
}
// IPv6 handling across the whole round-trip: a dual-stack tunnel, a peer with
// both families in its allowed addresses, and a bracketed IPv6 endpoint.
//
// The endpoint is the interesting part. OPNsense stores host and port in
// separate fields, so an IPv6 literal has to be bracketed when they are joined
// back together -- otherwise "fd00::1" and "51820" become "fd00::1:51820",
// which still parses as an address with the port silently absorbed.
func TestOpnsenseIPv6RoundTrip(t *testing.T) {
controller, _ := opnsenseTestController(t)
ctx := context.Background()
spare := domain.InterfaceIdentifier(os.Getenv("WG_PORTAL_OPNSENSE_SPARE_INTERFACE"))
if spare == "" {
spare = "wg9"
}
if existing, err := controller.GetInterface(ctx, spare); err == nil && existing != nil {
t.Skipf("refusing to run: %s already exists on this firewall", spare)
}
t.Cleanup(func() { _ = controller.DeleteInterface(context.Background(), spare) })
dualStack, err := domain.CidrsFromString("10.97.0.1/24,fd00:97::1/64")
require.NoError(t, err)
err = controller.SaveInterface(ctx, spare, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.Addresses = dualStack
pi.ListenPort = 51897
pi.KeyPair = domain.KeyPair{
PrivateKey: "d2dwb3J0YWwtdGVzdGJlZC1wcml2a2V5LUVYQU1QTCE=",
PublicKey: "d2dwb3J0YWwtdGVzdGJlZC1wdWJrZXktRVhBTVBMRSE=",
}
extras := pi.GetExtras().(domain.OpnsenseInterfaceExtras)
extras.Comment = "wg-portal-ipv6-test"
pi.SetExtras(extras)
return pi, nil
})
require.NoError(t, err, "a dual-stack tunnel should be accepted")
created, err := controller.GetInterface(ctx, spare)
require.NoError(t, err)
assert.Equal(t, "10.97.0.1/24,fd00:97::1/64", domain.CidrsToString(created.Addresses),
"both address families must survive the round-trip, in order")
// A peer with dual-stack allowed addresses and an IPv6 endpoint.
const peerKey = "C63a3Ddezps4AI4Gg5nzMGA978Cx/ASfQ9BnWdgAhlM="
peerId := domain.PeerIdentifier(peerKey)
t.Cleanup(func() { _ = controller.DeletePeer(context.Background(), spare, peerId) })
allowed, err := domain.CidrsFromString("10.97.0.5/32,fd00:97::5/128")
require.NoError(t, err)
err = controller.SavePeer(ctx, spare, peerId, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
pp.AllowedIPs = allowed
pp.Endpoint = "[2001:db8::1]:51820"
pp.PersistentKeepalive = 25
extras, _ := pp.GetExtras().(domain.OpnsensePeerExtras)
extras.Name = "wg-portal-ipv6-peer"
pp.SetExtras(extras)
return pp, nil
})
require.NoError(t, err, "a peer with IPv6 allowed addresses and endpoint should be accepted")
peers, err := controller.GetPeers(ctx, spare)
require.NoError(t, err)
var peer *domain.PhysicalPeer
for i := range peers {
if peers[i].Identifier == peerId {
peer = &peers[i]
break
}
}
require.NotNil(t, peer, "the IPv6 peer should be readable back")
assert.Equal(t, "10.97.0.5/32,fd00:97::5/128", domain.CidrsToString(peer.AllowedIPs))
assert.Equal(t, "[2001:db8::1]:51820", peer.Endpoint,
"the IPv6 endpoint must round-trip with its brackets and port intact")
}
@@ -0,0 +1,184 @@
package wgcontroller
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/h44z/wg-portal/internal/domain"
)
// Enumeration must survive records this portal did not create. A tunnel with no
// address is valid in OPNsense, and CidrsFromString reports the empty string as
// an error -- if that propagated, one such tunnel would fail GetInterfaces,
// which the startup importer treats as fatal for every backend.
func TestParseCidrsTolerant(t *testing.T) {
tests := []struct {
name string
value string
want []string
}{
{"empty is not an error", "", nil},
{"whitespace only", " ", nil},
{"single", "10.99.0.1/24", []string{"10.99.0.1/24"}},
{"multiple", "10.99.0.1/24,10.99.1.1/24", []string{"10.99.0.1/24", "10.99.1.1/24"}},
{"spaces around separators", " 10.99.0.1/24 , 10.99.1.1/24 ", []string{"10.99.0.1/24", "10.99.1.1/24"}},
{"ipv6", "fd00::1/64", []string{"fd00::1/64"}},
{"garbage is dropped, the rest survives", "garbage,10.99.0.1/24", []string{"10.99.0.1/24"}},
{"all garbage yields nothing rather than an error", "garbage,nonsense", nil},
{"trailing separator", "10.99.0.1/24,", []string{"10.99.0.1/24"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseCidrsTolerant(tt.value, "test", "owner")
if tt.want == nil {
assert.Empty(t, got)
return
}
assert.Equal(t, tt.want, splitCidrs(got))
})
}
}
func splitCidrs(cidrs []domain.Cidr) []string {
out := make([]string, 0, len(cidrs))
for _, c := range cidrs {
out = append(out, c.String())
}
return out
}
// An IPv6 endpoint must survive a read/write round-trip. Formatting it as
// "host:port" would produce "2001:db8::1:51820", which still parses as an IPv6
// address with the port silently absorbed into it.
func TestEndpointRoundTrip(t *testing.T) {
tests := []struct {
name string
host string
port string
endpoint string
}{
{"ipv4 with port", "203.0.113.5", "51820", "203.0.113.5:51820"},
{"ipv6 with port", "2001:db8::1", "51820", "[2001:db8::1]:51820"},
{"hostname with port", "vpn.example.org", "51820", "vpn.example.org:51820"},
{"ipv4 no port", "203.0.113.5", "", "203.0.113.5"},
{"ipv6 no port", "2001:db8::1", "", "2001:db8::1"},
{"empty", "", "", ""},
{"port without host is meaningless", "", "51820", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
joined := joinEndpoint(tt.host, tt.port)
assert.Equal(t, tt.endpoint, joined, "join")
if joined == "" {
return
}
host, port := splitEndpoint(joined)
assert.Equal(t, tt.host, host, "host survives the round-trip")
assert.Equal(t, tt.port, port, "port survives the round-trip")
})
}
}
// splitEndpoint also has to cope with values it did not produce itself.
func TestSplitEndpointTolerance(t *testing.T) {
host, port := splitEndpoint(" 203.0.113.5:51820 ")
assert.Equal(t, "203.0.113.5", host)
assert.Equal(t, "51820", port)
// An unbracketed IPv6 literal has no recoverable port.
host, port = splitEndpoint("2001:db8::1")
assert.Equal(t, "2001:db8::1", host)
assert.Empty(t, port)
host, port = splitEndpoint("")
assert.Empty(t, host)
assert.Empty(t, port)
}
// Optional numeric fields are sent as empty rather than omitted, so that
// clearing a value on the portal actually clears it on the firewall instead of
// leaving the previous value in place.
func TestOptionalPositiveInt(t *testing.T) {
assert.Equal(t, "1420", optionalPositiveInt(1420))
assert.Equal(t, "", optionalPositiveInt(0))
assert.Equal(t, "", optionalPositiveInt(-1))
}
func TestInstanceForDeviceName(t *testing.T) {
tests := map[string]string{
"wg0": "0",
"wg1": "1",
"wg42": "42",
"wg": "",
"wgx": "",
"vpn-admin": "",
"": "",
"WG0": "",
"wg 1": "",
}
for id, want := range tests {
assert.Equal(t, want, instanceForDeviceName(domain.InterfaceIdentifier(id)), "id %q", id)
}
}
func TestDeviceNameForInstance(t *testing.T) {
assert.Equal(t, "wg0", deviceNameForInstance("0"))
assert.Equal(t, "wg7", deviceNameForInstance("7"))
assert.Equal(t, "", deviceNameForInstance(""), "no instance means no derivable device name")
}
// OPNsense validates tunnel and peer names as 1-64 characters of alphanumerics,
// dash and underscore, and rejects anything else rather than coercing it.
func TestOpnsenseName(t *testing.T) {
// A real WireGuard public key: base64, so it contains "+", "/" and "=".
const publicKey = "cZRjnW9Si7uw6EMCLPjaYULJTz6PB93KAzvqaPDDZmA="
tests := []struct {
name string
preferred string
fallback string
want string
}{
{"plain name passes through", "laptop", publicKey, "laptop"},
{"spaces become dashes", "bob staff laptop", publicKey, "bob-staff-laptop"},
{"dash and underscore are kept", "bob_staff-laptop", publicKey, "bob_staff-laptop"},
{"runs of bad characters collapse", "a /// b", publicKey, "a-b"},
{"leading and trailing junk is trimmed", " !laptop! ", publicKey, "laptop"},
{"empty preferred falls back", "", publicKey, "cZRjnW9Si7uw6EMCLPjaYULJTz6PB93KAzvqaPDDZmA"},
{"unusable preferred falls back", "!!!", publicKey, "cZRjnW9Si7uw6EMCLPjaYULJTz6PB93KAzvqaPDDZmA"},
{"both unusable yields a constant", "!!!", "///", "wg-portal"},
{"unicode is folded out", "büro-läptop", publicKey, "b-ro-l-ptop"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, opnsenseName(tt.preferred, tt.fallback))
})
}
}
// Whatever goes in, the result must always satisfy OPNsense's validator.
func TestOpnsenseNameAlwaysValid(t *testing.T) {
inputs := []string{
"", " ", "!!!", strings.Repeat("x", 200),
strings.Repeat("a b/c+d=", 30),
"cZRjnW9Si7uw6EMCLPjaYULJTz6PB93KAzvqaPDDZmA=",
"日本語", "-leading", "trailing-", "--collapse--",
}
for _, in := range inputs {
got := opnsenseName(in, "")
assert.NotEmpty(t, got, "input %q produced an empty name", in)
assert.LessOrEqual(t, len(got), 64, "input %q produced %d characters", in, len(got))
for _, r := range got {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-'
assert.True(t, valid, "input %q produced disallowed character %q in %q", in, r, got)
}
}
}
@@ -44,6 +44,9 @@ func (c *ControllerManager) init() error {
return err
}
if err := c.registerOpnsenseControllers(); err != nil {
return err
}
if err := c.registerPfsenseControllers(); err != nil {
return err
}
@@ -113,6 +116,26 @@ func (c *ControllerManager) registerPfsenseControllers() error {
return nil
}
func (c *ControllerManager) registerOpnsenseControllers() error {
for _, backendConfig := range c.cfg.Backend.Opnsense {
if backendConfig.Id == config.LocalBackendName {
slog.Warn("skipping registration of OPNsense controller with reserved ID", "id", config.LocalBackendName)
continue
}
controller, err := wgcontroller.NewOpnsenseController(c.cfg, &backendConfig)
if err != nil {
return fmt.Errorf("failed to create OPNsense controller for backend %s: %w", backendConfig.Id, err)
}
c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{
Config: backendConfig.BackendBase,
Implementation: controller,
}
}
return nil
}
func (c *ControllerManager) logRegisteredControllers() {
for backend, controller := range c.controllers {
slog.Debug("backend controller registered",
@@ -0,0 +1,95 @@
package wireguard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// newBareControllerManager builds a manager without running init(), which would
// try to register the local controller and needs privileges this test lacks.
func newBareControllerManager(cfg *config.Config) *ControllerManager {
return &ControllerManager{
cfg: cfg,
controllers: make(map[domain.InterfaceBackend]backendInstance),
}
}
func TestRegisterOpnsenseControllers(t *testing.T) {
cfg := &config.Config{}
cfg.Backend.Opnsense = []config.BackendOpnsense{
{
BackendBase: config.BackendBase{Id: "opn1", DisplayName: "Edge firewall"},
ApiUrl: "https://127.0.0.1",
ApiKey: "key",
ApiSecret: "secret",
},
}
manager := newBareControllerManager(cfg)
require.NoError(t, manager.registerOpnsenseControllers())
instance, ok := manager.controllers["opn1"]
require.True(t, ok, "the opnsense backend should be registered under its configured id")
assert.Equal(t, "Edge firewall", instance.Config.GetDisplayName())
require.NotNil(t, instance.Implementation)
assert.Equal(t, domain.InterfaceBackend("opn1"), instance.Implementation.GetId())
// The controller must also satisfy the wg-quick and routing contracts, which
// the wireguard manager relies on when an interface is brought up or down.
_, isWgQuick := instance.Implementation.(WgQuickController)
assert.True(t, isWgQuick, "controller must implement WgQuickController")
}
// A backend that claims the reserved "local" id must be skipped rather than
// shadowing the built-in local controller.
func TestRegisterOpnsenseControllersSkipsReservedId(t *testing.T) {
cfg := &config.Config{}
cfg.Backend.Opnsense = []config.BackendOpnsense{
{
BackendBase: config.BackendBase{Id: config.LocalBackendName},
ApiUrl: "https://127.0.0.1",
ApiKey: "key",
ApiSecret: "secret",
},
}
manager := newBareControllerManager(cfg)
require.NoError(t, manager.registerOpnsenseControllers())
assert.Empty(t, manager.controllers, "the reserved id must not be registered")
}
// Missing credentials must fail loudly at startup rather than producing a
// controller that 401s on every call once the portal is running.
func TestRegisterOpnsenseControllersRequiresCredentials(t *testing.T) {
tests := map[string]config.BackendOpnsense{
"no url": {
BackendBase: config.BackendBase{Id: "opn1"},
ApiKey: "key", ApiSecret: "secret",
},
"no key": {
BackendBase: config.BackendBase{Id: "opn1"},
ApiUrl: "https://127.0.0.1", ApiSecret: "secret",
},
"no secret": {
BackendBase: config.BackendBase{Id: "opn1"},
ApiUrl: "https://127.0.0.1", ApiKey: "key",
},
}
for name, backendConfig := range tests {
t.Run(name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Backend.Opnsense = []config.BackendOpnsense{backendConfig}
manager := newBareControllerManager(cfg)
err := manager.registerOpnsenseControllers()
require.Error(t, err)
assert.Contains(t, err.Error(), "opn1")
})
}
}
+54 -10
View File
@@ -21,6 +21,7 @@ type Backend struct {
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
Pfsense []BackendPfsense `yaml:"pfsense"`
Opnsense []BackendOpnsense `yaml:"opnsense"`
}
// Validate checks the backend configuration for errors.
@@ -30,23 +31,31 @@ func (b *Backend) Validate() error {
}
uniqueMap := make(map[string]struct{})
for _, backend := range b.Mikrotik {
if backend.Id == LocalBackendName {
checkBackendId := func(id string) error {
if id == LocalBackendName {
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
}
if _, exists := uniqueMap[backend.Id]; exists {
return fmt.Errorf("backend ID %q is not unique", backend.Id)
if _, exists := uniqueMap[id]; exists {
return fmt.Errorf("backend ID %q is not unique", id)
}
uniqueMap[id] = struct{}{}
return nil
}
for _, backend := range b.Mikrotik {
if err := checkBackendId(backend.Id); err != nil {
return err
}
uniqueMap[backend.Id] = struct{}{}
}
for _, backend := range b.Pfsense {
if backend.Id == LocalBackendName {
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
if err := checkBackendId(backend.Id); err != nil {
return err
}
if _, exists := uniqueMap[backend.Id]; exists {
return fmt.Errorf("backend ID %q is not unique", backend.Id)
}
for _, backend := range b.Opnsense {
if err := checkBackendId(backend.Id); err != nil {
return err
}
uniqueMap[backend.Id] = struct{}{}
}
if b.Default != LocalBackendName {
@@ -153,3 +162,38 @@ func (b *BackendPfsense) GetApiTimeout() time.Duration {
}
return b.ApiTimeout
}
type BackendOpnsense struct {
BackendBase `yaml:",inline"` // Embed the base fields
// The base URL of the OPNsense API (e.g., "https://opnsense.example.com").
// Unlike the pfSense backend this is the host root, not an /api prefix: the
// controller paths already start with /api/wireguard/.
ApiUrl string `yaml:"api_url"`
// OPNsense authenticates with an API key/secret pair sent as HTTP Basic
// credentials, generated under 'System' -> 'Access' -> 'Users' -> 'API keys'.
// This differs from pfSense, which uses a single X-API-Key header value.
ApiKey string `yaml:"api_key"`
ApiSecret string `yaml:"api_secret"`
ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the OPNsense API
ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)
Debug bool `yaml:"debug"` // Enable debug logging for the OPNsense backend
// Note: there is deliberately no concurrency setting here. The Mikrotik and
// pfSense backends fan out one request per interface to collect details;
// OPNsense's searchXxx endpoints return every record with its fields already
// populated, so enumeration costs a fixed three calls regardless of size.
}
// GetApiTimeout returns the configured API timeout or a sane default (30 seconds)
// when the configured value is zero or negative.
func (b *BackendOpnsense) GetApiTimeout() time.Duration {
if b == nil {
return 30 * time.Second
}
if b.ApiTimeout <= 0 {
return 30 * time.Second
}
return b.ApiTimeout
}
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBackendValidate_DefaultsToLocal(t *testing.T) {
backend := Backend{}
require.NoError(t, backend.Validate())
assert.Equal(t, LocalBackendName, backend.Default)
}
func TestBackendValidate_RejectsReservedId(t *testing.T) {
tests := map[string]Backend{
"mikrotik": {Mikrotik: []BackendMikrotik{{BackendBase: BackendBase{Id: LocalBackendName}}}},
"pfsense": {Pfsense: []BackendPfsense{{BackendBase: BackendBase{Id: LocalBackendName}}}},
"opnsense": {Opnsense: []BackendOpnsense{{BackendBase: BackendBase{Id: LocalBackendName}}}},
}
for name, backend := range tests {
t.Run(name, func(t *testing.T) {
err := backend.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved keyword")
})
}
}
// IDs must be unique across backend *types*, not just within one list --
// otherwise two different firewalls could claim the same backend id and the
// controller map would silently keep only one of them.
func TestBackendValidate_RejectsDuplicateIdAcrossTypes(t *testing.T) {
backend := Backend{
Pfsense: []BackendPfsense{{BackendBase: BackendBase{Id: "fw1"}}},
Opnsense: []BackendOpnsense{{BackendBase: BackendBase{Id: "fw1"}}},
}
err := backend.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "not unique")
}
func TestBackendValidate_RejectsUnknownDefault(t *testing.T) {
backend := Backend{
Default: "does-not-exist",
Opnsense: []BackendOpnsense{{BackendBase: BackendBase{Id: "fw1"}}},
}
err := backend.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "is not defined")
}
func TestBackendValidate_AcceptsOpnsenseAsDefault(t *testing.T) {
backend := Backend{
Default: "fw1",
Opnsense: []BackendOpnsense{{BackendBase: BackendBase{Id: "fw1"}}},
}
assert.NoError(t, backend.Validate())
}
func TestBackendOpnsenseDefaults(t *testing.T) {
var nilBackend *BackendOpnsense
assert.Equal(t, 30*time.Second, nilBackend.GetApiTimeout(),
"a nil receiver must still yield the default timeout")
backend := &BackendOpnsense{}
assert.Equal(t, 30*time.Second, backend.GetApiTimeout())
backend.ApiTimeout = 5 * time.Second
assert.Equal(t, 5*time.Second, backend.GetApiTimeout())
}
func TestBackendOpnsenseDisplayName(t *testing.T) {
backend := BackendOpnsense{BackendBase: BackendBase{Id: "fw1"}}
assert.Equal(t, "fw1", backend.GetDisplayName(), "display name falls back to the id")
backend.DisplayName = "Edge firewall"
assert.Equal(t, "Edge firewall", backend.GetDisplayName())
}
+19
View File
@@ -6,6 +6,7 @@ const (
ControllerTypeMikrotik = "mikrotik"
ControllerTypeLocal = "wgctrl"
ControllerTypePfsense = "pfsense"
ControllerTypeOpnsense = "opnsense"
)
// Controller extras can be used to store additional information available for specific controllers only.
@@ -49,3 +50,21 @@ type PfsensePeerExtras struct {
ClientDns string
ClientKeepalive int
}
type OpnsenseInterfaceExtras struct {
Uuid string // internal OPNsense UUID of the WireGuard "server" (tunnel)
Instance string // the wg instance number; OPNsense derives the device name (wg0) from it
Comment string
Disabled bool
}
type OpnsensePeerExtras struct {
Uuid string // internal OPNsense UUID of the WireGuard "client" (peer)
Name string
Comment string
Disabled bool
ClientEndpoint string
ClientAddress string
ClientDns string
ClientKeepalive int
}
+23 -1
View File
@@ -283,7 +283,8 @@ func (p *PhysicalInterface) SetExtras(extras any) {
switch extras.(type) {
case MikrotikInterfaceExtras: // OK
case PfsenseInterfaceExtras: // OK
default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now
case OpnsenseInterfaceExtras: // OK
default: // we only support Mikrotik, Pfsense and Opnsense interface extras for now
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
}
@@ -354,6 +355,14 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
} else {
iface.Disabled = nil
}
case ControllerTypeOpnsense:
extras := pi.GetExtras().(OpnsenseInterfaceExtras)
iface.DisplayName = extras.Comment
if extras.Disabled {
iface.Disabled = &now
} else {
iface.Disabled = nil
}
}
return iface
@@ -382,6 +391,19 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
Disabled: i.IsDisabled(),
}
pi.SetExtras(extras)
case ControllerTypeOpnsense:
// Uuid and Instance are OPNsense's identity for this tunnel, not
// user-editable data, so carry them across the merge rather than
// letting the caller re-inject them afterwards.
extras := OpnsenseInterfaceExtras{
Comment: i.DisplayName,
Disabled: i.IsDisabled(),
}
if existing, ok := pi.GetExtras().(OpnsenseInterfaceExtras); ok {
extras.Uuid = existing.Uuid
extras.Instance = existing.Instance
}
pi.SetExtras(extras)
}
}
+38 -1
View File
@@ -243,7 +243,8 @@ func (p *PhysicalPeer) SetExtras(extras any) {
case MikrotikPeerExtras: // OK
case LocalPeerExtras: // OK
case PfsensePeerExtras: // OK
default: // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now
case OpnsensePeerExtras: // OK
default: // we only support Mikrotik, Local, Pfsense and Opnsense peer extras for now
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
}
@@ -326,6 +327,26 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
peer.Disabled = nil
peer.DisabledReason = ""
}
case ControllerTypeOpnsense:
extras := pp.GetExtras().(OpnsensePeerExtras)
peer.Notes = extras.Comment
peer.DisplayName = extras.Name
if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true)
peer.Interface.Type = InterfaceTypeClient
peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress)
peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true)
peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true)
} else {
peer.Interface.Type = InterfaceTypeServer
}
if extras.Disabled {
peer.Disabled = &now
peer.DisabledReason = "Disabled by OPNsense controller"
} else {
peer.Disabled = nil
peer.DisabledReason = ""
}
}
return peer
@@ -393,6 +414,22 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
ClientKeepalive: p.PersistentKeepalive.GetValue(),
}
pp.SetExtras(extras)
case ControllerTypeOpnsense:
extras := OpnsensePeerExtras{
Name: p.DisplayName,
Comment: p.Notes,
Disabled: p.IsDisabled(),
ClientEndpoint: p.Endpoint.GetValue(),
ClientAddress: CidrsToString(p.Interface.Addresses),
ClientDns: p.Interface.DnsStr.GetValue(),
ClientKeepalive: p.PersistentKeepalive.GetValue(),
}
// The UUID identifies the existing OPNsense client; losing it here would
// turn every update into a duplicate create.
if existing, ok := pp.GetExtras().(OpnsensePeerExtras); ok {
extras.Uuid = existing.Uuid
}
pp.SetExtras(extras)
}
}
+525
View File
@@ -0,0 +1,525 @@
package lowlevel
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/config"
)
// OpnsenseApiClient provides HTTP client functionality for the OPNsense REST API.
//
// Unlike the pfSense backend, which targets the third-party pfSense-API package
// (https://pfrest.org/), the endpoints used here are part of OPNsense core: a
// stock install answers /api/wireguard/* with no plugin to install.
//
// Two conventions differ from pfSense and drive the shape of this client:
//
// 1. Authentication is an API key/secret pair sent as HTTP Basic credentials,
// not a single header value.
// 2. Model-backed controllers are asymmetric between reads and writes. A
// getXxx returns "select" fields as maps keyed by option, each carrying a
// `selected` flag; a POST expects the same field as a comma-joined scalar,
// and posting back what getXxx returned fails with an opaque HTTP 500.
// FlattenForWrite converts between the two. Note the searchXxx endpoints
// are the exception: they already return the flattened form, which is why
// the controller reads through search and only needs FlattenForWrite where
// it round-trips a whole record back into a write.
// region models
const (
OpnsenseApiStatusOk = "ok"
OpnsenseApiStatusError = "error"
)
const (
OpnsenseApiErrorCodeUnknown = iota + 800
OpnsenseApiErrorCodeRequestPreparationFailed
OpnsenseApiErrorCodeRequestFailed
OpnsenseApiErrorCodeResponseDecodeFailed
OpnsenseApiErrorCodeValidationFailed
)
type OpnsenseApiResponse[T any] struct {
Status string
Code int
Data T
Error *OpnsenseApiError
}
type OpnsenseApiError struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"detail,omitempty"`
}
func (e *OpnsenseApiError) String() string {
if e == nil {
return "no error"
}
return fmt.Sprintf("API error %d: %s - %s", e.Code, e.Message, e.Details)
}
// OpnsenseSearchResult models the bootgrid-style payload returned by the
// searchXxx endpoints.
type OpnsenseSearchResult struct {
Rows []GenericJsonObject `json:"rows"`
RowCount int `json:"rowCount"`
Total int `json:"total"`
Current int `json:"current"`
}
// endregion models
// region select-field conversion
// FlattenForWrite converts an object as returned by a getXxx endpoint into the
// form a setXxx/addXxx endpoint accepts.
//
// OPNsense renders select fields as:
//
// "tunneladdress": {"10.0.0.1/24": {"value": "10.0.0.1/24", "selected": 1}}
//
// but only accepts them on write as:
//
// "tunneladdress": "10.0.0.1/24"
//
// Feeding the read form straight back produces HTTP 500 with
// "Unexpected error, check log for details", which gives no hint as to the
// cause. Selected keys are joined with "," in sorted order so that writes are
// deterministic; Go map iteration would otherwise reorder multi-value fields
// on every save.
func FlattenForWrite(obj GenericJsonObject) GenericJsonObject {
out := make(GenericJsonObject, len(obj))
for key, value := range obj {
switch typed := value.(type) {
case map[string]any:
out[key] = joinSelected(typed)
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
parts = append(parts, fmt.Sprintf("%v", item))
}
out[key] = strings.Join(parts, ",")
default:
out[key] = value
}
}
return out
}
// joinSelected extracts the keys of a select map whose option is marked
// selected. The empty key is OPNsense's "nothing chosen" placeholder and is
// never a real value.
func joinSelected(options map[string]any) string {
selected := make([]string, 0, len(options))
for key, raw := range options {
if key == "" {
continue
}
option, ok := raw.(map[string]any)
if !ok {
continue
}
if isSelected(option["selected"]) {
selected = append(selected, key)
}
}
sort.Strings(selected)
return strings.Join(selected, ",")
}
// isSelected copes with the several shapes OPNsense uses for the flag: it
// arrives as a JSON number through encoding/json, but has also been observed as
// a bare boolean and as a quoted string.
func isSelected(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case float64:
return typed != 0
case int:
return typed != 0
case string:
return typed == "1" || strings.EqualFold(typed, "true")
default:
return false
}
}
// SelectedKeys returns the selected option keys of a select field read from a
// getXxx response, in sorted order. Returns nil when the field is absent or is
// not a select map.
func SelectedKeys(obj GenericJsonObject, field string) []string {
raw, ok := obj[field]
if !ok {
return nil
}
options, ok := raw.(map[string]any)
if !ok {
return nil
}
joined := joinSelected(options)
if joined == "" {
return nil
}
return strings.Split(joined, ",")
}
// SelectedValue returns the single selected key of a select field, or "" when
// nothing is selected. Convenience for fields that are logically scalar.
func SelectedValue(obj GenericJsonObject, field string) string {
keys := SelectedKeys(obj, field)
if len(keys) == 0 {
return ""
}
return keys[0]
}
// endregion select-field conversion
// region API-client
type OpnsenseApiClient struct {
coreCfg *config.Config
cfg *config.BackendOpnsense
client *http.Client
log *slog.Logger
}
func NewOpnsenseApiClient(coreCfg *config.Config, cfg *config.BackendOpnsense) (*OpnsenseApiClient, error) {
if cfg.ApiUrl == "" {
return nil, fmt.Errorf("no API URL configured for OPNsense backend %s", cfg.Id)
}
if cfg.ApiKey == "" || cfg.ApiSecret == "" {
return nil, fmt.Errorf("both api_key and api_secret are required for OPNsense backend %s", cfg.Id)
}
c := &OpnsenseApiClient{
coreCfg: coreCfg,
cfg: cfg,
}
if err := c.setup(); err != nil {
return nil, err
}
c.debugLog("OPNsense api client created", "api_url", cfg.ApiUrl)
return c, nil
}
func (o *OpnsenseApiClient) setup() error {
o.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: !o.cfg.ApiVerifyTls,
},
},
Timeout: o.cfg.GetApiTimeout(),
}
if o.cfg.Debug {
o.log = slog.New(internal.GetLoggingHandler("debug",
o.coreCfg.Advanced.LogPretty,
o.coreCfg.Advanced.LogJson).
WithAttrs([]slog.Attr{
{
Key: "opnsense-bid", Value: slog.StringValue(o.cfg.Id),
},
}))
}
return nil
}
func (o *OpnsenseApiClient) debugLog(msg string, args ...any) {
if o.log != nil {
o.log.Debug("[OPN-API] "+msg, args...)
}
}
func (o *OpnsenseApiClient) getFullPath(command string) (string, error) {
// url.JoinPath treats its arguments as path elements and percent-encodes
// "?" and "&", which would turn a query string into a literal (and
// therefore unroutable) path segment. Split the query off, join the path,
// then re-attach it.
rawPath, rawQuery, hasQuery := strings.Cut(command, "?")
// url.JoinPath also *resolves* "." and ".." segments. Callers interpolate
// record UUIDs taken from firewall responses into these commands, so a
// crafted response could otherwise walk an authenticated POST out of the
// /api/wireguard/ namespace and aim it at an unrelated endpoint. Callers
// escape those values; refuse traversal here as well so that a single
// missed call site cannot turn into a redirected request.
for _, segment := range strings.Split(rawPath, "/") {
if segment == ".." {
return "", fmt.Errorf("refusing path traversal in API command %q", command)
}
}
path, err := url.JoinPath(o.cfg.ApiUrl, rawPath)
if err != nil {
return "", fmt.Errorf("failed to build request URL for %q: %w", command, err)
}
if hasQuery && rawQuery != "" {
path += "?" + rawQuery
}
return path, nil
}
func (o *OpnsenseApiClient) prepareRequest(
ctx context.Context,
method, fullUrl string,
payload any,
) (*http.Request, error) {
var body io.Reader
if payload != nil {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
o.debugLog("prepared payload", "payload", redactOpnsenseSecrets(payload))
body = bytes.NewReader(payloadBytes)
}
req, err := http.NewRequestWithContext(ctx, method, fullUrl, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
// OPNsense API keys authenticate as HTTP Basic key:secret.
req.SetBasicAuth(o.cfg.ApiKey, o.cfg.ApiSecret)
return req, nil
}
// opnsenseSecretFields are the request fields whose values must never reach a
// log. The WireGuard interface private key and the per-peer preshared key are
// both stored encrypted at rest by wg-portal (see the `serializer:encstr` tags
// in internal/domain), so writing them to a log stream in cleartext would put
// them at a lower level of protection than the database they came from.
var opnsenseSecretFields = map[string]struct{}{
"privkey": {},
"psk": {},
}
// redactOpnsenseSecrets renders a request payload for logging with secret
// values replaced. It reports the field names, since knowing *which* fields
// were sent is the useful part when debugging a rejected write.
func redactOpnsenseSecrets(payload any) string {
obj, ok := payload.(GenericJsonObject)
if !ok {
return "REDACTED-non-object-payload"
}
safe := make(GenericJsonObject, len(obj))
for key, value := range obj {
// The models nest the record under a single key, e.g. {"server": {...}}.
if nested, isNested := value.(GenericJsonObject); isNested {
inner := make(GenericJsonObject, len(nested))
for field, fieldValue := range nested {
if _, secret := opnsenseSecretFields[field]; secret && fmt.Sprintf("%v", fieldValue) != "" {
inner[field] = "REDACTED"
} else {
inner[field] = fieldValue
}
}
safe[key] = inner
continue
}
if _, secret := opnsenseSecretFields[key]; secret && fmt.Sprintf("%v", value) != "" {
safe[key] = "REDACTED"
continue
}
safe[key] = value
}
encoded, err := json.Marshal(safe)
if err != nil {
return "REDACTED-unrenderable-payload"
}
return string(encoded)
}
func errToOpnsenseApiResponse[T any](code int, message string, err error) OpnsenseApiResponse[T] {
details := ""
if err != nil {
details = err.Error()
}
return OpnsenseApiResponse[T]{
Status: OpnsenseApiStatusError,
Code: code,
Error: &OpnsenseApiError{
Code: code,
Message: message,
Details: details,
},
}
}
// parseOpnsenseHttpResponse decodes a response body into T.
//
// OPNsense does not wrap payloads in a common envelope the way the pfSense REST
// package does: getXxx returns the record directly, addXxx/setXxx return
// {"result": "saved"} or {"result": "failed", "validations": {...}}. A
// validation failure is reported with HTTP 200, so the result field has to be
// inspected rather than relying on the status code alone.
func parseOpnsenseHttpResponse[T any](resp *http.Response, err error) OpnsenseApiResponse[T] {
if err != nil {
return errToOpnsenseApiResponse[T](OpnsenseApiErrorCodeRequestFailed, "failed to execute request", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
slog.Error("failed to close response body", "error", closeErr)
}
}()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return errToOpnsenseApiResponse[T](OpnsenseApiErrorCodeResponseDecodeFailed,
"failed to read response body", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
preview := string(bodyBytes)
if len(preview) > 500 {
preview = preview[:500] + "..."
}
return errToOpnsenseApiResponse[T](resp.StatusCode,
fmt.Sprintf("HTTP %d from %s", resp.StatusCode, resp.Request.URL.Path),
fmt.Errorf("%s", preview))
}
if len(bodyBytes) == 0 {
return OpnsenseApiResponse[T]{Status: OpnsenseApiStatusOk, Code: resp.StatusCode}
}
var data T
if err := json.Unmarshal(bodyBytes, &data); err != nil {
preview := string(bodyBytes)
if len(preview) > 500 {
preview = preview[:500] + "..."
}
slog.Error("failed to decode OPNsense API response",
"status_code", resp.StatusCode,
"content_type", resp.Header.Get("Content-Type"),
"url", resp.Request.URL.String(),
"method", resp.Request.Method,
"body_preview", preview,
"error", err)
return errToOpnsenseApiResponse[T](OpnsenseApiErrorCodeResponseDecodeFailed,
fmt.Sprintf("failed to decode response (status %d)", resp.StatusCode), err)
}
return OpnsenseApiResponse[T]{Status: OpnsenseApiStatusOk, Code: resp.StatusCode, Data: data}
}
// checkMutationResult inspects the {"result": ...} body common to add/set/del
// and toggles the response to an error when OPNsense reports a failure. These
// arrive as HTTP 200, so without this a failed validation looks like success.
func checkMutationResult(resp OpnsenseApiResponse[GenericJsonObject]) OpnsenseApiResponse[GenericJsonObject] {
if resp.Status != OpnsenseApiStatusOk {
return resp
}
if resp.Data == nil {
return resp
}
result := resp.Data.GetString("result")
switch result {
case "saved", "deleted", "ok", "":
return resp
}
details := result
if validations, ok := resp.Data["validations"]; ok {
if encoded, err := json.Marshal(validations); err == nil {
details = fmt.Sprintf("%s: %s", result, string(encoded))
}
}
return OpnsenseApiResponse[GenericJsonObject]{
Status: OpnsenseApiStatusError,
Code: OpnsenseApiErrorCodeValidationFailed,
Data: resp.Data,
Error: &OpnsenseApiError{
Code: OpnsenseApiErrorCodeValidationFailed,
Message: "OPNsense rejected the request",
Details: details,
},
}
}
// opnsenseDo is a package-level generic because Go does not permit type
// parameters on methods, and the response type varies per endpoint family.
func opnsenseDo[T any](
o *OpnsenseApiClient,
ctx context.Context,
method, command string,
payload any,
) OpnsenseApiResponse[T] {
apiCtx, cancel := context.WithTimeout(ctx, o.cfg.GetApiTimeout())
defer cancel()
fullUrl, err := o.getFullPath(command)
if err != nil {
return errToOpnsenseApiResponse[T](OpnsenseApiErrorCodeRequestPreparationFailed,
"failed to build request URL", err)
}
req, err := o.prepareRequest(apiCtx, method, fullUrl, payload)
if err != nil {
return errToOpnsenseApiResponse[T](OpnsenseApiErrorCodeRequestPreparationFailed,
"failed to create request", err)
}
start := time.Now()
o.debugLog("executing API request", "method", method, "url", fullUrl)
response := parseOpnsenseHttpResponse[T](o.client.Do(req))
o.debugLog("retrieved API result",
"method", method, "url", fullUrl, "duration", time.Since(start).String())
return response
}
// Search calls a searchXxx endpoint and returns the matched rows.
func (o *OpnsenseApiClient) Search(ctx context.Context, command string) OpnsenseApiResponse[OpnsenseSearchResult] {
return opnsenseDo[OpnsenseSearchResult](o, ctx, http.MethodGet, command, nil)
}
// Get calls a getXxx endpoint. The returned object is in read form; run it
// through FlattenForWrite before sending it back.
func (o *OpnsenseApiClient) Get(ctx context.Context, command string) OpnsenseApiResponse[GenericJsonObject] {
return opnsenseDo[GenericJsonObject](o, ctx, http.MethodGet, command, nil)
}
// Post calls a mutating endpoint (addXxx, setXxx, delXxx, reconfigure).
func (o *OpnsenseApiClient) Post(
ctx context.Context,
command string,
payload GenericJsonObject,
) OpnsenseApiResponse[GenericJsonObject] {
if payload == nil {
payload = GenericJsonObject{}
}
return checkMutationResult(opnsenseDo[GenericJsonObject](o, ctx, http.MethodPost, command, payload))
}
// endregion API-client
+198
View File
@@ -0,0 +1,198 @@
package lowlevel
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
)
func testClient(t *testing.T) *OpnsenseApiClient {
t.Helper()
client, err := NewOpnsenseApiClient(&config.Config{}, &config.BackendOpnsense{
BackendBase: config.BackendBase{Id: "test"},
ApiUrl: "https://fw.example.org",
ApiKey: "key",
ApiSecret: "secret",
})
require.NoError(t, err)
return client
}
func TestNewOpnsenseApiClientRequiresCredentials(t *testing.T) {
tests := map[string]config.BackendOpnsense{
"no url": {ApiKey: "k", ApiSecret: "s"},
"no key": {ApiUrl: "https://fw", ApiSecret: "s"},
"no secret": {ApiUrl: "https://fw", ApiKey: "k"},
}
for name, cfg := range tests {
t.Run(name, func(t *testing.T) {
_, err := NewOpnsenseApiClient(&config.Config{}, &cfg)
assert.Error(t, err)
})
}
}
func TestGetFullPath(t *testing.T) {
client := testClient(t)
tests := []struct {
name string
command string
want string
}{
{"plain path", "/api/wireguard/server/searchServer",
"https://fw.example.org/api/wireguard/server/searchServer"},
{"query is preserved, not path-escaped", "/api/wireguard/server/searchServer?current=1&rowCount=-1",
"https://fw.example.org/api/wireguard/server/searchServer?current=1&rowCount=-1"},
{"uuid segment", "/api/wireguard/client/delClient/3a94f76f-67dd-4d65-8fb9-e7bae0fc0f65",
"https://fw.example.org/api/wireguard/client/delClient/3a94f76f-67dd-4d65-8fb9-e7bae0fc0f65"},
{"escaped traversal stays literal", "/api/wireguard/client/delClient/..%2F..%2Fcore",
"https://fw.example.org/api/wireguard/client/delClient/..%2F..%2Fcore"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := client.getFullPath(tt.command)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// url.JoinPath resolves ".." segments, which would let a value interpolated
// into the command -- a UUID read from a firewall response -- redirect an
// authenticated POST to an unrelated endpoint. Callers escape those values, but
// the client refuses traversal as well so one missed call site cannot do it.
func TestGetFullPathRefusesTraversal(t *testing.T) {
client := testClient(t)
for _, command := range []string{
"/api/wireguard/client/delClient/../../core/firmware/poweroff",
"/api/wireguard/client/delClient/x/../../../core/firmware/poweroff",
"/api/wireguard/../core/firmware/poweroff",
} {
got, err := client.getFullPath(command)
require.Error(t, err, "command %q must be refused", command)
assert.Empty(t, got)
assert.Contains(t, err.Error(), "traversal")
}
}
func TestRedactOpnsenseSecrets(t *testing.T) {
const priv = "GG9t5/5ancs3jARn0ZE2gfy/Dg0vxOmKVIGNhuYVlls="
const psk = "CuuWzzwdLP4zfNxQnzZ0CVpKHR1K+4WKexVgPggZ5gg="
rendered := redactOpnsenseSecrets(GenericJsonObject{
"server": GenericJsonObject{
"name": "wg0",
"privkey": priv,
"pubkey": "LWd/ZsnkjoT+YMHNgi6Qb66iGD4quml51734Mv1A40U=",
},
})
assert.NotContains(t, rendered, priv, "the private key must not reach the log")
assert.Contains(t, rendered, "REDACTED")
assert.Contains(t, rendered, "wg0", "non-secret fields stay visible for debugging")
assert.Contains(t, rendered, "privkey", "the field name stays visible")
rendered = redactOpnsenseSecrets(GenericJsonObject{
"client": GenericJsonObject{"name": "peer", "psk": psk},
})
assert.NotContains(t, rendered, psk)
assert.Contains(t, rendered, "REDACTED")
// An empty secret is not worth redacting; keep the payload readable.
rendered = redactOpnsenseSecrets(GenericJsonObject{
"client": GenericJsonObject{"name": "peer", "psk": ""},
})
assert.NotContains(t, rendered, "REDACTED")
}
func TestFlattenForWrite(t *testing.T) {
// The read form as getServer returns it.
read := GenericJsonObject{
"enabled": "1",
"name": "wg0",
"tunneladdress": map[string]any{
"10.99.0.1/24": map[string]any{"value": "10.99.0.1/24", "selected": float64(1)},
},
"dns": map[string]any{
"": map[string]any{"value": "", "selected": float64(1)},
},
"peers": map[string]any{
"bbb": map[string]any{"value": "b", "selected": float64(1)},
"aaa": map[string]any{"value": "a", "selected": float64(1)},
"ccc": map[string]any{"value": "c", "selected": float64(0)},
},
}
got := FlattenForWrite(read)
assert.Equal(t, "1", got["enabled"], "scalars pass through untouched")
assert.Equal(t, "wg0", got["name"])
assert.Equal(t, "10.99.0.1/24", got["tunneladdress"], "a select map collapses to its selected key")
assert.Equal(t, "", got["dns"], `the "" placeholder key is not a real value`)
assert.Equal(t, "aaa,bbb", got["peers"],
"only selected keys, joined in sorted order so writes are deterministic")
}
func TestIsSelectedAcceptsEveryShape(t *testing.T) {
// The flag has been observed as a JSON number, a bool and a quoted string.
for _, truthy := range []any{true, float64(1), 1, "1", "true", "True"} {
assert.True(t, isSelected(truthy), "%v (%T) should be selected", truthy, truthy)
}
for _, falsy := range []any{false, float64(0), 0, "0", "", "no", nil} {
assert.False(t, isSelected(falsy), "%v (%T) should not be selected", falsy, falsy)
}
}
func TestSelectedKeysAndValue(t *testing.T) {
obj := GenericJsonObject{
"peers": map[string]any{
"b": map[string]any{"selected": float64(1)},
"a": map[string]any{"selected": float64(1)},
"c": map[string]any{"selected": float64(0)},
},
"scalar": "not-a-select-map",
}
assert.Equal(t, []string{"a", "b"}, SelectedKeys(obj, "peers"))
assert.Equal(t, "a", SelectedValue(obj, "peers"))
assert.Nil(t, SelectedKeys(obj, "scalar"), "a scalar is not a select map")
assert.Nil(t, SelectedKeys(obj, "absent"))
assert.Empty(t, SelectedValue(obj, "absent"))
}
func TestCheckMutationResult(t *testing.T) {
ok := func(body GenericJsonObject) OpnsenseApiResponse[GenericJsonObject] {
return OpnsenseApiResponse[GenericJsonObject]{Status: OpnsenseApiStatusOk, Code: 200, Data: body}
}
// OPNsense reports validation failures with HTTP 200, so the body decides.
for _, good := range []string{"saved", "deleted", "ok", ""} {
got := checkMutationResult(ok(GenericJsonObject{"result": good}))
assert.Equal(t, OpnsenseApiStatusOk, got.Status, "result %q should be a success", good)
}
failed := checkMutationResult(ok(GenericJsonObject{
"result": "failed",
"validations": map[string]any{"client.name": "Should be a string between 1 and 64 characters."},
}))
require.Equal(t, OpnsenseApiStatusError, failed.Status)
require.NotNil(t, failed.Error)
assert.Contains(t, failed.Error.Details, "client.name",
"the validation detail is what makes the failure actionable")
// A response with no body at all must not be treated as a failure.
assert.Equal(t, OpnsenseApiStatusOk,
checkMutationResult(OpnsenseApiResponse[GenericJsonObject]{Status: OpnsenseApiStatusOk}).Status)
}
func TestOpnsenseApiErrorStringHandlesNil(t *testing.T) {
var err *OpnsenseApiError
assert.Equal(t, "no error", err.String())
assert.True(t, strings.Contains((&OpnsenseApiError{Code: 1, Message: "m", Details: "d"}).String(), "m"))
}