Skip to content

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 (ipsec or wireguard, immutable — switching protocol is delete-and-recreate, not an in-place migration), the allocated service CIDR, and the exposures/reverseExposures the gateway makes available.
  • VPNPeer (namespaced, spec.gatewayRef immutable — moving a peer to a different gateway is also delete-and-recreate) — one per remote party: the peer's own protocol-specific config (ipsec or wireguard, exactly one of the two, whichever the referenced gateway uses) and, new in this model, an authorization block 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 authorization block 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 authorization block that's present but empty (no exposures, no reverseExposures) is also False/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:

kubectl get vpnpeer acme-hq -o jsonpath='{.status.conditions[?(@.type=="Authorized")]}' | jq .

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.protocol
  • VPNPeer.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.