diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 2e3d5c94..cb2a6316 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -219,7 +219,7 @@ The current MikroTik backend is in **BETA** and may not support all features. ### `default` - **Default:** `local` - **Description:** The default backend to use for managing WireGuard interfaces. - Valid options are: `local`, or other backend id's configured in the `mikrotik` section. + Valid options are: `local`, or other backend id's configured in the `mikrotik`, `pfsense` or `opnsense` sections. ### `rekey_timeout_interval` - **Default:** `180s` @@ -293,6 +293,58 @@ Below are the properties for each entry inside `backend.mikrotik`: For more details on configuring the MikroTik backend, see the [Backends](../usage/backends.md) documentation. +### OPNsense + +The `opnsense` array contains a list of OPNsense backend definitions. Each entry describes how to connect to an OPNsense firewall that hosts WireGuard interfaces. +The WireGuard API used here is part of OPNsense core, so no add-on package needs to be installed on the firewall. + +Below are the properties for each entry inside `backend.opnsense`: + +#### `id` +- **Default:** *(empty)* +- **Description:** A unique identifier for this backend. + This value can be referenced by `backend.default` to use this backend as default. + The identifier must be unique across all backends and must not use the reserved keyword `local`. + +#### `display_name` +- **Default:** *(empty)* +- **Description:** A human-friendly display name for this backend. If omitted, the `id` will be used as the display name. + +#### `api_url` +- **Default:** *(empty)* +- **Description:** Base URL of the OPNsense appliance, including scheme, e.g., `https://opnsense.example.com`. + Do not append `/api`; the backend adds the API paths itself. + +#### `api_key` +- **Default:** *(empty)* +- **Description:** API key, created under `System -> Access -> Users -> -> 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 diff --git a/docs/documentation/usage/backends.md b/docs/documentation/usage/backends.md index cadc8d41..744c1ceb 100644 --- a/docs/documentation/usage/backends.md +++ b/docs/documentation/usage/backends.md @@ -9,9 +9,10 @@ A global default backend determines where newly created interfaces go (unless yo - **Local** (default): Manages interfaces on the host running WireGuard Portal (Linux WireGuard via wgctrl). Use this when the portal should directly configure wg devices on the same server. - **MikroTik** RouterOS (_beta_): Manages interfaces and peers on MikroTik devices via the RouterOS REST API. Use this to control WG interfaces on RouterOS v7+. - **pfSense** (_alpha_): Manages interfaces and peers on pfSense firewalls via the pfSense REST API. +- **OPNsense** (_alpha_): Manages interfaces and peers on OPNsense firewalls via the WireGuard API built into OPNsense core. Unlike the pfSense backend, no add-on package is required on the firewall. How backend selection works: -- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik backend). +- The default backend is configured at `backend.default` (_local_ or the id of a defined MikroTik, pfSense or OPNsense backend). New interfaces created in the UI will use this backend by default. - Each interface stores its backend. You can select a different backend when creating a new interface. @@ -89,3 +90,47 @@ backend: ### Known limitations: - Alpha quality: behavior and API coverage may change. - Statistics (rx/tx bytes, last handshake) are not available from the pfSense REST API today. + +## Configuring OPNsense backends + +> :warning: The OPNsense backend is currently **alpha**. Interface and peer CRUD are supported, as are **per-peer** traffic statistics (rx/tx bytes and last handshake), which the pfSense backend cannot provide. Interface-level byte counters are not reported, because OPNsense exposes counters per peer only. Interface hooks, DNS push and ping are not supported. + +The OPNsense backend talks to the WireGuard API that is part of **OPNsense core**. Unlike the pfSense backend, no add-on package needs to be installed — a stock OPNsense install already answers `/api/wireguard/*`. + +Point `api_url` at the appliance root (for example `https://opnsense.example.com`); the portal appends the API paths itself. + +### Prerequisites on OPNsense: +- OPNsense 24.1 or newer, where WireGuard is part of the base system. Developed and tested against 26.7. +- An API key/secret pair, created under `System -> Access -> Users -> -> 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. diff --git a/internal/adapters/wgcontroller/opnsense.go b/internal/adapters/wgcontroller/opnsense.go new file mode 100644 index 00000000..84988c7b --- /dev/null +++ b/internal/adapters/wgcontroller/opnsense.go @@ -0,0 +1,1024 @@ +package wgcontroller + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" + "github.com/h44z/wg-portal/internal/lowlevel" +) + +// OpnsenseController implements the InterfaceController interface for OPNsense +// firewalls, using the WireGuard API that ships in OPNsense core +// (https://docs.opnsense.org/development/api/plugins/wireguard.html). +// +// Unlike the pfSense backend, which depends on the third-party pfSense-API +// package, no add-on is required: a stock install answers /api/wireguard/*. +// +// Terminology mapping, which is the main thing to keep straight while reading +// this file: +// +// OPNsense "server" == a WireGuard tunnel == domain.PhysicalInterface +// OPNsense "client" == a peer on that tunnel == domain.PhysicalPeer +// +// A server's device name (wg0, wg1, ...) is derived by OPNsense from its +// `instance` number and reported back in the `interface` field. That device +// name is what wg-portal uses as the interface identifier, so that an interface +// imported from OPNsense looks the same as one managed by the local backend. +// The human-readable `name` field becomes the display name. +// +// Reads use the searchXxx endpoints rather than getXxx: search returns every +// record with select fields already flattened to comma-joined scalars, so a +// full enumeration costs a fixed number of calls and needs no per-record +// fan-out. getXxx, by contrast, returns select fields as maps and is only used +// where a full record must be round-tripped through a write. + +const ( + // OPNsense stages WireGuard changes and only applies them when the service + // is reconfigured. Verified against a live 26.7 instance: reconfiguring + // while a peer is connected neither drops nor rekeys the existing session, + // so it is safe to call after every mutation. + opnsenseReconfigureEndpoint = "/api/wireguard/service/reconfigure" + + // Bootgrid-style search endpoints paginate. Ask for everything explicitly + // rather than relying on the default page size: a silently truncated list + // would read as "these peers do not exist" and provoke duplicate creates. + opnsenseSearchAllParams = "?current=1&rowCount=-1" +) + +// Compile-time proof that the controller satisfies the backend contract. The +// wg-quick and routing interfaces live in the app layer and cannot be asserted +// here without an import cycle; they are covered in the controller manager test. +var _ domain.InterfaceController = (*OpnsenseController)(nil) + +type OpnsenseController struct { + coreCfg *config.Config + cfg *config.BackendOpnsense + + client *lowlevel.OpnsenseApiClient + + interfaceMutexes sync.Map // map[domain.InterfaceIdentifier]*sync.Mutex + peerMutexes sync.Map // map[domain.PeerIdentifier]*sync.Mutex + coreMutex sync.Mutex // for updating core configuration such as routes or DNS +} + +func NewOpnsenseController(coreCfg *config.Config, cfg *config.BackendOpnsense) (*OpnsenseController, error) { + client, err := lowlevel.NewOpnsenseApiClient(coreCfg, cfg) + if err != nil { + return nil, fmt.Errorf("failed to create OPNsense API client: %w", err) + } + + return &OpnsenseController{ + coreCfg: coreCfg, + cfg: cfg, + + client: client, + + interfaceMutexes: sync.Map{}, + peerMutexes: sync.Map{}, + coreMutex: sync.Mutex{}, + }, nil +} + +func (c *OpnsenseController) GetId() domain.InterfaceBackend { + return domain.InterfaceBackend(c.cfg.Id) +} + +// getInterfaceMutex returns a mutex for the given interface to prevent concurrent modifications +func (c *OpnsenseController) getInterfaceMutex(id domain.InterfaceIdentifier) *sync.Mutex { + mutex, _ := c.interfaceMutexes.LoadOrStore(id, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +// getPeerMutex returns a mutex for the given peer to prevent concurrent modifications +func (c *OpnsenseController) getPeerMutex(id domain.PeerIdentifier) *sync.Mutex { + mutex, _ := c.peerMutexes.LoadOrStore(id, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +// region helpers + +// opnsenseName renders a name OPNsense will accept for a tunnel or a peer. +// +// Both models validate the name as 1-64 characters of alphanumerics, dash and +// underscore only, and reject anything else outright rather than coercing it: +// +// {"client.name":"Should be a string between 1 and 64 characters. +// Allowed characters are alphanumeric characters, dash and underscores."} +// +// Two ordinary inputs fall foul of that. A wg-portal display name may contain +// spaces ("bob staff laptop"), and the fallback when no display name is set is +// the peer's identifier -- a WireGuard public key, which is base64 and so +// contains "+", "/" and "=". Both must be folded into the allowed set. +func opnsenseName(preferred, fallback string) string { + if name := sanitizeOpnsenseName(preferred); name != "" { + return name + } + if name := sanitizeOpnsenseName(fallback); name != "" { + return name + } + return "wg-portal" +} + +func sanitizeOpnsenseName(s string) string { + var b strings.Builder + b.Grow(len(s)) + lastDash := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + b.WriteRune(r) + lastDash = false + default: + // Collapse any run of disallowed characters into a single dash, + // and never start with one. + if !lastDash && b.Len() > 0 { + b.WriteRune('-') + lastDash = true + } + } + } + + out := strings.Trim(b.String(), "-") + if len(out) > 64 { + out = strings.Trim(out[:64], "-") + } + return out +} + +// parseCidrsTolerant parses a comma-separated CIDR list, dropping entries it +// cannot read instead of failing. +// +// Enumeration runs over every record the firewall holds, including ones this +// portal did not create. One malformed or absent address must not take out the +// whole listing: GetInterfaces errors propagate into the startup importer, +// which treats them as fatal for all backends. +func parseCidrsTolerant(value, what, owner string) []domain.Cidr { + if strings.TrimSpace(value) == "" { + return nil + } + + parsed := make([]domain.Cidr, 0, 1) + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + cidr, err := domain.CidrFromString(part) + if err != nil { + slog.Warn("ignoring unparsable OPNsense address", + "kind", what, "owner", owner, "value", part, "error", err) + continue + } + parsed = append(parsed, cidr) + } + if len(parsed) == 0 { + return nil + } + return parsed +} + +// optionalPositiveInt renders an optional numeric field: a positive value as +// its decimal string, anything else as empty. +// +// Sending the empty string rather than omitting the key is deliberate. These +// models patch by key, so an omitted field keeps whatever the firewall already +// held, which would make "the operator cleared the MTU" indistinguishable from +// "the operator did not mention the MTU". +func optionalPositiveInt(value int) string { + if value > 0 { + return strconv.Itoa(value) + } + return "" +} + +// opnsenseBool renders a Go bool in the "0"/"1" string form the API expects. +// OPNsense never accepts JSON booleans on these models. +func opnsenseBool(value bool) string { + if value { + return "1" + } + return "0" +} + +// deviceNameForInstance mirrors OPNsense's own naming: instance 0 is wg0. +func deviceNameForInstance(instance string) string { + if instance == "" { + return "" + } + return "wg" + instance +} + +// instanceForDeviceName is the inverse, used when creating a tunnel for an +// interface identifier that wg-portal chose (e.g. "wg2" -> instance "2"). +// Returns "" when the name does not follow the wgN convention, in which case +// the caller must let OPNsense allocate an instance. +func instanceForDeviceName(id domain.InterfaceIdentifier) string { + name := string(id) + if !strings.HasPrefix(name, "wg") { + return "" + } + suffix := strings.TrimPrefix(name, "wg") + if suffix == "" { + return "" + } + if _, err := strconv.Atoi(suffix); err != nil { + return "" + } + return suffix +} + +// opnsenseStats holds the runtime counters exposed by service/show, which are +// not part of the configuration models. +type opnsenseStats struct { + lastHandshake time.Time + bytesReceived uint64 + bytesSent uint64 + endpoint string + up bool +} + +// loadStats builds lookup tables from `wg show`-equivalent output. Peers are +// keyed by device name and public key because a public key may legitimately +// appear on more than one tunnel. +func (c *OpnsenseController) loadStats(ctx context.Context) ( + map[string]opnsenseStats, // by device name (wg0), for interfaces + map[string]opnsenseStats, // by "wg0/", for peers +) { + interfaceStats := make(map[string]opnsenseStats) + peerStats := make(map[string]opnsenseStats) + + reply := c.client.Search(ctx, "/api/wireguard/service/show") + if reply.Status != lowlevel.OpnsenseApiStatusOk { + // Statistics are decoration: a tunnel that is otherwise readable should + // not disappear because the service is stopped or the call failed. + slog.Debug("could not load OPNsense WireGuard statistics", + "backend", c.cfg.Id, "error", reply.Error.String()) + return interfaceStats, peerStats + } + + for _, row := range reply.Data.Rows { + device := row.GetString("if") + if device == "" { + continue + } + switch row.GetString("type") { + case "interface": + interfaceStats[device] = opnsenseStats{ + up: row.GetString("status") == "up", + endpoint: row.GetString("endpoint"), + } + case "peer": + publicKey := row.GetString("public-key") + if publicKey == "" { + continue + } + stats := opnsenseStats{ + bytesReceived: uint64(row.GetInt("transfer-rx")), + bytesSent: uint64(row.GetInt("transfer-tx")), + endpoint: row.GetString("endpoint"), + } + if epoch := row.GetInt("latest-handshake"); epoch > 0 { + stats.lastHandshake = time.Unix(int64(epoch), 0) + } + peerStats[device+"/"+publicKey] = stats + } + } + + return interfaceStats, peerStats +} + +// applyChanges commits staged WireGuard configuration. +func (c *OpnsenseController) applyChanges(ctx context.Context, what string) error { + reply := c.client.Post(ctx, opnsenseReconfigureEndpoint, nil) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to apply WireGuard changes after %s: %v", what, reply.Error) + } + return nil +} + +// ensureWireGuardEnabled turns on the global WireGuard switch. A tunnel that is +// configured while the service is disabled stays down with no error anywhere, +// which is a confusing failure to debug, so bringing an interface up implies +// enabling the service. This only ever enables; it never disables. +func (c *OpnsenseController) ensureWireGuardEnabled(ctx context.Context) error { + reply := c.client.Get(ctx, "/api/wireguard/general/get") + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to read WireGuard general settings: %v", reply.Error) + } + + general, ok := reply.Data["general"].(map[string]any) + if !ok { + return fmt.Errorf("unexpected WireGuard general settings payload") + } + if lowlevel.GenericJsonObject(general).GetBool("enabled") { + return nil + } + + slog.Info("enabling the OPNsense WireGuard service", "backend", c.cfg.Id) + setReply := c.client.Post(ctx, "/api/wireguard/general/set", lowlevel.GenericJsonObject{ + "general": lowlevel.GenericJsonObject{"enabled": "1"}, + }) + if setReply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to enable the WireGuard service: %v", setReply.Error) + } + return nil +} + +// findServerRow returns the searchServer row for the given interface, or nil +// when no such tunnel exists. +func (c *OpnsenseController) findServerRow( + ctx context.Context, + id domain.InterfaceIdentifier, +) (lowlevel.GenericJsonObject, error) { + reply := c.client.Search(ctx, "/api/wireguard/server/searchServer"+opnsenseSearchAllParams) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return nil, fmt.Errorf("failed to query interfaces: %v", reply.Error) + } + + for _, row := range reply.Data.Rows { + if serverIdentifier(row) == id { + return row, nil + } + } + return nil, nil +} + +// serverIdentifier derives the wg-portal interface identifier from a server +// row. OPNsense fills `interface` once the tunnel has been applied; before that +// only `instance` is set, so fall back to deriving the device name. +func serverIdentifier(row lowlevel.GenericJsonObject) domain.InterfaceIdentifier { + if device := row.GetString("interface"); device != "" { + return domain.InterfaceIdentifier(device) + } + return domain.InterfaceIdentifier(deviceNameForInstance(row.GetString("instance"))) +} + +// endregion helpers + +// region wireguard-related + +func (c *OpnsenseController) GetInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) { + reply := c.client.Search(ctx, "/api/wireguard/server/searchServer"+opnsenseSearchAllParams) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return nil, fmt.Errorf("failed to query interfaces: %v", reply.Error) + } + + interfaceStats, _ := c.loadStats(ctx) + + interfaces := make([]domain.PhysicalInterface, 0, len(reply.Data.Rows)) + for _, row := range reply.Data.Rows { + physicalInterface, err := c.convertServer(row, interfaceStats) + if err != nil { + return nil, fmt.Errorf("interface convert failed for %s: %w", row.GetString("name"), err) + } + interfaces = append(interfaces, *physicalInterface) + } + + return interfaces, nil +} + +func (c *OpnsenseController) GetInterface(ctx context.Context, id domain.InterfaceIdentifier) ( + *domain.PhysicalInterface, + error, +) { + row, err := c.findServerRow(ctx, id) + if err != nil { + return nil, err + } + if row == nil { + return nil, fmt.Errorf("interface %s not found", id) + } + + interfaceStats, _ := c.loadStats(ctx) + return c.convertServer(row, interfaceStats) +} + +func (c *OpnsenseController) convertServer( + row lowlevel.GenericJsonObject, + interfaceStats map[string]opnsenseStats, +) (*domain.PhysicalInterface, error) { + identifier := serverIdentifier(row) + + // searchServer already flattens select fields, so tunneladdress arrives as + // a comma-separated CIDR list rather than the map getServer would return. + // + // A tunnel with no address is valid in OPNsense, and CidrsFromString reports + // the empty string as an error. Enumeration must not fail because of it: a + // single unreadable tunnel would otherwise abort GetInterfaces, which the + // startup importer turns into a fatal error for *every* backend, not just + // this one. Tolerate it the way pfsense.go and mikrotik.go do. + addresses := parseCidrsTolerant(row.GetString("tunneladdress"), + "tunnel addresses", string(identifier)) + + enabled := row.GetBool("enabled") + stats := interfaceStats[string(identifier)] + + physicalInterface := &domain.PhysicalInterface{ + Identifier: identifier, + KeyPair: domain.KeyPair{ + PrivateKey: row.GetString("privkey"), + PublicKey: row.GetString("pubkey"), + }, + ListenPort: row.GetInt("port"), + Addresses: addresses, + Mtu: row.GetInt("mtu"), + FirewallMark: 0, // OPNsense does not expose fwmark on the tunnel model + DeviceUp: enabled && stats.up, + ImportSource: domain.ControllerTypeOpnsense, + DeviceType: domain.ControllerTypeOpnsense, + // Byte counters are per-peer in `wg show`; the tunnel itself has none. + BytesUpload: 0, + BytesDownload: 0, + } + + physicalInterface.SetExtras(domain.OpnsenseInterfaceExtras{ + Uuid: row.GetString("uuid"), + Instance: row.GetString("instance"), + Comment: row.GetString("name"), + Disabled: !enabled, + }) + + return physicalInterface, nil +} + +func (c *OpnsenseController) GetPeers(ctx context.Context, deviceId domain.InterfaceIdentifier) ( + []domain.PhysicalPeer, + error, +) { + serverRow, err := c.findServerRow(ctx, deviceId) + if err != nil { + return nil, err + } + if serverRow == nil { + return nil, fmt.Errorf("interface %s not found", deviceId) + } + serverUuid := serverRow.GetString("uuid") + + reply := c.client.Search(ctx, "/api/wireguard/client/searchClient"+opnsenseSearchAllParams) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return nil, fmt.Errorf("failed to query peers for %s: %v", deviceId, reply.Error) + } + + _, peerStats := c.loadStats(ctx) + + peers := make([]domain.PhysicalPeer, 0, len(reply.Data.Rows)) + for _, row := range reply.Data.Rows { + // A client carries the set of tunnels it is attached to; filter to the + // requested one. Membership is a comma-joined UUID list after search + // flattening. + if !clientBelongsToServer(row, serverUuid) { + continue + } + + peer, err := c.convertClient(row, deviceId, peerStats) + if err != nil { + return nil, fmt.Errorf("peer convert failed for %s: %w", row.GetString("name"), err) + } + peers = append(peers, *peer) + } + + return peers, nil +} + +func clientBelongsToServer(row lowlevel.GenericJsonObject, serverUuid string) bool { + if serverUuid == "" { + return false + } + for _, uuid := range strings.Split(row.GetString("servers"), ",") { + if strings.TrimSpace(uuid) == serverUuid { + return true + } + } + return false +} + +func (c *OpnsenseController) convertClient( + row lowlevel.GenericJsonObject, + deviceId domain.InterfaceIdentifier, + peerStats map[string]opnsenseStats, +) (*domain.PhysicalPeer, error) { + publicKey := row.GetString("pubkey") + + allowedIPs := parseCidrsTolerant(row.GetString("tunneladdress"), "allowed addresses", publicKey) + + // serveraddress/serverport describe a remote endpoint this client dials, + // i.e. the case where the OPNsense box is the one initiating. + endpoint := joinEndpoint(row.GetString("serveraddress"), row.GetString("serverport")) + + enabled := row.GetBool("enabled") + stats := peerStats[string(deviceId)+"/"+publicKey] + + peer := &domain.PhysicalPeer{ + Identifier: domain.PeerIdentifier(publicKey), + Endpoint: endpoint, + AllowedIPs: allowedIPs, + KeyPair: domain.KeyPair{ + PublicKey: publicKey, + // OPNsense stores only the public key for a peer; the private key + // stays with the client device. + PrivateKey: "", + }, + PresharedKey: domain.PreSharedKey(row.GetString("psk")), + PersistentKeepalive: row.GetInt("keepalive"), + LastHandshake: stats.lastHandshake, + ProtocolVersion: 0, + // Counters are named from the firewall's point of view but reported from + // the peer's, matching local.go:203-204: what the firewall received is + // what the peer uploaded. + BytesUpload: stats.bytesReceived, + BytesDownload: stats.bytesSent, + ImportSource: domain.ControllerTypeOpnsense, + } + + peer.SetExtras(domain.OpnsensePeerExtras{ + Uuid: row.GetString("uuid"), + Name: row.GetString("name"), + Comment: row.GetString("name"), + Disabled: !enabled, + ClientEndpoint: endpoint, + ClientAddress: row.GetString("tunneladdress"), + ClientDns: "", + ClientKeepalive: row.GetInt("keepalive"), + }) + + return peer, nil +} + +func (c *OpnsenseController) SaveInterface( + ctx context.Context, + id domain.InterfaceIdentifier, + updateFunc func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error), +) error { + mutex := c.getInterfaceMutex(id) + mutex.Lock() + defer mutex.Unlock() + + row, err := c.findServerRow(ctx, id) + if err != nil { + return err + } + + var physicalInterface *domain.PhysicalInterface + if row != nil { + physicalInterface, err = c.convertServer(row, nil) + if err != nil { + return err + } + } else { + physicalInterface = &domain.PhysicalInterface{ + Identifier: id, + ImportSource: domain.ControllerTypeOpnsense, + DeviceType: domain.ControllerTypeOpnsense, + } + physicalInterface.SetExtras(domain.OpnsenseInterfaceExtras{ + Instance: instanceForDeviceName(id), + }) + } + + if updateFunc != nil { + physicalInterface, err = updateFunc(physicalInterface) + if err != nil { + return err + } + } + + return c.createOrUpdateInterface(ctx, physicalInterface) +} + +func (c *OpnsenseController) createOrUpdateInterface(ctx context.Context, pi *domain.PhysicalInterface) error { + extras, ok := pi.GetExtras().(domain.OpnsenseInterfaceExtras) + if !ok { + return fmt.Errorf("interface %s is missing OPNsense extras", pi.Identifier) + } + + // The identity of a tunnel on this backend is its OPNsense `instance`, which + // determines the device name (instance 2 -> wg2) that wg-portal uses as the + // interface identifier. An identifier that does not follow that convention + // has no instance to map to, so OPNsense would allocate its own and the + // resulting tunnel could never be found again: every subsequent save would + // create yet another tunnel and every delete would be a silent no-op. + // Refuse it up front rather than corrupting the firewall's configuration. + instance := extras.Instance + if instance == "" { + instance = instanceForDeviceName(pi.Identifier) + } + if instance == "" && extras.Uuid == "" { + return fmt.Errorf( + "cannot create interface %q on an OPNsense backend: the identifier must be of the form wgN "+ + "(for example wg0), because OPNsense derives the device name from the tunnel instance number", + pi.Identifier) + } + + // OPNsense requires a non-empty name in a restricted character set; the + // wg-portal display name is free-form and optional. + name := opnsenseName(extras.Comment, string(pi.Identifier)) + + // Every field wg-portal owns is sent on every write, including when it is + // empty. Omitting zero values would make a cleared MTU or listen port + // unrepresentable: the previous value would simply persist on the firewall. + server := lowlevel.GenericJsonObject{ + "enabled": opnsenseBool(!extras.Disabled), + "name": name, + "pubkey": pi.KeyPair.PublicKey, + "privkey": pi.KeyPair.PrivateKey, + "tunneladdress": domain.CidrsToString(pi.Addresses), + "port": optionalPositiveInt(pi.ListenPort), + "mtu": optionalPositiveInt(pi.Mtu), + } + if instance != "" { + server["instance"] = instance + } + + if extras.Uuid == "" { + slog.Debug("creating new OPNsense tunnel", + "interface", pi.Identifier, "addresses", domain.CidrsToString(pi.Addresses)) + + reply := c.client.Post(ctx, "/api/wireguard/server/addServer", + lowlevel.GenericJsonObject{"server": server}) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to create interface %s: %v", pi.Identifier, reply.Error) + } + if newUuid := reply.Data.GetString("uuid"); newUuid != "" { + extras.Uuid = newUuid + pi.SetExtras(extras) + } + } else { + slog.Debug("updating OPNsense tunnel", "interface", pi.Identifier, "uuid", extras.Uuid) + + reply := c.client.Post(ctx, "/api/wireguard/server/setServer/"+url.PathEscape(extras.Uuid), + lowlevel.GenericJsonObject{"server": server}) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to update interface %s: %v", pi.Identifier, reply.Error) + } + } + + if pi.DeviceUp || !extras.Disabled { + if err := c.ensureWireGuardEnabled(ctx); err != nil { + return err + } + } + + return c.applyChanges(ctx, fmt.Sprintf("saving interface %s", pi.Identifier)) +} + +func (c *OpnsenseController) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error { + mutex := c.getInterfaceMutex(id) + mutex.Lock() + defer mutex.Unlock() + + row, err := c.findServerRow(ctx, id) + if err != nil { + return err + } + if row == nil { + return nil // tunnel does not exist, nothing to delete + } + + uuid := row.GetString("uuid") + reply := c.client.Post(ctx, "/api/wireguard/server/delServer/"+url.PathEscape(uuid), nil) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to delete interface %s: %v", id, reply.Error) + } + + return c.applyChanges(ctx, fmt.Sprintf("deleting interface %s", id)) +} + +func (c *OpnsenseController) SavePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, + updateFunc func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error), +) error { + mutex := c.getPeerMutex(id) + mutex.Lock() + defer mutex.Unlock() + + serverRow, err := c.findServerRow(ctx, deviceId) + if err != nil { + return err + } + if serverRow == nil { + return fmt.Errorf("interface %s not found", deviceId) + } + serverUuid := serverRow.GetString("uuid") + + clientRow, err := c.findClientRow(ctx, id) + if err != nil { + return err + } + + var physicalPeer *domain.PhysicalPeer + if clientRow != nil { + physicalPeer, err = c.convertClient(clientRow, deviceId, nil) + if err != nil { + return err + } + } else { + physicalPeer = &domain.PhysicalPeer{ + Identifier: id, + KeyPair: domain.KeyPair{PublicKey: string(id)}, + ImportSource: domain.ControllerTypeOpnsense, + } + physicalPeer.SetExtras(domain.OpnsensePeerExtras{}) + } + + if updateFunc != nil { + physicalPeer, err = updateFunc(physicalPeer) + if err != nil { + return err + } + } + + return c.createOrUpdatePeer(ctx, deviceId, serverUuid, clientRow, physicalPeer) +} + +// findClientRow locates a peer by public key. OPNsense has no filtered lookup +// on this controller, so the full list is fetched and matched locally. +func (c *OpnsenseController) findClientRow( + ctx context.Context, + id domain.PeerIdentifier, +) (lowlevel.GenericJsonObject, error) { + reply := c.client.Search(ctx, "/api/wireguard/client/searchClient"+opnsenseSearchAllParams) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return nil, fmt.Errorf("failed to query peers: %v", reply.Error) + } + + for _, row := range reply.Data.Rows { + if row.GetString("pubkey") == string(id) { + return row, nil + } + } + return nil, nil +} + +func (c *OpnsenseController) createOrUpdatePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + serverUuid string, + existingRow lowlevel.GenericJsonObject, + pp *domain.PhysicalPeer, +) error { + extras, ok := pp.GetExtras().(domain.OpnsensePeerExtras) + if !ok { + return fmt.Errorf("peer %s is missing OPNsense extras", pp.Identifier) + } + + name := opnsenseName(extras.Name, string(pp.Identifier)) + + // Attaching a peer to a tunnel is done from the client side: setting + // `servers` here is what populates the tunnel's `peers` list. Preserve any + // other tunnels this peer is already attached to rather than detaching it + // from them. + servers := map[string]struct{}{serverUuid: {}} + if existingRow != nil { + for _, uuid := range strings.Split(existingRow.GetString("servers"), ",") { + if uuid = strings.TrimSpace(uuid); uuid != "" { + servers[uuid] = struct{}{} + } + } + } + serverList := make([]string, 0, len(servers)) + for uuid := range servers { + serverList = append(serverList, uuid) + } + // Sort for the same reason FlattenForWrite does: Go map iteration order is + // randomised, so an unsorted join would send a different `servers` value on + // every save and churn the firewall's configuration with no real change. + sort.Strings(serverList) + + // As with the tunnel, send every field wg-portal owns on every write so that + // clearing one actually clears it on the firewall. + address, port := splitEndpoint(pp.Endpoint) + client := lowlevel.GenericJsonObject{ + "enabled": opnsenseBool(!extras.Disabled), + "name": name, + "pubkey": pp.KeyPair.PublicKey, + "psk": string(pp.PresharedKey), + "tunneladdress": domain.CidrsToString(pp.AllowedIPs), + "servers": strings.Join(serverList, ","), + "keepalive": optionalPositiveInt(pp.PersistentKeepalive), + "serveraddress": address, + "serverport": port, + } + + if extras.Uuid == "" { + slog.Debug("creating new OPNsense peer", + "peer", pp.Identifier, "interface", deviceId, + "allowed-ips", domain.CidrsToString(pp.AllowedIPs)) + + reply := c.client.Post(ctx, "/api/wireguard/client/addClient", + lowlevel.GenericJsonObject{"client": client}) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to create peer %s for interface %s: %v", + pp.Identifier, deviceId, reply.Error) + } + if newUuid := reply.Data.GetString("uuid"); newUuid != "" { + extras.Uuid = newUuid + pp.SetExtras(extras) + } + } else { + slog.Debug("updating OPNsense peer", + "peer", pp.Identifier, "interface", deviceId, "uuid", extras.Uuid, + "disabled", extras.Disabled) + + reply := c.client.Post(ctx, "/api/wireguard/client/setClient/"+url.PathEscape(extras.Uuid), + lowlevel.GenericJsonObject{"client": client}) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to update peer %s on interface %s: %v", + pp.Identifier, deviceId, reply.Error) + } + } + + return c.applyChanges(ctx, fmt.Sprintf("saving peer %s", pp.Identifier)) +} + +// joinEndpoint combines a host and port, bracketing IPv6 literals. +// +// Formatting this as "host:port" is wrong for IPv6: "2001:db8::1" and "51820" +// would become "2001:db8::1:51820", which still parses as an IPv6 address with +// the port silently absorbed into it. splitEndpoint would then hand back the +// whole string as the host with no port, so the value is corrupted on every +// read/write round-trip. +func joinEndpoint(host, port string) string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + + switch { + case host == "": + return "" + case port == "": + return host + default: + return net.JoinHostPort(host, port) + } +} + +// splitEndpoint is the inverse of joinEndpoint, tolerating a bare host with no +// port and an unbracketed IPv6 literal. +func splitEndpoint(endpoint string) (string, string) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "", "" + } + + if host, port, err := net.SplitHostPort(endpoint); err == nil { + return host, port + } + + // No port present, or an unbracketed IPv6 literal. Either way there is no + // port to recover; strip any brackets so the host round-trips cleanly. + return strings.Trim(endpoint, "[]"), "" +} + +func (c *OpnsenseController) DeletePeer( + ctx context.Context, + deviceId domain.InterfaceIdentifier, + id domain.PeerIdentifier, +) error { + mutex := c.getPeerMutex(id) + mutex.Lock() + defer mutex.Unlock() + + row, err := c.findClientRow(ctx, id) + if err != nil { + return err + } + if row == nil { + return nil // peer does not exist, nothing to delete + } + + // An OPNsense client is a single record that may be attached to several + // tunnels at once, and createOrUpdatePeer deliberately preserves those + // attachments. Deleting the record outright would therefore detach the peer + // from every other tunnel as a side effect of removing it from this one. + // Detach from this tunnel instead, and only delete once nothing references + // it. + serverRow, err := c.findServerRow(ctx, deviceId) + if err != nil { + return err + } + + uuid := row.GetString("uuid") + remaining := make([]string, 0) + if serverRow != nil { + serverUuid := serverRow.GetString("uuid") + for _, attached := range strings.Split(row.GetString("servers"), ",") { + if attached = strings.TrimSpace(attached); attached != "" && attached != serverUuid { + remaining = append(remaining, attached) + } + } + } + + if len(remaining) > 0 { + sort.Strings(remaining) + slog.Debug("detaching OPNsense peer from one tunnel, it remains on others", + "peer", id, "interface", deviceId, "remaining", len(remaining)) + + client := lowlevel.FlattenForWrite(row) + client["servers"] = strings.Join(remaining, ",") + delete(client, "uuid") // identity travels in the path, not the body + // search rows carry "%"-prefixed display renderings (e.g. "%servers": + // "tier-staff") alongside the real fields; they are not writable. + for key := range client { + if strings.HasPrefix(key, "%") { + delete(client, key) + } + } + + reply := c.client.Post(ctx, "/api/wireguard/client/setClient/"+url.PathEscape(uuid), + lowlevel.GenericJsonObject{"client": client}) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to detach peer %s from interface %s: %v", id, deviceId, reply.Error) + } + } else { + reply := c.client.Post(ctx, "/api/wireguard/client/delClient/"+url.PathEscape(uuid), nil) + if reply.Status != lowlevel.OpnsenseApiStatusOk { + return fmt.Errorf("failed to delete peer %s for interface %s: %v", id, deviceId, reply.Error) + } + } + + return c.applyChanges(ctx, fmt.Sprintf("deleting peer %s", id)) +} + +// endregion wireguard-related + +// region wg-quick-related + +func (c *OpnsenseController) ExecuteInterfaceHook( + _ context.Context, + id domain.InterfaceIdentifier, + _ string, +) error { + // Hooks would have to run as shell commands on the firewall; the WireGuard + // API offers no equivalent and running arbitrary commands is out of scope + // for this backend. + slog.Error("interface hooks are not supported for OPNsense backends, please open an issue on GitHub", + "interface", id) + return nil +} + +func (c *OpnsenseController) SetDNS( + _ context.Context, + id domain.InterfaceIdentifier, + _, _ string, +) error { + c.coreMutex.Lock() + defer c.coreMutex.Unlock() + + // DNS pushed to clients is a property of the tunnel (`peer_dns`) rather + // than something applied to the firewall's own resolver, so this hook has + // no sensible OPNsense equivalent. + slog.Warn("DNS setting is not supported for OPNsense backends", "interface", id) + return nil +} + +func (c *OpnsenseController) UnsetDNS( + _ context.Context, + id domain.InterfaceIdentifier, + _, _ string, +) error { + c.coreMutex.Lock() + defer c.coreMutex.Unlock() + + slog.Warn("DNS unsetting is not supported for OPNsense backends", "interface", id) + return nil +} + +// endregion wg-quick-related + +// region routing-related + +func (c *OpnsenseController) SetRoutes(_ context.Context, info domain.RoutingTableInfo) error { + // OPNsense derives routes from the tunnel's allowed addresses unless + // `disableroutes` is set; wg-portal does not need to manage them directly. + slog.Debug("route setting is handled by OPNsense itself", "interface", info.Interface.Identifier) + return nil +} + +func (c *OpnsenseController) RemoveRoutes(_ context.Context, info domain.RoutingTableInfo) error { + slog.Debug("route removal is handled by OPNsense itself", "interface", info.Interface.Identifier) + return nil +} + +// endregion routing-related + +// region statistics-related + +func (c *OpnsenseController) PingAddresses( + _ context.Context, + _ string, +) (*domain.PingerResult, error) { + return nil, fmt.Errorf("ping functionality is not implemented for OPNsense backends") +} + +// endregion statistics-related diff --git a/internal/adapters/wgcontroller/opnsense_integration_test.go b/internal/adapters/wgcontroller/opnsense_integration_test.go new file mode 100644 index 00000000..5e46fcdc --- /dev/null +++ b/internal/adapters/wgcontroller/opnsense_integration_test.go @@ -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") +} diff --git a/internal/adapters/wgcontroller/opnsense_test.go b/internal/adapters/wgcontroller/opnsense_test.go new file mode 100644 index 00000000..fc8f20b3 --- /dev/null +++ b/internal/adapters/wgcontroller/opnsense_test.go @@ -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) + } + } +} diff --git a/internal/app/wireguard/controller_manager.go b/internal/app/wireguard/controller_manager.go index 041b4aa1..f15ceaed 100644 --- a/internal/app/wireguard/controller_manager.go +++ b/internal/app/wireguard/controller_manager.go @@ -44,6 +44,9 @@ func (c *ControllerManager) init() error { return err } + if err := c.registerOpnsenseControllers(); err != nil { + return err + } if err := c.registerPfsenseControllers(); err != nil { return err } @@ -113,6 +116,26 @@ func (c *ControllerManager) registerPfsenseControllers() error { return nil } +func (c *ControllerManager) registerOpnsenseControllers() error { + for _, backendConfig := range c.cfg.Backend.Opnsense { + if backendConfig.Id == config.LocalBackendName { + slog.Warn("skipping registration of OPNsense controller with reserved ID", "id", config.LocalBackendName) + continue + } + + controller, err := wgcontroller.NewOpnsenseController(c.cfg, &backendConfig) + if err != nil { + return fmt.Errorf("failed to create OPNsense controller for backend %s: %w", backendConfig.Id, err) + } + + c.controllers[domain.InterfaceBackend(backendConfig.Id)] = backendInstance{ + Config: backendConfig.BackendBase, + Implementation: controller, + } + } + return nil +} + func (c *ControllerManager) logRegisteredControllers() { for backend, controller := range c.controllers { slog.Debug("backend controller registered", diff --git a/internal/app/wireguard/controller_manager_opnsense_test.go b/internal/app/wireguard/controller_manager_opnsense_test.go new file mode 100644 index 00000000..9a3d401b --- /dev/null +++ b/internal/app/wireguard/controller_manager_opnsense_test.go @@ -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") + }) + } +} diff --git a/internal/config/backend.go b/internal/config/backend.go index b1aeef51..3270f625 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -21,6 +21,7 @@ type Backend struct { Mikrotik []BackendMikrotik `yaml:"mikrotik"` Pfsense []BackendPfsense `yaml:"pfsense"` + Opnsense []BackendOpnsense `yaml:"opnsense"` } // Validate checks the backend configuration for errors. @@ -30,23 +31,31 @@ func (b *Backend) Validate() error { } uniqueMap := make(map[string]struct{}) - for _, backend := range b.Mikrotik { - if backend.Id == LocalBackendName { + checkBackendId := func(id string) error { + if id == LocalBackendName { return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName) } - if _, exists := uniqueMap[backend.Id]; exists { - return fmt.Errorf("backend ID %q is not unique", backend.Id) + if _, exists := uniqueMap[id]; exists { + return fmt.Errorf("backend ID %q is not unique", id) + } + uniqueMap[id] = struct{}{} + return nil + } + + for _, backend := range b.Mikrotik { + if err := checkBackendId(backend.Id); err != nil { + return err } - uniqueMap[backend.Id] = struct{}{} } for _, backend := range b.Pfsense { - if backend.Id == LocalBackendName { - return fmt.Errorf("backend ID %q is a reserved keyword", LocalBackendName) + if err := checkBackendId(backend.Id); err != nil { + return err } - if _, exists := uniqueMap[backend.Id]; exists { - return fmt.Errorf("backend ID %q is not unique", backend.Id) + } + for _, backend := range b.Opnsense { + if err := checkBackendId(backend.Id); err != nil { + return err } - uniqueMap[backend.Id] = struct{}{} } if b.Default != LocalBackendName { @@ -153,3 +162,38 @@ func (b *BackendPfsense) GetApiTimeout() time.Duration { } return b.ApiTimeout } + +type BackendOpnsense struct { + BackendBase `yaml:",inline"` // Embed the base fields + + // The base URL of the OPNsense API (e.g., "https://opnsense.example.com"). + // Unlike the pfSense backend this is the host root, not an /api prefix: the + // controller paths already start with /api/wireguard/. + ApiUrl string `yaml:"api_url"` + // OPNsense authenticates with an API key/secret pair sent as HTTP Basic + // credentials, generated under 'System' -> 'Access' -> 'Users' -> 'API keys'. + // This differs from pfSense, which uses a single X-API-Key header value. + ApiKey string `yaml:"api_key"` + ApiSecret string `yaml:"api_secret"` + ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the OPNsense API + ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds) + + Debug bool `yaml:"debug"` // Enable debug logging for the OPNsense backend + + // Note: there is deliberately no concurrency setting here. The Mikrotik and + // pfSense backends fan out one request per interface to collect details; + // OPNsense's searchXxx endpoints return every record with its fields already + // populated, so enumeration costs a fixed three calls regardless of size. +} + +// GetApiTimeout returns the configured API timeout or a sane default (30 seconds) +// when the configured value is zero or negative. +func (b *BackendOpnsense) GetApiTimeout() time.Duration { + if b == nil { + return 30 * time.Second + } + if b.ApiTimeout <= 0 { + return 30 * time.Second + } + return b.ApiTimeout +} diff --git a/internal/config/backend_test.go b/internal/config/backend_test.go new file mode 100644 index 00000000..733879db --- /dev/null +++ b/internal/config/backend_test.go @@ -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()) +} diff --git a/internal/domain/controller.go b/internal/domain/controller.go index 53aed05e..2e1da190 100644 --- a/internal/domain/controller.go +++ b/internal/domain/controller.go @@ -6,6 +6,7 @@ const ( ControllerTypeMikrotik = "mikrotik" ControllerTypeLocal = "wgctrl" ControllerTypePfsense = "pfsense" + ControllerTypeOpnsense = "opnsense" ) // Controller extras can be used to store additional information available for specific controllers only. @@ -49,3 +50,21 @@ type PfsensePeerExtras struct { ClientDns string ClientKeepalive int } + +type OpnsenseInterfaceExtras struct { + Uuid string // internal OPNsense UUID of the WireGuard "server" (tunnel) + Instance string // the wg instance number; OPNsense derives the device name (wg0) from it + Comment string + Disabled bool +} + +type OpnsensePeerExtras struct { + Uuid string // internal OPNsense UUID of the WireGuard "client" (peer) + Name string + Comment string + Disabled bool + ClientEndpoint string + ClientAddress string + ClientDns string + ClientKeepalive int +} diff --git a/internal/domain/interface.go b/internal/domain/interface.go index b26d5488..5a9c7a6a 100644 --- a/internal/domain/interface.go +++ b/internal/domain/interface.go @@ -283,7 +283,8 @@ func (p *PhysicalInterface) SetExtras(extras any) { switch extras.(type) { case MikrotikInterfaceExtras: // OK case PfsenseInterfaceExtras: // OK - default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now + case OpnsenseInterfaceExtras: // OK + default: // we only support Mikrotik, Pfsense and Opnsense interface extras for now panic(fmt.Sprintf("unsupported interface backend extras type %T", extras)) } @@ -354,6 +355,14 @@ func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface { } else { iface.Disabled = nil } + case ControllerTypeOpnsense: + extras := pi.GetExtras().(OpnsenseInterfaceExtras) + iface.DisplayName = extras.Comment + if extras.Disabled { + iface.Disabled = &now + } else { + iface.Disabled = nil + } } return iface @@ -382,6 +391,19 @@ func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) { Disabled: i.IsDisabled(), } pi.SetExtras(extras) + case ControllerTypeOpnsense: + // Uuid and Instance are OPNsense's identity for this tunnel, not + // user-editable data, so carry them across the merge rather than + // letting the caller re-inject them afterwards. + extras := OpnsenseInterfaceExtras{ + Comment: i.DisplayName, + Disabled: i.IsDisabled(), + } + if existing, ok := pi.GetExtras().(OpnsenseInterfaceExtras); ok { + extras.Uuid = existing.Uuid + extras.Instance = existing.Instance + } + pi.SetExtras(extras) } } diff --git a/internal/domain/peer.go b/internal/domain/peer.go index ea79f80a..7323a7b8 100644 --- a/internal/domain/peer.go +++ b/internal/domain/peer.go @@ -243,7 +243,8 @@ func (p *PhysicalPeer) SetExtras(extras any) { case MikrotikPeerExtras: // OK case LocalPeerExtras: // OK case PfsensePeerExtras: // OK - default: // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now + case OpnsensePeerExtras: // OK + default: // we only support Mikrotik, Local, Pfsense and Opnsense peer extras for now panic(fmt.Sprintf("unsupported peer backend extras type %T", extras)) } @@ -326,6 +327,26 @@ func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer { peer.Disabled = nil peer.DisabledReason = "" } + case ControllerTypeOpnsense: + extras := pp.GetExtras().(OpnsensePeerExtras) + peer.Notes = extras.Comment + peer.DisplayName = extras.Name + if extras.ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer + peer.Endpoint = NewConfigOption(extras.ClientEndpoint, true) + peer.Interface.Type = InterfaceTypeClient + peer.Interface.Addresses, _ = CidrsFromString(extras.ClientAddress) + peer.Interface.DnsStr = NewConfigOption(extras.ClientDns, true) + peer.PersistentKeepalive = NewConfigOption(extras.ClientKeepalive, true) + } else { + peer.Interface.Type = InterfaceTypeServer + } + if extras.Disabled { + peer.Disabled = &now + peer.DisabledReason = "Disabled by OPNsense controller" + } else { + peer.Disabled = nil + peer.DisabledReason = "" + } } return peer @@ -393,6 +414,22 @@ func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) { ClientKeepalive: p.PersistentKeepalive.GetValue(), } pp.SetExtras(extras) + case ControllerTypeOpnsense: + extras := OpnsensePeerExtras{ + Name: p.DisplayName, + Comment: p.Notes, + Disabled: p.IsDisabled(), + ClientEndpoint: p.Endpoint.GetValue(), + ClientAddress: CidrsToString(p.Interface.Addresses), + ClientDns: p.Interface.DnsStr.GetValue(), + ClientKeepalive: p.PersistentKeepalive.GetValue(), + } + // The UUID identifies the existing OPNsense client; losing it here would + // turn every update into a duplicate create. + if existing, ok := pp.GetExtras().(OpnsensePeerExtras); ok { + extras.Uuid = existing.Uuid + } + pp.SetExtras(extras) } } diff --git a/internal/lowlevel/opnsense.go b/internal/lowlevel/opnsense.go new file mode 100644 index 00000000..b27d8043 --- /dev/null +++ b/internal/lowlevel/opnsense.go @@ -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 diff --git a/internal/lowlevel/opnsense_test.go b/internal/lowlevel/opnsense_test.go new file mode 100644 index 00000000..ab2a3810 --- /dev/null +++ b/internal/lowlevel/opnsense_test.go @@ -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")) +}