mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-30 08:41:14 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user