Skip to content

CRD Reference — bnerd-ipsec-operator

API group: ipsec.bnerd.net/v1alpha1

Two kinds: IPsecGateway (namespaced, one per tenant gateway) and ClusterLink (cluster-scoped, one per connected service cluster — see ClusterLink below). Generated systematically from api/v1alpha1/ipsecgateway_types.go / clusterlink_types.go and charts/bnerd-ipsec-operator/crds/ — cross reference the Go source for the authoritative doc comments.


IPsecGateway

Namespaced

One CR = one client's IPsec site-to-site gateway (one Deployment, one Neutron port/security-group/floating-IP, one allocated /24 service CIDR).

Spec fields

Field Type Required Description
spec.endpoint.floatingIPPool string no The Neutron external network's UUID to allocate a floating IP from — passed straight through as gophercloud's floatingips.CreateOpts.FloatingNetworkID (internal/openstack.RealClient.EnsureFIP), which requires an ID, not a name. Falls back to the operator's configured fipPool chart value when both are empty; that value is the same external-network UUID, not a human-readable pool name.
spec.endpoint.floatingIP string no Pins an existing floating IP (e.g. across a VM-to-operator migration) instead of allocating a new one. Must already exist in the project and not be associated with a different port; a pinned FIP is treated as customer-owned and is never tagged or released by Cleanup — only disassociated. See Migration.
spec.ike.version int yes Must be 2 — the only value the CRD schema's enum and the reconciler's Validate() accept.
spec.ike.proposals []string yes, non-empty IKE crypto proposals. Every entry must exact-match the reconciler's approved allowlist (api/v1alpha1.ApprovedProposals, security review, spec §7): aes256-sha256-ecp256, aes256-sha512-ecp384, aes256gcm16-prfsha384-ecp384, aes256gcm16-prfsha256-ecp256. Any other string — including otherwise-valid strongSwan proposal syntax — is a validation error naming the offending entry and the full allowed set.
spec.ike.lifetime string no IKE SA rekey interval, e.g. 86400s. Defaults to 86400s (the field-proven Ansible role default) when unset. When set, must match ^[0-9]+[smh]$ (security review) — flows unquoted into rekey_time = in swanctl.conf.
spec.ike.dpd.delay string no Dead Peer Detection probe interval. Defaults to 30s. When set, must match ^[0-9]+[smh]$ (security review).
spec.ike.dpd.timeout string no Dead Peer Detection timeout. Defaults to 150s. When set, must match ^[0-9]+[smh]$ (security review).
spec.auth.pskSecretRef.name string yes Name of the Secret carrying the pre-shared key, in the CR's own namespace.
spec.auth.pskSecretRef.key string yes Key within that Secret. Mounted read-only into strongswan and status-agent at the fixed path /etc/swanctl-psk/psk regardless of the key's actual name — status-agent pushes its content into charon via VICI load-shared, never through swanctl.conf (see Networking and Operations).
spec.tunnels[] []TunnelSpec yes, at least one One entry per IKE/IPsec tunnel this gateway terminates.
spec.tunnels[].name string yes Also the swanctl connection name and its child SA's name — keep it stable; changing it is a new connection, not a rename. Must match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ (DNS-label style; security review) — enforced by Validate(), not a CRD-level pattern.
spec.tunnels[].peer.address string yes Remote peer's public IP. Must parse as a literal IPv4 address (netip.ParseAddr, security review) — no hostnames, no IPv6. Also opens the per-tunnel ingress security-group rule (UDP/500 + UDP/4500, /32) — see Networking.
spec.tunnels[].peer.id string yes Remote IKE identity (remote { id = ... }). Must match ^[A-Za-z0-9.@:_-]+$ (FQDN/IP/email-style IKE IDs; no braces or whitespace, security review). Also the VICI load-shared "owner" identity that scopes the PSK to this peer.
spec.tunnels[].remoteSubnets[] []string yes, non-empty CIDRs on the client's side reachable through this tunnel (remote_ts). Rejected by the reconciler's Validate() if empty; every entry must also parse as an IPv4 CIDR (netip.ParsePrefix, security review). Multiple subnets currently render into one child SA with a comma-joined remote_ts (not split per-subnet like the Ansible reference .j2) — revisit before a client whose firewall needs narrow per-subnet selectors; see Known Limitations.
spec.tunnels[].esp.proposals[] []string yes, non-empty ESP crypto proposals for this tunnel's child SA. Same approved allowlist as spec.ike.proposals above, checked per tunnel.
spec.tunnels[].esp.lifetime string no ESP/child-SA rekey interval. Defaults to 43200s (the Ansible role's ipsec_esp_lifetime default — not the IKE lifetime's 86400s). When set, must match ^[0-9]+[smh]$ (security review).
spec.tunnels[].mode snat | routed no Defaults to snat. snat = tunnel traffic is source-NATed through the gateway's own service CIDR. routed is accepted by the CRD's enum (the API shape is stable) but not implemented in v0.1.0Validate() rejects any tunnel that sets it, with an error naming the version. The intent (documented for when it lands): the tenant's real subnet routed through the gateway instead, skipping that NAT — opt-in, adding a router route and allowed-address-pairs on the port. This is meant as a NAT/routing-tier switch only; the IPsec encapsulation mode rendered into swanctl.conf is always tunnel regardless of this field. See Networking and Known Limitations.
spec.serviceCIDR string no Overrides the allocated /24; if empty, the operator allocates the next free /24 from its configured serviceCIDRPool and never re-rolls it once assigned (an operator-set override is honored verbatim on every reconcile). When set, must parse as an IPv4 /24 (security review); the reconciler also rejects it if it overlaps another gateway's already-allocated CIDR (netip.Prefix.Overlaps, security review) — the override is checked against the same used-CIDR set the pool allocator uses, so it can no longer silently double-book a tenant's CIDR.
spec.exposures[] []ExposureSpec no Local targets exposed through the gateway's virtual IPs, reachable from the client's side of the tunnel — the "client → our services" direction. Auto-assigned virtual IPs in this list use the .10.199 range.
spec.reverseExposures[] []ExposureSpec no Client-side targets exposed back through the gateway, reachable from our side — the "our services → client" direction. Requires an allowed-address-pair + router route (see Networking), both operator-rendered automatically. The matching security-group ingress is also operator-rendered, but only once the operator's internalSourceCIDRs chart value is set — see Networking's reverse-exposure security-groups section. Auto-assigned virtual IPs use the .200.254 range.
spec.exposures[].name / spec.reverseExposures[].name string yes Must be unique across both lists combined — a duplicate name (even between an exposure and a reverse exposure) is a hard allocation error, not a silent collision. Must also match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ (DNS-label style; security review), since it becomes an iptables comment.
spec.exposures[].virtualIP / ...reverseExposures[].virtualIP string no "" or "auto" (both mean the same thing) auto-assigns from the exposure's range. A pinned value outside its list's range, colliding with another exposure, or equal to the gateway's own .1 SNAT address is rejected.
spec.exposures[].target.host string one-of, forward-only A valid RFC 1123 hostname (security review) resolved fresh on every reconcile (no caching); the reconciler requeues every 5 minutes when any exposure uses this so DNS changes reach the NAT ConfigMap without a CR edit. Not accepted on reverseExposures — a reverse target lives in client address space where our DNS is meaningless; using host there is a render-time error.
spec.exposures[].target.address string one-of A literal IPv4 address (netip.ParseAddr, security review — no hostnames, no IPv6). Exactly one of host/address must be set — both or neither is a validation error.
spec.exposures[].target.ports[] []int32 no Empty = all-protocol DNAT (no port match); non-empty = one TCP DNAT+ACCEPT rule pair per listed port.

Status fields

Field Type Description
status.serviceCIDR string The allocated (or spec-overridden) /24. Also local_ts in every tunnel's child SA and the DNAT/SNAT anchor for RenderNATScript.
status.floatingIP string The gateway's public IP — also every tunnel's local_addrs/local IKE identity in swanctl.conf.
status.portFixedIP string The Neutron port's fixed (private) IP — the SNAT source for forward-exposure traffic.
status.nodeName string The Kubernetes node the gateway's OpenStack port is attached to and its pod is hard-pinned to (spec.nodeName, not a nodeSelector) — see Networking. Sticky across reconciles as long as that node still exists and is Ready; persisted before the attach call, same discipline as the CIDR/vIP allocation.
status.virtualIPs[] []VirtualIPStatus One entry per exposure/reverse exposure: name, ip, reverse (true for a reverseExposures entry). The allocator persists this before any OpenStack Ensure* call, so a later step failing never re-rolls an IP an OpenStack resource may already reference.
status.tunnels[] []TunnelStatus One entry per spec.tunnels[] name, in that order. Written only by status-agent via a JSON merge patch to the status subresource — the controller never writes this field.
status.tunnels[].state string The IKE_SA state exactly as charon reports it (ESTABLISHED, CONNECTING, REKEYING, ...), or DOWN if status-agent finds no matching IKE_SA at all (not-yet-negotiated or peer unreachable).
status.tunnels[].bytesIn / .bytesOut int64 Traffic counters from the tunnel's CHILD_SA (not the IKE_SA) — zero when the child-sas section is absent or unparseable, independent of state.
status.tunnels[].rekeyAt *metav1.Time Computed as now + rekey-time seconds from the CHILD_SA's rekey-time field (interpreted as seconds-remaining) — this interpretation is unverified against a live charon; see Known Limitations. nil when unavailable.
status.conditions[] []metav1.Condition See the condition/reason vocabulary below.

Condition vocabulary

Every condition below is written by the controller (never status-agent, which only ever touches status.tunnels). ObservedGeneration is set on every write.

Condition Meaning when True Failure reasons (False)
Validated spec.Validate() passed — ike.version == 2, every proposal (IKE and per-tunnel ESP) is in the approved allowlist, every tunnel/exposure/reverse-exposure name matches the DNS-label pattern, every address/subnet/serviceCIDR field parses as IPv4, peer.id matches its allowed-character pattern, target.host is a valid hostname, every set lifetime/DPD duration matches its format pattern, and every exposure target is exactly one of host/address. See the field table above for each constraint (security review). InvalidSpecterminal until the spec is edited; no requeue timer, only a fresh watch event on the next kubectl apply.
Allocated The service CIDR and all virtual IPs are resolved and persisted to status. AllocationFailed — pool/range exhaustion, a duplicate exposure name, or a spec.serviceCIDR override that overlaps another gateway's already-allocated CIDR (security review); requeued after 1 minute (the fix is often another tenant's gateway freeing its CIDR, which this controller does not watch for).
PortReady The security group and Neutron port exist and are tagged for this gateway. EnsureSecurityGroupFailed, EnsurePortFailed (both: fixed 15s requeue, see below)
PortAttached A node was selected (status.nodeName) and the port is hot-attached to that node's Nova server. NodeSelectionFailed (no Ready/schedulable/non-control-plane node found), ProviderIDUnparseable (the node's spec.providerID doesn't match the openstack:///<uuid> scheme) — both K8s-local, default controller-runtime backoff; EnsurePortAttachedToServerFailed (fixed 15s requeue, see below)
FIPAttached The floating IP is allocated/pinned and associated with the port. EnsureFIPFailed (fixed 15s requeue, see below)
RoutingReady Either NotRequired (no reverse exposures, no routed-mode tunnel — the common case) or Routed (allowed-address-pairs + router route applied). EnsureAllowedAddressPairsFailed, EnsureRouterRouteFailed (both: fixed 15s requeue, see below)
ConfigRendered The NAD is applied and both ConfigMaps (<name>-swanctl, <name>-nat) are rendered and written. NADFailed, ResolveFailed (a Host exposure's DNS lookup failed), RenderFailed, ConfigMapFailed
DeploymentReady The status-agent ServiceAccount/RoleBinding, the per-gateway NetworkPolicy and the gateway Deployment are all applied, and the Deployment reports at least one Available replica (status.availableReplicas >= 1) — actual pod health, not just that the object was accepted. ServiceAccountFailed, RoleBindingFailed, NetworkPolicyFailed, DeploymentFailed, DeploymentUnavailable (the Deployment applied cleanly but its pod isn't Available yet — Pending, unschedulable, or crash-looping; not a reconcile error, just a still-converging state the Deployment's own Owns() watch re-triggers on)

A condition going False on any step short-circuits the rest of that reconcile pass — with one caveat: not every False reason returns a controller-runtime error. Nine reasons persist status and return a requeue with a nil error instead: the two terminal paths InvalidSpec/AllocationFailed, DeploymentUnavailable, and the six OpenStack Ensure-step reasons routed through failOpenStackStepEnsureSecurityGroupFailed, EnsurePortFailed, EnsurePortAttachedToServerFailed, EnsureFIPFailed, EnsureAllowedAddressPairsFailed, EnsureRouterRouteFailed. The OpenStack six additionally requeue at a fixed 15-second interval* (requeueOpenStackErrorRetry), not controller-runtime's default ms-scale exponential error backoff — a deliberate floor added after a fast backoff turned one failed EnsureSecurityGroup call into 96 leaked security groups in ~25 seconds (2026-08-19; see Operations). Every other False reason (e.g. NodeSelectionFailed, ProviderIDUnparseable, ResolveFailed) returns a bare error and gets controller-runtime's default backoff. Status is never left silent on a failure, regardless of which path a given reason takes.

No +kubebuilder:printcolumn or shortNames markers are set on this type as of 0.1.0 — kubectl get ipsecgateway shows only the default NAME/AGE columns; there is no kubectl get ipsg-style short name.

Finalizer and deletion

Every IPsecGateway carries the finalizer ipsec.bnerd.net/cleanup. On deletion the controller deletes its owned Kubernetes children first (NAD, both ConfigMaps, the Deployment), requeues every 5 seconds until they're actually gone, then runs OpenStack cleanup (tag-scoped: routes whose next-hop was this gateway's port, the FIP if it was itself tagged — a pinned FIP is only disassociated, never released — the port, and the security group), and only then removes the finalizer. This ordering exists so the OpenStack port is never yanked out from under a still-terminating pod that's attached to it via the NAD.


Cluster-scoped

One CR = one service cluster plumbed into the transit fabric (one leg port on the operator's transit network, attached to that service cluster's router, plus one route on the gateway cluster's own router) — the automated form of the Connecting Service Clusters runbook. Introduced in v0.2.0.

Spec fields

Field Type Required Description
spec.serviceClusterRouter string yes The service cluster's Neutron router — a name or an ID, resolved at reconcile time (a name must resolve to exactly one router in the project; zero or more than one is a reconcile error).
spec.serviceClusterCIDR string yes, immutable The service cluster's node/Pod CIDR reachable once the link is up. Must be a canonical IPv4 CIDR (no host bits set) — the same non-canonical-rejection discipline as internalSourceCIDRs (see the operator config's own validation): a non-canonical entry would never round-trip through Neutron's own route canonicalization, causing permanent route churn on every reconcile. Immutable after creation, enforced by an apiserver-side CEL rule (self == oldSelf), not a reconciler check — an in-place edit is rejected outright, because nothing ever removes the OLD CIDR's route from the gateway router just because this field changed under it (only deletion does, using whatever the CURRENT value is at that time); an edit would silently orphan a stale route forever. Delete and recreate the ClusterLink to move it to a different CIDR.
spec.transitIP string no Pins the leg port's address on the transit network (e.g. to match a pre-existing hand-made leg, or to keep an address stable across a recreate). Must be a literal IPv4 address when set (netip.ParseAddr); whether it actually falls within the transit subnet is only checked at reconcile time, since the transit subnet is OpenStack-side configuration invisible to CRD validation. Empty auto-allocates — see Transit-IP allocation below. A pin is never retried on an OpenStack-side conflict (unlike auto-allocation): a pin failing is a configuration error to surface, not something to silently route around.
spec.credentialsSecretRef object no Reserved, not implemented. Intended for a future cross-project ClusterLink (a service cluster in a different OpenStack project than the operator's own credentials) — today the reconciler's OpenStack client is project-scoped from the operator's own boot-time credentials (internal/openstack's doc), which covers every same-project link, the whole estate as of v0.2.0. Cross-project support needs Neutron RBAC sharing beyond what a credential alone grants and is out of scope until a future design. Setting this field is a validation error, not a silently-ignored no-op — {name, namespace} shape (NamespacedSecretRef), reserved for that future use.

Status fields

Field Type Description
status.transitIP string The leg port's actual address on the transit network — either the spec.transitIP pin or the allocated address. Persisted before the leg port is created or adopted (persist-before-programming), so a later step failing never re-rolls the address an OpenStack port may already reference.
status.legPortID string The Neutron port ID of the leg port on the transit network, once ensured (created or adopted).
status.conditions[] []metav1.Condition See the condition/reason vocabulary below.

Condition vocabulary

Condition Meaning when True Failure reasons (False)
Validated spec.Validate() passed — serviceClusterRouter non-empty, serviceClusterCIDR canonical IPv4, transitIP (if set) a literal IPv4 address, credentialsSecretRef unset. InvalidSpecterminal until the spec is edited; no requeue timer, only a fresh watch event on the next kubectl apply.
LegAttached serviceClusterRouter resolved to a router ID, a transit IP is allocated/pinned, the leg port is ensured (created or adopted), and it's attached to that router. RouterResolveFailed, EnsureTransitLegPortFailed, EnsureRouterHasPortFailed (fixed 15s requeue, same requeueOpenStackErrorRetry floor as IPsecGateway's OpenStack-step failures — see that CRD's condition table for the 2026-08-19 incident this floor exists because of); AllocationFailed (the transit-IP scan found no free address; requeued after 1 minute, the fix usually being another ClusterLink being deleted, which this controller does not watch for)
RouteInstalled The route (serviceClusterCIDR via the transit IP) exists on the operator's configured gateway-cluster router. GatewayRouterNotConfigured (the operator has no routerID configured at all — terminal, no requeue); EnsureRouterRouteFailed (fixed 15s requeue — this reuses EnsureRouterRoute's existing conflict-refusal semantics: if a route for this CIDR already exists with a different next-hop, it is not silently overwritten, since that route may belong to something else)

A condition going False short-circuits the rest of that reconcile pass, same discipline as IPsecGateway. LegAttached staying True while RouteInstalled is False is a normal, visible intermediate state (the leg port and router attachment persist even if the gateway-router route can't be installed yet) — nothing already-programmed is unwound just because a later step failed.

Transit-IP allocation

When spec.transitIP is empty, the reconciler scans the operator's transit subnet (networkID, resolved via one Neutron subnets lookup) starting at host .3.1 is conventionally the gateway cluster's own router leg, .2 the network's DHCP port, both skipped by construction (the scan never generates them, rather than detecting and special-casing them) — up through .254. A candidate already claimed by another ClusterLink's status.transitIP (checked via an uncached List across every ClusterLink in the cluster, avoiding the same eventually-consistent-cache race IPsecGateway's CIDR allocator guards against) is skipped before anything is attempted on OpenStack. Each candidate is persisted to status.transitIP before it is attempted (persist-before-programming); if OpenStack itself reports the address is already in use by something not adoptable (see below), the next candidate is tried — "Ensure-time conflict = pick next". Once allocated, an address is sticky across reconciles (tried first, before the scan) the same way IPsecGateway's CIDR allocation is.

Adoption semantics (the acceptance case)

The leg port is ensured primarily by tag (the same adopt-or-create contract every OpenStack resource in this operator follows): a port already tagged for this ClusterLink is reused unconditionally, regardless of its fixed IP.

Otherwise, the candidate transit IP must be free — with one deliberate exception: an untagged port already sitting at that address, already attached to the target service-cluster router, is adopted (re-tagged) instead of treated as a conflict. This is how a hand-made transit leg — created out-of-band via the manual method before ClusterLink existed, or before this particular link was converted to a CR — is taken over rather than causing a permanent allocation conflict. Any other occupant of that address (tagged for a different ClusterLink; untagged but unattached; or untagged but attached to a different router) is a genuine conflict, not an adoption candidate.

Consequence for deletion: once adopted, a port is this ClusterLink's to delete — the finalizer's unwind does not distinguish "created outright" from "adopted-then-tagged". Deleting a ClusterLink that adopted a pre-existing hand-made leg deletes that port along with the route and router attachment, exactly as it would for a port the CR created itself.

Finalizer and deletion

Every ClusterLink carries the finalizer ipsec.bnerd.net/clusterlink- cleanup. Unlike IPsecGateway, there are no owned Kubernetes children to wait for — a ClusterLink's only footprint is OpenStack-side. On deletion the controller unwinds in reverse programming order: the gateway-router route (EnsureRouterRouteAbsent — tolerant of the route already being gone, and never touches a route that no longer has this ClusterLink's next-hop), then the port's attachment to the service-cluster router (best-effort: if the router itself can no longer be resolved, e.g. torn down out-of-band, the reconciler proceeds straight to deleting the port rather than getting stuck), then the leg port itself — see Adoption semantics above for what "the leg port itself" means for an adopted port.