The unified model: VPNGateway + VPNPeer¶
Pre-release, v0.3.0 — not yet e2e-validated
Everything below describes the reconciler as merged (internal/controller/vpngateway_controller.go,
api/vpn/v1alpha1); the whole unified stack has been proven against
envtest/fake-OpenStack suites but has not yet run a live end-to-end
validation pass. See the index page and Known
Limitations before relying on
anything here operationally.
v0.3.0 introduces a second, protocol-neutral API group —
vpn.bnerd.net/v1alpha1 — alongside the frozen ipsec.bnerd.net/v1alpha1
group the rest of this site documents. It covers both IPsec and
WireGuard site-to-site gateways under one CRD pair, VPNGateway +
VPNPeer, and adds a security feature the legacy IPsecGateway CRD never
had: per-peer authorization. See Migrating from
IPsecGateway if you're moving an existing
client over; this page is about the new model's shape and its
authorization semantics.
Why two CRDs instead of one¶
IPsecGateway bundled the gateway and every one of its tunnels into a
single CR (spec.tunnels[]). The unified model splits that in two:
VPNGateway(namespaced) — one per client gateway pod: the public endpoint, the protocol (ipsecorwireguard, immutable — switching protocol is delete-and-recreate, not an in-place migration), the allocated service CIDR, and theexposures/reverseExposuresthe gateway makes available.VPNPeer(namespaced,spec.gatewayRefimmutable — moving a peer to a different gateway is also delete-and-recreate) — one per remote party: the peer's own protocol-specific config (ipsecorwireguard, exactly one of the two, whichever the referenced gateway uses) and, new in this model, anauthorizationblock naming which of the gateway's exposures/reverseExposures this specific peer may reach.
This is what makes per-peer authorization possible at all: IPsecGateway
had no per-tunnel concept of "this tunnel may reach exposure X but not Y"
— every tunnel on a gateway could reach every exposure. VPNPeer's
authorization block is exactly that missing per-tunnel scope.
apiVersion: vpn.bnerd.net/v1alpha1
kind: VPNGateway
metadata:
name: acme-gw
spec:
protocol: ipsec
endpoint:
floatingIPPool: <external-network-uuid>
exposures:
- name: internal-api
target: { address: 100.64.7.10, ports: [443] }
reverseExposures:
- name: acme-erp
target: { address: 10.20.0.5, ports: [1433] }
ipsec:
ike: { version: 2, proposals: [aes256gcm16-prfsha256-ecp256] }
---
apiVersion: vpn.bnerd.net/v1alpha1
kind: VPNPeer
metadata:
name: acme-hq
spec:
gatewayRef: acme-gw
ipsec:
peer: { address: 203.0.113.9, id: acme-hq.example.com }
remoteSubnets: [10.20.0.0/16]
esp: { proposals: [aes256gcm16-prfsha256-ecp256] }
pskSecretRef: { name: acme-hq-psk, key: psk }
authorization:
exposures: [internal-api]
reverseExposures: [acme-erp]
See the CRD Reference for the full field list of both kinds.
Authorization semantics (spec §5)¶
This is the security-relevant part — read it before onboarding a multi-peer gateway.
Single-peer gateways default-allow (legacy parity)¶
A gateway with exactly one VPNPeer and no authorization block
on that peer behaves exactly like v0.1.0/v0.2.0's IPsecGateway: the
one peer reaches every exposure and reverse exposure the gateway defines,
with no grant list to maintain. The reconciler marks this peer's
Authorized condition True/DefaultAllow. This is the shape every
1:1 IPsecGateway migration (see Migrating from
IPsecGateway) lands in by default — no
authorization block needed unless you're intentionally narrowing it.
Multi-peer gateways are default-deny¶
The moment a gateway has more than one VPNPeer, the model flips to
default-deny: each peer reaches only the exposures/reverseExposures its
own authorization block names.
- A peer with no
authorizationblock on a multi-peer gateway is dark —Authorized=False/NoGrants("multi-peer gateway (default-deny) and this peer has no authorization block"). It establishes its tunnel/handshake normally, but every exposure is unreachable. - A peer with an
authorizationblock that's present but empty (noexposures, noreverseExposures) is alsoFalse/NoGrants("authorization block is present but grants nothing"). - A peer naming an exposure the gateway doesn't actually expose is
False/UnknownGrant. - Otherwise,
True/Authorized.
Check a peer's grant status directly:
There is no in-between state: a peer on a multi-peer gateway either has a
non-empty authorization block naming only exposures the gateway
actually has, or it's dark. This is deliberate — a silently-partial grant
(e.g. a typo'd exposure name quietly resolving to "nothing") would be far
worse than a loud, structurally-obvious deny.
Where authorization is enforced: iptables, not Neutron security groups¶
The default-deny grant model is enforced inside the gateway pod's own
netns, in the rendered NAT/authz iptables ruleset
(internal/render.RenderNATScriptUnified) — not in the OpenStack
Neutron security group attached to the gateway's port. This is not a
stylistic choice: a Neutron SG only ever sees the encrypted tunnel
traffic (IKE/ESP or the WireGuard UDP handshake) arriving at the gateway's
port. It has no visibility into which decapsulated, plaintext packet came
from which peer's tunnel once multiple peers share one gateway pod — that
distinction only exists post-decap, inside the pod's netns, which is
exactly where RenderNATScriptUnified's per-peer VPN-AUTHZ chain lives.
In the per-peer (default-deny) shape, every forward-exposure DNAT/FORWARD-
accept rule is additionally keyed on the granting peer's source CIDR —
authorization is checked as early as PREROUTING, so an ungranted peer's
traffic toward a shared exposure's virtual IP is never even translated.
Every peer additionally gets VPN-AUTHZ DROP rules (both directions) per
source CIDR regardless of whether it holds any grants, so denied traffic
is directly countable (iptables -L VPN-AUTHZ -v -n) rather than merely
inferred from an absence of traffic.
Reverse exposures (estate-side-initiator flows, "our services → client")
are keyed differently: on the operator's internalSourceCIDRs (the fabric
addresses allowed to initiate toward a reverse-exposure virtual IP), not
on the requesting peer's source CIDR — because the forward-accept rule for
a reverse exposure has to constrain the traffic's source (the internal
fabric), and its destination is the peer-owned target address itself.
See Known Limitations for the
operator-config prerequisite this implies (internalSourceCIDRs must be
set before any reverse exposure is usable) and the deployment constraint
it creates if internalSourceCIDRs ever overlaps a peer's own subnets.
Reverse exposures are single-grantee by construction¶
A reverse exposure's target lives inside exactly one peer's routed
subnet(s) (ipsec.remoteSubnets or wireguard.subnets) — there is no
sense in which two disjoint peers can both legitimately reach the same
reverse-exposure target, because the renderer checks that the target
address actually lies inside the granting peer's own SourceCIDRs before
rendering the rule. If a second peer, whose subnets don't cover that
target, also names the same reverse exposure in its authorization
block, rendering fails loud — the error names the peer and the exposure.
Grant a reverse exposure to the one peer whose subnet actually contains
its target; granting it to a second, disjoint peer is a configuration
error, not a supported multi-grant.
Immutability: what moves require delete-and-recreate¶
Three fields are immutable after creation (apiserver-enforced CEL rules,
self == oldSelf), each because nothing in the reconciler unwinds the old
value when it changes under it:
VPNGateway.spec.protocolVPNPeer.spec.gatewayRef
spec.serviceCIDR isn't CEL-immutable, but changing it after the
allocator has already assigned virtual IPs out of the old range has no
defined migration path either — treat it the same way. Moving any of
these means deleting and recreating the CR, the same discipline
Migrating from IPsecGateway already
uses for the ipsec.bnerd.net → vpn.bnerd.net move itself.