CRD Reference — bnerd-vpn-operator¶
This operator serves two API groups in parallel as of v0.3.0 — see
the unified model and Migrating from
IPsecGateway for how they
relate:
ipsec.bnerd.net/v1alpha1—IPsecGateway(feature-frozen, bugfix-only as ofv0.3.0) andClusterLink(not frozen — cross-tenant links added inv0.5.0), documented first below.vpn.bnerd.net/v1alpha1(new inv0.3.0) —VPNGatewayandVPNPeer, the protocol-neutral (ipsec + wireguard) unified model, documented further down.
Both sections are generated systematically from their respective
_types.go files and charts/bnerd-vpn-operator/crds/ — cross reference
the Go source for the authoritative doc comments.
IPsecGateway¶
(ipsec.bnerd.net/v1alpha1, feature-frozen as of v0.3.0)
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¶
(ipsec.bnerd.net/v1alpha1) · 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; as of v0.4.0 it also converges every
reverseExposures-bearing gateway's return route on its own
serviceClusterRouter — see Reverse-route
automation.
As of v0.5.0, setting spec.credentialsSecretRef links a service cluster
that lives in a different OpenStack project than the operator's own
credentials — see Cross-tenant
ClusterLinks.
Everything on this page that doesn't say otherwise applies to both shapes
unchanged; a same-tenant link (credentialsSecretRef unset, the default) is
byte-identical to every earlier version.
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, as of v0.5.0 |
Names the Secret ({name, namespace}, NamespacedSecretRef) carrying an OpenStack application credential for the customer's own project — makes this a cross-tenant ClusterLink (the service cluster lives in a different OpenStack project than the operator's own credentials). Nil (the default) is a same-tenant link, unchanged from every earlier version. See Cross-tenant ClusterLinks for the full contract, the attestation annotation it requires, and the condition set it adds. The Secret's name must be clusterlink-<metadata.name> (otherwise CredentialsResolved=False/SecretNameNotAllowed, before any Secret read). Immutable by presence after creation (apiserver CEL rule): the CRD allows re-pointing the ref (the operator still only accepts clusterlink-<metadata.name>), but never adding it to, or removing it from, a live ClusterLink — delete and recreate to switch between same-tenant and cross-tenant. Must be left empty together with spec.transitIP (rejected together by Validate() if both are set) — a cross-tenant leg's address is Neutron-allocated, never pinned. |
spec.customerProjectID |
string (max 64) | yes when credentialsSecretRef is set, forbidden otherwise; as of v0.5.0 |
The OpenStack project ID of the customer tenant, written by infra alongside the attestation annotation. The customer credential's token project is compared against it before any OpenStack change — a mismatch is CredentialsResolved=False/CredentialsProjectMismatch (both IDs named) and nothing is shared, created or attached; a value equal to the operator's own project is CredentialsAreOperatorProject. Validate(): required (non-empty, no surrounding whitespace) with credentialsSecretRef. Without credentialsSecretRef it is rejected at admission (apiserver CEL rule, create and update) and by Validate() (InvalidSpec). Immutable once set (apiserver CEL rule on the spec): neither changed nor removed; adding it to an object that predates the field is allowed. |
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.reverseRoutes[] |
[]object | As of v0.4.0: the reverse-exposure return routes this ClusterLink currently owns on spec.serviceClusterRouter — one entry (serviceCIDR, nextHop, gatewayRef) per VPNGateway/IPsecGateway (any namespace, either kind, estate-wide) with a non-empty reverseExposures. Converged every reconcile — see Reverse-route automation and the ReverseRoutesInstalled condition below. gatewayRef ("<namespace>/<name>") is observability only; the convergence/removal logic keys on serviceCIDR, which is unique estate-wide. |
status.customerProjectID |
string | As of v0.5.0, cross-tenant links only (never set on a same-tenant link): the OpenStack project ID that owns the customer-side leg port, recorded from the resolved credential before the leg is built — always equal to spec.customerProjectID, which the credential's project must match first. Also what the delete path dispatches on (§ below) — see Cross-tenant ClusterLinks. |
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 (if set) has both name and namespace, and not both transitIP and credentialsSecretRef are set, and customerProjectID is set exactly when credentialsSecretRef is. |
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 (same-tenant only: 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); TransitIPMismatch (cross-tenant only, terminal — see Cross-tenant ClusterLinks) |
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) |
ReverseRoutesInstalled |
As of v0.4.0: status.reverseRoutes[] matches exactly what every reverseExposures-bearing gateway in the estate currently needs on spec.serviceClusterRouter — True even when that set is empty (no gateway needs a reverse route right now). Independent of RouteInstalled: this condition covers the other router (the service cluster's, not the gateway cluster's), so a failure here never marks the forward path down. |
EnsureReverseRoutesFailed (fixed 15s requeue; the message names every gateway whose route could not be ensured or removed this pass — the rest still converge, one broken gateway never blocks the others) |
TenantIsolationAttested, CredentialsResolved, TransitNetworkShared |
As of v0.5.0, cross-tenant links only (never appear on a same-tenant link). |
See Cross-tenant ClusterLinks for the full reason table. |
A Validated/LegAttached/RouteInstalled 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. ReverseRoutesInstalled is reconciled last and never blocks (or is
blocked by) RouteInstalled. On a cross-tenant link, TransitNetworkShared/
LegAttached/RouteInstalled/ReverseRoutesInstalled failure messages are
prefixed customer credential (secret <ns>/<name>): or operator
credential:, naming which credential failed the step (TenantIsolationAttested
and CredentialsResolved name the Secret directly in their own message
instead, since they are the credential-resolution step).
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 (as of v0.4.0) every route still listed in
status.reverseRoutes[], 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 — the same best-effort resolve also skips
the reverse-route unwind, since there is nothing left to unroute on a
router that is already gone), then the leg port itself — see Adoption
semantics above for what "the leg
port itself" means for an adopted port.
A gateway's own deletion doesn't need the ClusterLink at all to unwind
ITS reverse route: VPNGatewayReconciler/IPsecGatewayReconciler's
Cleanup(tag) step already sweeps every router in the project for a route
whose next-hop is that gateway's tagged port's fixed IP (not just the
operator's configured routerID) — which is exactly a reverse route's
next-hop. The ClusterLink's own status.reverseRoutes[] catches up on
its next reconcile (fan-out watch-triggered, not waiting for the periodic
resync), pruning the now-stale entry.
The above is the whole story for a same-tenant link. A cross-tenant
link (status.customerProjectID set — this is what deletion dispatches on,
not spec.credentialsSecretRef) splits the unwind by credential and can
hold its finalizer open rather than unwind blind; see Cross-tenant
ClusterLinks: unbind order and the abandon escape
hatch.
VPNGateway¶
(vpn.bnerd.net/v1alpha1, new in v0.3.0) · Namespaced
One CR = one client's protocol-neutral gateway pod — see The unified
model for how this and VPNPeer relate, and
Migrating from IPsecGateway for
moving a client over from the ipsec.bnerd.net group. Generated from
api/vpn/v1alpha1/vpngateway_types.go.
Spec fields¶
| Field | Type | Required | Description |
|---|---|---|---|
spec.protocol |
ipsec | wireguard |
yes, immutable | Selects the gateway's VPN protocol. Enforced immutable by an apiserver CEL rule (self == oldSelf) — switching protocol on a live gateway is delete-and-recreate, not an in-place migration. |
spec.endpoint.floatingIPPool |
string | no | Same contract as IPsecGateway.spec.endpoint.floatingIPPool — a Neutron external-network UUID. |
spec.endpoint.floatingIP |
string | no | Pins an existing floating IP instead of allocating one — the mechanism Migrating from IPsecGateway uses to preserve a client's existing FIP across the cutover. |
spec.serviceCIDR |
string | no | Overrides the allocated /24; same canonical-IPv4-/24 validation as IPsecGateway.spec.serviceCIDR. |
spec.exposures[] / spec.reverseExposures[] |
[]ExposureSpec | no | Same ExposureSpec/TargetSpec shape as the ipsec.bnerd.net group (name, virtualIP, target.host/.address, target.ports[] — at most 15 entries, each 1-65535, xt_multiport's hard kernel limit). Which peer may reach which exposure is governed by that peer's own VPNPeer.spec.authorization — see Authorization semantics. |
spec.ipsec.ike.version |
int | yes iff protocol: ipsec |
Must be 2. |
spec.ipsec.ike.proposals[] |
[]string | yes, non-empty, iff protocol: ipsec |
Same approved allowlist as IPsecGateway.spec.ike.proposals (ApprovedProposals, copied into this group's own package). |
spec.ipsec.ike.lifetime |
string | no | IKE SA rekey interval, ^[0-9]+[smh]$ when set. |
spec.ipsec.ike.dpd.delay / .timeout |
string | no | Dead Peer Detection timers, same pattern as above. |
spec.wireguard.listenPort |
int32 | no, default 51820 |
UDP port the WireGuard interface listens on. |
spec.wireguard.privateKeySecretRef.name / .key |
string | no | References the gateway's WireGuard private key Secret. When unset, the operator generates Secret "<gw-name>-wg-key" (key privateKey) itself. See WireGuard gateways: key handling. |
spec.wireguard.roadWarriorCIDR |
string | no | Road-warrior tunnel range inside the tenant /24; defaults to the top /26 (e.g. .192/26 of a /24). Must be a canonical IPv4 CIDR inside serviceCIDR when both are pinned. See WireGuard gateways: road-warrior addressing. |
spec.ha.enabled |
bool | no | Reserved, not implemented. Validate() rejects ha.enabled: true outright — same honest-reject discipline as VPNPeer.spec.ipsec.mode: routed (also accepted by the CRD's enum, also rejected by Validate() as not-implemented). See Known Limitations. |
Exactly one of spec.ipsec/spec.wireguard must be set, matching
spec.protocol (Validate()); the other must be left unset (setting both
is a validation error).
Status fields¶
| Field | Type | Description |
|---|---|---|
status.serviceCIDR / .floatingIP / .portFixedIP / .nodeName / .virtualIPs[] |
— | Same meaning as the equivalent IPsecGateway status fields. |
status.peers[] |
[]PeerStatus | One entry per peer, as observed from the gateway side: name, state, lastHandshake, bytesIn, bytesOut, rekeyAt. Written by status-agent for both protocols (the wireguard path's only per-peer status surface, since VPNPeer.status itself is also populated — see below). |
status.wireguardPublicKey |
string | The gateway's WireGuard public key, derived from its private key. Public by definition — safe to read and hand to a peer out-of-band. Only set when protocol: wireguard. |
status.conditions[] |
[]metav1.Condition | See below. |
Condition vocabulary¶
The gateway-level condition set reuses the IPsecGateway controller's
condition names verbatim (Validated/Allocated/PortReady/
PortAttached/FIPAttached/RoutingReady/ConfigRendered/
DeploymentReady) — same meanings, same OpenStack-step 15s-requeue floor,
same short-circuit-on-False discipline as IPsecGateway's condition
table above. What differs for the unified
reconciler:
| Condition | New/changed reasons vs. IPsecGateway |
|---|---|
ConfigRendered |
Adds WGKeyFailed (protocol wireguard only: ensuring the private-key Secret failed), RenderInputFailed (model.BuildRenderInput rejected the peer set — e.g. an authorization grant naming an unknown exposure), and ReverseExposuresRequireInternalSourceCIDRs — set when the gateway would render a reverse exposure but the operator has no InternalSourceCIDRs configured; this is a fail-loud reconcile error in the unified renderer, unlike the legacy IPsecGateway path, which degrades silently (routing still goes True with a gap noted in its message). See Known Limitations. |
DeploymentReady |
Adds PeerSecretMissing — a referenced peer PSK/PSK-equivalent Secret was not found; requeues rather than erroring. |
Per-peer authorization has its own condition, written on VPNPeer (not
VPNGateway) — see VPNPeer's condition vocabulary below.
Finalizer and deletion¶
Every VPNGateway carries the finalizer vpn.bnerd.net/cleanup — a
distinct name from IPsecGateway's ipsec.bnerd.net/cleanup, keeping the
two controllers' finalizers independent even on a same-namespace object.
Deletion ordering (owned children first, then OpenStack cleanup, then
finalizer removal) follows the same discipline as IPsecGateway.
VPNPeer¶
(vpn.bnerd.net/v1alpha1, new in v0.3.0) · Namespaced
One CR = one remote party of a VPNGateway, in whichever protocol the
referenced gateway uses. Generated from
api/vpn/v1alpha1/vpnpeer_types.go.
Spec fields¶
| Field | Type | Required | Description |
|---|---|---|---|
spec.gatewayRef |
string | yes, immutable | Names the same-namespace VPNGateway this peer belongs to. Enforced immutable by an apiserver CEL rule — moving a peer to a different gateway is delete-and-recreate. |
spec.ipsec.peer.address |
string | yes iff ipsec peer | Remote IKE peer's public IP — literal IPv4 only, same as IPsecGateway.spec.tunnels[].peer.address. Also opens this peer's per-/32 IKE ingress security-group rule. |
spec.ipsec.peer.id |
string | yes iff ipsec peer | Remote IKE identity; same ^[A-Za-z0-9.@:_-]+$ pattern as the legacy group. |
spec.ipsec.remoteSubnets[] |
[]string | yes, non-empty, iff ipsec peer | Client-side CIDRs reachable through this tunnel. Also this peer's SourceCIDRs for authorization/NAT rendering purposes. |
spec.ipsec.esp.proposals[] |
[]string | yes, non-empty, iff ipsec peer | Same approved allowlist as spec.ipsec.ike.proposals. |
spec.ipsec.esp.lifetime |
string | no | ESP/child-SA rekey interval, ^[0-9]+[smh]$ when set. |
spec.ipsec.mode |
snat | routed |
no, default snat |
routed is accepted by the CRD's enum but rejected by Validate() as not-implemented, same discipline as IPsecGateway.spec.tunnels[].mode. |
spec.ipsec.pskSecretRef.name / .key |
string | yes iff ipsec peer | This peer's own PSK Secret reference — per-peer, unlike IPsecGateway's gateway-wide spec.auth.pskSecretRef. |
spec.wireguard.publicKey |
string | yes iff wireguard peer | Base64-encoded 32-byte WireGuard public key, ^[A-Za-z0-9+/]{43}=$ (CRD-level pattern and Validate(), belt-and-suspenders against an embedded-whitespace injection — see the type's doc comment). |
spec.wireguard.subnets[] |
[]string | no | A site peer's routed subnets. Empty means road-warrior (address used instead). Mutually exclusive with address (Validate() rejects both set). |
spec.wireguard.endpoint |
string | no | A site peer's static "host:port". Used for security-group scoping and wg's Endpoint=; rejected outright if it contains any whitespace (validateHostPort, injection guard) before net.SplitHostPort ever runs. |
spec.wireguard.address |
string | no | A road-warrior peer's tunnel /32: a literal IPv4 address, or "auto" to auto-assign from the gateway's road-warrior range. Ignored for site peers. |
spec.wireguard.presharedKeySecretRef.name / .key |
string | no | Optional per-peer WireGuard PSK, additive defense-in-depth. |
spec.wireguard.persistentKeepalive |
*int32 | no | WireGuard PersistentKeepalive= seconds, standard semantics. |
spec.authorization.exposures[] |
[]string | no | Names of the gateway's spec.exposures[] this peer may reach. See Authorization semantics. |
spec.authorization.reverseExposures[] |
[]string | no | Names of the gateway's spec.reverseExposures[] this peer may reach. A reverse-exposure grant whose target does not lie inside this peer's own SourceCIDRs (remoteSubnets/subnets) is a hard render error, not a silent no-op — see Reverse exposures are single-grantee by construction. |
Exactly one of spec.ipsec/spec.wireguard must be set, matching the
referenced gateway's protocol (checked at reconcile time, not by CRD
validation — a peer whose protocol block doesn't match its gateway simply
never gets a matching container/DNAT rule rendered).
Status fields¶
| Field | Type | Description |
|---|---|---|
status.state / .lastHandshake / .bytesIn / .bytesOut |
— | Same per-peer observed state status-agent also aggregates onto the gateway's status.peers[]. |
status.conditions[] |
[]metav1.Condition | See below. |
Condition vocabulary¶
| Condition | Meaning when True |
Failure reasons (False) |
|---|---|---|
Authorized |
The peer holds at least one valid grant, or the gateway is the single-peer default-allow shape. Reasons: DefaultAllow (single-peer gateway, no authorization block: all exposures reachable), Authorized (multi-peer gateway, valid non-empty grant). |
NoGrants — multi-peer gateway and this peer has no authorization block, or the block is present but empty. UnknownGrant — the block names an exposure/reverse exposure the gateway doesn't actually expose. InvalidSpec — the peer's own spec failed Validate(); set by the gateway's reconcile pass (listPeersForGateway) before the peer is even considered for rendering. |
Written entirely by the VPNGateway reconciler (computePeerAuthConditions/
setPeerAuthorizedCondition in internal/controller/vpnpeer_indexer.go) —
a VPNPeer has no reconciler of its own; it's reconciled as a side effect
of its referenced gateway's reconcile pass. ObservedGeneration is set on
every write. Check a peer's grant status directly: