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 goesNotReadytriggers 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.