Skip to content

feat(range): shared participant OpenVPN server pool on GCP - #2491

Open
Brad-Edwards wants to merge 10 commits into
devfrom
2480-vpn-server-pool
Open

Brad-Edwards wants to merge 10 commits into
devfrom
2480-vpn-server-pool

Conversation

@Brad-Edwards

Copy link
Copy Markdown
Owner

Refs #2480

Replaces per-range OpenVPN gateway VMs with one shared, horizontally scaled OpenVPN server pool per deployment (GCP). AWS follows in #2481.

What changes

  • Pool (Terraform modules/vpn-pool): regional autoscaled MIG of Container-Optimized OS VMs in its own subnet of the range VPC, no public IPs, behind one external passthrough UDP/1194 load balancer on a reserved address (client-IP affinity, HTTP health check). NAT scoped to the pool subnet for the portal call only. Firewall envelope: allows at 900, denies at 950.
  • Server image (shifter/engine/openvpn): OpenVPN 2.6 plus a controller on the management interface. Each connection is authorized by the portal, which returns one target address and its ports; a per-client nftables allowance (forward policy drop) admits only that tuple, and only that route is pushed. Container: own netns, --cap-drop ALL + NET_ADMIN/SETUID/SETGID, read-only root, no-new-privileges; OpenVPN drops to nobody.
  • Portal session control: PostgreSQL VpnSession, one active session per range (newer wins), 15 s heartbeat / 60 s lease. Destroy, pause, owner change, deadline, or a newer session disconnects within one heartbeat; a server that cannot confirm sessions drops all clients after 90 s. Controller authenticates with an exact-audience Google ID token pinned to the pool SA's email and numeric ID.
  • PKI: tenant CA + tls-crypt key in an issuer secret only the provisioner reads; pool server identity in a secret only the pool reads; per-generation client certs with notAfter = deadline and verify-x509-name pinned. Created/renewed by scripts/gcp/ensure_vpn_pki.py at deploy; private keys never enter Terraform state.
  • Deploy (_gcp-dev.yml): build, attest, exact-digest Trivy scan, and provenance-verify the pool image with the other release images; ensure PKI; write the release record (digest) only after the gates; surge-replace servers (max-unavailable 0), wait stable, require all backends healthy.
  • CI identity: the deploy/destroy identities get a custom role for templates, the regional MIG, and the autoscaler, IAM-conditioned so VM/disk/template permissions apply only to <prefix>-vpn-* names (not compute.instanceAdmin.v1, which the release-security guard forbids); actAs only on the pool SA. Requires applying global/cicd-oidc per identity profile before enabling the pool.
  • Capacity: VpnPoolCapacity in the shared-service profiles (25 participants per e2-standard-2, +1 spare, 30% CPU target, max = 2x min) and drift checking via check_event_capacity_drift.py --vpn-pool.
  • Removed: per-range gateway VMs and identities (sh-vpn-pool-*), the 1195 health port, per-range server secrets, the gateway secret-probe canaries, the stale gateway SA exception.
  • Docs/ADR: ADR-039-R10 rewritten; ADR-008-R7, ADR-063-R5, deploy/capacity/CTF docs and the containment threat model updated.

Verification

  • Targeted unit tests for every changed module (platform session service and control API, provisioner, pool controller at 90% coverage, PKI step, deploy guards, chart contract, capacity, drift checker).
  • Two-network docker lab: tunnel up with only the target route; target reachable on its port only; other ports, peers, the server, the tunnel gateway, and metadata unreachable; heartbeat revocation disconnects; reconnect re-authorized.
  • terraform validate on the environment and CI-identity roots.
  • Live tenant validation (isolation, one session per range, revocation, failover, fail-closed, no gateway VM on launch or warm claim) in progress before this is ready.

…eways

Participant OpenVPN moves from one gateway VM per range to a shared,
horizontally scaled server pool (#2480).

Portal:
- VpnSession records live sessions in PostgreSQL: one active session per
  range, and a newer connection supersedes the older one.
- authorize/renew/end re-check access on every call (READY range, current
  generation and owner, access deadline), so destroy, reassignment, the end
  of the event, or a newer session ends a live tunnel within one heartbeat.
- /api/v1/cms/vpn-control/ admits only the pool service account through its
  Google identity token (exact audience, email and numeric subject).
- Profiles must pin the pool server certificate name.

Provisioner:
- Profiles are signed by one tenant CA and point at the pool address; the
  realization contract and the download path are unchanged.
- Each OpenVPN range gets one ingress rule that admits the pool's networks to
  the target node on its declared participant channels only.
- The per-range gateway VM, its firewall envelope, health probe, server and
  issuer secrets, the gateway service-account slot and the legacy GCE and
  Terraform-path VPN plumbing (never reachable) are removed.

Refs #2480
One OpenVPN 2.6 process per pool VM with a controller on its management
interface. Every connection is authorized by the portal, which returns the
single target address and ports; a per-client nftables allowance admits only
that tuple and only that route is pushed. Sessions heartbeat to the portal,
revoked sessions are killed, and every session is dropped after 90 seconds
without portal confirmation.

The component has its own lint, SAST, and test lanes, Sonar coverage, and
Dependabot entries.

Refs #2480
A regional, autoscaled managed instance group of Container-Optimized OS VMs
in its own subnet of the range VPC, behind one external passthrough UDP/1194
load balancer on a reserved address. The pool is sized from the shared-service
capacity profile (25 participants per e2-standard-2 server, one spare, 30% CPU
target) and checked by the capacity drift inspector.

The deploy builds, attests, scans, and provenance-checks the pool image with
the other release images, creates the tenant CA and renews the pool server
certificate directly in Secret Manager (keys never enter Terraform state),
records the verified digest, and replaces the servers by surging new ones in
first. The deploy identity gets a custom role for the pool's templates,
instance group, and autoscaler whose VM, disk, and template permissions are
conditioned to the pool's names, plus actAs on the pool identity only.

The per-range gateway identity pool, its 1195 health port, and its secret
probe canaries are removed.

Refs #2480
ADR-039-R10 now describes termination on the shared pool, portal-authorized
sessions, revocation within one heartbeat, and fail-closed behavior; ADR-008-R7
and ADR-063-R5 drop the per-range gateway identity pool. The stale gateway
service-account exception is removed. Deploy, capacity, CTF, and containment
docs and code comments describe the pool, and the OpenVPN preflights note the
superseded termination.

Refs #2480
# Conflicts:
#	platform/charts/shifter/tests/test_chart_contract.py
@Brad-Edwards

Copy link
Copy Markdown
Owner Author

HOLD: do not merge. Live validation on a GCP tenant is still in progress (isolation, one session per range, revocation, failover, fail-closed). I'll remove this hold when it passes.

…lope

The pool server's metadata, Secret Manager, and portal calls now go through one
opener that handles only http and https, follows no redirects, ignores proxy
variables, and refuses other schemes (an OpenerDirector without UnknownHandler
silently returned None for file: URLs). This resolves bandit B310 at the root.

A credential-free Terraform contract test pins the pool envelope: one UDP 1194
listener, probe-range-only health ingress, 22/3389 range egress, deny-all below
every allow, no public addresses, Shielded VM, surge replacement, and a pool
identity that never reads the CA. The module is registered in the validation
inventory with that contract.

The gcloud subprocess in the PKI step follows the scripts/gcp argv-only
annotation convention, and detect-private-key excludes the two modules that
hold only the OpenVPN static-key armor labels.

Refs #2480
…ings

The pool image now defaults to an unprivileged user and cannot start without
the deploy's hardened invocation: the VM starts it as root only so setpriv can
switch to uid 10001 keeping NET_ADMIN as its sole capability (verified in the
two-network lab: OpenVPN and the controller run as 10001 with CapEff
NET_ADMIN only, isolation and heartbeat revocation unchanged). OpenVPN no
longer switches to nobody, and its management socket admits only that user.

The plain-HTTP health server is replaced by a TCP listener that opens once the
server is serving, with a TCP load-balancer health check. The HTTPS handler
uses the default verifying context. The tunnel network is a module variable
passed to the server instead of a hardcoded default.

Remaining smells: named refusal codes and management markers, a split target
resolver, docstrings, suppression comments Sonar can parse, the capacity
profile invariants in their own module, and a Dockerfile that follows the repo
convention (digest-only FROM, maintainer label, sorted packages).

Refs #2480
@Brad-Edwards

Copy link
Copy Markdown
Owner Author

Live validation on a GCP tenant passed (deployed from this branch's commits):

  • Deploy: pool created, image built/attested/scanned/provenance-verified, PKI created in Secret Manager, release record written, 3 servers surge-replaced and all backends HEALTHY; post-deploy smoke green.
  • Ranges: launches create no gateway VM; each range admits only the pool subnet to its target on 22/3389; nothing but the load balancer has a public address.
  • Isolation: a client reaches only its own target on 22/3389. Its range's other member, another range's target (which also admits the pool subnet at the VPC layer), the metadata server, every pool server, the tunnel gateway and the portal are all unreachable.
  • One session per range: a second client on the same profile supersedes the first (superseded).
  • Failover: deleting the serving VM cut access for about 50 s, then the client reconnected to the replacement server and was re-authorized; the dead server's session ended lease_expired.
  • Fail-closed: blocking pool to portal dropped the session about 90 s later, and reconnects were refused (AUTH_FAILED); recovery succeeded after unblocking.
  • Revocation: destroying the range cut the live tunnel within about 3 s (revoked); destroy deleted the profile secrets, VMs and per-range pool firewall rules.
  • Not exercised live: warm claim (warm pool disabled on that tenant); covered by unit tests.

Hold lifted once CI is green on the dev-merge commit just pushed.

@sonarqubecloud

sonarqubecloud Bot commented Oct 5, 2026

Copy link
Copy Markdown

@Brad-Edwards

Copy link
Copy Markdown
Owner Author

HOLD LIFTED. CI and SonarCloud are green on dd2433b (current with dev) and the tenant validation above passed. Ready for your review and merge.

The controller is the container's PID 1 and ignored SIGTERM, so docker stop
waited out its timeout and SIGKILLed OpenVPN; the configured
explicit-exit-notify never fired and clients sat out the full keepalive
timeout on every planned removal (deploy, scale-in, VM replacement). The
controller now passes SIGTERM/SIGINT to OpenVPN, which tells every client to
reconnect at once, and exits cleanly when it was asked to stop.

The GCP bootstrap guide now has an OpenVPN step: apply global/cicd-oidc for the
conditioned pool roles, set RANGE_OPENVPN_ENABLED, and enable through the CI
deploy, because the local gdc-bootstrap path never deploys the pool.

Refs #2480
@Brad-Edwards

Copy link
Copy Markdown
Owner Author

HOLD: do not merge. New commit 24c0309 fixes a shutdown bug: the exit notification never reached clients, so planned server removals cost participants the full keepalive timeout. It also adds the OpenVPN step to the GCP bootstrap guide. A keepalive change may follow. Both still need CI and a live failover recheck on the tenant.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant