Skip to content

Known Limitations

A candid list of what bnerd-vpn-operator does not cover yet, or has not proven for every scenario, as of v0.1.0. Nothing here is guessed.

v0.1.0 release status

Every reconciler/render/alloc code path is covered by envtest and unit suites against a fake OpenStack client (internal/openstack/fake) and a fake VICI session. The items below are this release's known, deliberate constraints and remaining gaps, ordered roughly by how much they matter.

v0.3.0 additions: the unified vpn.bnerd.net model

The items in this section are specific to VPNGateway/VPNPeer (see The unified model); everything else on this page still applies unchanged to the frozen IPsecGateway/ClusterLink group, which v0.3.0 continues to serve in parallel.

ha.enabled is reserved, not implemented — but node-loss detection quick-wins ARE in v0.3.0

VPNGatewaySpec.HA exists as a schema placeholder only; Validate() rejects ha.enabled: true outright rather than silently ignoring it — the same honest-reject discipline this operator uses everywhere a field exists before its behavior does (e.g. IPsecGateway.spec.tunnels[].mode: routed, rejected the same way). Warm-standby HA (a second, passive gateway pod that takes over on failure) is not built and was not delivered in v0.5.0.

What is in v0.3.0 is a pair of node-loss detection quick-wins that shorten the outage window for the existing single-replica model, not a true HA mechanism: the gateway pod's NoExecute tolerations for node.kubernetes.io/not-ready/unreachable are set to 30s (haTolerationSeconds) instead of Kubernetes' 5-minute default, and a Node watch re-triggers reconcile for every VPNGateway pinned (status.nodeName) to a node that goes NotReady, so the reselect-and- reattach onto a healthy node starts promptly rather than waiting for the next unrelated reconcile. This shortens, but does not eliminate, the outage described in "Gateway pods are hard-pinned to a single node" below — there is still exactly one pod, and the reattach still rolls it.

Active-active HA is ruled out for this horizon, not merely deferred

Unlike warm-standby (deferred to v0.5.0), active-active — two simultaneously-serving gateway pods for the same client — has been evaluated and ruled out under the current architecture, not just postponed: the model is one Neutron port with one 1:1 floating IP per gateway, and IKE/WireGuard both terminate at a single endpoint identity. There is no place in that model for two pods to both legitimately answer the same public IP without a load balancer sitting in front of the IKE/WireGuard negotiation itself — a materially different architecture, not a configuration option. Revisit only alongside a broader redesign, not as an incremental v0.3.x/v0.4.x/v0.5.x feature.

WireGuard gateways have no Prometheus exporter

See WireGuard gateways: no Prometheus exporter. WireGuard has no VICI-style RPC the way strongSwan does, and no exporter container exists in the wireguard container set as of v0.3.0. Monitor via status.peers[] instead.

A WireGuard hostname-endpoint site peer widens the gateway's listen port estate-wide

A WireGuard site peer whose endpoint is a hostname (not a literal IPv4 address) cannot be pinned to a /32 security-group rule, so its UDP listen port is opened estate-wide (0.0.0.0/0) — the same posture as a road-warrior peer, and safe because WireGuard authenticates by public key (unauthenticated packets are dropped in-kernel, so the open port is not an open door). The gateway's SecurityGroupScoped condition goes False with reason HostnameEndpointOpensPort, naming the affected peer(s); pin an IPv4 endpoint to keep the port scoped (True/AllSourcesPinned). Not set for ipsec gateways, which always pin per peer. See WireGuard gateways: security-group semantics.

Identity-issued road-warrior peers are specified but not built

A future integration point — road-warrior VPNPeers provisioned/revoked by an identity source (SSO/Keycloak) rather than a hand-applied CR per device — is spec'd in the design but not implemented in v0.3.0. Every road-warrior peer today is a manually-applied VPNPeer CR.

New-model ipsec gateways fail loud, not silently, when reverse exposures lack internalSourceCIDRs — a behavior change from the legacy path

This is a deliberate behavior change worth calling out explicitly if you're used to the legacy IPsecGateway path's behavior. The legacy group degrades silently when spec.reverseExposures[] is set but the operator has no internalSourceCIDRs configured: RoutingReady still goes True (routing genuinely is programmed), its message names the gap, and the reconciler logs a matching warning — but nothing stops the reconcile or surfaces a hard failure (see "Reverse-exposure security-group ingress requires internalSourceCIDRs" below).

The unified renderer refuses this outright: rendering any reverse exposure with InternalSourceCIDRs empty is a fail-loud error — ConfigRendered=False/ReverseExposuresRequireInternalSourceCIDRs — because post-decap iptables in the gateway pod's own netns is the only place reverse-exposure traffic can be constrained to fabric-originated sources at all (a Neutron Security Group never sees it), so rendering the rule without a source constraint would mean any tunnel peer sharing the gateway's serviceCIDR traffic-selector could reach another peer's reverse-exposure target. Set the operator's internalSourceCIDRs (IPSEC_INTERNAL_SOURCE_CIDRS) before onboarding the first VPNGateway/VPNPeer pair that uses a reverse exposure.

internalSourceCIDRs overlapping a peer's own subnets reopens that peer's reverse path (deployment constraint)

If the operator's internalSourceCIDRs is configured broadly enough to overlap a specific peer's own SourceCIDRs (its remoteSubnets/ subnets), that overlap effectively re-admits that peer's own addresses as a valid source for a reverse-exposure flow — reopening a path the per-peer authorization model intends to gate. This is an operator-level configuration discipline concern, not an attacker-controllable input: internalSourceCIDRs is a boot-time operator config value, never CR-supplied, so the risk is a careless CIDR choice by whoever configures the operator, not something a VPNPeer author can trigger. Keep internalSourceCIDRs scoped to genuine fabric-side address space that never overlaps any tenant's routed peer subnets. A render-time disjointness check (rejecting an internalSourceCIDRs entry that overlaps any peer's SourceCIDRs) is a candidate v0.3.x fast-follow, not built as of this release.

Established flows survive grant revocation until the conntrack entry expires

Revoking a peer's grant (removing it from authorization.exposures/ .reverseExposures, or deleting the block entirely) updates the rendered NAT/authz ruleset on the next reconcile — but the renderer only withdraws the rule; it does not proactively flush the kernel's existing conntrack entries for that flow. A connection that was already ESTABLISHED before the grant was revoked keeps flowing until its own conntrack entry naturally expires (idle timeout or explicit close), even though the iptables rule that would newly-admit it is now gone. A conntrack -D flush on grant change is ledgered as a fast-follow, not built as of v0.3.0 — treat a grant revocation as "no new connections after the next reconcile", not "existing connections are immediately cut".

Gateway pods are hard-pinned to a single node (no spreading, no HA)

The OpenStack port attachment doesn't follow the pod on its own — it has to be hot-attached to the exact Nova instance backing a specific Kubernetes node, and the pod has to be hard-pinned (spec.nodeName, not a nodeSelector/affinity hint) to that same node. See Networking for the full mechanics. Consequences:

  • Every gateway is bound to exactly one node at a time. There is no way to spread replicas across nodes for HA — see "No high availability" below, which this reinforces rather than duplicates: even a hypothetical multi-replica gateway would still need every replica pinned to nodes with their own dedicated Nova-attached ports, not a shared one.
  • Each node that ends up hosting one or more gateway pods accumulates one Nova interface attachment per gateway — this is capacity-relevant: Nova/Neutron enforce a per-instance interface limit, and nothing in this operator tracks or caps how many gateways can stack onto the same node before hitting it. Dedicating a node pool to gateway workloads (see "Gateway pods have no scheduling or resource controls yet" below) also bounds this exposure.
  • Sticky selection (kept across reconciles as long as the node stays Ready) minimizes churn, but a node that goes NotReady triggers a reselect-and-reattach onto a different node, which rolls the pod deliberately — there is no cordon/drain-aware pre-migration; the gateway is down for the duration of the reattach + pod reschedule + IKE renegotiation.

NAD/Multus attachment mechanics: host-device-by-hwaddr, verified end-to-end

The chart's default nadTemplate is the host-device-by-hwaddr CNI config verified end-to-end against a real OpenStack/Neutron network on our reference deployment — see Networking for the mechanics and the two cluster-level prerequisites (Multus + containernetworking/plugins installed, Cilium's cni-exclusive=false) neither the reconciler nor the chart can satisfy from inside the cluster. The reconciler still treats the whole CNI config as an opaque, string-substituted template, so a different SDN/attachment mechanism can be substituted by overriding nadTemplate — but the shipped default is now a verified config, not an unvalidated example.

status.tunnels[].rekeyAt semantics are unverified

RekeyAt is computed by adding the CHILD_SA's rekey-time VICI field (as seconds) to "now", on the assumption that rekey-time reports seconds-remaining-until-rekey (per the govici README). This has not been checked against a live charon daemon and must be verified during e2e before this field is treated as authoritative for anything operationally significant (alerting, capacity planning). A related, lower-severity gap: mid-rekey there is a dual-CHILD_SA window where BytesIn/BytesOut/RekeyAt can read as zero/nil while State stays correct — cosmetic, not a correctness bug.

NetworkPolicy egress is unrestricted (deliberate deviation, pending follow-up)

Every gateway's owned NetworkPolicy is ingress-only — egress on the gateway pod's NIC1 is not restricted at all. Correctly admitting DNS, the kube-apiserver (status-agent's k8s client), and the tunnel's own OpenStack-routed dataplane egress without getting any one of those wrong (which fails a tunnel silently rather than loudly) needs the real egress set characterized against a live gateway first. Separately, the operator-namespace ingress rule on that same NetworkPolicy is deliberately any-port, not scoped down further — headroom for operator-side tooling against a single-purpose pod, a considered choice rather than an oversight. See Networking for the full picture, including what NIC2 (the tunnel-carrying interface, outside NetworkPolicy's reach entirely) means for both of these.

No high availability

Every IPsecGateway renders exactly one Deployment replica. There is no active/passive or active/active failover model, no leader election between gateway pods, and no automatic re-scheduling guarantee beyond what the Kubernetes scheduler itself provides for a single-replica Deployment. A node failure taking down a gateway pod means that client's tunnel is down until the pod reschedules and strongSwan re-negotiates.

IKEv2 with pre-shared-key authentication only

There is no certificate-based (pubkey) authentication support, and spec.ike.version is schema-constrained to 2 — no IKEv1 path exists or is planned. A client whose device only supports IKEv1, or who requires certificate auth, cannot be onboarded onto this operator as it stands.

Single PSK per gateway

spec.auth.pskSecretRef is one Secret reference per IPsecGateway, shared across every tunnel the gateway terminates. There is no per-tunnel PSK override — a gateway with multiple tunnels to different peers authenticates all of them with the same pre-shared key (scoped per-peer via VICI load-shared's owners list, but the secret value itself is shared).

Multi-subnet tunnels render as one child SA, not split per subnet

spec.tunnels[].remoteSubnets with more than one CIDR currently renders into a single child SA with a comma-joined remote_ts, unlike the field-proven Ansible reference template (.j2), which splits each remote subnet into its own child SA. Every tunnel validated on the reference deployment to date has had exactly one subnet, so this gap hasn't mattered yet — but some third-party firewalls require narrow, per-subnet Phase 2 selectors and may reject or mis-negotiate the comma-joined form. Revisit before the first genuinely multi-subnet client.

CR string fields reaching swanctl.conf/iptables are validated by Validate(), not a CRD-level pattern

As of the v0.1.0 security review, spec.Validate() rejects malformed or injection-shaped values before they reach text/template: tunnel/exposure/ reverse-exposure names must match a DNS-label pattern, peer.id is restricted to FQDN/IP/email-style characters (no braces or whitespace), peer.address/target.address/serviceCIDR/remoteSubnets entries must parse as IPv4, target.host must be a valid RFC 1123 hostname, and every IKE and ESP proposal must exact-match the approved allowlist — see the CRD reference field table for the full list. This is enforced in Go (api/v1alpha1/ipsecgateway_types.go), not as a CRD-level +kubebuilder:validation:Pattern marker, so a value can still be rejected one reconcile after kubectl apply succeeds rather than at admission time.

Exposure-name uniqueness is enforced by the allocator, not the schema

spec.exposures[]/spec.reverseExposures[] name uniqueness (across both lists combined) is currently caught by the virtual-IP allocator at reconcile time (AllocationFailed), not by spec.Validate() up front or a CRD-level rule — so a duplicate name surfaces one reconcile-cycle later than it could. Functionally correct, just not fail-fast.

No live-cluster verification of PSK rotation or config reload

Both the PSK rotation and config-reload flows are covered by unit tests against fakes, but have not been exercised end-to-end against a real strongSwan charon process. In particular, the VICI load-shared field name (data, not shared) was corrected during development against the upstream vici README rather than left as an implementer's guess — but the overall flow (checksum → load-shared → successful auth) is still e2e-unverified.

RBAC drift risk between config/rbac and the chart

config/rbac/role.yaml (the kustomize/controller-gen-derived manifest) has drifted from the chart's templates/rbac.yaml, which is the one actually applied on a real Helm install and is kept in sync with the reconciler's +kubebuilder:rbac markers by hand. Not a functional gap for a Helm-installed operator, but a maintenance hazard if config/rbac is ever used as the source of truth without re-syncing it first.

spec.tunnels[].mode: routed is not implemented

The CRD's enum accepts routed alongside snat (the API shape is stable), but Validate() rejects any tunnel that actually sets it in v0.1.0, with an error naming the version — it is honest-reject-only, not silently treated as snat. The intended behavior (routing the tenant's real subnet through the gateway instead of SNATing it, reusing the same allowed-address-pair + router route machinery reverse exposures already have) is planned but not built. spec.reverseExposures[] is the only supported way today to reach a client's services from our side. See Networking.

Gateway pods have no scheduling or resource controls yet

buildDeployment sets no nodeSelector, tolerations, resources (requests/limits), livenessProbe, or readinessProbe on any container in the gateway pod's Deployment — it schedules like any ordinary, best-effort-QoS pod onto whatever node the scheduler picks, with no dedicated placement and no health-based restart/traffic-gating beyond the kubelet's default process-exit behavior. There is no CR or operator-config knob for any of this today. Until one exists, schedule a dedicated node pool for gateway pods (a NET_ADMIN/NET_RAW workload with a Multus second NIC benefits from isolation regardless) via a cluster-level mechanism outside the operator — e.g. a PodTolerationRestriction / nodeSelector webhook, or simply not co-locating other workloads on that pool.

Source-policy routing assumes NIC2 is always named net1

The init-nat prelude that fixes NIC2's reply-routing spoof-drop (see Networking) hardcodes the in-pod interface name net1 — Multus's naming convention for the first (and, today, only) network named in the pod's k8s.v1.cni.cncf.io/networks annotation. If a future change ever attaches a second NAD to the gateway pod, or a Multus configuration numbers interfaces differently, this name must be updated in lockstep (dedicatedNICName in internal/controller) — there is no dynamic interface-name discovery here.

The operator does not manage exposure-target security groups

Confirmed during live validation: DNAT'd traffic to a spec.exposures[] target arrives from the gateway's own Neutron port, which is not automatically a member of whatever security group the target (an internal LB VIP, a NodePort, a VM) normally trusts. This is deliberate, not an oversight — the target is a foreign-owned object the operator has no business reaching into and mutating the SG rules of. Admitting ingress from the gateway network's subnet (e.g. vpn-transit, 10.254.0.0/24) on the exposed ports is a prerequisite the requester of the exposure has to handle on the target side before onboarding; the operator does not check for it, and a missing rule fails silently (packets simply never arrive, same as any other SG drop). See Networking.

Reverse-exposure security-group ingress requires internalSourceCIDRs

Confirmed during live validation: spec.reverseExposures[] always gets its allowed-address-pair and router route from the reconciler (RoutingReady), so fabric-initiated traffic to a reverse-exposure virtual IP is routed onto the gateway's port — but by default nothing at the security group admits it there, since the per-peer IKE rules only ever open UDP 500/4500 from each tunnel's peer.address /32. Unlike the exposure-target gap above, this half is operator-rendered — it just needs the operator-wide internalSourceCIDRs chart value (IPSEC_INTERNAL_SOURCE_CIDRS) set to the fabric's own source CIDRs; see Networking for the exact rule set this renders. Left unset, RoutingReady still goes True (routing genuinely is programmed) but its message names the gap, and the reconciler logs a matching WARNING every reconcile a gateway with reverseExposures set has no CIDRs configured.

The initiate-if-down watchdog can't survive a charon-only container restart

The initiate-if-down watchdog covers a stuck/failed initiation and a whole-pod restart (that reruns status-agent's own boot sequence: reconnect to VICI, push the PSK, --load-conns, and only then the watchdog resumes). It does not cover strongswan/charon restarting on its own while status-agent's own container keeps running: status-agent connects to the VICI socket exactly once at boot (connectWithRetry in cmd/status-agent/main.go) and has no reconnect logic at all — a charon-only restart breaks that already-established govici.Session, and every subsequent VICI call (list-sas, load-shared, load-conns, initiate) starts erroring instead of reporting DOWN, so the watchdog never even sees a state it could act on. Only a full pod restart (which recreates the session from scratch) recovers. A VICI reconnect-and-re-push-on-reconnect mechanism is ledgered as a fast-follow, not built as of this release.

A cross-tenant gateway cluster's reverse routes are not scoped per customer

See Connecting service clusters: cross-tenant ClusterLinks for the shared-transit-network posture this builds on. Reverse-route automation converges every reverseExposures-bearing gateway's reverse route onto every ClusterLink's own router, estate-wide — a ClusterLink carries no reference back to which gateways it should or shouldn't serve, so this is not scoped to "only the gateways that customer project should be able to reach." On a gateway cluster linked to a single customer project this is harmless: every reverse route it installs belongs to a gateway whose traffic that customer's own router is already carrying anyway. It stops being harmless the moment a second customer project is linked to the same gateway cluster and a gateway serving that second customer also gets a reverseExposures entry: its reverse route would land on the first customer's router too (and vice versa), handing each customer's router a route into the other's service CIDR over the shared transit network. Per-customer transit-network scoping (which would close this structurally) is planned but not built. Until then: do not add a second customer's gateway with reverse exposures to a gateway cluster that already carries a cross-tenant ClusterLink, until reverse routes are scoped per link.

Owned children don't self-heal from manual tampering until the next reconcile

The reconciler has no Owns() watch on the per-gateway ServiceAccount, RoleBinding, or NetworkPolicy — only on ConfigMaps and Deployments. Manual edits or deletion of those three object kinds are only corrected on the next reconcile (a Host-exposure's 5-minute timer, or the manager's 10-hour resync period, whichever comes first), not immediately. Consistent with the minimal RBAC the operator is scoped to; revisit with a dedicated watch if drift between reconciles becomes operationally significant.