Skip to content

Known Limitations

A candid list of what bnerd-ipsec-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

This operator has been validated end-to-end on a live reference deployment (2026-08-19): tunnel establishment, exposures (including cross-cluster), the reverse-exposure path, PSK rotation, and teardown all ran against a real OpenStack project, a real Multus/host-device attachment, and a real strongSwan peer — see the index page for the summary. Every reconciler/render/alloc code path is additionally 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.

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

Validated end-to-end: 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.

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.