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.0 — Validate() 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). |
InvalidSpec — terminal 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 failOpenStackStep —
EnsureSecurityGroupFailed, 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.
Print columns / kubectl behavior¶
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.
ClusterLink¶
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. |
InvalidSpec — terminal 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.