mirror of
https://github.com/h44z/wg-portal.git
synced 2026-10-19 12:11:59 +00: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`
|
||||||
- **Default:** `local`
|
- **Default:** `local`
|
||||||
- **Description:** The default backend to use for managing WireGuard interfaces.
|
- **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`
|
### `rekey_timeout_interval`
|
||||||
- **Default:** `180s`
|
- **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.
|
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
|
## 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.
|
- **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+.
|
- **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.
|
- **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:
|
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.
|
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.
|
- Each interface stores its backend. You can select a different backend when creating a new interface.
|
||||||
|
|
||||||
@@ -89,3 +90,47 @@ backend:
|
|||||||
### Known limitations:
|
### Known limitations:
|
||||||
- Alpha quality: behavior and API coverage may change.
|
- Alpha quality: behavior and API coverage may change.
|
||||||
- Statistics (rx/tx bytes, last handshake) are not available from the pfSense REST API today.
|
- 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := c.registerOpnsenseControllers(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := c.registerPfsenseControllers(); err != nil {
|
if err := c.registerPfsenseControllers(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -113,6 +116,26 @@ func (c *ControllerManager) registerPfsenseControllers() error {
|
|||||||
return nil
|
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() {
|
func (c *ControllerManager) logRegisteredControllers() {
|
||||||
for backend, controller := range c.controllers {
|
for backend, controller := range c.controllers {
|
||||||
slog.Debug("backend controller registered",
|
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"`
|
Mikrotik []BackendMikrotik `yaml:"mikrotik"`
|
||||||
Pfsense []BackendPfsense `yaml:"pfsense"`
|
Pfsense []BackendPfsense `yaml:"pfsense"`
|
||||||
|
Opnsense []BackendOpnsense `yaml:"opnsense"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks the backend configuration for errors.
|
// Validate checks the backend configuration for errors.
|
||||||
@@ -30,23 +31,31 @@ func (b *Backend) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uniqueMap := make(map[string]struct{})
|
uniqueMap := make(map[string]struct{})
|
||||||
for _, backend := range b.Mikrotik {
|
checkBackendId := func(id string) error {
|
||||||
if backend.Id == LocalBackendName {
|
if id == LocalBackendName {
|
||||||
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
|
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
|
||||||
}
|
}
|
||||||
if _, exists := uniqueMap[backend.Id]; exists {
|
if _, exists := uniqueMap[id]; exists {
|
||||||
return fmt.Errorf("backend ID %q is not unique", backend.Id)
|
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 {
|
for _, backend := range b.Pfsense {
|
||||||
if backend.Id == LocalBackendName {
|
if err := checkBackendId(backend.Id); err != nil {
|
||||||
return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName)
|
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 {
|
if b.Default != LocalBackendName {
|
||||||
@@ -153,3 +162,38 @@ func (b *BackendPfsense) GetApiTimeout() time.Duration {
|
|||||||
}
|
}
|
||||||
return b.ApiTimeout
|
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"
|
ControllerTypeMikrotik = "mikrotik"
|
||||||
ControllerTypeLocal = "wgctrl"
|
ControllerTypeLocal = "wgctrl"
|
||||||
ControllerTypePfsense = "pfsense"
|
ControllerTypePfsense = "pfsense"
|
||||||
|
ControllerTypeOpnsense = "opnsense"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Controller extras can be used to store additional information available for specific controllers only.
|
// Controller extras can be used to store additional information available for specific controllers only.
|
||||||
@@ -49,3 +50,21 @@ type PfsensePeerExtras struct {
|
|||||||
ClientDns string
|
ClientDns string
|
||||||
ClientKeepalive int
|
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) {
|
switch extras.(type) {
|
||||||
case MikrotikInterfaceExtras: // OK
|
case MikrotikInterfaceExtras: // OK
|
||||||
case PfsenseInterfaceExtras: // 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))
|
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +355,14 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
|
|||||||
} else {
|
} else {
|
||||||
iface.Disabled = nil
|
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
|
return iface
|
||||||
@@ -382,6 +391,19 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
|
|||||||
Disabled: i.IsDisabled(),
|
Disabled: i.IsDisabled(),
|
||||||
}
|
}
|
||||||
pi.SetExtras(extras)
|
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 MikrotikPeerExtras: // OK
|
||||||
case LocalPeerExtras: // OK
|
case LocalPeerExtras: // OK
|
||||||
case PfsensePeerExtras: // 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))
|
panic(fmt.Sprintf("unsupported peer backend extras type %T", extras))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +327,26 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
|
|||||||
peer.Disabled = nil
|
peer.Disabled = nil
|
||||||
peer.DisabledReason = ""
|
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
|
return peer
|
||||||
@@ -393,6 +414,22 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
|
|||||||
ClientKeepalive: p.PersistentKeepalive.GetValue(),
|
ClientKeepalive: p.PersistentKeepalive.GetValue(),
|
||||||
}
|
}
|
||||||
pp.SetExtras(extras)
|
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