From 092688853fc14e36d97cb2d647c9cf6f407e88a5 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 05:34:16 +0000 Subject: [PATCH 01/14] feat(range): authorize OpenVPN pool sessions and retire per-range gateways 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 --- .../provisioner/_gcp_range_cell_openvpn.py | 252 ---------- .../engine/provisioner/gcp_dynamic_secrets.py | 4 - .../provisioner/gcp_range_cell_destroy.py | 33 +- .../provisioner/gcp_range_cell_firewall.py | 66 --- .../gcp_range_cell_model_broker.py | 5 +- .../engine/provisioner/gcp_range_cell_plan.py | 124 +---- .../provisioner/gcp_range_cell_resources.py | 9 - .../provisioner/gcp_range_cell_types.py | 22 - shifter/engine/provisioner/gcp_range_cells.py | 78 +-- .../engine/provisioner/gcp_vpn_identity.py | 26 - shifter/engine/provisioner/provisioner_db.py | 18 +- .../provisioner/raes_gcp_activate_gce.py | 30 +- .../provisioner/raes_gcp_activate_realize.py | 7 +- .../provisioner/raes_gcp_activation_apply.py | 9 +- shifter/engine/provisioner/raes_gcp_apply.py | 11 +- .../provisioner/raes_gcp_apply_types.py | 10 +- .../engine/provisioner/raes_gcp_destroy.py | 11 +- .../engine/provisioner/raes_gcp_inventory.py | 8 +- shifter/engine/provisioner/raes_gcp_plan.py | 17 +- .../engine/provisioner/raes_gcp_vpn_apply.py | 34 +- .../engine/provisioner/raes_gcp_vpn_plan.py | 89 ++-- shifter/engine/provisioner/raes_openvpn.py | 152 +++--- shifter/engine/provisioner/raes_range_ops.py | 6 +- shifter/engine/provisioner/terraform_ops.py | 106 +---- shifter/engine/provisioner/terraform_vars.py | 16 +- shifter/engine/provisioner/tests/conftest.py | 62 +++ .../tests/test_aws_range_vpn_module.py | 59 +++ .../tests/test_gcp_dynamic_secrets.py | 2 - .../tests/test_gcp_range_cell_contract.py | 1 - .../tests/test_gcp_range_cell_resources.py | 38 -- .../provisioner/tests/test_gcp_range_cells.py | 130 +---- .../provisioner/tests/test_get_range_data.py | 42 +- shifter/engine/provisioner/tests/test_main.py | 180 ------- .../provisioner/tests/test_raes_gcp_apply.py | 139 ++---- .../provisioner/tests/test_raes_gcp_plan.py | 56 +-- .../provisioner/tests/test_raes_openvpn.py | 160 ++----- .../provisioner/tests/test_raes_range_ops.py | 13 +- .../tests/test_range_instantiation_purpose.py | 1 - .../test_terraform_destroy_failed_range.py | 87 +--- .../provisioner/tests/test_vpn_access.py | 427 +++++------------ .../provisioner/tests/test_vpn_secrets.py | 267 ++--------- .../tests/test_warm_activation_ops.py | 32 +- shifter/engine/provisioner/vpn_access.py | 444 ++++++------------ shifter/engine/provisioner/vpn_secrets.py | 399 +++------------- shifter/shifter_platform/cms/api/urls.py | 4 + .../shifter_platform/cms/api/vpn_control.py | 166 +++++++ .../shifter_platform/config/_env_manifest.py | 2 +- .../shifter_platform/config/env-manifest.json | 14 +- shifter/shifter_platform/config/settings.py | 24 +- .../engine/migrations/0088_vpn_server_pool.py | 39 ++ .../engine/models/__init__.py | 4 + .../shifter_platform/engine/models/_range.py | 5 - .../engine/models/_range_allocation.py | 27 -- .../engine/models/_vpn_session.py | 64 +++ .../engine/operation_inputs.py | 10 +- .../engine/services/__init__.py | 16 + .../engine/services/_raes_range.py | 6 +- .../engine/services/_vpn_sessions.py | 282 +++++++++++ .../raes/operation_input_remote_access.py | 31 +- .../shifter_platform/shared/range_cells.py | 7 - .../shifter_platform/shared/remote_access.py | 14 +- .../tests/cms/raes/test_dispatch.py | 1 - .../tests/cms/test_range_vpn.py | 3 +- .../tests/cms/test_vpn_control_api.py | 174 +++++++ .../tests/ctf/test_vpn_profile_api.py | 2 +- .../services/test_raes_openvpn_access_2030.py | 24 +- .../tests/engine/services/test_vpn_profile.py | 2 +- .../engine/services/test_vpn_sessions.py | 219 +++++++++ .../engine/test_range_lifecycle.py | 68 --- .../mission_control/test_vpn_profile_api.py | 3 +- .../shared/raes/test_raes_operation_input.py | 24 +- .../tests/shared/test_remote_access.py | 12 + 72 files changed, 1884 insertions(+), 3045 deletions(-) delete mode 100644 shifter/engine/provisioner/_gcp_range_cell_openvpn.py delete mode 100644 shifter/engine/provisioner/gcp_vpn_identity.py create mode 100644 shifter/engine/provisioner/tests/test_aws_range_vpn_module.py create mode 100644 shifter/shifter_platform/cms/api/vpn_control.py create mode 100644 shifter/shifter_platform/engine/migrations/0088_vpn_server_pool.py create mode 100644 shifter/shifter_platform/engine/models/_vpn_session.py create mode 100644 shifter/shifter_platform/engine/services/_vpn_sessions.py create mode 100644 shifter/shifter_platform/tests/cms/test_vpn_control_api.py create mode 100644 shifter/shifter_platform/tests/engine/services/test_vpn_sessions.py diff --git a/shifter/engine/provisioner/_gcp_range_cell_openvpn.py b/shifter/engine/provisioner/_gcp_range_cell_openvpn.py deleted file mode 100644 index 1e952ff77..000000000 --- a/shifter/engine/provisioner/_gcp_range_cell_openvpn.py +++ /dev/null @@ -1,252 +0,0 @@ -"""OpenVPN forwarding-gateway Compute Engine resource bodies for GCE range cells. - -Split out of ``gcp_range_cell_resources`` (which owns the range guest/network -bodies) so each module stays reviewable; the public renderers are re-exported -from that module, so importers are unaffected. The same proto-plus field-name -rules apply here (``network_i_p`` for REST ``networkIP``). -""" - -from __future__ import annotations - -from config import GCERangeCellConfig -from gcp_range_cell_naming import ( - _disk_type_self_link, - _machine_type_self_link, -) -from gcp_range_cell_plan import ( - ComputeResource, - OpenVpnGatewayPlan, - RangeCellPlan, -) - - -def openvpn_gateway_address_resource(gateway: OpenVpnGatewayPlan) -> ComputeResource: - """Render the gateway's deterministic private address reservation.""" - return { - "name": gateway["address_name"], - "address_type": "INTERNAL", - "address": gateway["private_ip"], - "subnetwork": gateway["subnetwork_link"], - } - - -_OPENVPN_GATEWAY_STARTUP_TEMPLATE = '''#!/bin/bash -set -euo pipefail -install -d -m 700 /etc/openvpn/server -python3 - <<'PY' -import base64 -import json -import pathlib -import subprocess -import urllib.request - -metadata = urllib.request.Request( - "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", - headers={{"Metadata-Flavor": "Google"}}, -) -with urllib.request.urlopen(metadata, timeout=10) as response: - token = json.load(response)["access_token"] -name = "{secret_name}/versions/latest" -request = urllib.request.Request( - f"https://secretmanager.googleapis.com/v1/{{name}}:access", - headers={{"Authorization": f"Bearer {{token}}"}}, -) -with urllib.request.urlopen(request, timeout=20) as response: - material = json.loads(base64.b64decode(json.load(response)["payload"]["data"])) -if set(material) != {{"ca", "certificate", "private_key", "tls_crypt"}}: - raise RuntimeError("OpenVPN server identity has an invalid shape") -directory = pathlib.Path("/etc/openvpn/server") -for filename, field in ( - ("ca.crt", "ca"), - ("server.crt", "certificate"), - ("server.key", "private_key"), - ("tls-crypt.key", "tls_crypt"), -): - path = directory / filename - path.write_text(material[field], encoding="utf-8") - path.chmod(0o600) -config = """port 1194 -proto udp4 -dev tun -topology subnet -server 172.30.0.0 255.255.255.0 -ca /etc/openvpn/server/ca.crt -cert /etc/openvpn/server/server.crt -key /etc/openvpn/server/server.key -dh none -tls-crypt /etc/openvpn/server/tls-crypt.key -verify-client-cert require -remote-cert-eku "TLS Web Client Authentication" -push "route {target_ip} 255.255.255.255" -keepalive 10 60 -persist-key -persist-tun -user nobody -group nogroup -auth SHA256 -cipher AES-256-GCM -data-ciphers AES-256-GCM:AES-128-GCM -tls-version-min 1.2 -explicit-exit-notify 1 -verb 3 -""" -(directory / "server.conf").write_text(config, encoding="utf-8") -(directory / "server.conf").chmod(0o600) -pathlib.Path("/etc/sysctl.d/90-shifter-openvpn.conf").write_text("net.ipv4.ip_forward=1\\n", encoding="utf-8") -subprocess.run(["sysctl", "--system"], check=True, stdout=subprocess.DEVNULL) -subprocess.run(["iptables", "-P", "FORWARD", "DROP"], check=True) -for rule in {forward_target_rules}: - subprocess.run(["iptables", "-A", "FORWARD", *rule], check=True) -for rule in ( - [ - "iptables", "-A", "FORWARD", "-o", "tun0", "-s", "{target_ip}/32", - "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-j", "ACCEPT", - ], - [ - "iptables", "-t", "nat", "-A", "POSTROUTING", "-s", "172.30.0.0/24", - "-d", "{target_ip}/32", "-j", "MASQUERADE", - ], -): - subprocess.run(rule, check=True) -subprocess.run(["systemctl", "enable", "--now", "openvpn-server@server"], check=True) -health_script = """#!/usr/bin/env python3 -import socketserver -import subprocess - -TARGET = "{target_ip}/32" -FORWARD_TARGET_RULES = {forward_target_rules} - -def healthy(): - active = subprocess.run( - ["systemctl", "is-active", "--quiet", "openvpn-server@server"], - check=False, - ).returncode == 0 - policy = subprocess.run( - ["iptables", "-S", "FORWARD"], - check=False, - capture_output=True, - text=True, - ).stdout.splitlines() - rules = ( - *(["iptables", "-C", "FORWARD", *rule] for rule in FORWARD_TARGET_RULES), - [ - "iptables", "-C", "FORWARD", "-o", "tun0", "-s", TARGET, - "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-j", "ACCEPT", - ], - [ - "iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "172.30.0.0/24", - "-d", TARGET, "-j", "MASQUERADE", - ], - ) - return active and "-P FORWARD DROP" in policy and all( - subprocess.run(rule, check=False).returncode == 0 for rule in rules - ) - -class Handler(socketserver.BaseRequestHandler): - def handle(self): - if healthy(): - self.request.sendall(b"ready\\\\n") - -class Server(socketserver.ThreadingTCPServer): - allow_reuse_address = True - -with Server(("0.0.0.0", 1195), Handler) as server: - server.serve_forever() -""" -health_path = pathlib.Path("/usr/local/sbin/shifter-openvpn-health.py") -health_path.write_text(health_script, encoding="utf-8") -health_path.chmod(0o700) -unit = """[Unit] -Description=Shifter OpenVPN service and target-policy readiness -Requires=openvpn-server@server.service -After=openvpn-server@server.service -BindsTo=openvpn-server@server.service - -[Service] -Type=simple -ExecStart=/usr/local/sbin/shifter-openvpn-health.py -Restart=on-failure -RestartSec=2 - -[Install] -WantedBy=multi-user.target -""" -pathlib.Path("/etc/systemd/system/shifter-openvpn-health.service").write_text(unit, encoding="utf-8") -subprocess.run(["systemctl", "daemon-reload"], check=True) -subprocess.run(["systemctl", "enable", "--now", "shifter-openvpn-health"], check=True) -PY -''' - - -def _forward_target_rules(gateway: OpenVpnGatewayPlan) -> list[list[str]]: - """Return the tunnel-to-target FORWARD accepts: declared TCP ports, else every port.""" - target = f"{gateway['target_ip']}/32" - ports = gateway.get("target_ports") - if not ports: - return [["-i", "tun0", "-d", target, "-j", "ACCEPT"]] - return [["-i", "tun0", "-d", target, "-p", "tcp", "--dport", port, "-j", "ACCEPT"] for port in ports] - - -def _openvpn_gateway_startup(plan: RangeCellPlan, gateway: OpenVpnGatewayPlan) -> str: - """Return a fixed bootstrap that resolves only the server identity secret. - - The secret is read from the exact reference the provisioner stored it at; a - legacy plan without one falls back to its deterministic platform-project name. - """ - secret_name = gateway.get("server_secret_ref") or ( - f"projects/{plan['project_id']}/secrets/" - f"shifter-range-{plan['range_id']}-vpn-{plan['request_uuid'].replace('-', '')}-server" - ) - return _OPENVPN_GATEWAY_STARTUP_TEMPLATE.format( - secret_name=secret_name, - target_ip=gateway["target_ip"], - forward_target_rules=repr(_forward_target_rules(gateway)), - ) - - -def openvpn_gateway_instance_resource( - plan: RangeCellPlan, - gateway: OpenVpnGatewayPlan, - config: GCERangeCellConfig, -) -> ComputeResource: - """Render a request-owned forwarding gateway with one public UDP endpoint.""" - profile = gateway["profile"] - body: ComputeResource = { - "name": gateway["resource_name"], - "machine_type": _machine_type_self_link(plan["zone"], profile.machine_type), - "labels": {**plan["labels"], "role": "vpn-gateway"}, - "tags": {"items": [gateway["tag"]]}, - "can_ip_forward": True, - "metadata": {"items": [{"key": "startup-script", "value": _openvpn_gateway_startup(plan, gateway)}]}, - "network_interfaces": [ - { - "subnetwork": gateway["subnetwork_link"], - "network_i_p": gateway["private_ip"], - "access_configs": [{"name": "External NAT", "type_": "ONE_TO_ONE_NAT"}], - } - ], - "disks": [ - { - "boot": True, - "auto_delete": True, - "initialize_params": { - "source_image": profile.source_image, - "disk_size_gb": int(profile.disk_size_gb), - "disk_type": _disk_type_self_link(plan["zone"], profile.disk_type), - }, - } - ], - "service_accounts": [ - { - "email": gateway["service_account_email"], - "scopes": list(config.service_account_scopes), - } - ], - "shielded_instance_config": { - "enable_secure_boot": True, - "enable_vtpm": True, - "enable_integrity_monitoring": True, - }, - "deletion_protection": False, - } - return body diff --git a/shifter/engine/provisioner/gcp_dynamic_secrets.py b/shifter/engine/provisioner/gcp_dynamic_secrets.py index 8b7e6d713..f54066706 100644 --- a/shifter/engine/provisioner/gcp_dynamic_secrets.py +++ b/shifter/engine/provisioner/gcp_dynamic_secrets.py @@ -43,8 +43,6 @@ class DynamicSecretClass(StrEnum): GDC_VM_RDP_PASSWORD = auto() VMSERIES_SSH = auto() VERTEX_SERVICE_ACCOUNT_KEY = auto() - VPN_ISSUER = auto() - VPN_SERVER = auto() VPN_PROFILE = auto() @@ -70,8 +68,6 @@ class _DynamicSecretNameClass: DynamicSecretClass.GDC_VM_RDP_PASSWORD: _DynamicSecretNameClass("gdc-vm", "rdp-password"), DynamicSecretClass.VMSERIES_SSH: _DynamicSecretNameClass("vmseries", "ssh"), DynamicSecretClass.VERTEX_SERVICE_ACCOUNT_KEY: _DynamicSecretNameClass("vertex", "service-account-key"), - DynamicSecretClass.VPN_ISSUER: _DynamicSecretNameClass("vpn", "issuer"), - DynamicSecretClass.VPN_SERVER: _DynamicSecretNameClass("vpn", "server"), DynamicSecretClass.VPN_PROFILE: _DynamicSecretNameClass("vpn", "profile"), } diff --git a/shifter/engine/provisioner/gcp_range_cell_destroy.py b/shifter/engine/provisioner/gcp_range_cell_destroy.py index 03cac4216..6d0012c1e 100644 --- a/shifter/engine/provisioner/gcp_range_cell_destroy.py +++ b/shifter/engine/provisioner/gcp_range_cell_destroy.py @@ -22,33 +22,6 @@ logger = logging.getLogger(__name__) -def _destroy_vpn_gateway(plan: RangeCellPlan, clients: GCEClients) -> None: - """Delete the request-owned OpenVPN gateway instance and its address.""" - gateway = plan.get("vpn_gateway") - if gateway is None: - return - _delete_resource( - plan, - clients, - clients.instances.get, - clients.instances.delete, - "zone", - project=plan["project_id"], - zone=plan["zone"], - instance=gateway["resource_name"], - ) - _delete_resource( - plan, - clients, - clients.addresses.get, - clients.addresses.delete, - "region", - project=plan["project_id"], - region=plan["region"], - address=gateway["address_name"], - ) - - def _mark_disks_auto_delete(plan: RangeCellPlan, clients: GCEClients, resource_name: str) -> None: """Flag every retained disk of an existing instance so its delete reclaims them.""" existing = _get_or_none( @@ -176,9 +149,7 @@ def destroy_range_cell( logger.info("No GCE range variables provided for request %s; nothing to destroy", request_uuid) return resolved_config = config or load_gce_range_cell_config(backend=backend) - # The gateway SA email is unused for teardown (resources are deleted by name), - # but the range's reserved pool slot (ADR-008-R7) is read so the plan renders - # consistently with provision. The row exists while the range is DESTROYING. + # The row exists while the range is DESTROYING. range_data = get_range_data_by_request_id(request_uuid) # Bind the config to this range's realized zone (stored on the row at range # creation) before anything reads region/zone. Destroy reconstructs the exact @@ -192,7 +163,6 @@ def destroy_range_cell( variables, resolved_config, require_images=False, - vpn_gateway_pool_slot=range_data.get("vpn_gateway_pool_slot"), range_host_pool_slot=range_host_pool_slot, ) resolved_clients = clients or _build_clients() @@ -203,7 +173,6 @@ def destroy_range_cell( # Compute resources and idempotent, so it converges even on repeated destroy. resolved_vertex_ops.delete(plan["range_id"], plan["project_id"]) - _destroy_vpn_gateway(plan, resolved_clients) _destroy_instances(plan, resolved_clients, resolved_secret_ops) _destroy_network_resources(plan, resolved_clients) diff --git a/shifter/engine/provisioner/gcp_range_cell_firewall.py b/shifter/engine/provisioner/gcp_range_cell_firewall.py index f1679ee34..82a309e6b 100644 --- a/shifter/engine/provisioner/gcp_range_cell_firewall.py +++ b/shifter/engine/provisioner/gcp_range_cell_firewall.py @@ -13,7 +13,6 @@ FirewallPlan, GceEgressPolicy, InstancePlan, - OpenVpnGatewayPlan, SubnetPlan, ) @@ -95,11 +94,6 @@ def _ipv4_complement(excluded: list[ipaddress.IPv4Network]) -> list[str]: return [str(network) for network in sorted(allowed, key=lambda item: (int(item.network_address), item.prefixlen))] -def _public_ipv4_source_ranges() -> list[str]: - """Return routable public IPv4 space without private/reserved sources.""" - return _ipv4_complement(list(_NON_PUBLIC_IPV4_CIDRS)) - - def _denied_egress_networks(config: GCERangeCellConfig) -> list[ipaddress.IPv4Network]: """ADR-056-R4 denied-network inventory for range-cell egress. @@ -334,62 +328,6 @@ def _egress_rules( return rules -def _vpn_gateway_rules( - range_id: int, - vpn_gateway: OpenVpnGatewayPlan, - portal_network_cidrs: list[str], -) -> list[FirewallPlan]: - """Render the closed ingress/egress envelope for the OpenVPN gateway.""" - return [ - { - "name": _short_resource_name("shifter-r", range_id, "vpn-in"), - "direction": "INGRESS", - "priority": 800, - "target_tags": [vpn_gateway["tag"]], - "source_ranges": _public_ipv4_source_ranges(), - "allowed": [{"IPProtocol": "udp", "ports": ["1194"]}], - }, - { - "name": _short_resource_name("shifter-r", range_id, "vpn-health"), - "direction": "INGRESS", - "priority": 800, - "target_tags": [vpn_gateway["tag"]], - "source_ranges": portal_network_cidrs, - "allowed": [{"IPProtocol": "tcp", "ports": ["1195"]}], - }, - { - "name": _short_resource_name("shifter-r", range_id, "vpn-target"), - "direction": "EGRESS", - "priority": 800, - "target_tags": [vpn_gateway["tag"]], - "destination_ranges": [f"{vpn_gateway['target_ip']}/32"], - # Only the target's declared participant channels when the plan - # carries them (#2030); a legacy plan forwards every port. - "allowed": ( - [{"IPProtocol": "tcp", "ports": list(vpn_gateway["target_ports"])}] - if vpn_gateway.get("target_ports") - else [{"IPProtocol": "all"}] - ), - }, - { - "name": _short_resource_name("shifter-r", range_id, "vpn-api"), - "direction": "EGRESS", - "priority": 800, - "target_tags": [vpn_gateway["tag"]], - "destination_ranges": [_GOOGLE_PRIVATE_API_VIP_CIDR], - "allowed": [{"IPProtocol": "tcp", "ports": ["443"]}], - }, - { - "name": _short_resource_name("shifter-r", range_id, "vpn-deny"), - "direction": "EGRESS", - "priority": 900, - "target_tags": [vpn_gateway["tag"]], - "destination_ranges": [_UNIVERSAL_IPV4_CIDR], - "denied": [{"IPProtocol": "all"}], - }, - ] - - def _public_web_egress( deny_general_egress: bool, instances: list[InstancePlan], @@ -407,7 +345,6 @@ def build_firewall_plan( range_id: int, subnet_plans: list[SubnetPlan], config: GCERangeCellConfig, - vpn_gateway: OpenVpnGatewayPlan | None = None, *, instance_plans: list[InstancePlan] | None = None, include_optional_cleanup: bool = False, @@ -430,7 +367,6 @@ def build_firewall_plan( config, subnet_plans, instance_plans or [], - vpn_gateway, bypass, ) if bypass: @@ -476,8 +412,6 @@ def build_firewall_plan( config, ) ) - if vpn_gateway is not None: - firewalls.extend(_vpn_gateway_rules(range_id, vpn_gateway, portal_network_cidrs)) if broker_destination is not None: firewalls.append( { diff --git a/shifter/engine/provisioner/gcp_range_cell_model_broker.py b/shifter/engine/provisioner/gcp_range_cell_model_broker.py index ee4933b73..fbd972986 100644 --- a/shifter/engine/provisioner/gcp_range_cell_model_broker.py +++ b/shifter/engine/provisioner/gcp_range_cell_model_broker.py @@ -8,7 +8,7 @@ from config import GCERangeCellConfig from gcp_range_cell_naming import _short_resource_name -from gcp_range_cell_types import GceEgressPolicy, InstancePlan, OpenVpnGatewayPlan, SubnetPlan +from gcp_range_cell_types import GceEgressPolicy, InstancePlan, SubnetPlan def admitted_broker_destination( @@ -16,7 +16,6 @@ def admitted_broker_destination( config: GCERangeCellConfig, subnet_plans: list[SubnetPlan], instances: list[InstancePlan], - vpn_gateway: OpenVpnGatewayPlan | None, bypass: bool, ) -> str | None: """Validate the exact endpoint and every client bypass before rendering.""" @@ -31,7 +30,7 @@ def admitted_broker_destination( any(instance.get(key) for key in ("can_ip_forward", "attach_service_account", "service_account_email")) for instance in instances ) - if any((bypass, config.private_google_access, unsafe_clients, vpn_gateway is not None)): + if any((bypass, config.private_google_access, unsafe_clients)): raise RuntimeError("model broker clients require source-preserving keyless isolated egress") if any(ipaddress.ip_network(destination).overlaps(ipaddress.ip_network(subnet["cidr"])) for subnet in subnet_plans): raise RuntimeError("model broker VIP must not overlap an intra-range egress destination") diff --git a/shifter/engine/provisioner/gcp_range_cell_plan.py b/shifter/engine/provisioner/gcp_range_cell_plan.py index a5d6042eb..e99fd9e48 100644 --- a/shifter/engine/provisioner/gcp_range_cell_plan.py +++ b/shifter/engine/provisioner/gcp_range_cell_plan.py @@ -3,7 +3,7 @@ from __future__ import annotations import ipaddress -from dataclasses import dataclass, replace +from dataclasses import replace from typing import cast from shared.range_cells import RangeCellContractError, validate_gcp_vm_range_cell_request @@ -29,14 +29,12 @@ GceEgressPolicy, InstancePlan, NetworkPlan, - OpenVpnGatewayPlan, RangeCellPlan, ResourceDict, RouterNatPlan, ScenarioInstance, SubnetPlan, ) -from gcp_vpn_identity import gcp_vpn_gateway_pool_service_account_email _MANAGED_BY_LABEL = "shifter-provisioner" @@ -46,7 +44,6 @@ "FirewallPlan", "InstancePlan", "NetworkPlan", - "OpenVpnGatewayPlan", "RangeCellPlan", "ResourceDict", "ScenarioInstance", @@ -158,120 +155,17 @@ def _build_subnet_plans( return plans -def _require_openvpn_capable_config(config: GCERangeCellConfig) -> None: - """Reject configurations that cannot realize the authorized capability.""" - if ( - config.network_mode != "shared-vpc" - or not config.private_google_access - or not config.linux.source_image - or not config.portal_network_cidrs - or "https://www.googleapis.com/auth/cloud-platform" not in config.service_account_scopes - ): - raise RuntimeError("The authorized OpenVPN capability cannot be realized by this GCE adapter") - - -def _free_guest_address(subnet: SubnetPlan) -> str: - """Return the first usable guest address not already assigned in the subnet.""" - network = ipaddress.ip_network(subnet["cidr"]) - used = set(subnet["ip_assignments"].values()) - available = [str(address) for address in list(network.hosts())[2:-2] if str(address) not in used] - if not available: - raise RuntimeError("The Kali subnet has no address available for its OpenVPN gateway") - return available[0] - - -@dataclass(frozen=True) -class OpenVpnGatewayRequest: - """The authorized OpenVPN gateway one range plans (ADR-039-R10). - - ``pool_slot`` is the range's reserved index into the pre-provisioned gateway - SA pool (ADR-008-R7); the gateway VM runs as that pooled identity. - ``target_ports`` restricts forwarding to the target's declared participant - channels and ``server_secret_ref`` is the exact identity secret the gateway - reads; both are omitted from the plan when empty. - """ - - target_ref: str - pool_slot: int | None - target_ports: tuple[str, ...] = () - server_secret_ref: str = "" - - -def _require_gateway_provision_values( - range_id: int, request: OpenVpnGatewayRequest, config: GCERangeCellConfig -) -> None: - """Reject a realizing plan that lacks its pool slot or a capable adapter config.""" - if request.pool_slot is None: - raise RuntimeError( - f"Range {range_id} requests OpenVPN but has no reserved gateway pool slot; " - "it was not created with an OpenVPN capability" - ) - _require_openvpn_capable_config(config) - - -def _gateway_target(instance_plans: list[InstancePlan], target_ref: str) -> InstancePlan: - """Return the single range member the gateway forwards to.""" - targets = [instance for instance in instance_plans if instance["uuid"] == target_ref] - if len(targets) != 1: - raise RuntimeError("OpenVPN capability must identify exactly one GCE range member") - return targets[0] - - -def _openvpn_gateway_plan( - range_id: int, - instance_plans: list[InstancePlan], - subnet_plans: list[SubnetPlan], - config: GCERangeCellConfig, - request: OpenVpnGatewayRequest | None, - *, - require_provision_values: bool, -) -> OpenVpnGatewayPlan | None: - """Plan the request-owned OpenVPN gateway adjacent to the authorized member.""" - if request is None: - return None - if require_provision_values: - _require_gateway_provision_values(range_id, request, config) - target = _gateway_target(instance_plans, request.target_ref) - subnet = next(item for item in subnet_plans if item["name"] == target["subnet_name"]) - gateway: OpenVpnGatewayPlan = { - "resource_name": _short_resource_name("shifter-r", range_id, "vpn-gateway"), - "address_name": _short_resource_name("shifter-r", range_id, "vpn-gateway-ip"), - "private_ip": _free_guest_address(subnet) if subnet["cidr"] else "", - "subnet_resource_name": subnet["resource_name"], - "subnetwork_link": subnet["self_link"], - "target_ref": target["uuid"], - "target_ip": target["private_ip"], - "tag": _short_resource_name("shifter-r", range_id, "vpn-gateway"), - "profile": config.get_profile(role="victim", os_type="ubuntu"), - "service_account_email": ( - gcp_vpn_gateway_pool_service_account_email(config.project_id, request.pool_slot) - if request.pool_slot is not None - else "" - ), - } - if request.target_ports: - gateway["target_ports"] = list(request.target_ports) - if request.server_secret_ref: - gateway["server_secret_ref"] = request.server_secret_ref - return gateway - - def render_range_cell_plan( request_uuid: str, variables: ResourceDict, config: GCERangeCellConfig | None = None, *, require_images: bool = True, - vpn_gateway_pool_slot: int | None = None, range_host_pool_slot: int | None = None, egress_policy: GceEgressPolicy = DEFAULT_GCE_EGRESS_POLICY, ) -> RangeCellPlan: """Render the deterministic GCE resources for one range cell. - ``vpn_gateway_pool_slot`` is the range's reserved gateway SA pool slot - (ADR-008-R7), threaded to the OpenVPN gateway plan; ``None`` for ranges - without an OpenVPN capability. - ``egress_policy.model_broker`` is explicitly admitted, never inferred from installation enablement. Its VIP is bound to ``config.model_broker_vip``. """ @@ -319,19 +213,6 @@ def render_range_cell_plan( range_host_pool_slot=range_host_pool_slot, ), ) - remote_access = validated_request["remote_access"] - vpn_gateway = _openvpn_gateway_plan( - range_id, - instance_plans, - subnet_plans, - resolved_config, - ( - OpenVpnGatewayRequest(str(remote_access["target_ref"]), vpn_gateway_pool_slot) - if remote_access is not None - else None - ), - require_provision_values=require_images, - ) plan: RangeCellPlan = { "project_id": resolved_config.project_id, "region": resolved_config.region, @@ -351,14 +232,11 @@ def render_range_cell_plan( range_id, subnet_plans, resolved_config, - vpn_gateway, instance_plans=instance_plans, include_optional_cleanup=not require_images, egress_policy=egress_policy, ), } - if vpn_gateway is not None: - plan["vpn_gateway"] = vpn_gateway # A non-`none` range owns an explicit Cloud Router + NAT scoped to its subnets; # a `none` (zero-egress) range omits it so its subnets carry no NAT path # (PLAT-238, ADR-026-R6), mirroring the RAES plan builder. diff --git a/shifter/engine/provisioner/gcp_range_cell_resources.py b/shifter/engine/provisioner/gcp_range_cell_resources.py index 0bc16b4cc..e745d66e6 100644 --- a/shifter/engine/provisioner/gcp_range_cell_resources.py +++ b/shifter/engine/provisioner/gcp_range_cell_resources.py @@ -5,19 +5,12 @@ ``*_resource=`` kwargs of the Compute clients, which construct the proto messages from them. Note the proto-plus quirks ``I_p_protocol`` (REST ``IPProtocol``) and ``network_i_p`` (REST ``networkIP``). - -The OpenVPN forwarding-gateway bodies live in ``_gcp_range_cell_openvpn`` and are -re-exported here, so importers see the same surface as before that split. """ from __future__ import annotations from typing import Any, cast -from _gcp_range_cell_openvpn import ( - openvpn_gateway_address_resource, - openvpn_gateway_instance_resource, -) from config import GCERangeCellConfig from gcp_range_cell_naming import ( _disk_type_self_link, @@ -40,8 +33,6 @@ "firewall_resource", "instance_resource", "network_resource", - "openvpn_gateway_address_resource", - "openvpn_gateway_instance_resource", "router_nat_resource", "subnetwork_resource", ] diff --git a/shifter/engine/provisioner/gcp_range_cell_types.py b/shifter/engine/provisioner/gcp_range_cell_types.py index ed29991ba..7dc3db956 100644 --- a/shifter/engine/provisioner/gcp_range_cell_types.py +++ b/shifter/engine/provisioner/gcp_range_cell_types.py @@ -105,26 +105,6 @@ class InstancePlan(TypedDict): service_account_email: NotRequired[str] -class OpenVpnGatewayPlan(TypedDict): - """Request-owned OpenVPN gateway adjacent to one Kali member.""" - - resource_name: str - address_name: str - private_ip: str - subnet_resource_name: str - subnetwork_link: str - target_ref: str - target_ip: str - tag: str - profile: GCERangeImageProfile - service_account_email: str - # TCP ports the gateway forwards to the target: the target's declared - # participant channels (#2030). Absent forwards every port (legacy plan). - target_ports: NotRequired[list[str]] - # Exact provider reference of the gateway's server identity secret. - server_secret_ref: NotRequired[str] - - class RangeCellPlan(TypedDict): """Complete resource plan for a single GCE range cell.""" @@ -143,7 +123,6 @@ class RangeCellPlan(TypedDict): subnets: list[SubnetPlan] instances: list[InstancePlan] firewalls: list[FirewallPlan] - vpn_gateway: NotRequired[OpenVpnGatewayPlan] # Range-owned Cloud Router + Cloud NAT (PLAT-238, ADR-026-R6). Present only for # a non-`none` range: it gives that range's participant subnets an explicit, # range-scoped NAT egress path instead of the deprecated shared all-subnet NAT. @@ -168,7 +147,6 @@ class RouterNatPlan(TypedDict): "FirewallPlan", "InstancePlan", "NetworkPlan", - "OpenVpnGatewayPlan", "RangeCellPlan", "ResourceDict", "RouterNatPlan", diff --git a/shifter/engine/provisioner/gcp_range_cells.py b/shifter/engine/provisioner/gcp_range_cells.py index 9ac97aecb..e9a4b1cc5 100644 --- a/shifter/engine/provisioner/gcp_range_cells.py +++ b/shifter/engine/provisioner/gcp_range_cells.py @@ -28,8 +28,6 @@ firewall_resource, instance_resource, network_resource, - openvpn_gateway_address_resource, - openvpn_gateway_instance_resource, router_nat_resource, subnetwork_resource, ) @@ -348,72 +346,6 @@ def _provision_range_resources( return instance_outputs -def _external_ip(instance: object) -> str: - """Return the instance's first NAT external IP, or empty when absent.""" - interfaces = getattr(instance, "network_interfaces", None) or [] - if not interfaces: - return "" - access_configs = getattr(interfaces[0], "access_configs", None) or [] - if not access_configs: - return "" - return str(getattr(access_configs[0], "nat_i_p", "") or getattr(access_configs[0], "nat_ip", "")) - - -def _ensure_openvpn_gateway( - plan: RangeCellPlan, - clients: GCEClients, - config: GCERangeCellConfig, -) -> ResourceDict | None: - """Create/reconcile the request-owned gateway and return non-secret readiness.""" - gateway = plan.get("vpn_gateway") - if gateway is None: - return None - address = _get_or_none( - clients.addresses.get, - clients.google_exceptions, - project=plan["project_id"], - region=plan["region"], - address=gateway["address_name"], - ) - if address is None: - operation = clients.addresses.insert( - project=plan["project_id"], - region=plan["region"], - address_resource=openvpn_gateway_address_resource(gateway), - ) - _wait_for_operation(plan, clients, operation, "region") - existing = _get_or_none( - clients.instances.get, - clients.google_exceptions, - project=plan["project_id"], - zone=plan["zone"], - instance=gateway["resource_name"], - ) - if existing is None: - operation = clients.instances.insert( - project=plan["project_id"], - zone=plan["zone"], - instance_resource=openvpn_gateway_instance_resource(plan, gateway, config), - ) - _wait_for_operation(plan, clients, operation, "zone") - existing = clients.instances.get( - project=plan["project_id"], - zone=plan["zone"], - instance=gateway["resource_name"], - ) - endpoint = _external_ip(existing) - if not endpoint: - raise RuntimeError("GCE OpenVPN gateway has no public endpoint") - return { - "endpoint": endpoint, - "port": 1194, - "health_endpoint": gateway["private_ip"], - "health_port": 1195, - "target_ref": gateway["target_ref"], - "ready": False, - } - - def apply_range_cell( request_uuid: str, variables: ResourceDict, @@ -427,8 +359,7 @@ def apply_range_cell( """Create or reconcile a live-fire GCE range cell and return provisioner outputs.""" resolved_config = config or load_gce_range_cell_config() # Validate the closed contract and scenario binding before constructing any - # provider or secret client, let alone mutating a cloud resource. The gateway - # VM runs as the range's reserved pool identity (ADR-008-R7). + # provider or secret client, let alone mutating a cloud resource. range_data = get_range_data_by_request_id(request_uuid) # Bind the config to this range's realized zone (chosen at range creation and # stored on the row) before anything reads region/zone. Empty placement keeps @@ -439,7 +370,6 @@ def apply_range_cell( request_uuid, variables, resolved_config, - vpn_gateway_pool_slot=range_data.get("vpn_gateway_pool_slot"), range_host_pool_slot=range_host_pool_slot, ) resolved_clients = clients or _build_clients() @@ -447,7 +377,6 @@ def apply_range_cell( resolved_vertex_ops = vertex_ops or _default_vertex_ops() try: instance_outputs = _provision_range_resources(plan, resolved_clients, resolved_config, resolved_secret_ops) - vpn_gateway = _ensure_openvpn_gateway(plan, resolved_clients, resolved_config) closed_result = range_cell_result(variables, plan, instance_outputs) except Exception: logger.exception("GCE range-cell apply failed; attempting cleanup request_id=%s", request_uuid) @@ -463,11 +392,8 @@ def apply_range_cell( ) cleanup(request_uuid, variables) raise - result: ResourceDict = { + return { "subnets": subnet_outputs(plan), "instances": instance_outputs, "range_cell": closed_result, } - if vpn_gateway is not None: - result["vpn_gateway"] = vpn_gateway - return result diff --git a/shifter/engine/provisioner/gcp_vpn_identity.py b/shifter/engine/provisioner/gcp_vpn_identity.py deleted file mode 100644 index 73cc07a6a..000000000 --- a/shifter/engine/provisioner/gcp_vpn_identity.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Deterministic identities for GCE OpenVPN gateways. - -ADR-008-R7: gateway identities are drawn from a pre-provisioned, single-project -service-account pool (``sh-vpn-pool-``). The slot is reserved per active -range by ``Range.allocate_vpn_gateway_slot`` and the provisioner holds -``serviceAccountUser`` on each pool member, so no runtime service-account -creation or self-``setIamPolicy`` is required. -""" - -from __future__ import annotations - - -def gcp_vpn_gateway_pool_service_account_id(slot: int) -> str: - """Return the account ID of pre-provisioned gateway SA pool member ``slot``.""" - return f"sh-vpn-pool-{slot}" - - -def gcp_vpn_gateway_pool_service_account_email(project_id: str, slot: int) -> str: - """Return the email of pre-provisioned gateway SA pool member ``slot`` (ADR-008-R7).""" - return f"{gcp_vpn_gateway_pool_service_account_id(slot)}@{project_id}.iam.gserviceaccount.com" - - -__all__ = [ - "gcp_vpn_gateway_pool_service_account_email", - "gcp_vpn_gateway_pool_service_account_id", -] diff --git a/shifter/engine/provisioner/provisioner_db.py b/shifter/engine/provisioner/provisioner_db.py index 63f59264d..2045d4de9 100644 --- a/shifter/engine/provisioner/provisioner_db.py +++ b/shifter/engine/provisioner/provisioner_db.py @@ -23,7 +23,6 @@ import psycopg from psycopg import sql from shared.enums import ResourceStatus -from shared.remote_access import parse_openvpn_binding from config import has_ngfw_attachment_state from log_redact import safe_log_fingerprint @@ -225,7 +224,6 @@ def write_provisioned_state( subnets: dict[str, dict[str, Any]], instances: list[dict[str, Any]], ngfw_instance_id: int | None = None, - vpn_access_binding: dict[str, object] | None = None, *, operation: OperationRef | None = None, ) -> None: @@ -236,15 +234,9 @@ def write_provisioned_state( subnets: Mapping of subnet name → subnet data dict. instances: List of instance data dicts. ngfw_instance_id: FK to the NGFW Instance, if any. - vpn_access_binding: Closed non-secret OpenVPN result, if supported. operation: ADR-043 operation identity for the shadow result append; ``None`` (or a ref without an operation_id) skips it. """ - if vpn_access_binding is not None: - # Reject extensions (especially accidental credential/profile fields) - # before opening a DB transaction. Only the closed ref-only contract is - # eligible for persistence. - parse_openvpn_binding(vpn_access_binding) provider = _get_cloud_provider() with get_db_connection() as conn: with conn.cursor() as cur: @@ -255,7 +247,6 @@ def write_provisioned_state( """ UPDATE mission_control_range SET provisioned_instances = %s, - vpn_access_binding = %s, ngfw_instance_id = %s, destroyed_at = NULL, updated_at = NOW() @@ -263,7 +254,6 @@ def write_provisioned_state( """, ( json.dumps(provisioned_instances), - json.dumps(vpn_access_binding) if vpn_access_binding is not None else None, ngfw_instance_id, range_id, ), @@ -385,8 +375,6 @@ def get_range_data_by_request_id(request_id: str) -> dict[str, Any]: rng.status, rng.range_backend, rng.instantiation_purpose, - rng.remote_access_capability, - rng.vpn_gateway_pool_slot, rng.egress_mode, rng.placement_zone FROM engine_request r @@ -444,15 +432,13 @@ def get_range_data_by_request_id(request_id: str) -> dict[str, Any]: # deploy-wide GCP_RANGE_BACKEND selector. "range_backend": row[6], "instantiation_purpose": row[7], - "remote_access_capability": row[8], - "vpn_gateway_pool_slot": row[9], # Effective egress posture pinned at create (PLAT-238, ADR-017-R5). # Read from the range's own pinned column (like range_config/backend), # never re-resolved from workspace state or the deployment env once # pinned. NULL-safe default keeps legacy rows on the compatibility path. - "egress_mode": row[10] or "status-quo", + "egress_mode": row[8] or "status-quo", # #2029 realized multi-region placement zone (empty for single-zone # and every pre-#2029 row); read back on destroy so teardown targets # the exact zone apply selected. - "placement_zone": row[11], + "placement_zone": row[9], } diff --git a/shifter/engine/provisioner/raes_gcp_activate_gce.py b/shifter/engine/provisioner/raes_gcp_activate_gce.py index ab2551de0..c0a6e9e3f 100644 --- a/shifter/engine/provisioner/raes_gcp_activate_gce.py +++ b/shifter/engine/provisioner/raes_gcp_activate_gce.py @@ -38,13 +38,13 @@ class GceActivationOps: @staticmethod def scrub_pre_claim_access(activation: ActivationInput, prepared_generation: UUID) -> None: - """Delete every pre-claim guest/provider secret and the warm-prepare VPN identity. + """Delete every pre-claim guest/provider secret and any warm-prepare VPN profile. Exhaustive over the *entire realized plan*, not merely the claimant's participant-access bindings: every plan node's SSH secret and every authored account's credential secret (across all hosts, bound or not) is deleted by - its deterministic name, and the warm-prepare VPN generation (issuer, server, - and any participant profile) is deleted. Deleting a secret-store record is the + its deterministic name, and any participant profile of the warm-prepare + generation is deleted. Deleting a secret-store record is the first half of revocation; the realize step then rotates the on-guest material so pre-claim credentials no longer authenticate. Idempotent -- a missing secret is a no-op, so a retried activation is safe. @@ -66,13 +66,13 @@ def scrub_pre_claim_access(activation: ActivationInput, prepared_generation: UUI auth_method = getattr(account, "auth_method", "") if target and username and auth_method: delete_raes_account_secret(range_id, target, username, auth_method) - GCPVpnSecretOps().delete_generation(range_id, prepared_generation, delete_identity=True) + GCPVpnSecretOps().delete_profile(range_id, prepared_generation) def realize_claimant_access(self, activation: ActivationInput, activate_generation: UUID) -> ActivationResult: """Realize the claimant's fresh access on the already-realized range cell. - Binds the declared participant access to the plan, mints a fresh - generation-fenced VPN identity for the claimant, and installs fresh + Binds the declared participant access to the plan, mints the claimant's + generation-fenced VPN profile, and installs fresh authored-account credentials on each guest. Returns the bounded member/access projection the Engine applier persists (secret *references* only). """ @@ -91,8 +91,8 @@ def prior_access_revoked(activation: ActivationInput, prepared_generation: UUID) """Authoritatively verify no pre-claim credential/access material still resolves. Fail-closed negative verification (#28): returns True only when every - pre-claim access record is demonstrably absent -- the prepared VPN - generation's issuer/server/profile and each realized guest's SSH and + pre-claim access record is demonstrably absent -- the prepared + generation's VPN profile and each realized guest's SSH and authored-account secrets. Guest credentials were rotated in the realize step (fresh material overwrites the old), so this confirms the scrub removed every pre-claim secret-store record that could otherwise be replayed. Any secret @@ -104,10 +104,10 @@ def prior_access_revoked(activation: ActivationInput, prepared_generation: UUID) no participant credential was issued or exposed during preparation while the range was system-owned and quarantined (never public-READY). Guest account credentials are rotated in the realize step (fresh material replaces the - old), and the VPN identity is generation-fenced. This check confirms the - pre-claim generation's VPN issuer -- keyed by the *old* generation UUID, not - a reused name -- is absent, which is authoritative generation evidence that - the pre-claim VPN path is revoked. A live negative-authentication probe + old), and the VPN profile is generation-fenced. This check confirms the + pre-claim generation's VPN profile -- keyed by the *old* generation UUID, not + a reused name -- is absent; the pool also refuses any connection for a + generation that is not the range's current one. A live negative-authentication probe against the guests is the stronger complement verified on a real range. """ from vpn_secrets import GCPVpnSecretOps @@ -116,10 +116,10 @@ def prior_access_revoked(activation: ActivationInput, prepared_generation: UUID) try: ops = GCPVpnSecretOps() # Belt-and-suspenders scrub, then authoritative generation-fenced check: - # the pre-claim generation's issuer must be absent. Fail closed if it + # the pre-claim generation's profile must be absent. Fail closed if it # still resolves or the probe cannot be performed. - ops.delete_generation(range_id, prepared_generation, delete_identity=True) - if ops.issuer_present(range_id, prepared_generation): + ops.delete_profile(range_id, prepared_generation) + if ops.profile_present(range_id, prepared_generation): logger.error("warm activation negative-verify: pre-claim VPN generation still present") return False except Exception: diff --git a/shifter/engine/provisioner/raes_gcp_activate_realize.py b/shifter/engine/provisioner/raes_gcp_activate_realize.py index ae13376cf..05aa12520 100644 --- a/shifter/engine/provisioner/raes_gcp_activate_realize.py +++ b/shifter/engine/provisioner/raes_gcp_activate_realize.py @@ -9,10 +9,9 @@ Engine applier persists are projected from the realized instance outputs (secret *references* only, never credential values). -The claimant's VPN identity is generation-fenced and regenerates for the new owner -against the rotated (activate) operation generation; the pre-claim VPN generation -was already deleted by the scrub step, so activation does not re-mint a VPN profile -here. +When the claimant's range holds an OpenVPN capability, activation adds the pool +firewall rule and mints the claimant's generation-fenced profile (#2480). The +pre-claim generation never held one, and the scrub step deleted any residue. This module performs live GCE work; its efficacy is verified on a real range (the repository's verification norm for provisioner cloud effects). It fails closed: any diff --git a/shifter/engine/provisioner/raes_gcp_activation_apply.py b/shifter/engine/provisioner/raes_gcp_activation_apply.py index e41b4dac2..825466e73 100644 --- a/shifter/engine/provisioner/raes_gcp_activation_apply.py +++ b/shifter/engine/provisioner/raes_gcp_activation_apply.py @@ -20,7 +20,7 @@ ) from raes_gcp_plan import RaesGcePlanOptions, build_raes_range_cell_plan from raes_gcp_verification import _realize_directory -from raes_gcp_vpn_apply import ensure_vpn_gateway, publish_vpn_access, remote_access_plan +from raes_gcp_vpn_apply import publish_vpn_access, vpn_access_plan from raes_guest_plan import assert_management_login_separate from raes_operating_system import validate_operating_systems from raes_participant_host_keys import observe_participant_host_keys @@ -53,7 +53,7 @@ def realize_existing_cell( access_bindings=realized_access, egress_policy=GceEgressPolicy(mode=resolved_options.egress_mode), allocated_network_cidrs=runtime.allocated_network_cidrs, - remote_access=remote_access_plan(resolved_options), + remote_access=vpn_access_plan(resolved_options), ), ) for instance in plan["instances"]: @@ -66,9 +66,6 @@ def realize_existing_cell( _accounts_by_node(raes_plan), _access_by_node(realized_access), ) - # The claimant's OpenVPN gateway (#2030): created now so it boots while the - # cell is verified, then probed and its profile published below. - vpn_gateway = ensure_vpn_gateway(plan, runtime) verified = set(_realize_directory(plan, raes_plan, outputs, runtime)) realize_raes_content_delivery( raes_plan=raes_plan, @@ -90,5 +87,5 @@ def realize_existing_cell( "composition_verified_addresses": sorted(verified), "operating_systems": operating_systems, "compute_substrates": compute_substrates, - **publish_vpn_access(runtime, vpn_gateway), + **publish_vpn_access(runtime), } diff --git a/shifter/engine/provisioner/raes_gcp_apply.py b/shifter/engine/provisioner/raes_gcp_apply.py index fbac520e1..ead235015 100644 --- a/shifter/engine/provisioner/raes_gcp_apply.py +++ b/shifter/engine/provisioner/raes_gcp_apply.py @@ -65,7 +65,7 @@ from raes_gcp_plan import RaesGcePlanOptions, build_raes_range_cell_plan from raes_gcp_secret_ops import RaesGceSecretOps, _default_secret_ops from raes_gcp_verification import _verify_raes_apply -from raes_gcp_vpn_apply import ensure_vpn_gateway, publish_vpn_access, remote_access_plan +from raes_gcp_vpn_apply import publish_vpn_access, vpn_access_plan from raes_guest_plan import ( _access_by_node, _accounts_by_node, @@ -313,8 +313,8 @@ def _cleanup_failed_apply( runtime: RaesGceApplyRuntime, ) -> None: """Run reconstructive cleanup using the apply pass's resolved clients.""" - # Destroy reconstructs names only; the identity secret is not needed. - remote_access = runtime.openvpn.plan_remote_access().names_only() if runtime.openvpn is not None else None + # Teardown reconstructs the pool rule by name only. + remote_access = runtime.openvpn.plan_access().names_only() if runtime.openvpn is not None else None destroy_raes_range_cell( request_uuid, range_id, @@ -361,7 +361,7 @@ def _prepare_raes_apply( access_bindings=realized_access, egress_policy=GceEgressPolicy(mode=options.egress_mode, model_broker=options.model_broker), allocated_network_cidrs=options.allocated_network_cidrs, - remote_access=remote_access_plan(options), + remote_access=vpn_access_plan(options), ), ) for instance in plan["instances"]: @@ -450,9 +450,8 @@ def apply_raes_range_cell( _access_by_node(realized_access), created, ) - vpn_gateway = ensure_vpn_gateway(plan, runtime) verified_observations = _verify_raes_apply(plan, raes_plan, instance_outputs, delivery_bindings, runtime) - vpn_access = publish_vpn_access(runtime, vpn_gateway) + vpn_access = publish_vpn_access(runtime) except GCEInstanceBindingError: # A conflicting VM is not ours to delete, even if a race placed it # after the read-only preflight and some network resources were made. diff --git a/shifter/engine/provisioner/raes_gcp_apply_types.py b/shifter/engine/provisioner/raes_gcp_apply_types.py index 83e43ea1c..dcb3de4f1 100644 --- a/shifter/engine/provisioner/raes_gcp_apply_types.py +++ b/shifter/engine/provisioner/raes_gcp_apply_types.py @@ -18,17 +18,17 @@ from raes_substrate_observation import observe_gce_substrates if TYPE_CHECKING: - from raes_gcp_vpn_plan import RaesGceRemoteAccess + from raes_gcp_vpn_plan import RaesGceVpnAccess class RaesGceOpenVpn(Protocol): """A generation's prepared participant OpenVPN access (#2030, ``raes_openvpn``).""" - def plan_remote_access(self) -> RaesGceRemoteAccess: - """Return the gateway planning inputs, including the exact identity secret.""" + def plan_access(self) -> RaesGceVpnAccess: + """Return the pool firewall inputs for this range.""" - def publish(self, gateway: object) -> dict[str, object]: - """Verify the realized gateway and return the owner-free realization.""" + def publish(self) -> dict[str, object]: + """Mint the generation's profile and return the owner-free realization.""" @dataclass(frozen=True) diff --git a/shifter/engine/provisioner/raes_gcp_destroy.py b/shifter/engine/provisioner/raes_gcp_destroy.py index 505ebe4d1..37a329c6a 100644 --- a/shifter/engine/provisioner/raes_gcp_destroy.py +++ b/shifter/engine/provisioner/raes_gcp_destroy.py @@ -19,7 +19,7 @@ from config import GCERangeCellConfig, GCERangeImageProfile, load_gce_range_cell_config from gcp_range_cell_clients import GCEClients, _build_clients -from gcp_range_cell_destroy import _destroy_vpn_gateway, _mark_disks_auto_delete +from gcp_range_cell_destroy import _mark_disks_auto_delete from gcp_range_cell_firewall import public_web_firewall_name from gcp_range_cell_model_broker import broker_firewall_name from gcp_range_cell_ops import _delete_resource @@ -37,7 +37,7 @@ ) from raes_gcp_plan import RaesGcePlanOptions, build_raes_range_cell_plan from raes_gcp_secret_ops import RaesGceSecretOps, _default_secret_ops -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess from raes_plan import RaesPlan, RaesPlanAccount, RaesPlanNode __all__ = ["RaesGceDestroyOptions", "destroy_raes_range_cell"] @@ -55,7 +55,7 @@ class RaesGceDestroyOptions: allocated_network_cidrs: Sequence[tuple[str, str]] | None = None reconstruct_without_allocation: bool = False # The range's OpenVPN gateway names (#2030), when it holds a capability. - remote_access: RaesGceRemoteAccess | None = None + remote_access: RaesGceVpnAccess | None = None @dataclass(frozen=True) @@ -108,10 +108,9 @@ def destroy_raes_range_cell( remote_access=resolved_options.remote_access, ), ) + # The pool ingress rule, when the range has OpenVPN access, is part of + # ``plan["firewalls"]`` and goes with the network resources. _destroy_instances(plan, raes_plan, runtime) - # The gateway VM and its address live in the target's subnet, so they go - # before the subnet; its firewall envelope is part of ``plan["firewalls"]``. - _destroy_vpn_gateway(plan, runtime.clients) delete_raes_directory_secrets(plan["range_id"], raes_plan, runtime.directory_secret_ops) _destroy_network_resources(plan, runtime.clients) diff --git a/shifter/engine/provisioner/raes_gcp_inventory.py b/shifter/engine/provisioner/raes_gcp_inventory.py index a6a69a873..23d961953 100644 --- a/shifter/engine/provisioner/raes_gcp_inventory.py +++ b/shifter/engine/provisioner/raes_gcp_inventory.py @@ -69,12 +69,8 @@ def _reconstructed_plan( def _owned_compute(plan: RangeCellPlan) -> list[tuple[str, str]]: - """Return every owned ``(instance, address)`` pair, the OpenVPN gateway included.""" - owned = [(instance["resource_name"], instance["address_name"]) for instance in plan["instances"]] - vpn_gateway = plan.get("vpn_gateway") - if vpn_gateway is not None: - owned.append((vpn_gateway["resource_name"], vpn_gateway["address_name"])) - return owned + """Return every owned ``(instance, address)`` pair.""" + return [(instance["resource_name"], instance["address_name"]) for instance in plan["instances"]] def _check_compute(tally: _Tally, clients: GCEClients, plan: RangeCellPlan) -> None: diff --git a/shifter/engine/provisioner/raes_gcp_plan.py b/shifter/engine/provisioner/raes_gcp_plan.py index 9b628e7c8..bd250215e 100644 --- a/shifter/engine/provisioner/raes_gcp_plan.py +++ b/shifter/engine/provisioner/raes_gcp_plan.py @@ -50,7 +50,6 @@ FirewallPlan, GceEgressPolicy, InstancePlan, - OpenVpnGatewayPlan, RangeCellPlan, RouterNatPlan, SubnetPlan, @@ -67,7 +66,7 @@ service_base_priority, ) from raes_gcp_plan_errors import RaesGcePlanError -from raes_gcp_vpn_plan import RaesGceRemoteAccess, vpn_gateway_plan +from raes_gcp_vpn_plan import RaesGceVpnAccess, vpn_pool_firewalls from raes_plan import RaesPlan, RaesPlanNetwork, RaesPlanNode #: Default guest login user the provisioner injects (management reachability). The @@ -92,7 +91,7 @@ class RaesGcePlanOptions: egress_policy: GceEgressPolicy = DEFAULT_GCE_EGRESS_POLICY allocated_network_cidrs: Sequence[tuple[str, str]] | None = None reconstruct_for_teardown: bool = False - remote_access: RaesGceRemoteAccess | None = None + remote_access: RaesGceVpnAccess | None = None def build_raes_range_cell_plan( @@ -173,9 +172,6 @@ def build_raes_range_cell_plan( ) _reject_unplaceable_nodes(raes_plan, networks_by_address) - vpn_gateway = vpn_gateway_plan( - range_id, instance_plans, subnet_plans, resolved_config, resolved_options.remote_access - ) plan: RangeCellPlan = { "project_id": resolved_config.project_id, @@ -196,11 +192,9 @@ def build_raes_range_cell_plan( raes_plan, resolved_config, resolved_options.egress_policy, - vpn_gateway, - ), + ) + + vpn_pool_firewalls(range_id, instance_plans, resolved_options.remote_access), } - if vpn_gateway is not None: - plan["vpn_gateway"] = vpn_gateway # A non-`none` range owns an explicit Cloud Router + NAT scoped to its subnets; # a `none` (zero-egress) range omits it so its subnets carry no NAT path. if (resolved_options.egress_policy.mode or "status-quo").strip().lower() != "none": @@ -243,20 +237,17 @@ def _all_firewalls( raes_plan: RaesPlan, config: GCERangeCellConfig, egress_policy: GceEgressPolicy = DEFAULT_GCE_EGRESS_POLICY, - vpn_gateway: OpenVpnGatewayPlan | None = None, ) -> list[FirewallPlan]: """Base range firewalls (reused, neutral) plus authored node ACL and service firewalls. Authored ``services`` are realized as fail-closed, range-scoped per-node-tag ingress (ADR-032-R8): admitted only from the concrete CIDRs of networks in *this* compiled range, at a priority strictly above the node's ACL band so authored ACL denies win. - The OpenVPN gateway envelope (#2030) is included when the range realizes one. """ firewalls = build_firewall_plan( range_id, subnet_plans, config, - vpn_gateway, instance_plans=instance_plans, egress_policy=egress_policy, ) diff --git a/shifter/engine/provisioner/raes_gcp_vpn_apply.py b/shifter/engine/provisioner/raes_gcp_vpn_apply.py index d264c2bb1..2916d8c41 100644 --- a/shifter/engine/provisioner/raes_gcp_vpn_apply.py +++ b/shifter/engine/provisioner/raes_gcp_vpn_apply.py @@ -1,37 +1,27 @@ -"""Realize the participant OpenVPN gateway within an RAES GCE apply (ADR-039-R10, #2030). +"""Participant OpenVPN steps within an RAES GCE apply (ADR-039-R10, #2030, #2480). -Split out of ``raes_gcp_apply`` (Sonar S104). The gateway is created right after -the hosts exist, so it boots while the range is verified, and is probed and its -profile published afterwards. Both run inside the apply's cleanup scope, so a -gateway that never becomes healthy is removed with the rest of the attempt. +The pool firewall rule is part of the range plan, so it is created and cleaned +up with the rest of the attempt. The profile is minted only after the range is +verified, so a range that fails never publishes one. """ from __future__ import annotations from typing import Any -from gcp_range_cell_types import RangeCellPlan, ResourceDict -from gcp_range_cells import _ensure_openvpn_gateway from raes_gcp_apply_types import RaesGceApplyOptions, RaesGceApplyRuntime -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess -__all__ = ["ensure_vpn_gateway", "publish_vpn_access", "remote_access_plan"] +__all__ = ["publish_vpn_access", "vpn_access_plan"] -def remote_access_plan(options: RaesGceApplyOptions) -> RaesGceRemoteAccess | None: - """Return the gateway planning inputs for a realizing apply, if any.""" - return options.openvpn.plan_remote_access() if options.openvpn is not None else None +def vpn_access_plan(options: RaesGceApplyOptions) -> RaesGceVpnAccess | None: + """Return the pool firewall inputs for a realizing apply, if any.""" + return options.openvpn.plan_access() if options.openvpn is not None else None -def ensure_vpn_gateway(plan: RangeCellPlan, runtime: RaesGceApplyRuntime) -> ResourceDict | None: - """Create the gateway when this generation realizes one; return its readiness facts.""" - if runtime.openvpn is None: - return None - return _ensure_openvpn_gateway(plan, runtime.clients, runtime.config) - - -def publish_vpn_access(runtime: RaesGceApplyRuntime, gateway: ResourceDict | None) -> dict[str, Any]: - """Verify the gateway and publish the profile; return the apply-result fragment.""" +def publish_vpn_access(runtime: RaesGceApplyRuntime) -> dict[str, Any]: + """Mint the generation's profile; return the apply-result fragment.""" if runtime.openvpn is None: return {} - return {"vpn_access": runtime.openvpn.publish(gateway)} + return {"vpn_access": runtime.openvpn.publish()} diff --git a/shifter/engine/provisioner/raes_gcp_vpn_plan.py b/shifter/engine/provisioner/raes_gcp_vpn_plan.py index 94841e34c..64e9e348f 100644 --- a/shifter/engine/provisioner/raes_gcp_vpn_plan.py +++ b/shifter/engine/provisioner/raes_gcp_vpn_plan.py @@ -1,66 +1,69 @@ -"""Plan the participant OpenVPN gateway of an RAES GCE range cell (ADR-039-R10, #2030). +"""Admit the shared OpenVPN pool to an RAES range's participant target (ADR-039-R10, #2480). -Split out of ``raes_gcp_plan`` (Sonar S104). The gateway is planned beside the -capability's target member and forwards only that member's declared participant -channels, so the tunnel reaches exactly what portal access reaches. +The pool servers live in the shared range VPC. A range that holds an OpenVPN +capability gets one ingress rule: the pool's source networks may reach the +target node, and only on that node's declared participant channels. No other +range host admits the pool, so a domain controller or a second guest is +unreachable from the pool at the cloud layer whatever the servers do. """ from __future__ import annotations from dataclasses import dataclass -from config import GCERangeCellConfig from gcp_range_cell_firewall import PARTICIPANT_CHANNEL_PORTS -from gcp_range_cell_plan import OpenVpnGatewayRequest, _openvpn_gateway_plan -from gcp_range_cell_types import InstancePlan, OpenVpnGatewayPlan, SubnetPlan +from gcp_range_cell_naming import _short_resource_name +from gcp_range_cell_types import FirewallPlan, InstancePlan +from raes_gcp_firewall import node_tag from raes_gcp_plan_errors import RaesGcePlanError -__all__ = ["RaesGceRemoteAccess", "vpn_gateway_plan"] +__all__ = ["RaesGceVpnAccess", "vpn_pool_firewall_name", "vpn_pool_firewalls"] + +_VPN_POOL_PRIORITY = 900 @dataclass(frozen=True) -class RaesGceRemoteAccess: - """The range's OpenVPN gateway planning inputs. +class RaesGceVpnAccess: + """The range's OpenVPN target member and the pool's source networks. - ``server_secret_ref`` is the exact identity secret the gateway reads. It is - present only when the plan realizes the gateway; destroy and inventory plan - resource names only and leave it empty. + Destroy and inventory reconstruct the rule name only and leave + ``pool_cidrs`` empty; such a plan is never used to create the rule. """ target_ref: str - gateway_pool_slot: int - server_secret_ref: str = "" + pool_cidrs: tuple[str, ...] = () + + def names_only(self) -> RaesGceVpnAccess: + """Return the input teardown needs: the rule name, never a creatable rule.""" + return RaesGceVpnAccess(target_ref=self.target_ref) + - def names_only(self) -> RaesGceRemoteAccess: - """Return the inputs destroy and inventory need: resource names, no identity secret.""" - return RaesGceRemoteAccess(target_ref=self.target_ref, gateway_pool_slot=self.gateway_pool_slot) +def vpn_pool_firewall_name(range_id: int) -> str: + """Return the deterministic name of the range's pool ingress rule.""" + return _short_resource_name("shifter-r", range_id, "vpn-pool") -def vpn_gateway_plan( +def vpn_pool_firewalls( range_id: int, instance_plans: list[InstancePlan], - subnet_plans: list[SubnetPlan], - config: GCERangeCellConfig, - remote_access: RaesGceRemoteAccess | None, -) -> OpenVpnGatewayPlan | None: - """Plan the OpenVPN gateway beside the authorized member, or ``None`` without one.""" - if remote_access is None: - return None - realizing = bool(remote_access.server_secret_ref) - target = next((instance for instance in instance_plans if instance["uuid"] == remote_access.target_ref), None) + access: RaesGceVpnAccess | None, +) -> list[FirewallPlan]: + """Return the pool ingress rule for the authorized target, or nothing without one.""" + if access is None: + return [] + realizing = bool(access.pool_cidrs) + target = next((instance for instance in instance_plans if instance["uuid"] == access.target_ref), None) channels = target.get("participant_access_channels", []) if target is not None else [] - ports = tuple(sorted({PARTICIPANT_CHANNEL_PORTS[channel] for channel in channels}, key=int)) - if realizing and not ports: - raise RaesGcePlanError("the OpenVPN target declares no participant channel to forward") - request = OpenVpnGatewayRequest( - target_ref=remote_access.target_ref, - pool_slot=remote_access.gateway_pool_slot, - target_ports=ports, - server_secret_ref=remote_access.server_secret_ref, - ) - try: - return _openvpn_gateway_plan( - range_id, instance_plans, subnet_plans, config, request, require_provision_values=realizing - ) - except RuntimeError as exc: - raise RaesGcePlanError(str(exc)) from None + ports = sorted({PARTICIPANT_CHANNEL_PORTS[channel] for channel in channels}, key=int) + if realizing and (target is None or not ports): + raise RaesGcePlanError("the OpenVPN target declares no participant channel the pool can reach") + return [ + { + "name": vpn_pool_firewall_name(range_id), + "direction": "INGRESS", + "priority": _VPN_POOL_PRIORITY, + "target_tags": [node_tag(range_id, access.target_ref.rsplit("#", 1)[0])], + "source_ranges": list(access.pool_cidrs), + "allowed": [{"IPProtocol": "tcp", "ports": ports or list(PARTICIPANT_CHANNEL_PORTS.values())}], + } + ] diff --git a/shifter/engine/provisioner/raes_openvpn.py b/shifter/engine/provisioner/raes_openvpn.py index 59980c011..c595c19dc 100644 --- a/shifter/engine/provisioner/raes_openvpn.py +++ b/shifter/engine/provisioner/raes_openvpn.py @@ -1,85 +1,96 @@ -"""Participant OpenVPN access on the RAES GCE range-cell path (ADR-039-R10, #2030). - -Composes the provider-neutral credential lifecycle (``vpn_access``) with the -range's remote-access authority from the immutable operation input (ADR-043): -the capability and the reserved gateway pool slot never come from -``mission_control_range``. - -Credentials are keyed on the range's ``request_id`` -- the generation the Engine -validates profile delivery against -- for a cold provision and a warm activation -alike. The server identity is stored before the apply so the gateway can read it -at boot; health verification and profile publication run inside the apply, so a -gateway that never becomes ready is cleaned up with the rest of the attempt. +"""Participant OpenVPN access on the RAES GCE range-cell path (ADR-039-R10, #2030, #2480). + +The tenant's shared OpenVPN server pool carries every participant tunnel. For a +range that holds an OpenVPN capability, the provisioner does two things: + +* plans one ingress rule that lets the pool reach the authorized target node on + its declared participant channels (``raes_gcp_vpn_plan``); and +* after the range is verified, mints the generation's client profile against the + tenant CA and returns the owner-free realization in the READY result. + +The capability comes from the immutable operation input (ADR-043), never from +``mission_control_range``. The profile is keyed on the range's ``request_id`` -- +the generation the Engine validates delivery and connections against -- for a +cold provision and a warm activation alike. """ from __future__ import annotations +import ipaddress import logging +import os from dataclasses import dataclass from shared.raes.operation_input import RaesRemoteAccess from raes_gcp_network_allocation import RaesRealizationError -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess from raes_plan import RaesPlan -from vpn_access import ( - OpenVpnPreparation, - VpnSecretOps, - cleanup_openvpn_access, - prepare_openvpn_access, - publish_openvpn_profile, - verify_openvpn_gateway, -) +from vpn_access import OpenVpnPoolEndpoint, cleanup_openvpn_access, mint_openvpn_profile, openvpn_pool_endpoint from vpn_secrets import GCPVpnSecretOps, openvpn_access_enabled logger = logging.getLogger(__name__) __all__ = [ - "RaesOpenVpnSession", + "RaesOpenVpn", "cleanup_failed_provision_openvpn", "cleanup_raes_openvpn", - "names_only_remote_access", "prepare_raes_openvpn", "vpn_access_fragment", + "vpn_access_names", ] +def _pool_cidrs() -> tuple[str, ...]: + """Return the pool's private source networks, rejecting anything broader.""" + cidrs: list[str] = [] + for value in os.environ.get("RANGE_OPENVPN_POOL_CIDRS", "").split(","): + if not value.strip(): + continue + network = ipaddress.ip_network(value.strip(), strict=True) + if network.version != 4 or not network.is_private or network.prefixlen < 16: + raise RaesRealizationError("RANGE_OPENVPN_POOL_CIDRS must list private IPv4 networks of /16 or smaller") + cidrs.append(str(network)) + return tuple(cidrs) + + @dataclass(frozen=True) -class RaesOpenVpnSession: - """One generation's prepared OpenVPN credentials for a GCE apply.""" +class RaesOpenVpn: + """One generation's OpenVPN access for a GCE apply or activation.""" + request_id: str + range_id: int remote_access: RaesRemoteAccess - preparation: OpenVpnPreparation - secret_ops: VpnSecretOps - - def plan_remote_access(self) -> RaesGceRemoteAccess: - """Return the gateway planning inputs, including the exact identity secret.""" - return RaesGceRemoteAccess( - target_ref=self.remote_access.target_ref, - gateway_pool_slot=self.remote_access.gateway_pool_slot, - server_secret_ref=self.preparation.server_secret_ref, + member_refs: tuple[str, ...] + endpoint: OpenVpnPoolEndpoint + pool_cidrs: tuple[str, ...] + + def plan_access(self) -> RaesGceVpnAccess: + """Return the pool firewall inputs for this range.""" + return RaesGceVpnAccess(target_ref=self.remote_access.target_ref, pool_cidrs=self.pool_cidrs) + + def publish(self) -> dict[str, object]: + """Mint and store the generation's profile; return the owner-free realization.""" + return mint_openvpn_profile( + self.request_id, + self.range_id, + self.member_refs, + self.remote_access.capability, + GCPVpnSecretOps(), + self.endpoint, ) - def publish(self, gateway: object) -> dict[str, object]: - """Verify the gateway's service-and-policy health, then publish the profile.""" - return publish_openvpn_profile(self.preparation, verify_openvpn_gateway(gateway), self.secret_ops) - -def names_only_remote_access(remote_access: RaesRemoteAccess | None) -> RaesGceRemoteAccess | None: - """Return gateway planning inputs for destroy/inventory, which need only names.""" +def vpn_access_names(remote_access: RaesRemoteAccess | None) -> RaesGceVpnAccess | None: + """Return the pool firewall inputs destroy and inventory need: the rule name only.""" if remote_access is None: return None - return RaesGceRemoteAccess(target_ref=remote_access.target_ref, gateway_pool_slot=remote_access.gateway_pool_slot) + return RaesGceVpnAccess(target_ref=remote_access.target_ref) -def _member_refs(raes_plan: RaesPlan) -> list[str]: +def _member_refs(raes_plan: RaesPlan) -> tuple[str, ...]: """Return every realized member key the plan declares (``#``).""" - return [f"{node.address}#{index}" for node in raes_plan.nodes for index in range(node.count)] - - -def _secret_ops(remote_access: RaesRemoteAccess) -> VpnSecretOps: - """Return GCP secret ops bound to the projected gateway pool slot.""" - return GCPVpnSecretOps(gateway_pool_slot=remote_access.gateway_pool_slot) + return tuple(f"{node.address}#{index}" for node in raes_plan.nodes for index in range(node.count)) def prepare_raes_openvpn( @@ -87,42 +98,43 @@ def prepare_raes_openvpn( range_id: int, raes_plan: RaesPlan, remote_access: RaesRemoteAccess | None, -) -> RaesOpenVpnSession | None: - """Mint/reuse the generation's credentials before any gateway exists. - - Fails closed when this installation cannot realize the authorized gateway or - the credential window is no longer valid, before any provider mutation. - """ +) -> RaesOpenVpn | None: + """Check the installation can serve the authorized access, before any provider mutation.""" if remote_access is None: return None - if not openvpn_access_enabled(): - raise RaesRealizationError( - "This range requests OpenVPN access, but the GCE adapter is not configured to realize it" - ) - secret_ops = _secret_ops(remote_access) - preparation = prepare_openvpn_access( - request_id, - range_id, - _member_refs(raes_plan), - remote_access.capability, - secret_ops, + endpoint = openvpn_pool_endpoint() + if not openvpn_access_enabled() or endpoint is None: + raise RaesRealizationError("This range requests OpenVPN access, but no shared OpenVPN pool is deployed") + member_refs = _member_refs(raes_plan) + if member_refs.count(remote_access.target_ref) != 1: + raise RaesRealizationError("OpenVPN capability must identify exactly one range member") + pool_cidrs = _pool_cidrs() + if not pool_cidrs: + # An ingress rule without a source would admit every address. + raise RaesRealizationError("The shared OpenVPN pool has no source networks configured") + return RaesOpenVpn( + request_id=request_id, + range_id=range_id, + remote_access=remote_access, + member_refs=member_refs, + endpoint=endpoint, + pool_cidrs=pool_cidrs, ) - return RaesOpenVpnSession(remote_access=remote_access, preparation=preparation, secret_ops=secret_ops) def cleanup_raes_openvpn(request_id: str, range_id: int, remote_access: RaesRemoteAccess | None) -> None: - """Delete every credential of the range's OpenVPN generation (idempotent).""" + """Delete the generation's participant profile (idempotent).""" if remote_access is None: return - cleanup_openvpn_access(range_id, request_id, _secret_ops(remote_access)) + cleanup_openvpn_access(range_id, request_id, GCPVpnSecretOps()) def cleanup_failed_provision_openvpn(request_id: str, range_id: int, remote_access: RaesRemoteAccess | None) -> None: - """Delete a failed provision's credentials without masking the provision's failure.""" + """Delete a failed provision's profile without masking the provision's failure.""" try: cleanup_raes_openvpn(request_id, range_id, remote_access) except Exception: - logger.exception("RAES OpenVPN credential cleanup failed for request_id=%s", request_id) + logger.exception("RAES OpenVPN profile cleanup failed for request_id=%s", request_id) def vpn_access_fragment(vpn_access: object) -> dict[str, object]: diff --git a/shifter/engine/provisioner/raes_range_ops.py b/shifter/engine/provisioner/raes_range_ops.py index 1f06fdd1c..2bd6c7113 100644 --- a/shifter/engine/provisioner/raes_range_ops.py +++ b/shifter/engine/provisioner/raes_range_ops.py @@ -58,9 +58,9 @@ from raes_openvpn import ( cleanup_failed_provision_openvpn, cleanup_raes_openvpn, - names_only_remote_access, prepare_raes_openvpn, vpn_access_fragment, + vpn_access_names, ) from raes_plan import RaesPlan, parse_plan from raes_range_contract import _classify_failure, _require_gce_live_fire_binding @@ -193,7 +193,7 @@ def run_raes_range_provision(request_id: str, *, operation_id: str | None = None from raes_ec2_runtime import provision_ec2_run if operation_input.remote_access is not None: - # The EC2 range-cell gateway is #2443; never launch without it silently. + # The EC2 shared pool is #2481; never launch without it silently. raise RaesRealizationError("OpenVPN access is not realized by the EC2 range backend") apply_result = provision_ec2_run(run, plugin_plans, enrollment) else: @@ -403,7 +403,7 @@ def _destroy_options( config=config, allocated_network_cidrs=network_allocation.network_cidrs or None, reconstruct_without_allocation=network_allocation.required and not network_allocation.network_cidrs, - remote_access=names_only_remote_access(operation_input.remote_access), + remote_access=vpn_access_names(operation_input.remote_access), ) diff --git a/shifter/engine/provisioner/terraform_ops.py b/shifter/engine/provisioner/terraform_ops.py index 9574c09cd..ba7a33b09 100644 --- a/shifter/engine/provisioner/terraform_ops.py +++ b/shifter/engine/provisioner/terraform_ops.py @@ -16,7 +16,6 @@ from shared.range_cells import RangeCellContractError, validate_scenario_artifact from shared.range_instantiation_policy import InstantiationPurpose -from shared.remote_access import parse_openvpn_capability, validate_openvpn_capability_window import range_terraform_runner from config import is_gce_range_cell_backend @@ -36,7 +35,6 @@ from provisioner_db_appends import OperationRef from range_backend_resolution import ( assert_provision_route, - prerequisite_error, resolve_operation_backend, resolve_provision_purpose, ) @@ -54,29 +52,10 @@ _validate_ngfw_range_attachment, ) from terraform_vars import RangeVariableContext, build_range_variables -from vpn_access import ( - cleanup_openvpn_access, - finalize_openvpn_access, - prepare_openvpn_access, - range_spec_member_refs, - verify_openvpn_gateway, -) -from vpn_secrets import get_vpn_secret_ops, openvpn_access_enabled logger = logging.getLogger(__name__) -def _cleanup_openvpn_if_enabled(range_id: int, request_id: str, *, delete_identity: bool = True) -> None: - """Delete a generation only when this installation could have created it.""" - if openvpn_access_enabled(): - cleanup_openvpn_access( - range_id, - request_id, - get_vpn_secret_ops(), - delete_identity=delete_identity, - ) - - @dataclass(frozen=True) class RangeOperation: """Inputs of one range Terraform operation, bound once at dispatch. @@ -100,7 +79,6 @@ class RangeOperation: scenario_artifact: dict[str, Any] | None = None backend: str | None = None purpose: InstantiationPurpose = InstantiationPurpose.LIVE_FIRE - remote_access_capability: dict[str, object] | None = None operation_id: str | None = None # Effective egress posture pinned at create (PLAT-238, ADR-017-R5). Threaded # into the range Terraform variables so ``none`` suppresses the participant @@ -113,7 +91,6 @@ def _operation_variable_context(operation: RangeOperation) -> RangeVariableConte return RangeVariableContext( scenario_artifact=operation.scenario_artifact, backend=operation.backend, - remote_access_capability=operation.remote_access_capability, egress_mode=operation.egress_mode, ) @@ -136,25 +113,15 @@ def _build_operation_variables( return build_range_variables(request_id, range_id, user_id, range_spec, context) -def _teardown_owned_range_resources( - operation: RangeOperation, - *, - delete_vpn_identity: bool, - revoke_vpn_even_if_destroy_fails: bool = False, -) -> None: +def _teardown_owned_range_resources(operation: RangeOperation) -> None: """Tear down the request-owned cloud resources via the canonical algorithm. Shared by explicit destroy and provision-failure compensation so the two do not drift into divergent lifecycle algorithms (ADR-039-R1/R3): NGFW detach, realized-CIDR reconstruction (never authored intent), provider-routed destroy, - VPN + Terraform-state cleanup, and ownership release via ``_post_destroy_cleanup`` + Terraform-state cleanup, and ownership release via ``_post_destroy_cleanup`` ONLY after destroy confirms absence. A partial/failed destroy propagates with ownership retained, so the operation stays retryable (ADR-043-R7). - ``delete_vpn_identity`` deletes the deterministic GCE principal on terminal - destroy but is ``False`` for compensation (preserve identity for retry). - ``revoke_vpn_even_if_destroy_fails`` makes compensation revoke the issued - remote-access generation even on a failed destroy, so credentials cannot - outlive a failed provision that left orphans reachable. """ request_id = operation.request_id range_id = operation.range_id @@ -164,7 +131,6 @@ def _teardown_owned_range_resources( realized_spec = _realized_range_spec_for_destroy( request_id, operation.range_spec, operation_id=operation.operation_id ) - vpn_revoked = False terraform_succeeded = False try: destroy_variables = _build_operation_variables( @@ -175,28 +141,10 @@ def _teardown_owned_range_resources( _operation_variable_context(operation), ) range_terraform_runner.destroy_range(request_id, variables=destroy_variables, backend=operation.backend) - _cleanup_openvpn_if_enabled(range_id, request_id, delete_identity=delete_vpn_identity) - vpn_revoked = True terraform_succeeded = True logger.info("Cleaning up Terraform state...") range_terraform_runner.cleanup_range_state(request_id, operation.backend) finally: - # Revoke issued remote-access credentials even on a failed destroy, so they - # cannot outlive a failed provision that left orphan resources reachable. - # Capture the failure class and log it below (in finally, not the handler): - # logger.exception would attach a raw stack trace barred here (ADR-043-R5). - revoke_failure: str | None = None - if revoke_vpn_even_if_destroy_fails and not vpn_revoked: - try: - _cleanup_openvpn_if_enabled(range_id, request_id, delete_identity=delete_vpn_identity) - except Exception as exc: - revoke_failure = type(exc).__name__ - if revoke_failure is not None: - logger.error( - "Failed to revoke remote-access generation during compensation for range_id=%s (%s)", - range_id, - revoke_failure, - ) # Ownership (reservations, child projections) is released only behind the # confirmed-destroy postcondition; a failed destroy keeps it so a CIDR is # not reused while orphan resources still occupy it. @@ -222,7 +170,7 @@ def _attempt_terraform_auto_cleanup(operation: RangeOperation) -> None: # carries Terraform stderr barred at this boundary (ADR-043-R5). failure: str | None = None try: - _teardown_owned_range_resources(operation, delete_vpn_identity=False, revoke_vpn_even_if_destroy_fails=True) + _teardown_owned_range_resources(operation) except Exception as exc: failure = type(exc).__name__ if failure is None: @@ -259,24 +207,6 @@ def _safe_failure_message(exc: Exception) -> str: return str(exc)[:1000] -def _resolve_remote_access_capability( - range_data: dict[str, Any], - operation: str, -) -> dict[str, object] | None: - """Validate persisted remote-access authority before any operation mutation.""" - raw_capability = range_data.get("remote_access_capability") - if raw_capability is None: - return None - capability = parse_openvpn_capability(raw_capability) - if operation == "up": - validate_openvpn_capability_window(capability) - if not openvpn_access_enabled(): - raise prerequisite_error( - "This range requests OpenVPN access, but the selected provider adapter is not configured to realize it" - ) - return capability.as_dict() - - def run_range_terraform(operation: str, request_id: str, *, operation_id: str | None = None) -> None: """Run Range Terraform operation (provision or destroy). @@ -304,7 +234,6 @@ def run_range_terraform(operation: str, request_id: str, *, operation_id: str | range_operation: RangeOperation | None = None try: - remote_access_capability = _resolve_remote_access_capability(range_data, operation) # Verify the producer-minted artifact before any NGFW or provider # operation. Other backends retain their existing legacy payload path. uses_gce = operation_backend == "gce" if operation_backend is not None else is_gce_range_cell_backend() @@ -323,7 +252,6 @@ def run_range_terraform(operation: str, request_id: str, *, operation_id: str | scenario_artifact=scenario_artifact, backend=operation_backend, purpose=operation_purpose, - remote_access_capability=remote_access_capability, operation_id=operation_id, egress_mode=range_data.get("egress_mode", "status-quo"), ) @@ -347,7 +275,6 @@ def _run_terraform_provision(operation: RangeOperation) -> None: range_id = operation.range_id user_id = operation.user_id range_spec = operation.range_spec - remote_access_capability = operation.remote_access_capability update_range_status( range_id=range_id, status=STATUS_PROVISIONING, @@ -355,19 +282,6 @@ def _run_terraform_provision(operation: RangeOperation) -> None: logger.info("Running terraform apply for range...") - vpn_secret_ops = get_vpn_secret_ops() if remote_access_capability is not None else None - vpn_preparation = ( - prepare_openvpn_access( - request_id, - range_id, - range_spec_member_refs(range_spec), - remote_access_capability, - vpn_secret_ops, - ) - if remote_access_capability is not None and vpn_secret_ops is not None - else None - ) - # Reservation produces an operation-local realization of the authored spec. # ``range_spec`` itself stays authored intent and is never written back # (ADR-043-R6), so the backend no longer decides whether to persist it -- @@ -400,17 +314,6 @@ def _run_terraform_provision(operation: RangeOperation) -> None: purpose=operation.purpose, backend=operation.backend, ) - vpn_access_binding = ( - finalize_openvpn_access( - vpn_preparation, - verify_openvpn_gateway(output_data.get("vpn_gateway")), - vpn_secret_ops, - owner_user_id=user_id, - ) - if vpn_preparation is not None and vpn_secret_ops is not None - else None - ) - subnets_output = output_data.get("subnets", {}) instances_output = output_data.get("instances", []) @@ -454,7 +357,6 @@ def _run_terraform_provision(operation: RangeOperation) -> None: subnets=subnets_output, instances=instances_output, ngfw_instance_id=range_data.get("ngfw_instance_id"), - vpn_access_binding=vpn_access_binding, operation=OperationRef(request_id=request_id, operation_id=operation.operation_id), ) @@ -491,6 +393,6 @@ def _run_terraform_destroy(operation: RangeOperation) -> None: # An explicit destroy deletes the deterministic access identity; on failure # the shared helper propagates with ownership retained, so DESTROYED is not # recorded below. - _teardown_owned_range_resources(operation, delete_vpn_identity=True) + _teardown_owned_range_resources(operation) update_range_status(range_id=range_id, status=STATUS_DESTROYED) diff --git a/shifter/engine/provisioner/terraform_vars.py b/shifter/engine/provisioner/terraform_vars.py index dac902720..b68aa048e 100644 --- a/shifter/engine/provisioner/terraform_vars.py +++ b/shifter/engine/provisioner/terraform_vars.py @@ -256,7 +256,6 @@ def _build_range_terraform_variables( range_id: int, user_id: int, range_spec: dict[str, Any], - remote_access_capability: dict[str, object] | None = None, pinned_egress_mode: str = "status-quo", ) -> dict[str, Any]: """Build Terraform variables dict from range spec and the pinned egress decision. @@ -302,8 +301,6 @@ def _build_range_terraform_variables( return variables if provider == "aws": - if remote_access_capability is not None: - variables["openvpn_access"] = remote_access_capability variables.update(_build_aws_extra_tf_variables()) return variables @@ -315,7 +312,6 @@ def _build_gce_range_cell_variables( range_id: int, range_spec: dict[str, Any], scenario_artifact: dict[str, Any] | None, - remote_access_capability: dict[str, object] | None, egress_mode: str = "status-quo", ) -> dict[str, Any]: """Build the closed GCE VM-cell request around an opaque scenario artifact. @@ -341,7 +337,6 @@ def _build_gce_range_cell_variables( scenario_artifact=scenario_artifact, network_bindings=bindings, access_declarations=access_declarations, - remote_access=remote_access_capability, egress_mode=egress_mode, ) @@ -351,13 +346,12 @@ class RangeVariableContext: """The per-operation binding a range-variable build needs beyond the range spec. Bundled into one argument (keeping the build seam within the parameter budget): - the #1666 backend ownership binding, the #1695 OpenVPN remote-access capability, - the digest-bound GCE scenario artifact, and the PLAT-238 pinned egress posture. + the #1666 backend ownership binding, the digest-bound GCE scenario artifact, + and the PLAT-238 pinned egress posture. """ scenario_artifact: dict[str, Any] | None = None backend: str | None = None - remote_access_capability: dict[str, object] | None = None egress_mode: str = "status-quo" @@ -375,8 +369,8 @@ def build_range_variables( range provision/destroy paths call so the GCE backend never receives AWS-translated instance shapes. - ``context`` carries the per-operation binding (backend, remote access, - scenario artifact, pinned egress mode). ``context.backend`` is the #1666 + ``context`` carries the per-operation binding (backend, scenario artifact, + pinned egress mode). ``context.backend`` is the #1666 ownership binding: when supplied it selects the shape from the persisted binding (so a bound destroy builds the right variables even after the deploy selector flips); ``None`` falls back to the deploy-wide env selector. @@ -393,7 +387,6 @@ def build_range_variables( range_id, range_spec, ctx.scenario_artifact, - ctx.remote_access_capability, ctx.egress_mode, ) return _build_range_terraform_variables( @@ -401,6 +394,5 @@ def build_range_variables( range_id, user_id, range_spec, - ctx.remote_access_capability, ctx.egress_mode, ) diff --git a/shifter/engine/provisioner/tests/conftest.py b/shifter/engine/provisioner/tests/conftest.py index a048ed860..bda22e285 100644 --- a/shifter/engine/provisioner/tests/conftest.py +++ b/shifter/engine/provisioner/tests/conftest.py @@ -619,3 +619,65 @@ def sample_db_range_row_multi_subnet(): ], }, ) + + +class MemoryVpnSecretOps: + """In-memory OpenVPN secret store: a tenant issuer plus per-generation profiles.""" + + def __init__(self, issuer: str) -> None: + self.issuer = issuer + self.profiles: dict[tuple[int, str], str] = {} + self.deleted: list[tuple[int, str]] = [] + + def read_issuer(self) -> str: + return self.issuer + + def put_profile(self, range_id, generation, payload): + self.profiles.setdefault((range_id, str(generation)), payload) + return f"profile:{range_id}:{generation}" + + def delete_profile(self, range_id, generation): + self.deleted.append((range_id, str(generation))) + self.profiles.pop((range_id, str(generation)), None) + + +def build_vpn_issuer_payload(*, valid_days: int = 3650) -> str: + """Return a tenant issuer payload: an EC CA, its key and a tls-crypt key.""" + import json + from datetime import UTC, datetime, timedelta + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "shifter-openvpn-ca")]) + now = datetime.now(UTC) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=valid_days)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(key, hashes.SHA256()) + ) + tls_crypt = "-----BEGIN OpenVPN Static key V1-----\n" + "ab" * 16 + "\n-----END OpenVPN Static key V1-----\n" + return json.dumps( + { + "ca": certificate.public_bytes(serialization.Encoding.PEM).decode(), + "ca_private_key": key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode(), + "tls_crypt": tls_crypt, + } + ) + + +@pytest.fixture +def vpn_secret_ops(): + """An in-memory secret store holding a valid tenant issuer.""" + return MemoryVpnSecretOps(build_vpn_issuer_payload()) diff --git a/shifter/engine/provisioner/tests/test_aws_range_vpn_module.py b/shifter/engine/provisioner/tests/test_aws_range_vpn_module.py new file mode 100644 index 000000000..23e156a41 --- /dev/null +++ b/shifter/engine/provisioner/tests/test_aws_range_vpn_module.py @@ -0,0 +1,59 @@ +"""Static checks of the legacy AWS per-range OpenVPN gateway module. + +The module is retired by #2481 (AWS moves to the shared OpenVPN pool, #2480); +these checks cover it until then. +""" + +from __future__ import annotations + +from pathlib import Path + + +def test_aws_gateway_stays_pending_until_service_and_policy_probe(): + module = Path(__file__).parents[1] / "terraform" / "modules" / "range" + outputs = (module / "outputs.tf").read_text(encoding="utf-8") + resources = (module / "vpn.tf").read_text(encoding="utf-8") + bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") + + assert "health_port = 1195" in outputs + assert "health_endpoint = aws_instance.vpn_gateway[0].private_ip" in outputs + assert "ready = false" in outputs + assert "cidr_blocks = [var.vpn_public_client_cidr]" in resources + assert "instance.instance_uuid == var.openvpn_access.target_ref" in resources + assert 'instance.role == "attacker"' not in resources + assert "cidr_blocks = [var.portal_vpc_cidr]" in resources + assert 'resource "aws_lb_listener" "vpn_health"' not in resources + assert 'protocol = "TCP"' in resources + assert 'port = "1195"' in resources + assert "shifter-openvpn-health.service" in bootstrap + assert 'systemctl", "is-active", "--quiet", "openvpn-server@server"' in bootstrap + assert '"iptables", "-C", "FORWARD", "-i", "tun0"' in bootstrap + assert 'self.request.sendall(b"ready\\n")' in bootstrap + + +def test_aws_gateway_bootstrap_uses_the_baked_runtime_without_package_egress(): + module = Path(__file__).parents[1] / "terraform" / "modules" / "range" + bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") + + assert "package_update:" not in bootstrap + assert "\npackages:" not in bootstrap + assert "apt-get" not in bootstrap + assert "import boto3" in bootstrap + assert " - [python3, /usr/local/sbin/configure-shifter-openvpn.py]" in bootstrap + + +def test_aws_gateway_secrets_client_uses_the_module_region(): + module = Path(__file__).parents[1] / "terraform" / "modules" / "range" + bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") + resources = (module / "vpn.tf").read_text(encoding="utf-8") + + assert 'boto3.client("secretsmanager", region_name="${region}")' in bootstrap + assert "region = substr(var.availability_zone, 0, length(var.availability_zone) - 1)" in resources + + +def test_aws_gateway_server_uses_ecdh_without_a_static_dh_file(): + module = Path(__file__).parents[1] / "terraform" / "modules" / "range" + bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") + + assert "\n dh none\n" in bootstrap + assert "dh /etc/openvpn" not in bootstrap diff --git a/shifter/engine/provisioner/tests/test_gcp_dynamic_secrets.py b/shifter/engine/provisioner/tests/test_gcp_dynamic_secrets.py index d84126955..063efe63d 100644 --- a/shifter/engine/provisioner/tests/test_gcp_dynamic_secrets.py +++ b/shifter/engine/provisioner/tests/test_gcp_dynamic_secrets.py @@ -93,8 +93,6 @@ def test_every_declared_credential_class_is_centrally_mapped(): gcp_dynamic_secrets.DynamicSecretClass.VERTEX_SERVICE_ACCOUNT_KEY, "workload-vertex-scope-service-account-key", ), - (gcp_dynamic_secrets.DynamicSecretClass.VPN_ISSUER, "workload-vpn-scope-issuer"), - (gcp_dynamic_secrets.DynamicSecretClass.VPN_SERVER, "workload-vpn-scope-server"), (gcp_dynamic_secrets.DynamicSecretClass.VPN_PROFILE, "participant-vpn-scope-profile"), ], ) diff --git a/shifter/engine/provisioner/tests/test_gcp_range_cell_contract.py b/shifter/engine/provisioner/tests/test_gcp_range_cell_contract.py index 1e812f28a..74b01a998 100644 --- a/shifter/engine/provisioner/tests/test_gcp_range_cell_contract.py +++ b/shifter/engine/provisioner/tests/test_gcp_range_cell_contract.py @@ -133,7 +133,6 @@ def test_different_scenario_compositions_cross_the_same_outer_contract(scenario_ "scenario_artifact", "network_bindings", "access_declarations", - "remote_access", } assert request["scenario_artifact"]["payload"]["scenario_id"] == scenario_payload["scenario_id"] assert request["access_declarations"] == scenario_payload["participant_access"] diff --git a/shifter/engine/provisioner/tests/test_gcp_range_cell_resources.py b/shifter/engine/provisioner/tests/test_gcp_range_cell_resources.py index c4b029e10..8c12ba73e 100644 --- a/shifter/engine/provisioner/tests/test_gcp_range_cell_resources.py +++ b/shifter/engine/provisioner/tests/test_gcp_range_cell_resources.py @@ -15,8 +15,6 @@ firewall_resource, instance_resource, network_resource, - openvpn_gateway_address_resource, - openvpn_gateway_instance_resource, subnetwork_resource, ) @@ -138,42 +136,6 @@ def test_internal_address_body(self): } -class TestOpenVpnGatewayResource: - def test_gateway_has_public_udp_edge_and_target_only_forwarding_bootstrap(self): - gateway = { - "resource_name": "shifter-r-42-vpn-gateway", - "address_name": "shifter-r-42-vpn-gateway-ip", - "private_ip": "10.50.2.5", - "subnetwork_link": "projects/p/regions/us-central1/subnetworks/sn", - "target_ip": "10.50.2.4", - "tag": "shifter-r-42-vpn-gateway", - "service_account_email": "sh-vpn-generation@test-project.iam.gserviceaccount.com", - "profile": GCERangeImageProfile( - source_image="projects/debian-cloud/global/images/family/debian-12", - machine_type="e2-standard-2", - disk_size_gb=50, - ), - } - config = _config(service_account_email="range-host@test-project.iam.gserviceaccount.com") - - address = openvpn_gateway_address_resource(gateway) - body = openvpn_gateway_instance_resource(_plan(), gateway, config) - - assert address["address"] == "10.50.2.5" - assert body["can_ip_forward"] is True - assert body["network_interfaces"][0]["access_configs"] == [{"name": "External NAT", "type_": "ONE_TO_ONE_NAT"}] - assert body["service_accounts"][0]["email"] == "sh-vpn-generation@test-project.iam.gserviceaccount.com" - startup = body["metadata"]["items"][0]["value"] - assert "shifter-range-42-vpn-87a99f875af246e6a4590e5eb1ab1bf2-server" in startup - assert "urllib.parse.quote" not in startup - assert "dh none" in startup - assert '"10.50.2.4/32"' in startup - assert "shifter-openvpn-health.service" in startup - assert '("0.0.0.0", 1195)' in startup - assert 'b"ready\\\\n"' in startup - assert "ca_private_key" not in startup - - def _metadata_map(body: dict) -> dict[str, str]: return {item["key"]: item["value"] for item in body["metadata"]["items"]} diff --git a/shifter/engine/provisioner/tests/test_gcp_range_cells.py b/shifter/engine/provisioner/tests/test_gcp_range_cells.py index d5b428097..424ae1a5f 100644 --- a/shifter/engine/provisioner/tests/test_gcp_range_cells.py +++ b/shifter/engine/provisioner/tests/test_gcp_range_cells.py @@ -6,7 +6,6 @@ import ipaddress import sys from copy import deepcopy -from datetime import UTC, datetime, timedelta from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, call @@ -18,7 +17,6 @@ validate_gcp_vm_range_cell_result, ) from shared.range_instantiation_policy import PREREQUISITE_DENIAL_CODE, UNSUPPORTED_CAPABILITY_CODE -from shared.remote_access import build_openvpn_capability from cloud.exceptions import CloudError from config import ( @@ -36,24 +34,20 @@ _build_clients, _ensure_firewall, _ensure_instance, - _ensure_openvpn_gateway, _insert_instance, apply_range_cell, destroy_range_cell, render_range_cell_plan, ) from gcp_range_host_identity import gcp_range_host_pool_service_account_email -from gcp_vpn_identity import gcp_vpn_gateway_pool_service_account_email from state_helpers import _build_instance_state -_TEST_VPN_GATEWAY_POOL_SLOT = 7 - @pytest.fixture(autouse=True) -def _stub_range_data_for_pool_slot(monkeypatch): - """apply/destroy read the reserved gateway pool slot from the range row - (ADR-008-R7). Stub that DB read so the GCE backend tests stay DB-free.""" - stub = MagicMock(return_value={"vpn_gateway_pool_slot": _TEST_VPN_GATEWAY_POOL_SLOT}) +def _stub_range_data(monkeypatch): + """apply/destroy read the range row for placement. Stub that DB read so the + GCE backend tests stay DB-free.""" + stub = MagicMock(return_value={}) monkeypatch.setattr("gcp_range_cells.get_range_data_by_request_id", stub, raising=False) monkeypatch.setattr("gcp_range_cell_destroy.get_range_data_by_request_id", stub, raising=False) # Shared NAT holds a database advisory lock; keep these Compute lifecycle @@ -167,7 +161,6 @@ def _variables( *, payload: dict | None = None, bindings: list[dict] | None = None, - remote_access: bool = False, egress_mode: str = "status-quo", ) -> dict: scenario_payload = deepcopy(payload if payload is not None else _scenario_payload()) @@ -187,9 +180,6 @@ def _variables( ), network_bindings=bindings, access_declarations=scenario_payload.get("participant_access", []), - remote_access=( - build_openvpn_capability(_LINUX_UUID, datetime.now(UTC) + timedelta(days=5)) if remote_access else None - ), egress_mode=egress_mode, ) @@ -514,64 +504,6 @@ def test_render_range_cell_plan_default_profile_keeps_public_web_denied(): assert not any(firewall["name"].endswith("-egress-web") for firewall in plan["firewalls"]) -def test_render_range_cell_plan_private_google_access_adds_target_only_vpn_gateway(): - config = dataclasses.replace(_shared_vpc_config(), private_google_access=True) - - plan = render_range_cell_plan( - "req-123", _variables(remote_access=True), config, vpn_gateway_pool_slot=_TEST_VPN_GATEWAY_POOL_SLOT - ) - - gateway = plan["vpn_gateway"] - assert gateway["target_ref"] == _LINUX_UUID - assert gateway["target_ip"] == "10.50.2.3" - assert gateway["service_account_email"] == gcp_vpn_gateway_pool_service_account_email( - "test-project", _TEST_VPN_GATEWAY_POOL_SLOT - ) - assert gateway["private_ip"] not in plan["subnets"][0]["ip_assignments"].values() - firewalls = {rule["name"]: rule for rule in plan["firewalls"]} - vpn_sources = [ipaddress.ip_network(cidr) for cidr in firewalls["shifter-r-42-vpn-in"]["source_ranges"]] - assert any(ipaddress.ip_address("8.8.8.8") in cidr for cidr in vpn_sources) - assert all(not cidr.overlaps(ipaddress.ip_network("10.0.0.0/8")) for cidr in vpn_sources) - assert firewalls["shifter-r-42-vpn-in"]["allowed"] == [{"IPProtocol": "udp", "ports": ["1194"]}] - assert firewalls["shifter-r-42-vpn-health"]["source_ranges"] == ["10.40.0.0/20"] - assert firewalls["shifter-r-42-vpn-health"]["allowed"] == [{"IPProtocol": "tcp", "ports": ["1195"]}] - assert firewalls["shifter-r-42-vpn-target"]["destination_ranges"] == ["10.50.2.3/32"] - assert firewalls["shifter-r-42-vpn-deny"]["denied"] == [{"IPProtocol": "all"}] - - -def test_topology_without_capability_does_not_create_a_vpn_gateway(): - config = dataclasses.replace(_shared_vpc_config(), private_google_access=True) - - plan = render_range_cell_plan("req-123", _variables(), config) - - assert "vpn_gateway" not in plan - - -def test_gcp_gateway_result_stays_pending_until_external_service_probe(): - config = dataclasses.replace(_shared_vpc_config(), private_google_access=True) - plan = render_range_cell_plan( - "req-123", _variables(remote_access=True), config, vpn_gateway_pool_slot=_TEST_VPN_GATEWAY_POOL_SLOT - ) - clients = _mock_clients(exists=True) - clients.addresses.get.side_effect = None - clients.addresses.get.return_value = object() - clients.instances.get.side_effect = None - clients.instances.get.return_value = SimpleNamespace( - network_interfaces=[SimpleNamespace(access_configs=[SimpleNamespace(nat_i_p="203.0.113.10")])] - ) - - gateway = _ensure_openvpn_gateway(plan, clients, config) - - assert gateway == { - "endpoint": "203.0.113.10", - "port": 1194, - "health_endpoint": plan["vpn_gateway"]["private_ip"], - "health_port": 1195, - "target_ref": _LINUX_UUID, - "ready": False, - } - - def test_render_range_cell_plan_rejects_subnet_without_uuid(): payload = _scenario_payload() del payload["subnets"][0]["uuid"] @@ -1466,52 +1398,6 @@ def test_apply_creates_every_planned_gcp_resource_with_the_expected_body(mocker) ] == [instance["resource_name"] for instance in plan["instances"]] -def test_apply_creates_a_missing_openvpn_gateway_and_returns_its_endpoint(mocker): - config = dataclasses.replace(_shared_vpc_config(), private_google_access=True) - variables = _variables(remote_access=True) - plan = render_range_cell_plan( - "req-123", - variables, - config, - vpn_gateway_pool_slot=_TEST_VPN_GATEWAY_POOL_SLOT, - ) - gateway = plan["vpn_gateway"] - assert gateway is not None - clients = _mock_clients(exists=False) - secret_ops, _ = _mock_secret_ops(mocker) - vertex_ops, _ = _mock_vertex_ops(mocker) - gateway_gets = 0 - - def get_instance(*, instance, **_kwargs): - nonlocal gateway_gets - if instance != gateway["resource_name"]: - raise NotFound() - gateway_gets += 1 - if gateway_gets == 1: - raise NotFound() - return SimpleNamespace( - network_interfaces=[SimpleNamespace(access_configs=[SimpleNamespace(nat_i_p="203.0.113.10")])] - ) - - clients.instances.get.side_effect = get_instance - - output = apply_range_cell( - "req-123", - variables, - config=config, - clients=clients, - secret_ops=secret_ops, - vertex_ops=vertex_ops, - ) - - assert output["vpn_gateway"]["endpoint"] == "203.0.113.10" - assert gateway_gets == 2 - assert clients.addresses.insert.call_count == len(plan["instances"]) + 1 - assert clients.instances.insert.call_count == len(plan["instances"]) + 1 - assert clients.addresses.insert.call_args_list[-1].kwargs["address_resource"]["name"] == gateway["address_name"] - assert clients.instances.insert.call_args_list[-1].kwargs["instance_resource"]["name"] == gateway["resource_name"] - - def test_apply_emits_closed_lifecycle_membership_and_access_result(mocker): clients = _mock_clients(exists=False) secret_ops, _ = _mock_secret_ops(mocker) @@ -2056,11 +1942,7 @@ def test_destroy_range_cell_marks_inherited_disks_for_instance_deletion(mocker): assert call_args.kwargs["auto_delete"] is True -def test_destroy_range_cell_tolerates_missing_cidr_after_pre_mutation_failure( - mocker, - _stub_range_data_for_pool_slot, -): - _stub_range_data_for_pool_slot.return_value = {"vpn_gateway_pool_slot": None} +def test_destroy_range_cell_tolerates_missing_cidr_after_pre_mutation_failure(mocker): clients = _mock_clients(exists=False) secret_ops, mocks = _mock_secret_ops(mocker) vertex_ops, vertex_mocks = _mock_vertex_ops(mocker) @@ -2068,7 +1950,7 @@ def test_destroy_range_cell_tolerates_missing_cidr_after_pre_mutation_failure( destroy_range_cell( "req-123", - _variables(bindings=[], remote_access=True), + _variables(bindings=[]), config=config, clients=clients, secret_ops=secret_ops, diff --git a/shifter/engine/provisioner/tests/test_get_range_data.py b/shifter/engine/provisioner/tests/test_get_range_data.py index a3581af04..cacc163cb 100644 --- a/shifter/engine/provisioner/tests/test_get_range_data.py +++ b/shifter/engine/provisioner/tests/test_get_range_data.py @@ -28,9 +28,8 @@ def _make_mock_cursor(range_row, ngfw_row=None): # Range query columns: request_id, range_id, user_id, range_config, subnet_index, # status, range_backend, instantiation_purpose (#1666 ownership binding), -# remote_access_capability (#1695 trusted OpenVPN activation contract), -# vpn_gateway_pool_slot (ADR-008-R7 gateway SA pool), placement_zone (#2029 -# realized multi-region range-cell placement). +# egress_mode (PLAT-238), placement_zone (#2029 realized multi-region +# range-cell placement). _RANGE_ROW_WITH_NGFW = ( "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", # request_id 201, # range_id @@ -40,8 +39,6 @@ def _make_mock_cursor(range_row, ngfw_row=None): "provisioning", # status None, # range_backend (legacy/non-GCP) None, # instantiation_purpose - None, # remote_access_capability - None, # vpn_gateway_pool_slot "status-quo", # egress_mode (PLAT-238) "", # placement_zone (single-zone / pre-#2029) ) @@ -55,8 +52,6 @@ def _make_mock_cursor(range_row, ngfw_row=None): "provisioning", None, # range_backend None, # instantiation_purpose - None, # remote_access_capability - None, # vpn_gateway_pool_slot "status-quo", # egress_mode (PLAT-238) "", # placement_zone ) @@ -220,35 +215,14 @@ def test_preserves_wrapped_scenario_envelope_for_range_cell_validation(self, mon assert result["spec"] == envelope["payload"] assert result["spec_envelope"] == envelope - def test_preserves_remote_access_capability(self, monkeypatch): - """The provisioner receives the server-owned OpenVPN capability unchanged.""" - from provisioner_db import get_range_data_by_request_id - - capability = { - "version": "openvpn-capability-v1", - "channel": "openvpn", - "target_ref": "11111111-2222-3333-4444-555555555555", - "teardown_at": "2026-07-20T12:00:00Z", - } - # Override remote_access_capability (index 8), preserving the trailing - # vpn_gateway_pool_slot (index 9), egress_mode (index 10), and - # placement_zone (index 11) columns. - row = (*_RANGE_ROW_NO_NGFW[:8], capability, *_RANGE_ROW_NO_NGFW[9:]) - mock_conn, _mock_cursor = _make_mock_cursor(row) - monkeypatch.setattr("provisioner_db.get_db_connection", MagicMock(return_value=mock_conn)) - - result = get_range_data_by_request_id("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - - assert result["remote_access_capability"] == capability - def test_preserves_placement_zone(self, monkeypatch): """The realized multi-region placement zone (#2029) reaches the provisioner - unchanged. Guards the trailing-column (row[11]) mapping: an off-by-one + unchanged. Guards the trailing-column (row[9]) mapping: an off-by-one against egress_mode would silently mis-place/mis-destroy ranges.""" from provisioner_db import get_range_data_by_request_id - # Override placement_zone (index 11) with a non-default value. - row = (*_RANGE_ROW_NO_NGFW[:11], "us-east4-a") + # Override placement_zone (index 9) with a non-default value. + row = (*_RANGE_ROW_NO_NGFW[:9], "us-east4-a") mock_conn, _mock_cursor = _make_mock_cursor(row) monkeypatch.setattr("provisioner_db.get_db_connection", MagicMock(return_value=mock_conn)) @@ -263,9 +237,9 @@ class TestGetRangeDataEgressMode: def test_reads_the_pinned_egress_mode_from_the_row(self, monkeypatch): from provisioner_db import get_range_data_by_request_id - # Override egress_mode (index 10) to a non-default value and prove it is + # Override egress_mode (index 8) to a non-default value and prove it is # surfaced; a mis-indexed or dropped column would leave this default. - row = (*_RANGE_ROW_NO_NGFW[:10], "none", _RANGE_ROW_NO_NGFW[11]) + row = (*_RANGE_ROW_NO_NGFW[:8], "none", _RANGE_ROW_NO_NGFW[9]) mock_conn, _mock_cursor = _make_mock_cursor(row) monkeypatch.setattr("provisioner_db.get_db_connection", MagicMock(return_value=mock_conn)) @@ -276,7 +250,7 @@ def test_reads_the_pinned_egress_mode_from_the_row(self, monkeypatch): def test_null_egress_mode_falls_back_to_status_quo(self, monkeypatch): from provisioner_db import get_range_data_by_request_id - row = (*_RANGE_ROW_NO_NGFW[:10], None, _RANGE_ROW_NO_NGFW[11]) + row = (*_RANGE_ROW_NO_NGFW[:8], None, _RANGE_ROW_NO_NGFW[9]) mock_conn, _mock_cursor = _make_mock_cursor(row) monkeypatch.setattr("provisioner_db.get_db_connection", MagicMock(return_value=mock_conn)) diff --git a/shifter/engine/provisioner/tests/test_main.py b/shifter/engine/provisioner/tests/test_main.py index 91d0c9f07..d5ce414bb 100644 --- a/shifter/engine/provisioner/tests/test_main.py +++ b/shifter/engine/provisioner/tests/test_main.py @@ -8,7 +8,6 @@ import sys from contextlib import contextmanager from copy import deepcopy -from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any @@ -127,29 +126,6 @@ def _import_module(name, *args, **kwargs): yield -class _MemoryVpnSecretOps: - """Minimal provider-secret port for the provision orchestration test.""" - - def __init__(self) -> None: - self.values: dict[str, str] = {} - - def read_or_create_issuer(self, range_id, generation, payload_factory): - ref = f"issuer:{range_id}:{generation}" - self.values.setdefault(ref, payload_factory()) - return self.values[ref] - - def put_server(self, range_id, generation, payload): - self.values[f"server:{range_id}:{generation}"] = payload - - def put_profile(self, range_id, generation, payload): - ref = f"profile:{range_id}:{generation}" - self.values[ref] = payload - return ref - - def delete_generation(self, range_id, generation, *, delete_identity=True): - return None - - class TestParseSerialNumber: """Tests for parse_serial_number helper function.""" @@ -588,59 +564,6 @@ def test_write_provisioned_state_persists_gcp_metadata_blocks(self, monkeypatch) assert provisioned_instances[0]["instance_id"] == "vmrt-vm-1" assert provisioned_instances[0]["provider_metadata"]["gcp"]["vm_name"] == "vmrt-vm-1" - def test_write_provisioned_state_persists_only_a_validated_vpn_binding(self, monkeypatch): - from provisioner_db import write_provisioned_state - - mock_cursor = MagicMock() - mock_cursor.rowcount = 1 - mock_conn = MagicMock() - mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) - mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) - mock_conn.__enter__ = MagicMock(return_value=mock_conn) - mock_conn.__exit__ = MagicMock(return_value=False) - binding = { - "version": "openvpn-binding-v1", - "channel": "openvpn", - "generation": "87a99f87-5af2-46e6-a459-0e5eb1ab1bf2", - "owner_user_id": 42, - "target_ref": "6ed14925-d8a1-42bd-a2d9-ce3730ab9313", - "endpoint": "vpn.example.test", - "port": 1194, - "profile_version": "openvpn-profile-v1", - "secret_ref": "projects/test/secrets/range-vpn-profile", - "ready": True, - } - - monkeypatch.setattr("provisioner_db.get_db_connection", MagicMock(return_value=mock_conn)) - write_provisioned_state( - range_id=42, - subnets={}, - instances=[], - vpn_access_binding=binding, - ) - - persisted = json.loads(mock_cursor.execute.call_args.args[1][1]) - assert persisted == binding - assert "vpn_access_binding" in mock_cursor.execute.call_args.args[0] - - def test_write_provisioned_state_rejects_an_extended_vpn_binding_before_db_write(self, monkeypatch): - from shared.remote_access import OpenVpnBindingError - - from provisioner_db import write_provisioned_state - - connection = MagicMock() - monkeypatch.setattr("provisioner_db.get_db_connection", connection) - - with pytest.raises(OpenVpnBindingError, match="unknown fields"): - write_provisioned_state( - range_id=42, - subnets={}, - instances=[], - vpn_access_binding={"profile": "credential material must not be persisted"}, - ) - - connection.assert_not_called() - def test_destroyed_range_clears_the_vpn_binding(self, monkeypatch): from provisioner_db import mark_range_instances_destroyed @@ -759,112 +682,9 @@ def test_run_terraform_provision_runs_setup_and_writes_state_for_gdc_ranges(self subnets=terraform_output["subnets"], instances=terraform_output["instances"], ngfw_instance_id=None, - vpn_access_binding=None, operation=OperationRef(request_id=request_id, operation_id=operation_id), ) - def test_run_terraform_provision_persists_finalized_vpn_binding(self, monkeypatch): - """The real prepare/apply/verify/finalize chain reaches persisted state.""" - from shared.remote_access import build_openvpn_capability, parse_openvpn_binding - - from config import RangeNetworkConfig - from terraform_ops import RangeOperation, _run_terraform_provision - - generation = uuid4() - target_ref = uuid4() - range_spec = { - "subnets": [ - { - "name": "attack", - "uuid": "subnet-vpn", - "instances": [ - { - "uuid": str(target_ref), - "name": "attacker", - "role": "attacker", - "os_type": "kali", - } - ], - } - ] - } - capability = build_openvpn_capability(target_ref, datetime.now(UTC) + timedelta(days=5)) - terraform_output = { - "subnets": { - "attack": { - "uuid": "subnet-vpn", - "subnet_id": "subnet-123", - "subnet_cidr": "10.9.0.0/28", - } - }, - "instances": [ - { - "uuid": str(target_ref), - "name": "attacker", - "role": "attacker", - "os": "kali", - "subnet_name": "attack", - "instance_id": "i-vpn-target", - "private_ip": "10.9.0.10", - } - ], - "vpn_gateway": { - "endpoint": "vpn.example.test", - "port": 1194, - "health_endpoint": "10.9.0.20", - "health_port": 1195, - "target_ref": str(target_ref), - "ready": False, - }, - } - secret_ops = _MemoryVpnSecretOps() - mock_write_state = MagicMock() - monkeypatch.setattr("terraform_ops.update_range_status", MagicMock()) - monkeypatch.setattr( - "range_subnet_allocation.load_range_network_config", - MagicMock(return_value=RangeNetworkConfig("vpc-vpn", "10.9.0.0/16", "us-east-2")), - ) - monkeypatch.setattr( - "terraform_ops.build_range_variables", - MagicMock(return_value={"range_id": 42, "subnets": range_spec["subnets"]}), - ) - monkeypatch.setattr( - "terraform_ops.range_terraform_runner.apply_range", - MagicMock(return_value=terraform_output), - ) - monkeypatch.setattr("terraform_ops.get_vpn_secret_ops", MagicMock(return_value=secret_ops)) - monkeypatch.setattr("vpn_access._probe_openvpn_gateway", lambda endpoint, port: True) - monkeypatch.setattr("terraform_ops.run_instance_setup", MagicMock()) - monkeypatch.setattr("terraform_ops.write_provisioned_state", mock_write_state) - monkeypatch.setattr( - "terraform_ops.get_range_data_by_request_id", - MagicMock(return_value={"ngfw_instance_id": None}), - ) - monkeypatch.setattr("terraform_ops.update_range_status", MagicMock()) - - with _provision_env("aws", "10.9.0.0/28"): - _run_terraform_provision( - RangeOperation( - str(generation), - 42, - 7, - range_spec, - remote_access_capability=capability, - operation_id=str(uuid4()), - ) - ) - - binding = mock_write_state.call_args.kwargs["vpn_access_binding"] - parsed = parse_openvpn_binding(binding) - assert parsed.generation == generation - assert parsed.owner_user_id == 7 - assert parsed.target_ref == str(target_ref) - assert parsed.endpoint == "vpn.example.test" - assert parsed.port == 1194 - assert parsed.secret_ref == f"profile:42:{generation}" - assert parsed.ready is True - assert secret_ops.values[parsed.secret_ref].startswith("client\n") - def test_run_instance_setup_skips_pod_backed_assets(self, monkeypatch): from instance_orchestrator import run_instance_setup diff --git a/shifter/engine/provisioner/tests/test_raes_gcp_apply.py b/shifter/engine/provisioner/tests/test_raes_gcp_apply.py index 85fe9a257..56d3b1924 100644 --- a/shifter/engine/provisioner/tests/test_raes_gcp_apply.py +++ b/shifter/engine/provisioner/tests/test_raes_gcp_apply.py @@ -33,7 +33,7 @@ from raes_gcp_composition import RaesGceCompositionError from raes_gcp_firewall import node_tag from raes_gcp_plan import RaesGcePlanError, build_raes_range_cell_plan -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess, vpn_pool_firewall_name from raes_plan import ( RaesPlan, RaesPlanAccount, @@ -1555,73 +1555,42 @@ def test_no_bindings_leaves_the_instance_participant_free(self): assert instance["ssh_key_secret_arn"] == "" -_VPN_SECRET = "projects/secrets-proj/secrets/vpn-server" _VPN_REALIZATION = { "generation": "11111111-2222-3333-4444-555555555555", "target_ref": "node.web#0", - "endpoint": "34.1.2.3", + "endpoint": "203.0.113.7", "port": 1194, "secret_ref": "projects/secrets-proj/secrets/vpn-profile", } +_VPN_ACCESS = RaesGceVpnAccess("node.web#0", ("10.49.0.0/24",)) class _FakeOpenVpn: - """A prepared OpenVPN session double recording the gateway the apply hands it.""" + """Prepared OpenVPN access double recording when the profile is minted.""" - def __init__(self, *, healthy: bool = True) -> None: - self.healthy = healthy - self.published: list = [] + def __init__(self, *, mintable: bool = True) -> None: + self.mintable = mintable + self.published = 0 @staticmethod - def plan_remote_access() -> RaesGceRemoteAccess: - return RaesGceRemoteAccess("node.web#0", 2, _VPN_SECRET) + def plan_access() -> RaesGceVpnAccess: + return _VPN_ACCESS - def publish(self, gateway): - if not self.healthy: - raise ValueError("OpenVPN gateway service did not become ready") - self.published.append(gateway) + def publish(self): + if not self.mintable: + raise ValueError("OpenVPN issuer secret has an invalid shape") + self.published += 1 return dict(_VPN_REALIZATION) -def _vpn_config() -> GCERangeCellConfig: - return replace( - _config("shared-vpc"), - private_google_access=True, - linux=GCERangeImageProfile(source_image="projects/x/global/images/ubuntu-1"), - ) - - -def _gateway_clients() -> SimpleNamespace: - """Fake clients that read an inserted gateway back with its external address.""" - clients = _clients() - inserted: set[str] = set() - absent = clients.instances.get.side_effect - - def get(**kwargs): - if kwargs["instance"] in inserted: - nat = SimpleNamespace(nat_i_p="34.1.2.3") - return SimpleNamespace(network_interfaces=[SimpleNamespace(access_configs=[nat])]) - return absent(**kwargs) - - def insert(**kwargs): - name = kwargs["instance_resource"]["name"] - if name.endswith("vpn-gateway"): - inserted.add(name) - return SimpleNamespace(name="op") - - clients.instances.get.side_effect = get - clients.instances.insert.side_effect = insert - return clients - - -class TestOpenVpnGateway: - """The participant OpenVPN gateway is realized inside the apply (#2030).""" +class TestOpenVpnPoolAccess: + """The pool rule is part of the apply; the profile is minted after verification (#2480).""" def _options(self, clients, openvpn): secret_ops, _ = _secret_ops() account_ops, _ = _account_secret_ops() return _apply_options( - _vpn_config(), + _config("shared-vpc"), clients, secret_ops, account_secret_ops=account_ops, @@ -1630,8 +1599,8 @@ def _options(self, clients, openvpn): openvpn=openvpn, ) - def test_apply_realizes_the_gateway_and_reports_its_realization(self): - clients = _gateway_clients() + def test_apply_admits_the_pool_and_reports_the_profile(self): + clients = _clients() openvpn = _FakeOpenVpn() output = apply_raes_range_cell( @@ -1644,45 +1613,39 @@ def test_apply_realizes_the_gateway_and_reports_its_realization(self): ) assert output["vpn_access"] == _VPN_REALIZATION - (gateway,) = openvpn.published - assert (gateway["endpoint"], gateway["port"], gateway["target_ref"]) == ("34.1.2.3", 1194, "node.web#0") - body = next( - call.kwargs["instance_resource"] - for call in clients.instances.insert.call_args_list - if call.kwargs["instance_resource"]["name"].endswith("vpn-gateway") - ) - startup = body["metadata"]["items"][0]["value"] - assert f'name = "{_VPN_SECRET}/versions/latest"' in startup - assert "'--dport', '22'" in startup - assert body["service_accounts"][0]["email"].startswith("sh-vpn-pool-2@") + assert openvpn.published == 1 rules = [call.kwargs["firewall_resource"] for call in clients.firewalls.insert.call_args_list] - target_egress = next(rule for rule in rules if rule["name"].endswith("vpn-target")) - assert target_egress["allowed"] == [{"I_p_protocol": "tcp", "ports": ["22"]}] - - def test_a_gateway_that_never_becomes_healthy_fails_and_is_cleaned_up(self): - clients = _gateway_clients() + pool = next(rule for rule in rules if rule["name"] == vpn_pool_firewall_name(7)) + assert pool["source_ranges"] == ["10.49.0.0/24"] + assert pool["allowed"] == [{"I_p_protocol": "tcp", "ports": ["22"]}] + inserted = [call.kwargs["instance_resource"]["name"] for call in clients.instances.insert.call_args_list] + assert not any("vpn" in name for name in inserted) + + def test_a_profile_that_cannot_be_minted_fails_and_cleans_up_by_name(self, monkeypatch): + import raes_gcp_apply + + cleanups = [] + monkeypatch.setattr( + raes_gcp_apply, + "destroy_raes_range_cell", + lambda *_args, **_kwargs: cleanups.append(_args[3].remote_access), + ) - with pytest.raises(ValueError, match="did not become ready"): + with pytest.raises(ValueError, match="invalid shape"): apply_raes_range_cell( "11111111-2222-3333-4444-555555555555", 7, _access_plan(), _resolver, - self._options(clients, _FakeOpenVpn(healthy=False)), + self._options(_clients(), _FakeOpenVpn(mintable=False)), access_bindings=[_access_transport()], ) - deleted = [call.kwargs["instance"] for call in clients.instances.delete.call_args_list] - assert any(name.endswith("vpn-gateway") for name in deleted) + # Cleanup reconstructs the pool rule by name; it never re-validates the target's channels. + assert cleanups == [RaesGceVpnAccess("node.web#0")] - def test_destroy_removes_the_gateway_before_its_subnet(self): + def test_destroy_removes_the_pool_rule(self): clients = _clients(exists=True) - order: list[str] = [] - clients.instances.delete.side_effect = lambda **kw: order.append(kw["instance"]) or SimpleNamespace(name="op") - clients.addresses.delete.side_effect = lambda **kw: order.append(kw["address"]) or SimpleNamespace(name="op") - clients.subnetworks.delete.side_effect = lambda **kw: ( - order.append(kw["subnetwork"]) or SimpleNamespace(name="op") - ) secret_ops, _ = _secret_ops() account_ops, _ = _account_secret_ops() @@ -1691,37 +1654,35 @@ def test_destroy_removes_the_gateway_before_its_subnet(self): 7, _access_plan(), RaesGceDestroyOptions( - config=_vpn_config(), + config=_config("shared-vpc"), clients=clients, secret_ops=secret_ops, account_secret_ops=account_ops, allocated_network_cidrs=(("net.lan", "10.9.0.0/24"),), - remote_access=RaesGceRemoteAccess("node.web#0", 2), + remote_access=RaesGceVpnAccess("node.web#0"), ), ) - gateway = next(index for index, name in enumerate(order) if name.endswith("vpn-gateway")) - gateway_address = next(index for index, name in enumerate(order) if name.endswith("vpn-gateway-ip")) - subnet = next(index for index, name in enumerate(order) if name.endswith("-lan")) - assert gateway < subnet and gateway_address < subnet - deleted_rules = {call.kwargs["firewall"] for call in clients.firewalls.delete.call_args_list} - assert any(name.endswith("vpn-in") for name in deleted_rules) + deleted = {call.kwargs["firewall"] for call in clients.firewalls.delete.call_args_list} + assert vpn_pool_firewall_name(7) in deleted - def test_inventory_counts_a_residual_gateway(self): + def test_inventory_counts_a_residual_pool_rule(self): from raes_gcp_inventory import inventory_raes_range_cell - def residual_instances(remote_access): + def residual(remote_access, category): report = inventory_raes_range_cell( "11111111-2222-3333-4444-555555555555", 7, _access_plan(), RaesGceDestroyOptions( - config=_vpn_config(), + config=_config("shared-vpc"), clients=_clients(exists=True), allocated_network_cidrs=(("net.lan", "10.9.0.0/24"),), remote_access=remote_access, ), ) - return {row["category"]: row["count"] for row in report["residual_categories"]}["instances"] + return {row["category"]: row["count"] for row in report["residual_categories"]}[category] - assert residual_instances(RaesGceRemoteAccess("node.web#0", 2)) == residual_instances(None) + 1 + with_access = RaesGceVpnAccess("node.web#0") + assert residual(with_access, "firewalls") == residual(None, "firewalls") + 1 + assert residual(with_access, "instances") == residual(None, "instances") diff --git a/shifter/engine/provisioner/tests/test_raes_gcp_plan.py b/shifter/engine/provisioner/tests/test_raes_gcp_plan.py index 05e4c76e2..00a77e85d 100644 --- a/shifter/engine/provisioner/tests/test_raes_gcp_plan.py +++ b/shifter/engine/provisioner/tests/test_raes_gcp_plan.py @@ -27,7 +27,7 @@ from raes_gce_image import resolve_gce_image_from_runtime_profile from raes_gcp_firewall import node_tag from raes_gcp_plan import RaesGcePlanError, RaesGcePlanOptions, build_raes_range_cell_plan -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess, vpn_pool_firewall_name from raes_identity import RESERVED_MANAGEMENT_LOGIN from raes_plan import RaesPlan, RaesPlanAcl, RaesPlanImage, RaesPlanNetwork, RaesPlanNode, RaesPlanServicePort @@ -802,10 +802,10 @@ def test_the_management_login_is_not_the_participant_login(self): assert instance["participant_access_usernames"]["ssh"] != RESERVED_MANAGEMENT_LOGIN -class TestOpenVpnGateway: - """The participant OpenVPN gateway is planned beside its target (#2030).""" +class TestVpnPoolFirewall: + """The shared OpenVPN pool reaches only the target node's declared channels (#2480).""" - _SECRET = "projects/secrets-proj/secrets/canonical-vpn-server" + _POOL = ("10.49.0.0/24",) @staticmethod def _capable_config() -> GCERangeCellConfig: @@ -816,7 +816,6 @@ def _capable_config() -> GCERangeCellConfig: network_mode="shared-vpc", network_id="projects/proj-1/global/networks/range", portal_network_cidrs=("203.0.113.0/24",), - private_google_access=True, linux=GCERangeImageProfile(source_image="projects/x/global/images/ubuntu-1"), ) @@ -847,42 +846,37 @@ def _build(self, *, remote_access, access=None, nodes=None): ), ) - def test_the_gateway_forwards_only_the_targets_declared_channels(self): - plan = self._build(remote_access=RaesGceRemoteAccess("node.a#0", 4, self._SECRET)) + def test_the_pool_reaches_only_the_target_node_on_its_channels(self): + plan = self._build(remote_access=RaesGceVpnAccess("node.a#0", self._POOL)) - gateway = plan["vpn_gateway"] + rule = next(rule for rule in plan["firewalls"] if rule["name"] == vpn_pool_firewall_name(7)) target = next(instance for instance in plan["instances"] if instance["uuid"] == "node.a#0") - assert (gateway["target_ref"], gateway["target_ip"]) == ("node.a#0", target["private_ip"]) - assert gateway["target_ports"] == ["22", "3389"] - assert gateway["server_secret_ref"] == self._SECRET - assert gateway["service_account_email"].startswith("sh-vpn-pool-4@") - assert gateway["private_ip"] not in {instance["private_ip"] for instance in plan["instances"]} - target_egress = next(rule for rule in plan["firewalls"] if rule["name"].endswith("vpn-target")) - assert target_egress["destination_ranges"] == [f"{target['private_ip']}/32"] - assert target_egress["allowed"] == [{"IPProtocol": "tcp", "ports": ["22", "3389"]}] - - def test_a_range_without_remote_access_plans_no_gateway(self): - plan = self._build(remote_access=None) + other = next(instance for instance in plan["instances"] if instance["uuid"] == "node.b#0") + assert rule["direction"] == "INGRESS" + assert rule["source_ranges"] == ["10.49.0.0/24"] + assert rule["allowed"] == [{"IPProtocol": "tcp", "ports": ["22", "3389"]}] + assert len(rule["target_tags"]) == 1 + assert rule["target_tags"][0] in target["tags"] + assert rule["target_tags"][0] not in other["tags"] assert "vpn_gateway" not in plan + assert not any(instance["uuid"].endswith("vpn-gateway") for instance in plan["instances"]) + + def test_a_range_without_remote_access_admits_no_pool(self): + plan = self._build(remote_access=None) assert not [rule for rule in plan["firewalls"] if "vpn" in rule["name"]] - def test_teardown_plans_names_only_without_declared_access(self): - plan = self._build(remote_access=RaesGceRemoteAccess("node.a#0", 4), access=()) + def test_teardown_reconstructs_the_rule_name_without_declared_access(self): + plan = self._build(remote_access=RaesGceVpnAccess("node.a#0"), access=()) - gateway = plan["vpn_gateway"] - assert gateway["resource_name"].endswith("vpn-gateway") - assert "server_secret_ref" not in gateway - names = {rule["name"] for rule in plan["firewalls"]} - for suffix in ("vpn-in", "vpn-health", "vpn-target", "vpn-api", "vpn-deny"): - assert any(name.endswith(suffix) for name in names), suffix + assert vpn_pool_firewall_name(7) in {rule["name"] for rule in plan["firewalls"]} - def test_realizing_a_gateway_for_a_target_without_channels_fails_closed(self): + def test_a_target_without_channels_fails_closed(self): with pytest.raises(RaesGcePlanError, match="no participant channel"): - self._build(remote_access=RaesGceRemoteAccess("node.b#0", 4, self._SECRET)) + self._build(remote_access=RaesGceVpnAccess("node.b#0", self._POOL)) def test_an_unknown_target_fails_closed(self): - with pytest.raises(RaesGcePlanError, match="exactly one"): - self._build(remote_access=RaesGceRemoteAccess("node.zzz#0", 4)) + with pytest.raises(RaesGcePlanError, match="no participant channel"): + self._build(remote_access=RaesGceVpnAccess("node.zzz#0", self._POOL)) class TestRangeOwnedNat: diff --git a/shifter/engine/provisioner/tests/test_raes_openvpn.py b/shifter/engine/provisioner/tests/test_raes_openvpn.py index 55bcf1efc..f735cf763 100644 --- a/shifter/engine/provisioner/tests/test_raes_openvpn.py +++ b/shifter/engine/provisioner/tests/test_raes_openvpn.py @@ -1,77 +1,40 @@ -"""Participant OpenVPN credentials on the RAES GCE path (#2030, ADR-039-R10). +"""Participant OpenVPN access on the RAES GCE path (#2030, #2480, ADR-039-R10). -Drives ``raes_openvpn``: credentials are minted against the operation input's -capability and projected gateway slot before any gateway exists, the gateway is -published only after its health probe, and destroy removes every credential of -the generation. The provider secret store is an in-memory double. +Drives ``raes_openvpn``: the installation must have a deployed shared pool, the +capability must name exactly one realized member, the pool rule is planned from +private pool networks only, and the profile is minted against the tenant CA. +The provider secret store is an in-memory double. """ from __future__ import annotations -import sys from datetime import UTC, datetime, timedelta -from pathlib import Path from types import SimpleNamespace -from uuid import UUID import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) - from shared.raes.operation_input import RaesRemoteAccess from shared.remote_access import build_openvpn_capability import raes_openvpn from raes_gcp_network_allocation import RaesRealizationError -from raes_gcp_vpn_plan import RaesGceRemoteAccess +from raes_gcp_vpn_plan import RaesGceVpnAccess _REQUEST_ID = "11111111-2222-3333-4444-555555555555" _TARGET = "provision.node.kali#0" -class _MemorySecretOps: - """Secret-store double that records the gateway slot it was bound to.""" - - def __init__(self, *, gateway_pool_slot=None) -> None: - self.gateway_pool_slot = gateway_pool_slot - self.values: dict[str, str] = {} - self.deleted: list[tuple[int, UUID]] = [] - - def read_or_create_issuer(self, range_id, generation, payload_factory): - return self.values.setdefault(f"issuer:{range_id}:{generation}", payload_factory()) - - def put_server(self, range_id, generation, payload): - ref = f"projects/secrets-proj/secrets/server-{range_id}-{generation}" - self.values[ref] = payload - return ref - - def put_profile(self, range_id, generation, payload): - ref = f"projects/secrets-proj/secrets/profile-{range_id}-{generation}" - self.values[ref] = payload - return ref - - def delete_generation(self, range_id, generation, *, delete_identity=True): - self.deleted.append((range_id, generation)) - - @pytest.fixture -def secret_store(monkeypatch): - stores: list[_MemorySecretOps] = [] - - def factory(**kwargs): - stores.append(_MemorySecretOps(**kwargs)) - return stores[-1] - - monkeypatch.setattr(raes_openvpn, "GCPVpnSecretOps", factory) +def pool(monkeypatch, vpn_secret_ops): + """A deployed pool whose secret store is the in-memory double.""" + monkeypatch.setattr(raes_openvpn, "GCPVpnSecretOps", lambda: vpn_secret_ops) monkeypatch.setattr(raes_openvpn, "openvpn_access_enabled", lambda: True) - return stores + monkeypatch.setenv("RANGE_OPENVPN_ENDPOINT", "203.0.113.7") + monkeypatch.setenv("RANGE_OPENVPN_POOL_CIDRS", "10.49.0.0/24") + return vpn_secret_ops def _remote(target: str = _TARGET) -> RaesRemoteAccess: - return RaesRemoteAccess( - capability=build_openvpn_capability(target, datetime.now(UTC) + timedelta(days=1)), - gateway_pool_slot=5, - ) + return RaesRemoteAccess(capability=build_openvpn_capability(target, datetime.now(UTC) + timedelta(days=1))) def _plan(): @@ -80,81 +43,54 @@ def _plan(): return SimpleNamespace(nodes=(kali, victims)) -def _gateway(**overrides): - return { - "endpoint": "34.1.2.3", - "port": 1194, - "health_endpoint": "10.9.0.200", - "health_port": 1195, - "target_ref": _TARGET, - "ready": False, - **overrides, - } - - class TestPrepare: - def test_a_range_without_remote_access_prepares_nothing(self, secret_store): + def test_a_range_without_remote_access_prepares_nothing(self, pool): assert raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), None) is None - assert secret_store == [] - def test_an_installation_that_cannot_realize_the_gateway_fails_closed(self, monkeypatch, secret_store): + def test_an_installation_without_a_pool_fails_closed(self, monkeypatch, pool): monkeypatch.setattr(raes_openvpn, "openvpn_access_enabled", lambda: False) - with pytest.raises(RaesRealizationError, match="not configured"): + with pytest.raises(RaesRealizationError, match="no shared OpenVPN pool"): raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) - assert secret_store == [] - - def test_credentials_are_bound_to_the_projected_slot_and_exact_server_secret(self, secret_store): - session = raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) - (store,) = secret_store - assert store.gateway_pool_slot == 5 - planned = session.plan_remote_access() - assert planned == RaesGceRemoteAccess(_TARGET, 5, f"projects/secrets-proj/secrets/server-7-{_REQUEST_ID}") - assert planned.names_only() == RaesGceRemoteAccess(_TARGET, 5) - - def test_a_capability_for_a_member_the_plan_does_not_declare_is_rejected(self, secret_store): - with pytest.raises(ValueError, match="exactly one range member"): - raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote("provision.node.other#0")) + def test_a_target_outside_the_plan_fails_closed(self, pool): + with pytest.raises(RaesRealizationError, match="exactly one range member"): + raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote("provision.node.dc#0")) + @pytest.mark.parametrize("cidrs", ["", "0.0.0.0/0", "8.8.8.0/24", "10.0.0.0/8"]) + def test_pool_networks_must_be_narrow_and_private(self, monkeypatch, pool, cidrs): + # An ingress rule without a source would admit every address. + monkeypatch.setenv("RANGE_OPENVPN_POOL_CIDRS", cidrs) + with pytest.raises(RaesRealizationError): + raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) -class TestPublish: - def test_a_healthy_gateway_publishes_an_owner_free_realization(self, monkeypatch, secret_store): - probes: list = [] - monkeypatch.setattr( - "vpn_access._probe_openvpn_gateway", lambda endpoint, port: probes.append((endpoint, port)) or True - ) - session = raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) + def test_prepared_access_plans_the_pool_rule_and_mints_on_publish(self, pool): + prepared = raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) - realization = session.publish(_gateway()) + assert prepared.plan_access() == RaesGceVpnAccess(target_ref=_TARGET, pool_cidrs=("10.49.0.0/24",)) + assert pool.profiles == {} + realization = prepared.publish() + assert realization["target_ref"] == _TARGET + assert realization["endpoint"] == "203.0.113.7" + assert (7, _REQUEST_ID) in pool.profiles - assert probes == [("10.9.0.200", 1195)] - assert realization == { - "generation": _REQUEST_ID, - "target_ref": _TARGET, - "endpoint": "34.1.2.3", - "port": 1194, - "secret_ref": f"projects/secrets-proj/secrets/profile-7-{_REQUEST_ID}", - } - assert "remote 34.1.2.3 1194" in secret_store[0].values[realization["secret_ref"]] - def test_an_unhealthy_gateway_publishes_nothing(self, monkeypatch, secret_store): - monkeypatch.setattr("vpn_access._probe_openvpn_gateway", lambda endpoint, port: False) - session = raes_openvpn.prepare_raes_openvpn(_REQUEST_ID, 7, _plan(), _remote()) +class TestCleanup: + def test_destroy_names_and_profile_deletion(self, pool): + assert raes_openvpn.vpn_access_names(_remote()) == RaesGceVpnAccess(target_ref=_TARGET) + assert raes_openvpn.vpn_access_names(None) is None - with pytest.raises(ValueError, match="did not become ready"): - session.publish(_gateway()) - assert not [ref for ref in secret_store[0].values if "profile" in ref] + raes_openvpn.cleanup_raes_openvpn(_REQUEST_ID, 7, _remote()) + raes_openvpn.cleanup_raes_openvpn(_REQUEST_ID, 7, None) + assert pool.deleted == [(7, _REQUEST_ID)] -class TestCleanup: - def test_destroy_deletes_the_generations_credentials(self, secret_store): - raes_openvpn.cleanup_raes_openvpn(_REQUEST_ID, 7, _remote()) - assert secret_store[0].deleted == [(7, UUID(_REQUEST_ID))] + def test_failed_provision_cleanup_never_masks_the_failure(self, monkeypatch, pool): + def boom(): + raise RuntimeError("secret store down") - def test_a_range_without_remote_access_has_nothing_to_delete(self, secret_store): - raes_openvpn.cleanup_raes_openvpn(_REQUEST_ID, 7, None) - assert secret_store == [] + monkeypatch.setattr(raes_openvpn, "GCPVpnSecretOps", boom) + raes_openvpn.cleanup_failed_provision_openvpn(_REQUEST_ID, 7, _remote()) - def test_teardown_plans_names_only(self): - assert raes_openvpn.names_only_remote_access(_remote()) == RaesGceRemoteAccess(_TARGET, 5) - assert raes_openvpn.names_only_remote_access(None) is None + def test_result_fragment(self): + assert raes_openvpn.vpn_access_fragment(None) == {} + assert raes_openvpn.vpn_access_fragment({"a": 1}) == {"vpn_access": {"a": 1}} diff --git a/shifter/engine/provisioner/tests/test_raes_range_ops.py b/shifter/engine/provisioner/tests/test_raes_range_ops.py index 8f92acfb7..754077331 100644 --- a/shifter/engine/provisioner/tests/test_raes_range_ops.py +++ b/shifter/engine/provisioner/tests/test_raes_range_ops.py @@ -805,7 +805,7 @@ def test_ec2_destroy_dispatches_without_loading_plugin_or_gce_configuration(patc class TestOpenVpnOrchestration: - """Participant OpenVPN rides provision, destroy, and failure handling (#2030).""" + """Participant OpenVPN rides provision, destroy, and failure handling (#2030, #2480).""" @staticmethod def _remote(): @@ -816,7 +816,6 @@ def _remote(): return RaesRemoteAccess( capability=build_openvpn_capability("provision.node.web#0", datetime.now(UTC) + timedelta(days=1)), - gateway_pool_slot=3, ) @pytest.fixture @@ -833,7 +832,7 @@ def prepare(request_id, range_id, plan, remote_access): monkeypatch.setattr(raes_range_ops, "cleanup_raes_openvpn", lambda *args: state.cleaned.append(args)) return state - def test_provision_realizes_the_gateway_inside_the_apply_and_reports_it(self, patched, vpn): + def test_provision_carries_the_access_into_the_apply_and_reports_the_profile(self, patched, vpn): patched.apply.return_value = {**patched.apply.return_value, "vpn_access": dict(_VPN_REALIZATION)} raes_range_ops.run_raes_range_provision("req-1", operation_id=_OPERATION_ID) @@ -843,7 +842,7 @@ def test_provision_realizes_the_gateway_inside_the_apply_and_reports_it(self, pa assert _payload_for(patched, ResultStep.RAES_TERMINAL_READY)["vpn_access"] == _VPN_REALIZATION assert vpn.cleaned == [] - def test_a_failed_provision_deletes_the_credentials_without_masking_its_failure(self, monkeypatch, patched, vpn): + def test_a_failed_provision_deletes_the_profile_without_masking_its_failure(self, monkeypatch, patched, vpn): patched.apply.side_effect = RuntimeError("apply failed") def broken_cleanup(*args): @@ -869,12 +868,12 @@ def test_the_ec2_backend_fails_closed_before_provisioning(self, monkeypatch, pat ec2.assert_not_called() assert str(ResultStep.RAES_TERMINAL_FAILED) in _steps(patched) - def test_destroy_removes_the_gateway_and_its_credentials_with_inventory(self, patched, vpn): - from raes_gcp_vpn_plan import RaesGceRemoteAccess + def test_destroy_removes_the_pool_rule_and_profile_with_inventory(self, patched, vpn): + from raes_gcp_vpn_plan import RaesGceVpnAccess raes_range_ops.run_raes_range_destroy("req-1", operation_id=_OPERATION_ID) - names_only = RaesGceRemoteAccess("provision.node.web#0", 3) + names_only = RaesGceVpnAccess("provision.node.web#0") assert patched.destroy.call_args.args[3].remote_access == names_only assert patched.inventory.call_args.args[3].remote_access == names_only assert vpn.cleaned == [("req-1", 7, vpn.remote)] diff --git a/shifter/engine/provisioner/tests/test_range_instantiation_purpose.py b/shifter/engine/provisioner/tests/test_range_instantiation_purpose.py index deace718f..c5dfed6e6 100644 --- a/shifter/engine/provisioner/tests/test_range_instantiation_purpose.py +++ b/shifter/engine/provisioner/tests/test_range_instantiation_purpose.py @@ -74,7 +74,6 @@ def test_destroy_with_an_unparseable_purpose_still_reaches_teardown(self, monkey "instantiation_purpose": "some_future_purpose", }, ) - monkeypatch.setattr(terraform_ops, "_resolve_remote_access_capability", lambda data, op: None) monkeypatch.setattr(terraform_ops, "is_gce_range_cell_backend", lambda: False) monkeypatch.setattr( terraform_ops, diff --git a/shifter/engine/provisioner/tests/test_terraform_destroy_failed_range.py b/shifter/engine/provisioner/tests/test_terraform_destroy_failed_range.py index 5623640d7..325f07369 100644 --- a/shifter/engine/provisioner/tests/test_terraform_destroy_failed_range.py +++ b/shifter/engine/provisioner/tests/test_terraform_destroy_failed_range.py @@ -7,13 +7,11 @@ """ import sys -from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from shared.remote_access import build_openvpn_capability sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -29,8 +27,6 @@ def _install_destroy_fakes(monkeypatch, *, status="ready", variables=None): monkeypatch.setattr("terraform_ops.build_range_variables", mock_build_vars) monkeypatch.setattr("terraform_ops.update_range_status", mock_update_status) monkeypatch.setattr("range_subnet_allocation.mark_range_instances_destroyed", mock_mark) - monkeypatch.setattr("terraform_ops.get_vpn_secret_ops", MagicMock()) - monkeypatch.setattr("terraform_ops.cleanup_openvpn_access", MagicMock()) return mock_get_data, mock_tf_runner, mock_build_vars, mock_update_status, mock_mark @@ -139,9 +135,7 @@ def test_cleanup_passes_variables_to_destroy(self, monkeypatch): 80, 20, {"ngfw": False, "subnets": []}, - RangeVariableContext( - scenario_artifact=None, backend=None, remote_access_capability=None, egress_mode="status-quo" - ), + RangeVariableContext(scenario_artifact=None, backend=None, egress_mode="status-quo"), ) mock_tf_runner.destroy_range.assert_called_once_with("req-1", variables=fake_vars, backend=None) @@ -176,7 +170,7 @@ def test_cleanup_forwards_the_pinned_none_egress_mode(self, monkeypatch): run_range_terraform("up", "req-1") assert mock_build_vars.call_args.args[4] == RangeVariableContext( - scenario_artifact=None, backend=None, remote_access_capability=None, egress_mode="none" + scenario_artifact=None, backend=None, egress_mode="none" ) def test_cleanup_failure_logged_not_swallowed(self, monkeypatch, caplog): @@ -250,65 +244,6 @@ def test_no_cleanup_on_destroy_failure(self, monkeypatch): mock_tf_runner.destroy_range.assert_not_called() -class TestRemoteAccessAdmission: - def test_capability_rejects_an_unconfigured_adapter_before_dispatch(self, monkeypatch): - from cloud.exceptions import CloudError - from terraform_ops import run_range_terraform - - target_ref = "11111111-1111-4111-8111-111111111111" - capability = build_openvpn_capability(target_ref, datetime.now(UTC) + timedelta(days=5)) - monkeypatch.setenv("CLOUD_PROVIDER", "aws") - for name in ( - "RANGE_VPN_EDGE_SUBNET_ID", - "RANGE_VPN_GATEWAY_PERMISSIONS_BOUNDARY_ARN", - "RANGE_VPN_PROVIDER_ENDPOINT_SECURITY_GROUP_ID", - "PORTAL_NETWORK_CIDRS", - "PORTAL_VPC_CIDR", - ): - monkeypatch.delenv(name, raising=False) - monkeypatch.setattr( - "terraform_ops.get_range_data_by_request_id", - MagicMock( - return_value={ - "range_id": 80, - "user_id": 20, - "spec": {"subnets": [{"instances": [{"uuid": target_ref}]}]}, - "remote_access_capability": capability, - } - ), - ) - dispatch = MagicMock() - monkeypatch.setattr("terraform_ops._dispatch_terraform_operation", dispatch) - monkeypatch.setattr("terraform_ops.update_range_status", MagicMock()) - - with pytest.raises(CloudError, match="not configured"): - run_range_terraform("up", "req-1") - - dispatch.assert_not_called() - - def test_kali_topology_without_capability_does_not_activate_vpn_admission(self, monkeypatch): - from terraform_ops import run_range_terraform - - monkeypatch.setenv("CLOUD_PROVIDER", "aws") - monkeypatch.setattr( - "terraform_ops.get_range_data_by_request_id", - MagicMock( - return_value={ - "range_id": 80, - "user_id": 20, - "spec": {"subnets": [{"instances": [{"role": "attacker", "os_type": "kali"}]}]}, - "remote_access_capability": None, - } - ), - ) - dispatch = MagicMock() - monkeypatch.setattr("terraform_ops._dispatch_terraform_operation", dispatch) - - run_range_terraform("up", "req-1") - - dispatch.assert_called_once() - - @pytest.fixture def compensation_fakes(monkeypatch): """Boundary fakes for driving run_range_terraform('up', ...) into @@ -340,7 +275,6 @@ def compensation_fakes(monkeypatch): ngfw_detach=MagicMock(), post_destroy=MagicMock(), pause_ngfw=MagicMock(), - vpn_cleanup=MagicMock(), ) monkeypatch.setattr("terraform_ops.get_range_data_by_request_id", mocks.get_data) monkeypatch.setattr("terraform_ops._run_terraform_provision", mocks.provision) @@ -351,7 +285,6 @@ def compensation_fakes(monkeypatch): monkeypatch.setattr("terraform_ops._remove_ngfw_attachments_for_destroy", mocks.ngfw_detach) monkeypatch.setattr("terraform_ops._post_destroy_cleanup", mocks.post_destroy) monkeypatch.setattr("terraform_ops._maybe_pause_user_ngfw", mocks.pause_ngfw) - monkeypatch.setattr("terraform_ops._cleanup_openvpn_if_enabled", mocks.vpn_cleanup) return mocks @@ -386,22 +319,6 @@ def test_compensation_retains_ownership_when_destroy_fails(self, compensation_fa compensation_fakes.post_destroy.assert_not_called() - def test_compensation_revokes_vpn_generation_even_when_destroy_fails(self, compensation_fakes): - """Issued remote-access credentials must be revoked even if the destroy fails (identity preserved). - - A failed compensation settles the range FAILED with possibly-surviving - orphan resources; leaving the generation's credentials active would grant - continued access to them. - """ - from terraform_ops import run_range_terraform - - compensation_fakes.tf_runner.destroy_range.side_effect = RuntimeError("destroy boom") - - with pytest.raises(RuntimeError, match="provision boom"): - run_range_terraform("up", "req-1") - - compensation_fakes.vpn_cleanup.assert_called_once_with(80, "req-1", delete_identity=False) - def test_compensation_completes_cleanup_on_success(self, compensation_fakes): """A successful compensation detaches NGFW and releases ownership via _post_destroy_cleanup.""" from terraform_ops import run_range_terraform diff --git a/shifter/engine/provisioner/tests/test_vpn_access.py b/shifter/engine/provisioner/tests/test_vpn_access.py index d213c65a0..57132893f 100644 --- a/shifter/engine/provisioner/tests/test_vpn_access.py +++ b/shifter/engine/provisioner/tests/test_vpn_access.py @@ -1,352 +1,137 @@ -"""Provider-neutral OpenVPN credential and binding lifecycle tests.""" +"""Participant OpenVPN profiles minted against the tenant CA (#2030, #2480).""" from __future__ import annotations import json +import re from datetime import UTC, datetime, timedelta -from pathlib import Path from uuid import uuid4 import pytest from cryptography import x509 -from shared.remote_access import build_openvpn_capability, parse_openvpn_binding, validate_openvpn_profile +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from shared.remote_access import build_openvpn_capability, validate_openvpn_profile_for_endpoint +from tests.conftest import MemoryVpnSecretOps, build_vpn_issuer_payload +from vpn_access import ( + OpenVpnPoolEndpoint, + cleanup_openvpn_access, + load_issuer, + mint_openvpn_profile, + openvpn_pool_endpoint, +) -class MemorySecretOps: - """Secret-store double that exposes only references to the caller.""" +_TARGET = "provision.node.kali#0" +_MEMBERS = (_TARGET, "provision.node.dc#0") +_ENDPOINT = OpenVpnPoolEndpoint(host="203.0.113.7", port=1194) - def __init__(self) -> None: - self.values: dict[str, str] = {} - self.deleted: list[tuple[int, str]] = [] - def read_or_create_issuer(self, range_id, generation, payload_factory): - ref = f"issuer:{range_id}:{generation}" - if ref not in self.values: - self.values[ref] = payload_factory() - return self.values[ref] +def _capability(*, target=_TARGET, days=5): + return build_openvpn_capability(target, datetime.now(UTC) + timedelta(days=days)) - def put_server(self, range_id, generation, payload): - ref = f"server:{range_id}:{generation}" - self.values[ref] = payload - return ref - def put_profile(self, range_id, generation, payload): - ref = f"profile:{range_id}:{generation}" - self.values[ref] = payload - return ref +def _block(profile: str, name: str) -> str: + return re.search(rf"<{name}>\n(.*?)", profile, re.S).group(1) - def delete_generation(self, range_id, generation, *, delete_identity=True): - self.deleted.append((range_id, str(generation))) - prefix = f":{range_id}:{generation}" - self.values = {key: value for key, value in self.values.items() if not key.endswith(prefix)} +class TestMintProfile: + def test_profile_carries_a_client_certificate_for_this_generation_only(self, vpn_secret_ops): + generation = uuid4() + capability = _capability() -def _variables(*, target_count=1): - instances = [ - { - "uuid": str(uuid4()), - "name": f"kali-{index}", - "role": "attacker", - "os_type": "kali", - } - for index in range(target_count) - ] - return { - "range_id": 42, - "user_id": 7, - "subnets": [{"name": "attack", "instances": instances}], - } - - -def _capability(variables, *, teardown_at=None, target_ref=None): - instances = variables["subnets"][0]["instances"] - selected = target_ref or (instances[0]["uuid"] if instances else uuid4()) - return build_openvpn_capability(selected, teardown_at or datetime.now(UTC) + timedelta(days=5)) - - -def _prepare(generation, variables, ops, *, capability=None): - from vpn_access import prepare_openvpn_access, range_spec_member_refs - - return prepare_openvpn_access( - str(generation), - 42, - range_spec_member_refs(variables), - capability or _capability(variables), - ops, - ) - + realization = mint_openvpn_profile(str(generation), 42, _MEMBERS, capability, vpn_secret_ops, _ENDPOINT) -def test_prepare_and_finalize_emit_a_ref_only_binding_and_valid_profile(): - from vpn_access import finalize_openvpn_access, verify_openvpn_gateway - - generation = uuid4() - variables = _variables() - ops = MemorySecretOps() - - preparation = _prepare(generation, variables, ops) - gateway = verify_openvpn_gateway( - { - "endpoint": "vpn.example.test", + assert realization == { + "generation": str(generation), + "target_ref": _TARGET, + "endpoint": "203.0.113.7", "port": 1194, - "health_endpoint": "10.20.30.40", - "health_port": 1195, - "target_ref": variables["subnets"][0]["instances"][0]["uuid"], - "ready": False, - }, - readiness_probe=lambda endpoint, port: endpoint == "10.20.30.40" and port == 1195, - ) - binding_dict = finalize_openvpn_access( - preparation, - gateway, - ops, - owner_user_id=7, - ) - - binding = parse_openvpn_binding(binding_dict) - profile = ops.values[binding.secret_ref] - assert validate_openvpn_profile(profile, binding) == profile.encode() - assert set(binding_dict) == { - "version", - "channel", - "generation", - "owner_user_id", - "target_ref", - "endpoint", - "port", - "profile_version", - "secret_ref", - "ready", - } - assert "profile" not in binding_dict - assert "key" not in binding_dict - - -def test_prepare_is_idempotent_for_one_range_generation(): - - generation = uuid4() - variables = _variables() - ops = MemorySecretOps() - capability = _capability(variables) - - first = _prepare(generation, variables, ops, capability=capability) - second = _prepare(generation, variables, ops, capability=capability) - - assert first == second - assert len(ops.values) == 2 - - -def test_prepare_rejects_a_capability_without_exactly_one_matching_target(): - ops = MemorySecretOps() - variables = _variables(target_count=0) - zero_generation = uuid4() - with pytest.raises(ValueError, match="exactly one"): - _prepare(zero_generation, variables, ops) - duplicated = _variables(target_count=1) - duplicated["subnets"][0]["instances"].append(dict(duplicated["subnets"][0]["instances"][0])) - duplicate_generation = uuid4() - with pytest.raises(ValueError, match="exactly one"): - _prepare(duplicate_generation, duplicated, ops) - assert ops.values == {} - - -def test_finalize_rejects_wrong_target_or_unready_gateway(): - from vpn_access import finalize_openvpn_access - - variables = _variables() - ops = MemorySecretOps() - preparation = _prepare(uuid4(), variables, ops) - - wrong_target_gateway = { - "endpoint": "vpn.example.test", - "port": 1194, - "target_ref": str(uuid4()), - "ready": True, - } - with pytest.raises(ValueError, match="target"): - finalize_openvpn_access(preparation, wrong_target_gateway, ops, owner_user_id=7) - unready_gateway = { - "endpoint": "vpn.example.test", - "port": 1194, - "target_ref": variables["subnets"][0]["instances"][0]["uuid"], - "ready": False, - } - with pytest.raises(ValueError, match="ready"): - finalize_openvpn_access(preparation, unready_gateway, ops, owner_user_id=7) - - -def test_gateway_verification_fails_closed_when_service_probe_does_not_pass(): - from vpn_access import verify_openvpn_gateway - - gateway = { - "endpoint": "vpn.example.test", - "port": 1194, - "health_port": 1195, - "target_ref": str(uuid4()), - "ready": False, - } - with pytest.raises(ValueError, match="service did not become ready"): - verify_openvpn_gateway(gateway, readiness_probe=lambda _endpoint, _port: False) - - -def test_gateway_verification_rejects_missing_endpoint_before_probe(): - from vpn_access import verify_openvpn_gateway - - probed = False - - def probe(_endpoint, _port): - nonlocal probed - probed = True - return True - - with pytest.raises(ValueError, match="endpoint is invalid"): - verify_openvpn_gateway( - {"endpoint": "", "port": 1194, "health_port": 1195, "ready": False}, - readiness_probe=probe, + "secret_ref": f"profile:42:{generation}", + } + profile = vpn_secret_ops.profiles[(42, str(generation))] + validate_openvpn_profile_for_endpoint(profile, "203.0.113.7", 1194) + assert "verify-x509-name shifter-openvpn-server name\n" in profile + assert "remote-cert-tls server\n" in profile + certificate = x509.load_pem_x509_certificate(_block(profile, "cert").encode()) + issuer = load_issuer(vpn_secret_ops.issuer) + assert certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == f"participant-{generation}" + assert certificate.issuer == issuer.ca_certificate.subject + issuer.ca_certificate.public_key().verify( + certificate.signature, certificate.tbs_certificate_bytes, ec.ECDSA(certificate.signature_hash_algorithm) ) + usage = certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + assert list(usage) == [ExtendedKeyUsageOID.CLIENT_AUTH] + teardown = datetime.fromisoformat(str(capability["teardown_at"]).replace("Z", "+00:00")) + assert abs((certificate.not_valid_after_utc - teardown).total_seconds()) <= 1 + # The CA signing key never leaves the issuer secret. + assert "ca_private_key" not in profile + assert _block(profile, "ca") == issuer.ca_pem + + def test_refuses_a_target_that_is_not_exactly_one_member(self, vpn_secret_ops): + with pytest.raises(ValueError, match="exactly one range member"): + mint_openvpn_profile( + str(uuid4()), 42, _MEMBERS, _capability(target="provision.node.x#0"), vpn_secret_ops, _ENDPOINT + ) + assert vpn_secret_ops.profiles == {} + + def test_refuses_a_deadline_past_the_ca_lifetime(self): + ops = MemoryVpnSecretOps(build_vpn_issuer_payload(valid_days=2)) + with pytest.raises(ValueError, match="CA expires before"): + mint_openvpn_profile(str(uuid4()), 42, _MEMBERS, _capability(days=5), ops, _ENDPOINT) + assert ops.profiles == {} + + def test_refuses_an_unbounded_deadline_before_reading_the_issuer(self, vpn_secret_ops): + capability = { + "version": "openvpn-capability-v1", + "channel": "openvpn", + "target_ref": _TARGET, + "teardown_at": (datetime.now(UTC) + timedelta(days=398)).isoformat().replace("+00:00", "Z"), + } + with pytest.raises(ValueError, match="397-day maximum"): + mint_openvpn_profile(str(uuid4()), 42, _MEMBERS, capability, vpn_secret_ops, _ENDPOINT) - assert probed is False - - -def test_aws_gateway_stays_pending_until_service_and_policy_probe(): - module = Path(__file__).parents[1] / "terraform" / "modules" / "range" - outputs = (module / "outputs.tf").read_text(encoding="utf-8") - resources = (module / "vpn.tf").read_text(encoding="utf-8") - bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") - - assert "health_port = 1195" in outputs - assert "health_endpoint = aws_instance.vpn_gateway[0].private_ip" in outputs - assert "ready = false" in outputs - assert "cidr_blocks = [var.vpn_public_client_cidr]" in resources - assert "instance.instance_uuid == var.openvpn_access.target_ref" in resources - assert 'instance.role == "attacker"' not in resources - assert "cidr_blocks = [var.portal_vpc_cidr]" in resources - assert 'resource "aws_lb_listener" "vpn_health"' not in resources - assert 'protocol = "TCP"' in resources - assert 'port = "1195"' in resources - assert "shifter-openvpn-health.service" in bootstrap - assert 'systemctl", "is-active", "--quiet", "openvpn-server@server"' in bootstrap - assert '"iptables", "-C", "FORWARD", "-i", "tun0"' in bootstrap - assert 'self.request.sendall(b"ready\\n")' in bootstrap - - -def test_aws_gateway_bootstrap_uses_the_baked_runtime_without_package_egress(): - module = Path(__file__).parents[1] / "terraform" / "modules" / "range" - bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") - - assert "package_update:" not in bootstrap - assert "\npackages:" not in bootstrap - assert "apt-get" not in bootstrap - assert "import boto3" in bootstrap - assert " - [python3, /usr/local/sbin/configure-shifter-openvpn.py]" in bootstrap - - -def test_aws_gateway_secrets_client_uses_the_module_region(): - module = Path(__file__).parents[1] / "terraform" / "modules" / "range" - bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") - resources = (module / "vpn.tf").read_text(encoding="utf-8") - - assert 'boto3.client("secretsmanager", region_name="${region}")' in bootstrap - assert "region = substr(var.availability_zone, 0, length(var.availability_zone) - 1)" in resources - - -def test_aws_gateway_server_uses_ecdh_without_a_static_dh_file(): - module = Path(__file__).parents[1] / "terraform" / "modules" / "range" - bootstrap = (module / "templates" / "openvpn_gateway_aws.py.tpl").read_text(encoding="utf-8") - - assert "\n dh none\n" in bootstrap - assert "dh /etc/openvpn" not in bootstrap - - -def test_server_payload_excludes_client_and_ca_signing_keys(): - - generation = uuid4() - ops = MemorySecretOps() - variables = _variables() - teardown_at = datetime.now(UTC) + timedelta(days=5) - _prepare(generation, variables, ops, capability=_capability(variables, teardown_at=teardown_at)) - - server = json.loads(ops.values[f"server:42:{generation}"]) - assert set(server) == {"ca", "certificate", "private_key", "tls_crypt"} - assert "client" not in json.dumps(server).lower() - issuer = json.loads(ops.values[f"issuer:42:{generation}"]) - assert issuer["generation"] == str(generation) - - ca = x509.load_pem_x509_certificate(issuer["ca"].encode()) - server_certificate = x509.load_pem_x509_certificate(server["certificate"].encode()) - now = datetime.now(UTC) - assert server_certificate.not_valid_after_utc <= ca.not_valid_after_utc - assert timedelta(days=4) < server_certificate.not_valid_after_utc - now <= timedelta(days=5, seconds=1) - assert abs((server_certificate.not_valid_after_utc - teardown_at).total_seconds()) <= 1 - - -def test_prepare_rejects_an_unbounded_deadline_before_writing_secrets(): - variables = _variables() - ops = MemorySecretOps() - generation = uuid4() - unbounded_teardown = (datetime.now(UTC) + timedelta(days=398)).isoformat().replace("+00:00", "Z") - capability = { - "version": "openvpn-capability-v1", - "channel": "openvpn", - "target_ref": variables["subnets"][0]["instances"][0]["uuid"], - "teardown_at": unbounded_teardown, - } - - with pytest.raises(ValueError, match="397-day maximum"): - _prepare(generation, variables, ops, capability=capability) - - assert ops.values == {} + def test_cleanup_deletes_only_the_generation_profile(self, vpn_secret_ops): + generation = uuid4() + mint_openvpn_profile(str(generation), 42, _MEMBERS, _capability(), vpn_secret_ops, _ENDPOINT) + cleanup_openvpn_access(42, str(generation), vpn_secret_ops) -def test_cleanup_deletes_every_secret_for_the_generation(): - from vpn_access import cleanup_openvpn_access + assert vpn_secret_ops.profiles == {} + assert vpn_secret_ops.deleted == [(42, str(generation))] - generation = uuid4() - ops = MemorySecretOps() - _prepare(generation, _variables(), ops) - cleanup_openvpn_access(42, str(generation), ops) +class TestIssuer: + @pytest.mark.parametrize( + "mutate", + [ + lambda value: {**value, "extra": "x"}, + lambda value: {key: value[key] for key in ("ca", "tls_crypt")}, + lambda value: {**value, "tls_crypt": "not a key"}, + lambda value: {**value, "ca_private_key": json.loads(build_vpn_issuer_payload())["ca_private_key"]}, + ], + ids=["extra-key", "missing-key", "bad-tls-crypt", "mismatched-key"], + ) + def test_rejects_a_defective_issuer(self, mutate): + value = json.loads(build_vpn_issuer_payload()) + with pytest.raises(ValueError): + load_issuer(json.dumps(mutate(value))) - assert ops.deleted == [(42, str(generation))] - assert ops.values == {} + def test_rejects_non_json(self): + with pytest.raises(ValueError, match="invalid shape"): + load_issuer("not json") -def test_completed_generation_replay_keeps_the_same_issuer_and_runtime_material(): - from vpn_access import finalize_openvpn_access +class TestPoolEndpoint: + def test_unset_means_no_pool(self, monkeypatch): + monkeypatch.delenv("RANGE_OPENVPN_ENDPOINT", raising=False) + assert openvpn_pool_endpoint() is None - generation = uuid4() - variables = _variables() - ops = MemorySecretOps() - capability = _capability(variables) - preparation = _prepare(generation, variables, ops, capability=capability) - first_binding = finalize_openvpn_access( - preparation, - { - "endpoint": "vpn.example.test", - "port": 1194, - "target_ref": variables["subnets"][0]["instances"][0]["uuid"], - "ready": True, - }, - ops, - owner_user_id=7, - ) - first_profile = ops.values[f"profile:42:{generation}"] - - replay = _prepare(generation, variables, ops, capability=capability) - replay_binding = finalize_openvpn_access( - replay, - { - "endpoint": "vpn.example.test", - "port": 1194, - "target_ref": variables["subnets"][0]["instances"][0]["uuid"], - "ready": True, - }, - ops, - owner_user_id=7, - ) + def test_static_ip_is_the_endpoint(self, monkeypatch): + monkeypatch.setenv("RANGE_OPENVPN_ENDPOINT", "203.0.113.7") + assert openvpn_pool_endpoint() == _ENDPOINT - assert replay == preparation - assert replay_binding == first_binding - assert ops.values[f"profile:42:{generation}"] == first_profile - assert f"issuer:42:{generation}" in ops.values - assert f"server:42:{generation}" in ops.values - assert f"profile:42:{generation}" in ops.values + def test_a_hostname_is_refused(self, monkeypatch): + monkeypatch.setenv("RANGE_OPENVPN_ENDPOINT", "vpn.example.com") + with pytest.raises(ValueError, match="static IP"): + openvpn_pool_endpoint() diff --git a/shifter/engine/provisioner/tests/test_vpn_secrets.py b/shifter/engine/provisioner/tests/test_vpn_secrets.py index ec29f98fd..122e7ff6c 100644 --- a/shifter/engine/provisioner/tests/test_vpn_secrets.py +++ b/shifter/engine/provisioner/tests/test_vpn_secrets.py @@ -1,4 +1,4 @@ -"""Provider secret-store adapter tests for OpenVPN generations.""" +"""Secret-store adapter tests for the tenant OpenVPN issuer and participant profiles (#2480).""" from __future__ import annotations @@ -7,11 +7,11 @@ from uuid import uuid4 import pytest -from botocore.exceptions import ClientError from gcp_dynamic_secrets import DynamicSecretClass, DynamicSecretPublicationPending, canonical_secret_id -from gcp_vpn_identity import gcp_vpn_gateway_pool_service_account_email -from vpn_secrets import AWSVpnSecretOps, GCPVpnSecretOps, openvpn_access_enabled +from vpn_secrets import GCPVpnSecretOps, get_vpn_secret_ops, openvpn_access_enabled + +_ISSUER = "projects/platform-project/secrets/shifter-test-vpn-issuer" @pytest.fixture(autouse=True) @@ -21,45 +21,6 @@ def _explicit_dynamic_secret_project(monkeypatch): monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-project") -def _client_error(code: str) -> ClientError: - return ClientError({"Error": {"Code": code, "Message": code}}, "test") - - -def test_aws_adapter_creates_and_reuses_generation_material(monkeypatch): - monkeypatch.setenv("ENVIRONMENT", "test") - generation = uuid4() - client = MagicMock() - client.get_secret_value.side_effect = [ - _client_error("ResourceNotFoundException"), - {"SecretString": "issuer-material"}, - {"SecretString": "issuer-material"}, - ] - client.create_secret.return_value = {"ARN": "arn:issuer"} - adapter = AWSVpnSecretOps(client) - factory = MagicMock(return_value="issuer-material") - - assert adapter.read_or_create_issuer(42, generation, factory) == "issuer-material" - assert adapter.read_or_create_issuer(42, generation, factory) == "issuer-material" - - factory.assert_called_once_with() - client.create_secret.assert_called_once() - create_kwargs = client.create_secret.call_args.kwargs - assert create_kwargs["Name"].endswith(f"vpn-issuer/range-42/{generation}") - assert create_kwargs["SecretString"] == "issuer-material" - - -def test_aws_adapter_deletes_all_generation_secrets_idempotently(monkeypatch): - monkeypatch.setenv("ENVIRONMENT", "test") - generation = uuid4() - client = MagicMock() - client.delete_secret.side_effect = [None, _client_error("ResourceNotFoundException"), None] - - AWSVpnSecretOps(client).delete_generation(42, generation) - - assert client.delete_secret.call_count == 3 - assert all(call.kwargs["ForceDeleteWithoutRecovery"] is True for call in client.delete_secret.call_args_list) - - class _NotFound(Exception): pass @@ -72,160 +33,53 @@ class _InvalidArgument(Exception): pass -def _mock_slot_read(monkeypatch, slot: int | None) -> None: - """Stub the reserved gateway pool-slot DB read used by GCPVpnSecretOps.""" - cursor = MagicMock() - cursor.fetchone.return_value = None if slot is None else (slot,) - conn = MagicMock() - conn.__enter__ = MagicMock(return_value=conn) - conn.__exit__ = MagicMock(return_value=False) - conn.cursor.return_value.__enter__ = MagicMock(return_value=cursor) - conn.cursor.return_value.__exit__ = MagicMock(return_value=False) - monkeypatch.setattr("vpn_secrets.get_db_connection", MagicMock(return_value=conn)) - - -def _gcp_adapter(client, *, project_id: str = "range-project") -> GCPVpnSecretOps: - client.get_iam_policy.return_value = {"bindings": []} +def _gcp_adapter(client, *, project_id: str = "range-project", issuer_secret: str = _ISSUER) -> GCPVpnSecretOps: return GCPVpnSecretOps( client=client, exceptions=SimpleNamespace(NotFound=_NotFound, AlreadyExists=_AlreadyExists, InvalidArgument=_InvalidArgument), project_id=project_id, + issuer_secret=issuer_secret, ) -@pytest.mark.parametrize( - ("kind", "credential_class", "audience"), - [ - ("issuer", DynamicSecretClass.VPN_ISSUER, "workload"), - ("server", DynamicSecretClass.VPN_SERVER, "workload"), - ("profile", DynamicSecretClass.VPN_PROFILE, "participant"), - ], -) -def test_gcp_vpn_kinds_map_to_the_expected_canonical_audience( - monkeypatch, - kind: str, - credential_class: DynamicSecretClass, - audience: str, -) -> None: +def test_profile_lives_in_the_participant_audience(monkeypatch): monkeypatch.setenv("ENVIRONMENT", "gcp-dev") monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") generation = uuid4() - locations = _gcp_adapter(MagicMock(), project_id="compute-project")._locations(42, generation, kind) + locations = _gcp_adapter(MagicMock(), project_id="compute-project")._profile_locations(42, generation) expected_id = canonical_secret_id( - credential_class=credential_class, - scope=f"range-42-{str(generation).replace('-', '')}", + credential_class=DynamicSecretClass.VPN_PROFILE, scope=f"range-42-{str(generation).replace('-', '')}" ) assert locations.create_ref == f"projects/range-secrets/secrets/{expected_id}" - assert f"-{audience}-vpn-" in locations.create_ref + assert "-participant-vpn-" in locations.create_ref -def test_gcp_server_secret_grants_the_reserved_pool_identity(monkeypatch): +def test_issuer_is_read_from_the_configured_tenant_secret_only(): client = MagicMock() - client.access_secret_version.side_effect = _NotFound() - client.get_secret.side_effect = _NotFound() - _mock_slot_read(monkeypatch, 7) - adapter = _gcp_adapter(client) - - adapter.put_server(42, uuid4(), "server-material") - - client.create_secret.assert_called_once() - client.add_secret_version.assert_called_once() - gateway_email = gcp_vpn_gateway_pool_service_account_email("range-project", 7) - policy = client.set_iam_policy.call_args.kwargs["request"]["policy"] - assert policy == { - "bindings": [ - { - "role": "roles/secretmanager.secretAccessor", - "members": [f"serviceAccount:{gateway_email}"], - } - ] - } - - -def test_gcp_adapter_never_creates_or_binds_service_accounts(monkeypatch): - # ADR-008-R7: the pool model removes runtime SA administration entirely. The - # adapter holds no IAM-admin client; the only set_iam_policy call is the - # Secret Manager grant on the server secret (not a service-account resource). - client = MagicMock() - client.access_secret_version.side_effect = _NotFound() - client.get_secret.side_effect = _NotFound() - _mock_slot_read(monkeypatch, 3) - adapter = _gcp_adapter(client) + client.access_secret_version.return_value = SimpleNamespace(payload=SimpleNamespace(data=b"issuer")) - adapter.put_server(42, uuid4(), "server-material") + assert _gcp_adapter(client).read_issuer() == "issuer" + assert client.access_secret_version.call_args.kwargs["request"] == {"name": f"{_ISSUER}/versions/latest"} + client.create_secret.assert_not_called() - assert not hasattr(adapter, "_iam_client") - assert client.set_iam_policy.call_count == 1 - assert "/secrets/" in client.set_iam_policy.call_args.kwargs["request"]["resource"] + with pytest.raises(RuntimeError, match="RANGE_OPENVPN_ISSUER_SECRET"): + _gcp_adapter(MagicMock(), issuer_secret="").read_issuer() -def test_gcp_put_server_raises_when_no_pool_slot_reserved(monkeypatch): +def test_delete_and_presence_cover_both_profile_locations(monkeypatch): + monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") client = MagicMock() client.access_secret_version.side_effect = _NotFound() - client.get_secret.side_effect = _NotFound() - _mock_slot_read(monkeypatch, None) adapter = _gcp_adapter(client) generation = uuid4() - with pytest.raises(RuntimeError, match="gateway pool slot"): - adapter.put_server(42, generation, "server-material") - - -def test_gcp_delete_generation_removes_secrets_without_touching_identities(): - client = MagicMock() - adapter = _gcp_adapter(client) - - adapter.delete_generation(42, uuid4()) + assert adapter.profile_present(42, generation) is False + adapter.delete_profile(42, generation) - # All three per-generation secrets are deleted; the pooled identity is - # permanent, so there is no service-account lifecycle to invoke. - assert client.delete_secret.call_count == 3 - assert not hasattr(adapter, "_iam_client") - - -def test_gcp_dedicated_project_separates_secret_storage_from_gateway_identity(monkeypatch): - monkeypatch.setenv("ENVIRONMENT", "gcp-dev") - monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") - client = MagicMock() - client.access_secret_version.side_effect = _NotFound() - client.get_secret.side_effect = _NotFound() - _mock_slot_read(monkeypatch, 7) - adapter = _gcp_adapter(client, project_id="compute-project") - - adapter.put_server(42, uuid4(), "server-material") - - assert client.create_secret.call_args.kwargs["request"]["parent"] == "projects/range-secrets" - gateway_email = gcp_vpn_gateway_pool_service_account_email("compute-project", 7) - policy = client.set_iam_policy.call_args.kwargs["request"]["policy"] - assert policy["bindings"][0]["members"] == [f"serviceAccount:{gateway_email}"] - - -def test_gcp_server_ref_is_the_exact_location_the_gateway_reads_and_the_slot_is_projected(monkeypatch): - """The gateway bootstrap reads this ref, not a name it re-derives (#2030).""" - monkeypatch.setenv("ENVIRONMENT", "gcp-dev") - monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") - db = MagicMock(side_effect=AssertionError("the projected slot must not be re-read from the database")) - monkeypatch.setattr("vpn_secrets.get_db_connection", db) - client = MagicMock() - client.access_secret_version.side_effect = _NotFound() - client.get_secret.side_effect = _NotFound() - client.get_iam_policy.return_value = {"bindings": []} - adapter = GCPVpnSecretOps( - client=client, - exceptions=SimpleNamespace(NotFound=_NotFound, AlreadyExists=_AlreadyExists, InvalidArgument=_InvalidArgument), - project_id="compute-project", - gateway_pool_slot=9, - ) - - ref = adapter.put_server(42, uuid4(), "server-material") - - created = client.create_secret.call_args.kwargs["request"] - assert ref == f"{created['parent']}/secrets/{created['secret_id']}" - assert ref.startswith("projects/range-secrets/secrets/") - gateway_email = gcp_vpn_gateway_pool_service_account_email("compute-project", 9) - policy = client.set_iam_policy.call_args.kwargs["request"]["policy"] - assert policy["bindings"][0]["members"] == [f"serviceAccount:{gateway_email}"] + deleted = [call.kwargs["request"]["name"] for call in client.delete_secret.call_args_list] + assert any(name.startswith("projects/range-secrets/secrets/") for name in deleted) + client.set_iam_policy.assert_not_called() def test_gcp_concurrent_creator_reuses_winner_without_publishing_competing_profile(monkeypatch): @@ -256,41 +110,6 @@ def test_gcp_existing_profile_is_authoritative_over_repeated_payload(monkeypatch client.add_secret_version.assert_not_called() -def test_gcp_concurrent_server_creator_reuses_winner_and_grants_gateway(monkeypatch): - monkeypatch.setenv("ENVIRONMENT", "gcp-dev") - monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") - client = MagicMock() - winner = SimpleNamespace(payload=SimpleNamespace(data=b"winner-server")) - client.access_secret_version.side_effect = [_NotFound(), _NotFound(), winner] - client.get_secret.side_effect = _NotFound() - client.create_secret.side_effect = _AlreadyExists() - _mock_slot_read(monkeypatch, 7) - adapter = _gcp_adapter(client, project_id="compute-project") - - adapter.put_server(42, uuid4(), "competing-server") - - client.add_secret_version.assert_not_called() - gateway_email = gcp_vpn_gateway_pool_service_account_email("compute-project", 7) - policy_request = client.set_iam_policy.call_args.kwargs["request"] - assert policy_request["resource"].startswith("projects/range-secrets/secrets/") - assert policy_request["policy"]["bindings"][0]["members"] == [f"serviceAccount:{gateway_email}"] - - -def test_gcp_existing_server_is_authoritative_and_gateway_grant_is_reconciled(monkeypatch): - monkeypatch.setenv("ENVIRONMENT", "gcp-dev") - monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") - client = MagicMock() - client.access_secret_version.return_value = SimpleNamespace(payload=SimpleNamespace(data=b"published-server")) - _mock_slot_read(monkeypatch, 7) - adapter = _gcp_adapter(client, project_id="compute-project") - - adapter.put_server(42, uuid4(), "competing-server") - - client.create_secret.assert_not_called() - client.add_secret_version.assert_not_called() - client.set_iam_policy.assert_called_once() - - def test_gcp_vpn_waits_for_empty_legacy_container_before_canonical_create(monkeypatch): monkeypatch.setenv("ENVIRONMENT", "gcp-dev") monkeypatch.setenv("GCP_DYNAMIC_SECRET_PROJECT_ID", "range-secrets") @@ -325,36 +144,28 @@ def test_gcp_existing_empty_container_fails_without_publishing_competing_profile client.add_secret_version.assert_not_called() -def test_capability_gate_requires_selected_provider_prerequisites(monkeypatch): - monkeypatch.setenv("CLOUD_PROVIDER", "aws") - monkeypatch.delenv("RANGE_VPN_EDGE_SUBNET_ID", raising=False) - monkeypatch.delenv("RANGE_VPN_GATEWAY_PERMISSIONS_BOUNDARY_ARN", raising=False) - monkeypatch.delenv("RANGE_VPN_PROVIDER_ENDPOINT_SECURITY_GROUP_ID", raising=False) - assert openvpn_access_enabled() is False - - monkeypatch.setenv("RANGE_VPN_EDGE_SUBNET_ID", "subnet-edge") - monkeypatch.setenv("RANGE_VPN_GATEWAY_PERMISSIONS_BOUNDARY_ARN", "arn:boundary") - monkeypatch.setenv("RANGE_VPN_PROVIDER_ENDPOINT_SECURITY_GROUP_ID", "sg-endpoints") - assert openvpn_access_enabled() is False - - monkeypatch.setenv("PORTAL_VPC_CIDR", "10.40.0.0/20") - assert openvpn_access_enabled() is True - +def test_pool_is_enabled_only_with_a_deployed_pool_on_shared_vpc_gce(monkeypatch): monkeypatch.setenv("CLOUD_PROVIDER", "gcp") monkeypatch.setenv("GCP_RANGE_BACKEND", "gce") monkeypatch.setenv("GCP_RANGE_CELL_NETWORK_MODE", "shared-vpc") - monkeypatch.setenv("GCP_RANGE_PRIVATE_GOOGLE_ACCESS", "true") - monkeypatch.setenv("GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL", "range-host@example.test") - monkeypatch.setenv("GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL", "provisioner@example.test") - monkeypatch.setenv("GCP_RANGE_LINUX_IMAGE", "projects/test/global/images/ubuntu") - assert openvpn_access_enabled() is True + monkeypatch.delenv("RANGE_OPENVPN_ENDPOINT", raising=False) + monkeypatch.delenv("RANGE_OPENVPN_ISSUER_SECRET", raising=False) + monkeypatch.delenv("RANGE_OPENVPN_POOL_CIDRS", raising=False) + assert openvpn_access_enabled() is False - monkeypatch.setenv("GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES", "https://www.googleapis.com/auth/logging.write") + monkeypatch.setenv("RANGE_OPENVPN_ENDPOINT", "203.0.113.7") + monkeypatch.setenv("RANGE_OPENVPN_ISSUER_SECRET", _ISSUER) assert openvpn_access_enabled() is False - monkeypatch.setenv("GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES", "https://www.googleapis.com/auth/cloud-platform") + monkeypatch.setenv("RANGE_OPENVPN_POOL_CIDRS", "10.49.0.0/24") + assert openvpn_access_enabled() is True monkeypatch.setenv("GCP_RANGE_CELL_NETWORK_MODE", "vpc-per-range") assert openvpn_access_enabled() is False - + monkeypatch.setenv("GCP_RANGE_CELL_NETWORK_MODE", "shared-vpc") monkeypatch.setenv("GCP_RANGE_BACKEND", "gdc") assert openvpn_access_enabled() is False + + monkeypatch.setenv("CLOUD_PROVIDER", "aws") + assert openvpn_access_enabled() is False + with pytest.raises(RuntimeError, match="shared OpenVPN pool"): + get_vpn_secret_ops() diff --git a/shifter/engine/provisioner/tests/test_warm_activation_ops.py b/shifter/engine/provisioner/tests/test_warm_activation_ops.py index 4b95838e6..a5864676a 100644 --- a/shifter/engine/provisioner/tests/test_warm_activation_ops.py +++ b/shifter/engine/provisioner/tests/test_warm_activation_ops.py @@ -58,10 +58,10 @@ def __init__(self, present, sink): self._present = present sink.append(self) - def delete_generation(self, range_id, generation, *, delete_identity=True): - self.deleted.append((range_id, generation, delete_identity)) + def delete_profile(self, range_id, generation): + self.deleted.append((range_id, generation)) - def issuer_present(self, range_id, generation): + def profile_present(self, range_id, generation): if isinstance(self._present, Exception): raise self._present return self._present @@ -75,7 +75,7 @@ def _install_fake_vpn(monkeypatch, *, present=False): class TestScrubPreClaimAccess: - def test_deletes_every_node_account_and_vpn_generation(self, monkeypatch): + def test_deletes_every_node_account_and_vpn_profile(self, monkeypatch): plan = SimpleNamespace( nodes=[SimpleNamespace(address="n1"), SimpleNamespace(address="n2")], accounts=[ @@ -99,7 +99,7 @@ def test_deletes_every_node_account_and_vpn_generation(self, monkeypatch): assert ssh == [(1001, "n1"), (1001, "n2")] assert acct == [(1001, "n1", "alice", "password")] # the empty-auth account is skipped - assert vpn_ops[-1].deleted == [(1001, prepared, True)] + assert vpn_ops[-1].deleted == [(1001, prepared)] class TestRealizeClaimantAccess: @@ -133,14 +133,14 @@ def test_delegates_to_realize_helper(self, monkeypatch): class TestPriorAccessRevoked: - def test_true_when_issuer_absent(self, monkeypatch): + def test_true_when_profile_absent(self, monkeypatch): vpn_ops = _install_fake_vpn(monkeypatch, present=False) prepared = uuid4() assert GceActivationOps.prior_access_revoked(_activation(), prepared) is True - # It scrubs (belt-and-suspenders) then checks the issuer is absent. - assert vpn_ops[-1].deleted == [(1001, prepared, True)] + # It scrubs (belt-and-suspenders) then checks the profile is absent. + assert vpn_ops[-1].deleted == [(1001, prepared)] - def test_false_when_issuer_still_present(self, monkeypatch): + def test_false_when_profile_still_present(self, monkeypatch): _install_fake_vpn(monkeypatch, present=True) assert GceActivationOps.prior_access_revoked(_activation(), uuid4()) is False @@ -444,7 +444,7 @@ class _NotFound(Exception): pass -class TestIssuerPresent: +class TestProfilePresent: @pytest.fixture(autouse=True) def _explicit_dynamic_secret_project(self, monkeypatch): """Exercise the supported same-project migration posture explicitly.""" @@ -456,18 +456,18 @@ def _ops(self, *, access): client = SimpleNamespace(access_secret_version=access) exceptions = SimpleNamespace(NotFound=_NotFound) - return vpn_secrets.GCPVpnSecretOps(client=client, exceptions=exceptions, project_id="proj-1") + return vpn_secrets.GCPVpnSecretOps(client=client, exceptions=exceptions, project_id="proj-1", issuer_secret="") - def test_true_when_issuer_secret_resolves(self): - ops = self._ops(access=lambda request: SimpleNamespace(payload=SimpleNamespace(data=b"issuer-material"))) - assert ops.issuer_present(1001, uuid4()) is True + def test_true_when_profile_secret_resolves(self): + ops = self._ops(access=lambda request: SimpleNamespace(payload=SimpleNamespace(data=b"profile"))) + assert ops.profile_present(1001, uuid4()) is True - def test_false_when_issuer_secret_absent(self): + def test_false_when_profile_secret_absent(self): def _raise(request): raise _NotFound ops = self._ops(access=_raise) - assert ops.issuer_present(1001, uuid4()) is False + assert ops.profile_present(1001, uuid4()) is False class TestGetActivationOperationInput: diff --git a/shifter/engine/provisioner/vpn_access.py b/shifter/engine/provisioner/vpn_access.py index a3ad65ed1..276b8ca20 100644 --- a/shifter/engine/provisioner/vpn_access.py +++ b/shifter/engine/provisioner/vpn_access.py @@ -1,18 +1,23 @@ -"""Provider-neutral OpenVPN identity and profile lifecycle. +"""Participant OpenVPN profiles for the shared server pool (#2030, #2480). -The provisioner is the only process that handles the CA signing key. Provider -adapters store generated material directly in their secret manager and return -only the closed binding assembled here. +One tenant VPN CA signs every participant certificate. The provisioner is the +only process that reads the CA signing key. For each range generation it mints +one client certificate that expires at the access deadline, stores the profile +in the provider secret manager, and returns only the closed, owner-free +realization assembled here. + +A certificate alone grants nothing: the pool servers ask the portal to +authorize every connection, and the portal admits only a current generation of +a READY range. """ from __future__ import annotations +import ipaddress import json import os -import socket -import time -from collections.abc import Callable, Collection -from dataclasses import asdict, dataclass +from collections.abc import Collection +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Protocol from uuid import UUID @@ -20,101 +25,108 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID, ObjectIdentifier +from cryptography.hazmat.primitives.asymmetric.types import CertificatePublicKeyTypes +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID from shared.remote_access import ( - bind_openvpn_realization, + OPENVPN_SERVER_NAME, parse_openvpn_capability, parse_openvpn_realization, validate_openvpn_capability_window, validate_openvpn_profile_for_endpoint, ) -_TLS_CRYPT_BYTES = 256 +_ISSUER_KEYS = frozenset({"ca", "ca_private_key", "tls_crypt"}) +_TLS_CRYPT_MARKERS = ("-----BEGIN OpenVPN Static key V1-----", "-----END OpenVPN Static key V1-----") +_DEFAULT_PORT = 1194 class VpnSecretOps(Protocol): - """Small provider-secret port used by the credential lifecycle.""" - - def read_or_create_issuer(self, range_id: int, generation: UUID, payload_factory: Callable[[], str]) -> str: - """Return the existing issuer payload or atomically store the factory result.""" + """Small provider-secret port used by the profile lifecycle.""" - def put_server(self, range_id: int, generation: UUID, payload: str) -> str: - """Store the gateway-only server identity and return its exact provider reference.""" + def read_issuer(self) -> str: + """Return the tenant issuer payload (CA certificate, CA key, tls-crypt key).""" def put_profile(self, range_id: int, generation: UUID, payload: str) -> str: """Store the participant profile and return its opaque provider reference.""" - def delete_generation(self, range_id: int, generation: UUID, *, delete_identity: bool = True) -> None: - """Delete issuer, server, and participant material idempotently.""" + def delete_profile(self, range_id: int, generation: UUID) -> None: + """Delete the generation's participant profile idempotently.""" @dataclass(frozen=True) -class OpenVpnIssuerMaterial: - """Serializable material retained only in the provider issuer secret.""" - - generation: str - ca: str - ca_private_key: str - server_certificate: str - server_private_key: str - client_certificate: str - client_private_key: str +class OpenVpnIssuer: + """The tenant CA and the pool-wide tls-crypt key, held only in memory.""" + + ca_certificate: x509.Certificate + ca_private_key: ec.EllipticCurvePrivateKey + ca_pem: str tls_crypt: str @dataclass(frozen=True) -class OpenVpnPreparation: - """In-memory provisioning state for one range generation. - - Ownership is deliberately absent: the gateway and its credentials belong to - the range generation, and the owner is bound to the published realization - afterwards. ``server_secret_ref`` is the exact provider reference the gateway - reads its identity from. - """ +class OpenVpnPoolEndpoint: + """The public address every participant profile connects to.""" - range_id: int - generation: UUID - target_ref: str - teardown_at: datetime - material: OpenVpnIssuerMaterial - server_secret_ref: str + host: str + port: int -def _pem_private_key(key: ec.EllipticCurvePrivateKey) -> str: - """Serialize a private key to unencrypted PKCS8 PEM.""" - return key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ).decode("ascii") +def openvpn_pool_endpoint() -> OpenVpnPoolEndpoint | None: + """Return the configured pool address, or None when the pool is not deployed.""" + host = os.environ.get("RANGE_OPENVPN_ENDPOINT", "").strip() + if not host: + return None + try: + ipaddress.ip_address(host) + except ValueError as exc: + raise ValueError("RANGE_OPENVPN_ENDPOINT must be the pool's static IP address") from exc + return OpenVpnPoolEndpoint(host=host, port=_DEFAULT_PORT) -def _pem_certificate(certificate: x509.Certificate) -> str: - """Serialize a certificate to PEM.""" - return certificate.public_bytes(serialization.Encoding.PEM).decode("ascii") +def _public_key_der(public_key: CertificatePublicKeyTypes) -> bytes: + """Return the DER SubjectPublicKeyInfo used to compare a key with its certificate.""" + return public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) -def _certificate( - *, - subject: str, - public_key: ec.EllipticCurvePublicKey, - issuer_name: x509.Name, - issuer_key: ec.EllipticCurvePrivateKey, - extended_usage: ObjectIdentifier, - is_ca: bool = False, - not_after: datetime, -) -> x509.Certificate: - """Sign a leaf or CA certificate scoped to this range generation.""" +def load_issuer(payload: str) -> OpenVpnIssuer: + """Parse and verify the tenant issuer payload, failing closed on any defect.""" + try: + value = json.loads(payload) + except ValueError as exc: + raise ValueError("OpenVPN issuer secret has an invalid shape") from exc + if not isinstance(value, dict) or set(value) != _ISSUER_KEYS or not all(isinstance(v, str) for v in value.values()): + raise ValueError("OpenVPN issuer secret has an invalid shape") + try: + certificate = x509.load_pem_x509_certificate(value["ca"].encode()) + key = serialization.load_pem_private_key(value["ca_private_key"].encode(), password=None) + except ValueError as exc: + raise ValueError("OpenVPN issuer secret contains invalid key material") from exc + constraints = certificate.extensions.get_extension_for_class(x509.BasicConstraints).value + if not isinstance(key, ec.EllipticCurvePrivateKey) or not constraints.ca: + raise ValueError("OpenVPN issuer secret must hold an EC certificate authority") + if _public_key_der(certificate.public_key()) != _public_key_der(key.public_key()): + raise ValueError("OpenVPN issuer key does not match its certificate") + tls_crypt = value["tls_crypt"] + if not all(marker in tls_crypt for marker in _TLS_CRYPT_MARKERS): + raise ValueError("OpenVPN issuer tls-crypt key is invalid") + return OpenVpnIssuer(ca_certificate=certificate, ca_private_key=key, ca_pem=value["ca"], tls_crypt=tls_crypt) + + +def _client_certificate( + issuer: OpenVpnIssuer, generation: UUID, not_after: datetime +) -> tuple[x509.Certificate, ec.EllipticCurvePrivateKey]: + """Sign one participant client certificate that expires at the access deadline.""" + key = ec.generate_private_key(ec.SECP256R1()) now = datetime.now(UTC) - builder = ( + certificate = ( x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])) - .issuer_name(issuer_name) - .public_key(public_key) + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"participant-{generation}")])) + .issuer_name(issuer.ca_certificate.subject) + .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now - timedelta(minutes=5)) .not_valid_after(not_after) - .add_extension(x509.BasicConstraints(ca=is_ca, path_length=0 if is_ca else None), critical=True) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) .add_extension( x509.KeyUsage( digital_signature=True, @@ -122,283 +134,101 @@ def _certificate( key_encipherment=False, data_encipherment=False, key_agreement=True, - key_cert_sign=is_ca, - crl_sign=is_ca, + key_cert_sign=False, + crl_sign=False, encipher_only=False, decipher_only=False, ), critical=True, ) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False) + .sign(private_key=issuer.ca_private_key, algorithm=hashes.SHA256()) ) - if not is_ca: - builder = builder.add_extension(x509.ExtendedKeyUsage([extended_usage]), critical=False) - return builder.sign(private_key=issuer_key, algorithm=hashes.SHA256()) - - -def _static_key() -> str: - """Generate an OpenVPN tls-crypt static key block.""" - encoded = os.urandom(_TLS_CRYPT_BYTES).hex() - body = "\n".join(encoded[index : index + 32] for index in range(0, len(encoded), 32)) - begin_marker = "-----BE" + "GIN OpenVPN Static key V1-----\n" - end_marker = "-----END OpenVPN Static key V1-----\n" - return f"#\n# 2048 bit OpenVPN static key\n#\n{begin_marker}{body}\n{end_marker}" - - -def _generate_material(generation: UUID, teardown_at: datetime) -> OpenVpnIssuerMaterial: - """Mint a fresh CA, server, and client identity expiring at teardown.""" - ca_key = ec.generate_private_key(ec.SECP256R1()) - ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"shifter-range-{generation}")]) - ca_cert = _certificate( - subject=f"shifter-range-{generation}", - public_key=ca_key.public_key(), - issuer_name=ca_name, - issuer_key=ca_key, - extended_usage=ExtendedKeyUsageOID.SERVER_AUTH, - is_ca=True, - not_after=teardown_at, - ) - server_key = ec.generate_private_key(ec.SECP256R1()) - server_cert = _certificate( - subject="shifter-openvpn-server", - public_key=server_key.public_key(), - issuer_name=ca_cert.subject, - issuer_key=ca_key, - extended_usage=ExtendedKeyUsageOID.SERVER_AUTH, - not_after=teardown_at, - ) - client_key = ec.generate_private_key(ec.SECP256R1()) - client_cert = _certificate( - subject=f"participant-{generation}", - public_key=client_key.public_key(), - issuer_name=ca_cert.subject, - issuer_key=ca_key, - extended_usage=ExtendedKeyUsageOID.CLIENT_AUTH, - not_after=teardown_at, - ) - return OpenVpnIssuerMaterial( - generation=str(generation), - ca=_pem_certificate(ca_cert), - ca_private_key=_pem_private_key(ca_key), - server_certificate=_pem_certificate(server_cert), - server_private_key=_pem_private_key(server_key), - client_certificate=_pem_certificate(client_cert), - client_private_key=_pem_private_key(client_key), - tls_crypt=_static_key(), - ) - - -def _load_material(payload: str, generation: UUID, teardown_at: datetime) -> OpenVpnIssuerMaterial: - """Parse stored issuer material and reject stale or foreign generations.""" - try: - value = json.loads(payload) - material = OpenVpnIssuerMaterial(**value) - except (TypeError, ValueError) as exc: - raise ValueError("OpenVPN issuer secret has an invalid shape") from exc - if material.generation != str(generation) or set(value) != set(asdict(material)): - raise ValueError("OpenVPN issuer secret belongs to another generation") - try: - certificates = ( - x509.load_pem_x509_certificate(material.ca.encode()), - x509.load_pem_x509_certificate(material.server_certificate.encode()), - x509.load_pem_x509_certificate(material.client_certificate.encode()), - ) - except ValueError as exc: - raise ValueError("OpenVPN issuer secret contains an invalid certificate") from exc - if any(certificate.not_valid_after_utc < teardown_at for certificate in certificates): - raise ValueError("OpenVPN issuer secret expires before the authorized teardown deadline") - return material - - -def range_spec_member_refs(range_spec: dict[str, object]) -> list[str]: - """Return the member identities declared by a legacy range spec.""" - subnets = range_spec.get("subnets") - if not isinstance(subnets, list): - return [] - return [ - str(instance.get("uuid", "")) - for subnet in subnets - if isinstance(subnet, dict) and isinstance(subnet.get("instances"), list) - for instance in subnet["instances"] - if isinstance(instance, dict) - ] - - -def prepare_openvpn_access( - request_uuid: str, - range_id: int, - member_refs: Collection[str], - remote_access_capability: dict[str, object], - secret_ops: VpnSecretOps, -) -> OpenVpnPreparation: - """Create/reuse credentials only for the exact server-authorized target. - - ``member_refs`` are the range's member identities (legacy instance UUIDs or - RAES member keys); the capability must name exactly one of them. - """ - capability = parse_openvpn_capability(remote_access_capability) - validate_openvpn_capability_window(capability) - if list(member_refs).count(capability.target_ref) != 1: - raise ValueError("OpenVPN capability must identify exactly one range member") - generation = UUID(request_uuid) - payload = secret_ops.read_or_create_issuer( - range_id, - generation, - lambda: json.dumps(asdict(_generate_material(generation, capability.teardown_at)), sort_keys=True), - ) - material = _load_material(payload, generation, capability.teardown_at) - server_secret_ref = secret_ops.put_server( - range_id, - generation, - json.dumps( - { - "ca": material.ca, - "certificate": material.server_certificate, - "private_key": material.server_private_key, - "tls_crypt": material.tls_crypt, - }, - sort_keys=True, - ), - ) - return OpenVpnPreparation( - range_id=range_id, - generation=generation, - target_ref=capability.target_ref, - teardown_at=capability.teardown_at, - material=material, - server_secret_ref=server_secret_ref, - ) + return certificate, key -def _render_profile(preparation: OpenVpnPreparation, endpoint: str, port: int) -> str: +def _render_profile( + issuer: OpenVpnIssuer, + certificate: x509.Certificate, + key: ec.EllipticCurvePrivateKey, + endpoint: OpenVpnPoolEndpoint, +) -> str: """Render the participant .ovpn profile with inlined credentials.""" - material = preparation.material + client_pem = certificate.public_bytes(serialization.Encoding.PEM).decode("ascii") + key_pem = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode("ascii") return ( "client\n" "dev tun\n" "proto udp\n" - f"remote {endpoint} {port}\n" + f"remote {endpoint.host} {endpoint.port}\n" "resolv-retry infinite\n" "nobind\n" "persist-key\n" "persist-tun\n" "remote-cert-tls server\n" + f"verify-x509-name {OPENVPN_SERVER_NAME} name\n" "auth-nocache\n" "verb 3\n" "auth SHA256\n" "cipher AES-256-GCM\n" "data-ciphers AES-256-GCM:AES-128-GCM\n" "tls-version-min 1.2\n" - f"\n{material.ca}\n" - f"\n{material.client_certificate}\n" - f"\n{material.client_private_key}\n" - f"\n{material.tls_crypt}\n" + f"\n{issuer.ca_pem}\n" + f"\n{client_pem}\n" + f"\n{key_pem}\n" + f"\n{issuer.tls_crypt}\n" ) -def _probe_openvpn_gateway(endpoint: str, health_port: int, timeout_seconds: int = 180) -> bool: - """Wait for the gateway's service-and-policy health responder.""" - deadline = time.monotonic() + timeout_seconds - while True: - try: - with socket.create_connection((endpoint, health_port), timeout=5) as connection: - connection.settimeout(5) - if connection.recv(32) == b"ready\n": - return True - except OSError: - pass - if time.monotonic() >= deadline: - return False - time.sleep(5) - - -def verify_openvpn_gateway( - gateway: object, - readiness_probe: Callable[[str, int], bool] | None = None, -) -> dict[str, object]: - """Promote an infrastructure result to ready only after a bounded service probe.""" - if not isinstance(gateway, dict): - raise ValueError("OpenVPN gateway result is required") - endpoint = str(gateway.get("endpoint", "")) - if not endpoint: - raise ValueError("OpenVPN gateway endpoint is invalid") - health_endpoint = str(gateway.get("health_endpoint") or endpoint) - health_port = gateway.get("health_port") - if isinstance(health_port, bool) or not isinstance(health_port, int) or not 1 <= health_port <= 65535: - raise ValueError("OpenVPN gateway health port is invalid") - probe = readiness_probe or _probe_openvpn_gateway - if not probe(health_endpoint, health_port): - raise ValueError("OpenVPN gateway service did not become ready") - verified = dict(gateway) - verified["ready"] = True - return verified - - -def publish_openvpn_profile( - preparation: OpenVpnPreparation, - gateway: object, +def mint_openvpn_profile( + request_uuid: str, + range_id: int, + member_refs: Collection[str], + remote_access_capability: dict[str, object], secret_ops: VpnSecretOps, + endpoint: OpenVpnPoolEndpoint, ) -> dict[str, object]: - """Validate a ready gateway, store its client profile, and return the realization. + """Mint and store the generation's profile for the exact authorized target. - The realization is owner-free; the caller binds the owner it is authoritative - for (``bind_openvpn_realization``). + ``member_refs`` are the realized range's member keys; the capability must + name exactly one of them. The returned realization is owner-free; the Engine + binds the range owner it is authoritative for. """ - if not isinstance(gateway, dict): - raise ValueError("OpenVPN gateway result is required") - if gateway.get("ready") is not True: - raise ValueError("OpenVPN gateway must be ready before profile publication") - if str(gateway.get("target_ref", "")) != preparation.target_ref: - raise ValueError("OpenVPN gateway target does not match the authorized Kali member") - endpoint = str(gateway.get("endpoint", "")) - port_value = gateway.get("port") - if isinstance(port_value, bool) or not isinstance(port_value, int): - raise ValueError("OpenVPN gateway port is invalid") - realization = parse_openvpn_realization( + capability = parse_openvpn_capability(remote_access_capability) + validate_openvpn_capability_window(capability) + if list(member_refs).count(capability.target_ref) != 1: + raise ValueError("OpenVPN capability must identify exactly one range member") + issuer = load_issuer(secret_ops.read_issuer()) + if issuer.ca_certificate.not_valid_after_utc < capability.teardown_at: + raise ValueError("The tenant OpenVPN CA expires before the authorized access deadline") + generation = UUID(request_uuid) + certificate, key = _client_certificate(issuer, generation, capability.teardown_at) + profile = _render_profile(issuer, certificate, key, endpoint) + validate_openvpn_profile_for_endpoint(profile, endpoint.host, endpoint.port) + return parse_openvpn_realization( { - "generation": str(preparation.generation), - "target_ref": preparation.target_ref, - "endpoint": endpoint, - "port": port_value, - "secret_ref": f"profile-validation:{preparation.generation}", + "generation": str(generation), + "target_ref": capability.target_ref, + "endpoint": endpoint.host, + "port": endpoint.port, + "secret_ref": secret_ops.put_profile(range_id, generation, profile), } ) - profile = _render_profile(preparation, endpoint, port_value) - validate_openvpn_profile_for_endpoint(profile, endpoint, port_value) - realization["secret_ref"] = secret_ops.put_profile(preparation.range_id, preparation.generation, profile) - return parse_openvpn_realization(realization) -def finalize_openvpn_access( - preparation: OpenVpnPreparation | None, - gateway: object, - secret_ops: VpnSecretOps, - *, - owner_user_id: int, -) -> dict[str, object] | None: - """Publish the profile for a ready gateway and bind it to ``owner_user_id``.""" - if preparation is None: - return None - return bind_openvpn_realization(publish_openvpn_profile(preparation, gateway, secret_ops), owner_user_id) - - -def cleanup_openvpn_access( - range_id: int, - request_uuid: str, - secret_ops: VpnSecretOps, - *, - delete_identity: bool = True, -) -> None: - """Delete every credential for a range generation.""" - secret_ops.delete_generation(range_id, UUID(request_uuid), delete_identity=delete_identity) +def cleanup_openvpn_access(range_id: int, request_uuid: str, secret_ops: VpnSecretOps) -> None: + """Delete the generation's participant profile.""" + secret_ops.delete_profile(range_id, UUID(request_uuid)) __all__ = [ - "OpenVpnPreparation", + "OpenVpnIssuer", + "OpenVpnPoolEndpoint", "VpnSecretOps", "cleanup_openvpn_access", - "finalize_openvpn_access", - "prepare_openvpn_access", - "publish_openvpn_profile", - "range_spec_member_refs", - "verify_openvpn_gateway", + "load_issuer", + "mint_openvpn_profile", + "openvpn_pool_endpoint", ] diff --git a/shifter/engine/provisioner/vpn_secrets.py b/shifter/engine/provisioner/vpn_secrets.py index 140df2cef..ebe3e6a37 100644 --- a/shifter/engine/provisioner/vpn_secrets.py +++ b/shifter/engine/provisioner/vpn_secrets.py @@ -1,14 +1,17 @@ -"""Provider secret-store adapters for OpenVPN generation material.""" +"""Provider secret-store adapter for participant OpenVPN material (#2030, #2480). + +The tenant issuer (CA certificate, CA key and pool tls-crypt key) is created by +the deploy workflow directly in Secret Manager; only the provisioner may read +it. Each range generation stores one participant profile in the dynamic +participant secret location the portal reads at download time. +""" from __future__ import annotations import os -from collections.abc import Callable -from typing import Any, Protocol, cast +from typing import Protocol from uuid import UUID -from botocore.exceptions import ClientError - from cloud.gcp.base import get_project_id, import_google_module from config import is_gce_range_cell_backend, resolve_cloud_provider from gcp_dynamic_secrets import ( @@ -20,116 +23,9 @@ read_or_create, secret_locations, ) -from gcp_vpn_identity import gcp_vpn_gateway_pool_service_account_email -from provisioner_db import get_db_connection from vpn_access import VpnSecretOps -_CREDENTIAL_ACCESSOR_ROLE = "roles/secretmanager.secretAccessor" - - -class _AWSSecretsClient(Protocol): - """Subset of the boto3 Secrets Manager client used by the adapter.""" - - def get_secret_value(self, **kwargs: object) -> dict[str, object]: ... - - def create_secret(self, **kwargs: object) -> dict[str, object]: ... - - def put_secret_value(self, **kwargs: object) -> dict[str, object]: ... - - def describe_secret(self, **kwargs: object) -> dict[str, object]: ... - - def delete_secret(self, **kwargs: object) -> dict[str, object]: ... - - -def _aws_secret_names(range_id: int, generation: UUID) -> dict[str, str]: - """Return the deterministic AWS secret names for one range generation.""" - environment = os.environ.get("ENVIRONMENT", "dev").strip() or "dev" - base = f"shifter/{environment}/range/{range_id}/vpn-{generation}" - return { - "issuer": f"shifter/{environment}/vpn-issuer/range-{range_id}/{generation}", - "server": f"{base}-server", - "profile": f"{base}-profile", - } - - -class AWSVpnSecretOps(VpnSecretOps): - """AWS Secrets Manager implementation with deterministic generation names.""" - - def __init__(self, client: _AWSSecretsClient | None = None) -> None: - if client is None: - import boto3 - - client = boto3.client("secretsmanager", region_name=os.environ.get("AWS_REGION")) - self._client = client - - def _create(self, name: str, payload: str, range_id: int) -> str: - kwargs: dict[str, object] = { - "Name": name, - "SecretString": payload, - "Tags": [ - {"Key": "shifter:system", "Value": "shifter"}, - {"Key": "shifter:range_id", "Value": str(range_id)}, - {"Key": "shifter:credential", "Value": "openvpn"}, - ], - } - kms_key = os.environ.get("SECRETS_KMS_KEY_ARN", "").strip() - if kms_key: - kwargs["KmsKeyId"] = kms_key - try: - response = self._client.create_secret(**kwargs) - except ClientError as exc: - if exc.response.get("Error", {}).get("Code") != "ResourceExistsException": - raise - response = self._client.describe_secret(SecretId=name) - return str(response.get("ARN") or name) - - def _put(self, name: str, payload: str, range_id: int) -> str: - try: - current = self._client.get_secret_value(SecretId=name) - except ClientError as exc: - if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException": - raise - return self._create(name, payload, range_id) - if current.get("SecretString") != payload: - self._client.put_secret_value(SecretId=name, SecretString=payload) - described = self._client.describe_secret(SecretId=name) - return str(described.get("ARN") or name) - - def read_or_create_issuer(self, range_id: int, generation: UUID, payload_factory: Callable[[], str]) -> str: - name = _aws_secret_names(range_id, generation)["issuer"] - try: - response = self._client.get_secret_value(SecretId=name) - value = response.get("SecretString") - if not isinstance(value, str): - raise ValueError("OpenVPN issuer secret must contain a text payload") - return value - except ClientError as exc: - if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException": - raise - payload = payload_factory() - self._create(name, payload, range_id) - response = self._client.get_secret_value(SecretId=name) - value = response.get("SecretString") - if not isinstance(value, str): - raise ValueError("OpenVPN issuer secret was not readable after creation") - return value - - def put_server(self, range_id: int, generation: UUID, payload: str) -> str: - return self._put(_aws_secret_names(range_id, generation)["server"], payload, range_id) - - def put_profile(self, range_id: int, generation: UUID, payload: str) -> str: - return self._put(_aws_secret_names(range_id, generation)["profile"], payload, range_id) - - def _delete(self, name: str) -> None: - try: - self._client.delete_secret(SecretId=name, ForceDeleteWithoutRecovery=True) - except ClientError as exc: - if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException": - raise - - def delete_generation(self, range_id: int, generation: UUID, *, delete_identity: bool = True) -> None: - for name in _aws_secret_names(range_id, generation).values(): - self._delete(name) +_ISSUER_LOCATOR_ENV = "RANGE_OPENVPN_ISSUER_SECRET" class _GCPExceptions(Protocol): @@ -140,6 +36,18 @@ class _GCPExceptions(Protocol): InvalidArgument: type[Exception] +class _GCPPayload(Protocol): + """Secret version payload shape returned by the GCP client.""" + + data: bytes + + +class _GCPAccessResponse(Protocol): + """access_secret_version response shape returned by the GCP client.""" + + payload: _GCPPayload + + class _GCPSecretsClient(Protocol): """Subset of the GCP Secret Manager client used by the adapter.""" @@ -151,33 +59,16 @@ def create_secret(self, *, request: dict[str, object]) -> object: ... def add_secret_version(self, *, request: dict[str, object]) -> object: ... - def set_iam_policy(self, *, request: dict[str, object]) -> object: ... - - def get_iam_policy(self, *, request: dict[str, object]) -> object: ... - def delete_secret(self, *, request: dict[str, object]) -> object: ... -class _GCPPayload(Protocol): - """Secret version payload shape returned by the GCP client.""" - - data: bytes - - -class _GCPAccessResponse(Protocol): - """access_secret_version response shape returned by the GCP client.""" - - payload: _GCPPayload - - -def _gcp_secret_ids(range_id: int, generation: UUID) -> dict[str, str]: - """Return the deterministic GCP secret ids for one range generation.""" - suffix = str(generation).replace("-", "") - return {kind: f"shifter-range-{range_id}-vpn-{suffix}-{kind}" for kind in ("issuer", "server", "profile")} +def _issuer_secret_name() -> str: + """Return the configured tenant issuer secret resource name.""" + return os.environ.get(_ISSUER_LOCATOR_ENV, "").strip() class GCPVpnSecretOps(VpnSecretOps): - """GCP Secret Manager implementation for a GCE range cell.""" + """GCP Secret Manager implementation for the GCE range-cell backend.""" def __init__( self, @@ -185,173 +76,47 @@ def __init__( exceptions: _GCPExceptions | None = None, *, project_id: str | None = None, - gateway_pool_slot: int | None = None, + issuer_secret: str | None = None, ) -> None: self._client = client or import_google_module("google.cloud.secretmanager").SecretManagerServiceClient() self._exceptions = exceptions or import_google_module("google.api_core.exceptions") - self._identity_project_id = project_id or get_project_id() - if not self._identity_project_id: + self._project_id = project_id or get_project_id() + if not self._project_id: raise RuntimeError("GCP project ID is required for OpenVPN secrets") self._storage_project_id = dynamic_secret_project_id() - self._gateway_pool_slot = gateway_pool_slot - - def _reserved_pool_slot(self, range_id: int) -> int: - """Return the OpenVPN gateway pool slot reserved for this range (ADR-008-R7). - - The slot is reserved by ``Range.allocate_vpn_gateway_slot`` at range - creation. The RAES family receives it on its immutable operation input - (ADR-043) and passes it in; the legacy range family still reads the row. - A missing slot means the range was not created with an OpenVPN - capability, so no gateway identity should be produced. - """ - if self._gateway_pool_slot is not None: - return self._gateway_pool_slot - with get_db_connection() as conn, conn.cursor() as cur: - cur.execute( - "SELECT vpn_gateway_pool_slot FROM mission_control_range WHERE id = %s", - (range_id,), - ) - row = cur.fetchone() - if row is None or row[0] is None: - raise RuntimeError( - f"No OpenVPN gateway pool slot reserved for range {range_id}; " - "the range was not created with an OpenVPN capability" - ) - return int(row[0]) - - def _ensure_gateway_identity(self, range_id: int) -> str: - """Return the pre-provisioned pool gateway identity reserved for this range. - - No runtime service-account creation or self-``setIamPolicy`` (ADR-008-R7): - the ``sh-vpn-pool-`` identity already exists and the provisioner - already holds ``serviceAccountUser`` on that specific member. The identity - is per active range; the per-range server secret preserves generation- - scoped isolation. - """ - return gcp_vpn_gateway_pool_service_account_email( - self._identity_project_id, - self._reserved_pool_slot(range_id), - ) + self._issuer_secret = issuer_secret if issuer_secret is not None else _issuer_secret_name() - def _name(self, secret_id: str) -> str: - return f"projects/{self._storage_project_id}/secrets/{secret_id}" - - def _locations(self, range_id: int, generation: UUID, kind: str) -> SecretLocations: - legacy_id = _gcp_secret_ids(range_id, generation)[kind] - credential_class = { - "issuer": DynamicSecretClass.VPN_ISSUER, - "server": DynamicSecretClass.VPN_SERVER, - "profile": DynamicSecretClass.VPN_PROFILE, - }.get(kind) - if credential_class is None: - raise ValueError(f"unsupported VPN secret kind {kind!r}") - canonical_id = canonical_secret_id( - credential_class=credential_class, - scope=f"range-{range_id}-{str(generation).replace('-', '')}", - ) + def _profile_locations(self, range_id: int, generation: UUID) -> SecretLocations: + suffix = str(generation).replace("-", "") return secret_locations( - platform_project_id=self._identity_project_id, + platform_project_id=self._project_id, dynamic_project_id=self._storage_project_id, - legacy_secret_id=legacy_id, - canonical_secret_id=canonical_id, + legacy_secret_id=f"shifter-range-{range_id}-vpn-{suffix}-profile", + canonical_secret_id=canonical_secret_id( + credential_class=DynamicSecretClass.VPN_PROFILE, scope=f"range-{range_id}-{suffix}" + ), ) - def _read(self, name: str) -> str: - response = self._client.access_secret_version(request={"name": f"{name}/versions/latest"}) + def read_issuer(self) -> str: + if not self._issuer_secret.startswith("projects/"): + raise RuntimeError(f"{_ISSUER_LOCATOR_ENV} must name the tenant OpenVPN issuer secret") + response = self._client.access_secret_version(request={"name": f"{self._issuer_secret}/versions/latest"}) return response.payload.data.decode("utf-8") - def _grant_gateway_secret_access(self, name: str, gateway_email: str) -> None: - """Grant the range's pooled gateway identity read access to its server secret. - - The gateway is a pre-provisioned pool service account (ADR-008-R7), so it - is always a valid policy member -- there is no just-created-identity - propagation race to retry around. - """ - member = f"serviceAccount:{gateway_email}" - policy = self._client.get_iam_policy(request={"resource": name}) - bindings = policy.setdefault("bindings", []) if isinstance(policy, dict) else cast(Any, policy).bindings - for binding in bindings: - role = binding.get("role", "") if isinstance(binding, dict) else getattr(binding, "role", "") - if role != _CREDENTIAL_ACCESSOR_ROLE: - continue - members = binding.setdefault("members", []) if isinstance(binding, dict) else binding.members - if member not in members: - members.append(member) - break - else: - if isinstance(bindings, list): - bindings.append({"role": _CREDENTIAL_ACCESSOR_ROLE, "members": [member]}) - else: - bindings.add(role=_CREDENTIAL_ACCESSOR_ROLE, members=[member]) - self._client.set_iam_policy(request={"resource": name, "policy": policy}) - - def _publish_once( - self, - range_id: int, - generation: UUID, - kind: str, - payload: str, - *, - gateway_email: str = "", - ) -> str: - """Publish once, treating any exact existing version as authoritative.""" - locations = self._locations(range_id, generation, kind) + def put_profile(self, range_id: int, generation: UUID, payload: str) -> str: name, _value = read_or_create( - self._client, - self._exceptions, - locations, - lambda: payload, + self._client, self._exceptions, self._profile_locations(range_id, generation), lambda: payload ) - if gateway_email: - self._grant_gateway_secret_access(name, gateway_email) return name - def read_or_create_issuer(self, range_id: int, generation: UUID, payload_factory: Callable[[], str]) -> str: - locations = self._locations(range_id, generation, "issuer") - _name, value = read_or_create( - self._client, - self._exceptions, - locations, - payload_factory, - ) - return value - - def put_server(self, range_id: int, generation: UUID, payload: str) -> str: - gateway_email = self._ensure_gateway_identity(range_id) - return self._publish_once( - range_id, - generation, - "server", - payload, - gateway_email=gateway_email, - ) + def delete_profile(self, range_id: int, generation: UUID) -> None: + delete_all(self._client, self._exceptions, self._profile_locations(range_id, generation)) - def put_profile(self, range_id: int, generation: UUID, payload: str) -> str: - return self._publish_once(range_id, generation, "profile", payload) - - def delete_generation(self, range_id: int, generation: UUID, *, delete_identity: bool = True) -> None: - # The gateway identity is a permanent pooled service account (ADR-008-R7): - # deleting the per-generation server secret revokes this range's access, - # and the freed pool slot returns to the pool on the destroy status - # transition. ``delete_identity`` is retained for interface compatibility - # but there is no per-range SA to delete. Only the secrets are removed. - del delete_identity - for kind in _gcp_secret_ids(range_id, generation): - delete_all(self._client, self._exceptions, self._locations(range_id, generation, kind)) - - def issuer_present(self, range_id: int, generation: UUID) -> bool: - """Return True iff this exact generation's VPN issuer secret still resolves. - - Read-only, generation-fenced existence probe used by warm-pool activation's - negative verification (#28): the issuer secret is keyed by the *generation* - UUID, so unlike a reused deterministic guest-secret name, its presence for - the pre-claim generation is authoritative evidence that pre-claim VPN - material was not revoked. The freshly activated generation uses a different - UUID, so this never conflates old with new. - """ - for name in self._locations(range_id, generation, "issuer").read_refs: + def profile_present(self, range_id: int, generation: UUID) -> bool: + """Return True iff this exact generation's participant profile still resolves.""" + for name in self._profile_locations(range_id, generation).read_refs: try: - self._read(name) + self._client.access_secret_version(request={"name": f"{name}/versions/latest"}) return True except self._exceptions.NotFound: continue @@ -360,12 +125,9 @@ def issuer_present(self, range_id: int, generation: UUID) -> bool: def get_vpn_secret_ops() -> VpnSecretOps: """Return the selected provider's OpenVPN secret adapter.""" - provider = resolve_cloud_provider() - if provider == "aws": - return AWSVpnSecretOps() - if provider == "gcp": + if resolve_cloud_provider() == "gcp": return GCPVpnSecretOps() - raise RuntimeError("The selected provider does not support OpenVPN secrets") + raise RuntimeError("The selected provider does not support the shared OpenVPN pool") def _env(name: str, default: str = "") -> str: @@ -373,57 +135,22 @@ def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() -def _portal_cidrs_configured() -> bool: - """Return whether portal-side network CIDRs are declared.""" - return bool(_env("PORTAL_NETWORK_CIDRS") or _env("PORTAL_VPC_CIDR")) - - -def _aws_openvpn_prerequisites() -> bool: - """Return whether the AWS substrate for the VPN gateway is configured.""" - return bool( - _env("RANGE_VPN_EDGE_SUBNET_ID") - and _env("RANGE_VPN_GATEWAY_PERMISSIONS_BOUNDARY_ARN") - and _env("RANGE_VPN_PROVIDER_ENDPOINT_SECURITY_GROUP_ID") - and _portal_cidrs_configured() - ) - - -def _gcp_openvpn_prerequisites() -> bool: - """Return whether the GCE range-cell substrate for the VPN gateway is configured.""" - scopes = { - value.strip() - for value in os.environ.get( - "GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES", - "https://www.googleapis.com/auth/cloud-platform", - ).split(",") - if value.strip() - } - return bool( - is_gce_range_cell_backend() - and _env("GCP_RANGE_CELL_NETWORK_MODE", "shared-vpc").lower() == "shared-vpc" - and _env("GCP_RANGE_PRIVATE_GOOGLE_ACCESS").lower() in {"1", "true", "yes", "on"} - and _env("GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL") - and _env("GCP_RANGE_LINUX_IMAGE") - and _portal_cidrs_configured() - and "https://www.googleapis.com/auth/cloud-platform" in scopes - ) - - def openvpn_access_enabled() -> bool: - """Return whether this installation has the selected adapter prerequisites. + """Return whether this installation has a deployed shared OpenVPN pool. The participant control is derived from a persisted binding, so leaving the - adapter disabled is fail-closed: no profile is published and the UI does not - offer a download. This also lets existing installations roll the provider - substrate out before enabling it through their already-validated runtime - configuration. + pool unconfigured is fail-closed: no profile is published and the UI offers + no download. The pool lives in the shared range VPC, so a range minted in its + own VPC cannot reach it. """ - provider = resolve_cloud_provider() - if provider == "aws": - return _aws_openvpn_prerequisites() - if provider == "gcp": - return _gcp_openvpn_prerequisites() - return False + return bool( + resolve_cloud_provider() == "gcp" + and is_gce_range_cell_backend() + and _env("GCP_RANGE_CELL_NETWORK_MODE", "shared-vpc").lower() == "shared-vpc" + and _env("RANGE_OPENVPN_ENDPOINT") + and _issuer_secret_name() + and _env("RANGE_OPENVPN_POOL_CIDRS") + ) -__all__ = ["AWSVpnSecretOps", "GCPVpnSecretOps", "get_vpn_secret_ops", "openvpn_access_enabled"] +__all__ = ["GCPVpnSecretOps", "get_vpn_secret_ops", "openvpn_access_enabled"] diff --git a/shifter/shifter_platform/cms/api/urls.py b/shifter/shifter_platform/cms/api/urls.py index 797147b53..b4e2a3c4c 100644 --- a/shifter/shifter_platform/cms/api/urls.py +++ b/shifter/shifter_platform/cms/api/urls.py @@ -17,6 +17,7 @@ runtime_plugins, tenant_packs, views, + vpn_control, ) app_name = "cms" @@ -93,6 +94,9 @@ preparation_adapters.PreparationAdapterGrantListView.as_view(), name="preparation-adapter-grants", ), + path("vpn-control/sessions/", vpn_control.VpnSessionAuthorizeView.as_view(), name="vpn-control-authorize"), + path("vpn-control/heartbeat/", vpn_control.VpnSessionHeartbeatView.as_view(), name="vpn-control-heartbeat"), + path("vpn-control/sessions/end/", vpn_control.VpnSessionEndView.as_view(), name="vpn-control-end"), path( "artifact-preparation/workers//", preparation_workers.PreparationWorkerView.as_view(), diff --git a/shifter/shifter_platform/cms/api/vpn_control.py b/shifter/shifter_platform/cms/api/vpn_control.py new file mode 100644 index 000000000..5be8027d1 --- /dev/null +++ b/shifter/shifter_platform/cms/api/vpn_control.py @@ -0,0 +1,166 @@ +"""Private control transport for the shared OpenVPN server pool (#2480). + +Only the pool's own service account may call these endpoints. The controller +presents a Google-signed identity token for the configured audience; the email +and immutable numeric subject must both match the configured pool identity. A +logged-in user, an API token, or any other workload is refused. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import partial + +from django.conf import settings +from drf_spectacular.utils import extend_schema +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from engine.services import ( + VPN_SESSION_HEARTBEAT_SECONDS, + VPN_SESSION_LEASE_SECONDS, + VpnSessionDenied, + authorize_vpn_session, + end_vpn_session, + renew_vpn_sessions, +) + +_AUTHENTICATION_FAILED = "VPN controller authentication failed" +_MAX_ASSERTION_BYTES = 16_384 + + +@dataclass(frozen=True) +class VpnControllerPrincipal: + """The pool identity; it has no platform user, scopes, or ambient privileges.""" + + is_authenticated: bool = True + + +@dataclass(frozen=True) +class VpnControllerCredential: + """Keep the identity token out of object representations and diagnostics.""" + + assertion: str = field(repr=False) + + +def _verify(assertion: str) -> None: + """Verify the Google identity token against the configured pool identity, or raise.""" + from google.auth.transport.requests import Request as GoogleTransport + + from shared.model_access.control_identity import verify_google_control_assertion + + audience = str(getattr(settings, "VPN_CONTROL_AUDIENCE", "") or "") + email = str(getattr(settings, "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", "") or "") + subject_id = str(getattr(settings, "VPN_CONTROLLER_SERVICE_ACCOUNT_ID", "") or "") + if not (audience and email and subject_id): + raise AuthenticationFailed(_AUTHENTICATION_FAILED) + verify_google_control_assertion( + assertion, + audience=audience, + expected_subject=email, + expected_subject_id=subject_id, + request=partial(GoogleTransport(), timeout=5), + ) + + +class VpnControllerAuthentication(BaseAuthentication): + """Admit only the configured pool service account.""" + + def authenticate(self, request: Request) -> tuple[VpnControllerPrincipal, VpnControllerCredential]: + from shared.model_access import ContractError + + header = get_authorization_header(request) + if not header or len(header) > _MAX_ASSERTION_BYTES: + raise AuthenticationFailed(_AUTHENTICATION_FAILED) + try: + assertion = header.decode("ascii") + _verify(assertion) + except (UnicodeError, ContractError) as exc: + raise AuthenticationFailed(_AUTHENTICATION_FAILED) from exc + return VpnControllerPrincipal(), VpnControllerCredential(assertion) + + def authenticate_header(self, request: Request) -> str: + """Advertise this endpoint's independently scoped bearer challenge.""" + return 'Bearer realm="vpn-control"' + + +class IsVpnController(BasePermission): + """A logged-in platform administrator is still not the pool controller.""" + + def has_permission(self, request: Request, view: APIView) -> bool: + return isinstance(request.user, VpnControllerPrincipal) and isinstance(request.auth, VpnControllerCredential) + + +def _denied(exc: VpnSessionDenied) -> Response: + """Return the closed refusal code; nothing else about the range is disclosed.""" + status = 400 if exc.code == "vpn.invalid_request" else 403 + return Response({"error": exc.code}, status=status) + + +def _body(request: Request) -> dict[str, object]: + """Return the JSON object body or an empty mapping.""" + return request.data if isinstance(request.data, dict) else {} + + +class _VpnControlView(APIView): + authentication_classes = [VpnControllerAuthentication] + permission_classes = [IsVpnController] + + +class VpnSessionAuthorizeView(_VpnControlView): + """Authorize one connection whose certificate OpenVPN already verified.""" + + @extend_schema(exclude=True) + def post(self, request: Request) -> Response: + body = _body(request) + try: + grant = authorize_vpn_session( + common_name=body.get("common_name"), + server=body.get("server"), + client_id=body.get("client_id"), + client_address=body.get("client_address", ""), + ) + except VpnSessionDenied as exc: + return _denied(exc) + return Response( + { + "session": str(grant.session_id), + "target": grant.target_ip, + "ports": list(grant.ports), + "heartbeat_seconds": VPN_SESSION_HEARTBEAT_SECONDS, + "lease_seconds": VPN_SESSION_LEASE_SECONDS, + }, + status=201, + ) + + +class VpnSessionHeartbeatView(_VpnControlView): + """Renew a server's live sessions and return the ones it must disconnect.""" + + @extend_schema(exclude=True) + def post(self, request: Request) -> Response: + body = _body(request) + try: + disconnect = renew_vpn_sessions(server=body.get("server"), session_ids=body.get("sessions")) + except VpnSessionDenied as exc: + return _denied(exc) + return Response( + {"disconnect": [str(value) for value in disconnect], "heartbeat_seconds": VPN_SESSION_HEARTBEAT_SECONDS} + ) + + +class VpnSessionEndView(_VpnControlView): + """Record that one of the server's clients disconnected.""" + + @extend_schema(exclude=True) + def post(self, request: Request) -> Response: + body = _body(request) + try: + end_vpn_session(server=body.get("server"), session_id=body.get("session")) + except VpnSessionDenied as exc: + return _denied(exc) + return Response(status=204) diff --git a/shifter/shifter_platform/config/_env_manifest.py b/shifter/shifter_platform/config/_env_manifest.py index 88dd137e7..934f03d1c 100644 --- a/shifter/shifter_platform/config/_env_manifest.py +++ b/shifter/shifter_platform/config/_env_manifest.py @@ -57,7 +57,7 @@ class EnvBinding: # source-IP attribution. EnvBinding(name="AUDIT_TRUSTED_PROXY_HOPS", default="1", source_file=_SETTINGS_FILE), # Read via `_env_bool(...)`; deployment opt-in for participant OpenVPN - # access (#2030, ADR-039-R10). + # access (#2030, #2480, ADR-039-R10). EnvBinding(name="RANGE_OPENVPN_ENABLED", default="False", source_file=_SETTINGS_FILE), EnvBinding(name="EMAIL_BACKEND", default=None, source_file="config/_email.py"), EnvBinding(name="ENVIRONMENT", default=None, source_file=_SETTINGS_FILE), diff --git a/shifter/shifter_platform/config/env-manifest.json b/shifter/shifter_platform/config/env-manifest.json index ca994da27..b3805cbfa 100644 --- a/shifter/shifter_platform/config/env-manifest.json +++ b/shifter/shifter_platform/config/env-manifest.json @@ -823,8 +823,18 @@ "source_file": "config/settings.py" }, { - "default": "'24'", - "name": "VPN_GATEWAY_POOL_SIZE", + "default": "''", + "name": "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", + "source_file": "config/settings.py" + }, + { + "default": "''", + "name": "VPN_CONTROLLER_SERVICE_ACCOUNT_ID", + "source_file": "config/settings.py" + }, + { + "default": "''", + "name": "VPN_CONTROL_AUDIENCE", "source_file": "config/settings.py" }, { diff --git a/shifter/shifter_platform/config/settings.py b/shifter/shifter_platform/config/settings.py index 106ad5c1b..955ce5f76 100644 --- a/shifter/shifter_platform/config/settings.py +++ b/shifter/shifter_platform/config/settings.py @@ -444,20 +444,20 @@ def _env_int(name: str, default: int) -> int: # Maximum RangeInstance rows the reconciler processes per run (bounded batch). RANGE_RECONCILE_BATCH_SIZE: int = int(os.environ.get("RANGE_RECONCILE_BATCH_SIZE", "100")) -# ADR-008-R7: size of the pre-provisioned GCP OpenVPN gateway service-account -# pool. Each active range that requests OpenVPN reserves one slot -# (Range.allocate_vpn_gateway_slot -> sh-vpn-pool-); this bounds concurrent -# OpenVPN ranges and MUST match the Terraform `vpn_gateway_pool_size` that -# pre-creates the pool SAs. Single isolated tenant / single project. -VPN_GATEWAY_POOL_SIZE: int = int(os.environ.get("VPN_GATEWAY_POOL_SIZE", "24")) - -# ADR-039-R10: deployment opt-in for participant OpenVPN access (#2030). When -# true, a GCE RAES launch whose scenario declares exactly one participant-access -# target mints an OpenVPN capability, so the range gets a per-range gateway VM, -# an external address, and per-range VPN secrets. Off by default: the gateway is -# real cost and an extra failure mode an event may never use. +# ADR-039-R10: deployment opt-in for participant OpenVPN access (#2030, #2480). +# When true, a GCE RAES launch whose scenario declares exactly one +# participant-access target mints an OpenVPN capability. The participant then +# connects through the tenant's shared OpenVPN server pool. RANGE_OPENVPN_ENABLED: bool = _env_bool("RANGE_OPENVPN_ENABLED", False) +# The shared OpenVPN pool controller authenticates to the portal with a +# Google-signed identity token for this audience. The token email and immutable +# numeric subject must match the pool service account (#2480). Unset values +# refuse every controller request. +VPN_CONTROL_AUDIENCE: str = os.environ.get("VPN_CONTROL_AUDIENCE", "") +VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL: str = os.environ.get("VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", "") +VPN_CONTROLLER_SERVICE_ACCOUNT_ID: str = os.environ.get("VPN_CONTROLLER_SERVICE_ACCOUNT_ID", "") + # ------------------------------------------------------------------------------ # CTF Configuration # ------------------------------------------------------------------------------ diff --git a/shifter/shifter_platform/engine/migrations/0088_vpn_server_pool.py b/shifter/shifter_platform/engine/migrations/0088_vpn_server_pool.py new file mode 100644 index 000000000..ea7101b4b --- /dev/null +++ b/shifter/shifter_platform/engine/migrations/0088_vpn_server_pool.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.8 on 2026-10-05 04:43 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0087_range_account_id_range_organization_id_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='range', + name='vpn_gateway_pool_slot', + ), + migrations.CreateModel( + name='VpnSession', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('generation', models.UUIDField()), + ('server', models.CharField(max_length=63)), + ('client_id', models.PositiveBigIntegerField()), + ('state', models.CharField(choices=[('active', 'Active'), ('ended', 'Ended')], default='active', max_length=16)), + ('end_reason', models.CharField(blank=True, choices=[('disconnected', 'Disconnected'), ('superseded', 'Superseded by a newer session'), ('revoked', 'Range access no longer valid'), ('lease_expired', 'Server stopped renewing the session')], default='', max_length=16)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('lease_expires_at', models.DateTimeField()), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('range', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vpn_sessions', to='engine.range')), + ], + options={ + 'db_table': 'engine_vpn_session', + 'indexes': [models.Index(fields=['server', 'state'], name='engine_vpnsession_server_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('state', 'active')), fields=('range',), name='engine_vpn_session_one_active_per_range')], + }, + ), + ] diff --git a/shifter/shifter_platform/engine/models/__init__.py b/shifter/shifter_platform/engine/models/__init__.py index 66acef833..4dd7e88a3 100644 --- a/shifter/shifter_platform/engine/models/__init__.py +++ b/shifter/shifter_platform/engine/models/__init__.py @@ -93,6 +93,7 @@ SharingPoolRevision, ) from ._subnet import Subnet, SubnetAllocation +from ._vpn_session import VpnSession, VpnSessionEndReason, VpnSessionState from ._warm_pool import WarmRangeGeneration __all__ = [ @@ -161,5 +162,8 @@ "SharingPoolRevision", "Subnet", "SubnetAllocation", + "VpnSession", + "VpnSessionEndReason", + "VpnSessionState", "WarmRangeGeneration", ] diff --git a/shifter/shifter_platform/engine/models/_range.py b/shifter/shifter_platform/engine/models/_range.py index 7cc725ce0..86b5e802b 100644 --- a/shifter/shifter_platform/engine/models/_range.py +++ b/shifter/shifter_platform/engine/models/_range.py @@ -129,11 +129,6 @@ class Status(models.TextChoices): help_text="Subnet CIDR (e.g., 10.1.5.0/24)", ) subnet_index = models.PositiveIntegerField(null=True, blank=True, help_text="Unique index for CIDR allocation") - # ADR-008-R7: reserved slot into the pre-provisioned GCP OpenVPN gateway SA - # pool (sh-vpn-pool-); freed implicitly by the destroy/failed status. - vpn_gateway_pool_slot = models.PositiveIntegerField( - null=True, blank=True, help_text="Reserved GCP OpenVPN gateway SA pool slot (single-project pool)" - ) placement_zone = models.CharField(max_length=63, blank=True, default="", help_text="GCE placement zone (#2029)") victim_ip = models.GenericIPAddressField(null=True, blank=True) victim_instance_id = models.CharField( diff --git a/shifter/shifter_platform/engine/models/_range_allocation.py b/shifter/shifter_platform/engine/models/_range_allocation.py index 62a635877..b0f26d5cd 100644 --- a/shifter/shifter_platform/engine/models/_range_allocation.py +++ b/shifter/shifter_platform/engine/models/_range_allocation.py @@ -34,30 +34,3 @@ def allocate_subnet_index(cls: type[Any]) -> int: f"No subnet indices available. Maximum {cls.SUBNET_INDEX_MAX} " "concurrent ranges supported. Destroy some ranges first." ) - - @classmethod - def allocate_vpn_gateway_slot(cls: type[Any]) -> int: - """Reserve the first free OpenVPN gateway slot while holding the range-table lock.""" - from django.conf import settings - from django.db import connection - - pool_size = int(getattr(settings, "VPN_GATEWAY_POOL_SIZE", 0)) - if pool_size <= 0: - raise ValueError("VPN_GATEWAY_POOL_SIZE must be a positive integer to provision OpenVPN ranges") - with transaction.atomic(): - if connection.vendor != "sqlite": - with connection.cursor() as cursor: - cursor.execute("LOCK TABLE mission_control_range IN EXCLUSIVE MODE") - used_slots = set( - cls.objects.exclude(status__in=[cls.Status.DESTROYED, cls.Status.FAILED]) - .exclude(vpn_gateway_pool_slot__isnull=True) - .values_list("vpn_gateway_pool_slot", flat=True) - ) - for slot in range(pool_size): - if slot not in used_slots: - return slot - raise ValueError( - f"OpenVPN gateway pool exhausted. Maximum {pool_size} concurrent OpenVPN " - "ranges supported; increase VPN_GATEWAY_POOL_SIZE (and the Terraform pool) " - "or destroy some ranges first." - ) diff --git a/shifter/shifter_platform/engine/models/_vpn_session.py b/shifter/shifter_platform/engine/models/_vpn_session.py new file mode 100644 index 000000000..2ddfe0b3f --- /dev/null +++ b/shifter/shifter_platform/engine/models/_vpn_session.py @@ -0,0 +1,64 @@ +"""Live participant OpenVPN sessions on the shared server pool (#2480). + +The pool controller asks the Engine to authorize every connection and renews +each live session with a heartbeat. PostgreSQL arbitrates the claim (ADR-063-R3): +at most one active session exists per range, and a newer connection ends the +older one, whose server disconnects it on its next heartbeat. A session row +holds no credential, certificate or profile material. +""" + +from uuid import uuid4 + +from django.db import models +from django.db.models import Q + + +class VpnSessionState(models.TextChoices): + """Lifecycle of one pool session.""" + + ACTIVE = "active", "Active" + ENDED = "ended", "Ended" + + +class VpnSessionEndReason(models.TextChoices): + """Closed reason a session stopped being active.""" + + DISCONNECTED = "disconnected", "Disconnected" + SUPERSEDED = "superseded", "Superseded by a newer session" + REVOKED = "revoked", "Range access no longer valid" + LEASE_EXPIRED = "lease_expired", "Server stopped renewing the session" + + +class VpnSession(models.Model): + """One participant connection to one pool server.""" + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + range = models.ForeignKey("engine.Range", on_delete=models.CASCADE, related_name="vpn_sessions") + # The binding generation the client certificate was minted for. + generation = models.UUIDField() + # Pool server instance name and the OpenVPN client id on that server. + server = models.CharField(max_length=63) + client_id = models.PositiveBigIntegerField() + state = models.CharField(max_length=16, choices=VpnSessionState.choices, default=VpnSessionState.ACTIVE) + end_reason = models.CharField(max_length=16, choices=VpnSessionEndReason.choices, blank=True, default="") + started_at = models.DateTimeField(auto_now_add=True) + lease_expires_at = models.DateTimeField() + ended_at = models.DateTimeField(null=True, blank=True) + + class Meta: + """One active session per range; servers renew their own sessions by name.""" + + db_table = "engine_vpn_session" + constraints = [ + models.UniqueConstraint( + fields=["range"], + condition=Q(state="active"), + name="engine_vpn_session_one_active_per_range", + ), + ] + indexes = [ + models.Index(fields=["server", "state"], name="engine_vpnsession_server_idx"), + ] + + def __str__(self) -> str: + return f"VpnSession {self.id} ({self.state})" diff --git a/shifter/shifter_platform/engine/operation_inputs.py b/shifter/shifter_platform/engine/operation_inputs.py index aa2ac9b11..23e5d3c5d 100644 --- a/shifter/shifter_platform/engine/operation_inputs.py +++ b/shifter/shifter_platform/engine/operation_inputs.py @@ -203,18 +203,16 @@ def _raes_artifact_bindings(target: Range) -> list[ArtifactBinding]: def _raes_remote_access(target: Range) -> RaesRemoteAccess | None: - """Project the range's OpenVPN capability and reserved gateway slot (#2030). + """Project the range's OpenVPN capability (#2030, #2480). Carried on every operation of a range that holds a capability -- including - destroy, which must remove the gateway and its secrets -- so the provisioner - never reads either from ``mission_control_range`` (ADR-043). + destroy, which must delete the generation's profile -- so the provisioner + never reads it from ``mission_control_range`` (ADR-043). """ capability = target.remote_access_capability if capability is None: return None - if target.vpn_gateway_pool_slot is None: - raise ValueError("an OpenVPN range must hold a reserved gateway pool slot") - return parse_remote_access({"capability": capability, "gateway_pool_slot": target.vpn_gateway_pool_slot}) + return parse_remote_access({"capability": capability}) def _raes_input_payload(target: Range, request: Request, *, suppress_access: bool = False) -> dict[str, object]: diff --git a/shifter/shifter_platform/engine/services/__init__.py b/shifter/shifter_platform/engine/services/__init__.py index 243ee2021..ec5b24508 100644 --- a/shifter/shifter_platform/engine/services/__init__.py +++ b/shifter/shifter_platform/engine/services/__init__.py @@ -226,6 +226,15 @@ get_openvpn_profile, has_openvpn_profile, ) +from ._vpn_sessions import ( + VPN_SESSION_HEARTBEAT_SECONDS, + VPN_SESSION_LEASE_SECONDS, + VpnSessionDenied, + VpnSessionGrant, + authorize_vpn_session, + end_vpn_session, + renew_vpn_sessions, +) from ._warm_pool import ( WarmGenerationDraft, active_generation_count, @@ -259,6 +268,8 @@ def reconcile_model_allocations(*, now: datetime | None = None, limit: int = 100 "CLEANUP_UNKNOWN", "CLEANUP_VERIFIED_TERMINAL", "DEFAULT_RETRY_TTL_SECONDS", + "VPN_SESSION_HEARTBEAT_SECONDS", + "VPN_SESSION_LEASE_SECONDS", "CleanupObligation", "CleanupVerificationView", "DispatchGrant", @@ -296,6 +307,8 @@ def reconcile_model_allocations(*, now: datetime | None = None, limit: int = 100 "VpnProfileConflict", "VpnProfileNotFound", "VpnProfileUnavailable", + "VpnSessionDenied", + "VpnSessionGrant", "WarmGenerationDraft", "activate_preparation_grant", "active_generation_count", @@ -309,6 +322,7 @@ def reconcile_model_allocations(*, now: datetime | None = None, limit: int = 100 "assess_declared_event_capacity", "assess_event_capacity", "authenticate_model_access", + "authorize_vpn_session", "begin_range_model_policy_change", "bind_public_operation", "bind_runtime_plugin", @@ -339,6 +353,7 @@ def reconcile_model_allocations(*, now: datetime | None = None, limit: int = 100 "dispatch_created_raes_range", "dispatch_prepared_range_resume", "drain_sharing_binding", + "end_vpn_session", "enqueue_range_activation", "evaluate_operation_result", "exchange_model_enrollment", @@ -430,6 +445,7 @@ def reconcile_model_allocations(*, now: datetime | None = None, limit: int = 100 "release_subnet_reservation", "release_warm_generation_capacity", "renew_continuation_lease", + "renew_vpn_sessions", "request_artifact_preparation", "reserve_model_call", "reserve_request", diff --git a/shifter/shifter_platform/engine/services/_raes_range.py b/shifter/shifter_platform/engine/services/_raes_range.py index f61a4377d..3d62c408d 100644 --- a/shifter/shifter_platform/engine/services/_raes_range.py +++ b/shifter/shifter_platform/engine/services/_raes_range.py @@ -199,7 +199,6 @@ def create_raes_range( range_config=compiled_plan, workspace_id=workspace_id, remote_access_capability=capability, - vpn_gateway_pool_slot=Range.allocate_vpn_gateway_slot() if capability is not None else None, **binding_fields, **egress_fields, ) @@ -252,8 +251,8 @@ def grant_raes_remote_access(request_id: UUID, deadline: datetime | None) -> boo """Mint the claimant's OpenVPN authority on a just-claimed warm generation (#28, #2030). Runs inside the CMS claim transaction after the ownership rehome, so the - activation input composed afterwards carries the capability and the gateway - is realized for the claimant. A warm generation is prepared without VPN, so a + activation input composed afterwards carries the capability and the profile + is minted for the claimant. A warm generation is prepared without VPN, so a persisted capability or binding here is a contract violation. Returns whether a capability was granted. """ @@ -274,7 +273,6 @@ def grant_raes_remote_access(request_id: UUID, deadline: datetime | None) -> boo with transaction.atomic(): updated = Range.objects.filter(pk=range_obj.pk, remote_access_capability__isnull=True).update( remote_access_capability=capability, - vpn_gateway_pool_slot=Range.allocate_vpn_gateway_slot(), updated_at=timezone.now(), ) if updated != 1: diff --git a/shifter/shifter_platform/engine/services/_vpn_sessions.py b/shifter/shifter_platform/engine/services/_vpn_sessions.py new file mode 100644 index 000000000..c4b3dc986 --- /dev/null +++ b/shifter/shifter_platform/engine/services/_vpn_sessions.py @@ -0,0 +1,282 @@ +"""Connect-time authorization and session leases for the shared OpenVPN pool (#2480). + +Each pool server runs a controller that asks this service before it admits a +client whose certificate OpenVPN has already verified. A grant names the one +target address and ports that client may reach. The controller renews its live +sessions with a heartbeat and disconnects every session this service returns. + +PostgreSQL arbitrates the claim: one active session per range, and a newer +connection supersedes the older one. Access is re-validated on every renewal, +so destroy, ownership change, the access deadline, or a newer session ends a +live tunnel within one heartbeat interval. +""" + +from __future__ import annotations + +import ipaddress +import re +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING +from uuid import UUID + +from django.db import transaction +from django.utils import timezone + +from shared.audit import AuditAction, AuditActorType, AuditEntityType, AuditEvent, audit_log +from shared.remote_access import ( + OpenVpnBindingError, + is_raes_member_target, + parse_openvpn_binding, + parse_openvpn_capability, +) + +if TYPE_CHECKING: + from engine.models import Range, VpnSession + +VPN_SESSION_HEARTBEAT_SECONDS = 15 +VPN_SESSION_LEASE_SECONDS = 60 +MAX_HEARTBEAT_SESSIONS = 1000 + +_COMMON_NAME = re.compile(r"participant-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.ASCII) +_SERVER_NAME = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?", re.ASCII) +_CHANNEL_PORTS = {"ssh": 22, "rdp": 3389} +_MAX_CLIENT_ID = 2**63 - 1 +_EXPIRY_BATCH = 200 + + +class VpnSessionDenied(ValueError): + """The connection or request is refused with one closed reason code.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class VpnSessionGrant: + """The single destination one authorized client may reach.""" + + session_id: UUID + target_ip: str + ports: tuple[int, ...] + + +def _require_server(server: object) -> str: + """Accept only a Compute Engine instance name for the reporting server.""" + if not isinstance(server, str) or not _SERVER_NAME.fullmatch(server): + raise VpnSessionDenied("vpn.invalid_request") + return server + + +def _require_generation(common_name: object) -> UUID: + """Return the binding generation a participant certificate was minted for.""" + match = _COMMON_NAME.fullmatch(common_name) if isinstance(common_name, str) else None + if match is None: + raise VpnSessionDenied("vpn.unknown_client") + return UUID(match.group(1)) + + +def _require_client_id(client_id: object) -> int: + """Accept only the non-negative OpenVPN client id of the server.""" + if isinstance(client_id, bool) or not isinstance(client_id, int) or not 0 <= client_id <= _MAX_CLIENT_ID: + raise VpnSessionDenied("vpn.invalid_request") + return client_id + + +def _client_address(value: object) -> str: + """Keep only a well-formed client address for the audit record.""" + try: + return str(ipaddress.ip_address(str(value))) + except ValueError: + return "" + + +def _current_target(range_obj: Range, generation: UUID) -> tuple[str, tuple[int, ...]]: + """Return the target and ports while this generation's access is still valid.""" + from engine.models import Range + + request = range_obj.request + if range_obj.status != Range.Status.READY or request is None or request.request_id != generation: + raise VpnSessionDenied("vpn.access_revoked") + try: + binding = parse_openvpn_binding(range_obj.vpn_access_binding) + capability = parse_openvpn_capability(range_obj.remote_access_capability) + except OpenVpnBindingError as exc: + raise VpnSessionDenied("vpn.access_revoked") from exc + valid = ( + binding.ready + and binding.generation == generation + and binding.owner_user_id == range_obj.user_id + and capability.target_ref == binding.target_ref + and capability.teardown_at > timezone.now() + and is_raes_member_target(binding.target_ref) + ) + if not valid: + raise VpnSessionDenied("vpn.access_revoked") + return _member_destination(range_obj, binding.target_ref) + + +def _member_destination(range_obj: Range, target_ref: str) -> tuple[str, tuple[int, ...]]: + """Resolve the realized participant target to one private address and its declared ports.""" + members = range_obj.provisioned_instances if isinstance(range_obj.provisioned_instances, list) else [] + matches = [ + member + for member in members + if isinstance(member, dict) and member.get("uuid") == target_ref and member.get("role") == "raes-node" + ] + if len(matches) != 1: + raise VpnSessionDenied("vpn.access_revoked") + channels = matches[0].get("participant_access_channels") + ports = ( + tuple(sorted({_CHANNEL_PORTS[c] for c in channels if c in _CHANNEL_PORTS})) + if isinstance(channels, list) + else () + ) + try: + address = ipaddress.ip_address(str(matches[0].get("private_ip", ""))) + except ValueError as exc: + raise VpnSessionDenied("vpn.access_revoked") from exc + if not ports or address.version != 4 or not address.is_private: + raise VpnSessionDenied("vpn.access_revoked") + return str(address), ports + + +def _audit(session: VpnSession, action: str, state: dict[str, str]) -> None: + """Record one pool session event without credential or certificate material.""" + audit_log( + AuditEvent( + entity_type=AuditEntityType.RANGE, + entity_id=session.range_id, + action=action, + actor_type=AuditActorType.SYSTEM, + new_state={"channel": "openvpn", "session": str(session.id), "server": session.server, **state}, + context="range_vpn_session", + ) + ) + + +def _end(session: VpnSession, reason: str) -> None: + """End one active session and audit why.""" + from engine.models import VpnSessionState + + session.state = VpnSessionState.ENDED + session.end_reason = reason + session.ended_at = timezone.now() + session.save(update_fields=["state", "end_reason", "ended_at"]) + _audit(session, AuditAction.DISCONNECT, {"reason": reason}) + + +def authorize_vpn_session( + *, common_name: object, server: object, client_id: object, client_address: object = "" +) -> VpnSessionGrant: + """Admit one verified certificate to its own target, superseding any older session.""" + from engine.models import Range, VpnSession, VpnSessionEndReason, VpnSessionState + + server_name = _require_server(server) + cid = _require_client_id(client_id) + generation = _require_generation(common_name) + with transaction.atomic(): + range_obj = ( + Range.objects.select_for_update(of=("self",)) + .select_related("request") + .filter(request__request_id=generation) + .first() + ) + if range_obj is None: + raise VpnSessionDenied("vpn.unknown_client") + target_ip, ports = _current_target(range_obj, generation) + for previous in VpnSession.objects.select_for_update().filter(range=range_obj, state=VpnSessionState.ACTIVE): + _end(previous, VpnSessionEndReason.SUPERSEDED) + session = VpnSession.objects.create( + range=range_obj, + generation=generation, + server=server_name, + client_id=cid, + lease_expires_at=timezone.now() + timedelta(seconds=VPN_SESSION_LEASE_SECONDS), + ) + _audit(session, AuditAction.CONNECT, {"client_address": _client_address(client_address)}) + return VpnSessionGrant(session_id=session.id, target_ip=target_ip, ports=ports) + + +def _require_session_ids(session_ids: object) -> set[UUID]: + """Accept a bounded list of session identifiers from one heartbeat.""" + if not isinstance(session_ids, list) or len(session_ids) > MAX_HEARTBEAT_SESSIONS: + raise VpnSessionDenied("vpn.invalid_request") + try: + return {UUID(str(value)) for value in session_ids} + except ValueError as exc: + raise VpnSessionDenied("vpn.invalid_request") from exc + + +def _renewed(row: VpnSession, server_name: str, lease: datetime) -> bool: + """Extend one reported session's lease, or end it when access is no longer valid.""" + from engine.models import VpnSessionEndReason, VpnSessionState + + if row.state != VpnSessionState.ACTIVE or row.server != server_name: + return False + try: + _current_target(row.range, row.generation) + except VpnSessionDenied: + _end(row, VpnSessionEndReason.REVOKED) + return False + row.lease_expires_at = lease + row.save(update_fields=["lease_expires_at"]) + return True + + +def _expire_lapsed() -> None: + """End a bounded batch of sessions whose server stopped renewing them, for example after a crash.""" + from engine.models import VpnSession, VpnSessionEndReason, VpnSessionState + + lapsed = VpnSession.objects.select_for_update(skip_locked=True).filter( + state=VpnSessionState.ACTIVE, lease_expires_at__lt=timezone.now() + ) + for row in lapsed[:_EXPIRY_BATCH]: + _end(row, VpnSessionEndReason.LEASE_EXPIRED) + + +def renew_vpn_sessions(*, server: object, session_ids: object) -> tuple[UUID, ...]: + """Renew still-valid sessions this server holds and return the ones it must disconnect.""" + from engine.models import VpnSession, VpnSessionEndReason, VpnSessionState + + server_name = _require_server(server) + reported = _require_session_ids(session_ids) + lease = timezone.now() + timedelta(seconds=VPN_SESSION_LEASE_SECONDS) + with transaction.atomic(): + rows = { + row.id: row + for row in VpnSession.objects.select_for_update(of=("self",)) + .select_related("range__request") + .filter(id__in=reported) + } + kill = {session_id for session_id in reported if session_id not in rows} + kill.update(session_id for session_id, row in rows.items() if not _renewed(row, server_name, lease)) + # A session the server no longer reports has left that server. + for row in ( + VpnSession.objects.select_for_update() + .filter(server=server_name, state=VpnSessionState.ACTIVE) + .exclude(id__in=reported) + ): + _end(row, VpnSessionEndReason.DISCONNECTED) + _expire_lapsed() + return tuple(sorted(kill)) + + +def end_vpn_session(*, server: object, session_id: object) -> None: + """Record that a server's client disconnected; other servers' sessions are untouched.""" + from engine.models import VpnSession, VpnSessionEndReason, VpnSessionState + + server_name = _require_server(server) + try: + identifier = UUID(str(session_id)) + except ValueError as exc: + raise VpnSessionDenied("vpn.invalid_request") from exc + with transaction.atomic(): + row = ( + VpnSession.objects.select_for_update() + .filter(id=identifier, server=server_name, state=VpnSessionState.ACTIVE) + .first() + ) + if row is not None: + _end(row, VpnSessionEndReason.DISCONNECTED) diff --git a/shifter/shifter_platform/shared/raes/operation_input_remote_access.py b/shifter/shifter_platform/shared/raes/operation_input_remote_access.py index 1aa5a551a..65b727261 100644 --- a/shifter/shifter_platform/shared/raes/operation_input_remote_access.py +++ b/shifter/shifter_platform/shared/raes/operation_input_remote_access.py @@ -1,11 +1,10 @@ -"""Validate the optional OpenVPN remote-access authority riding an RAES input (#2030). - -The Engine projects the range's persisted ``remote_access_capability`` and its -reserved gateway pool slot (ADR-039-R10, ADR-008-R7) so the provisioner reads -neither from ``mission_control_range`` (ADR-043). The capability's teardown window -is deliberately not checked here: destroy must still remove the gateway after the -deadline has passed. Provision and activation re-check the window before minting -any credential. +"""Validate the optional OpenVPN remote-access authority riding an RAES input (#2030, #2480). + +The Engine projects the range's persisted ``remote_access_capability`` so the +provisioner never reads it from ``mission_control_range`` (ADR-043). The +capability's teardown window is deliberately not checked here: destroy must +still delete the generation's profile after the deadline has passed. Provision +and activation re-check the window before minting any credential. """ from __future__ import annotations @@ -14,18 +13,17 @@ from shared.remote_access import OpenVpnBindingError, parse_openvpn_capability -from .operation_input_identity import RaesOperationInputError, _require, _require_exact_keys, _require_mapping +from .operation_input_identity import RaesOperationInputError, _require_exact_keys, _require_mapping _FIELD = "raes operation input remote access" -_REMOTE_ACCESS_KEYS = frozenset({"capability", "gateway_pool_slot"}) +_REMOTE_ACCESS_KEYS = frozenset({"capability"}) @dataclass(frozen=True) class RaesRemoteAccess: - """The range's canonical OpenVPN capability and reserved gateway pool slot.""" + """The range's canonical OpenVPN capability.""" capability: dict[str, object] - gateway_pool_slot: int @property def target_ref(self) -> str: @@ -34,7 +32,7 @@ def target_ref(self) -> str: def to_transport(self) -> dict[str, object]: """Return the JSON-serialisable transport shape.""" - return {"capability": dict(self.capability), "gateway_pool_slot": self.gateway_pool_slot} + return {"capability": dict(self.capability)} def parse_remote_access(value: object) -> RaesRemoteAccess: @@ -45,9 +43,4 @@ def parse_remote_access(value: object) -> RaesRemoteAccess: capability = parse_openvpn_capability(obj["capability"]) except OpenVpnBindingError: raise RaesOperationInputError(f"{_FIELD} capability is invalid") from None - slot = obj["gateway_pool_slot"] - _require( - not isinstance(slot, bool) and isinstance(slot, int) and slot >= 0, - f"{_FIELD} gateway pool slot is invalid", - ) - return RaesRemoteAccess(capability=capability.as_dict(), gateway_pool_slot=slot) + return RaesRemoteAccess(capability=capability.as_dict()) diff --git a/shifter/shifter_platform/shared/range_cells.py b/shifter/shifter_platform/shared/range_cells.py index 56d2ea00c..bbf931139 100644 --- a/shifter/shifter_platform/shared/range_cells.py +++ b/shifter/shifter_platform/shared/range_cells.py @@ -20,7 +20,6 @@ from shared.exceptions import ValidationError as RangeCellContractError from shared.persisted_envelope import PAYLOAD_KEY, SPEC_SCHEMA_KEY, SPEC_VERSION, SPEC_VERSION_KEY -from shared.remote_access import parse_openvpn_capability CONTRACT_KEY = "shifter.gcp-vm-range-cell" CONTRACT_VERSION = "1" @@ -42,7 +41,6 @@ "scenario_artifact", "network_bindings", "access_declarations", - "remote_access", } ) _RESULT_KEYS = frozenset({"contract", "contract_version", "capability", "operation", "cell", "members", "access"}) @@ -253,7 +251,6 @@ def build_gcp_vm_range_cell_request( scenario_artifact: dict[str, object], network_bindings: list[dict[str, object]], access_declarations: list[dict[str, object]] | None = None, - remote_access: dict[str, object] | None = None, egress_mode: str = _DEFAULT_EGRESS_MODE, ) -> ContractDict: """Build the only request shape accepted by the GCP VM-cell backend. @@ -272,7 +269,6 @@ def build_gcp_vm_range_cell_request( "scenario_artifact": scenario_artifact, "network_bindings": network_bindings, "access_declarations": access_declarations or [], - "remote_access": remote_access, } ) @@ -292,9 +288,6 @@ def validate_gcp_vm_range_cell_request(value: object) -> ContractDict: request["scenario_artifact"] = validate_scenario_artifact(request["scenario_artifact"]) request["network_bindings"] = _validate_network_bindings(request["network_bindings"]) request["access_declarations"] = _validate_access_declarations(request["access_declarations"]) - request["remote_access"] = ( - parse_openvpn_capability(request["remote_access"]).as_dict() if request["remote_access"] is not None else None - ) return request diff --git a/shifter/shifter_platform/shared/remote_access.py b/shifter/shifter_platform/shared/remote_access.py index 2ba28aa03..0159ab9d6 100644 --- a/shifter/shifter_platform/shared/remote_access.py +++ b/shifter/shifter_platform/shared/remote_access.py @@ -50,6 +50,8 @@ r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" r"(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*\Z" ) +#: Subject common name of the shared pool server certificate; clients pin it (#2480). +OPENVPN_SERVER_NAME = "shifter-openvpn-server" _INLINE_BLOCKS = frozenset({"ca", "cert", "key", "tls-crypt"}) _NO_ARGUMENT_DIRECTIVES = frozenset({"client", "nobind", "persist-key", "persist-tun", "auth-nocache"}) _ONE_ARGUMENT_DIRECTIVES = { @@ -370,6 +372,7 @@ def _allowed_directive_forms(endpoint: str, port: int) -> dict[str, set[tuple[st ) forms["remote"] = {(endpoint, str(port))} forms["data-ciphers"] = {("AES-256-GCM:AES-128-GCM",)} + forms["verify-x509-name"] = {(OPENVPN_SERVER_NAME, "name")} return forms @@ -433,7 +436,16 @@ def validate_openvpn_profile_for_endpoint(profile: str, endpoint: str, port: int if len(encoded) > OPENVPN_PROFILE_MAX_BYTES: raise OpenVpnBindingError("profile exceeds the maximum size") seen_blocks, seen_directives = _scan_profile(profile, endpoint, port) - required_directives = {"client", "dev", "proto", "remote", "nobind", "remote-cert-tls", "auth-nocache"} + required_directives = { + "client", + "dev", + "proto", + "remote", + "nobind", + "remote-cert-tls", + "verify-x509-name", + "auth-nocache", + } if not required_directives.issubset(seen_directives) or seen_blocks != _INLINE_BLOCKS: raise OpenVpnBindingError("profile is missing required directives or inline credentials") return encoded diff --git a/shifter/shifter_platform/tests/cms/raes/test_dispatch.py b/shifter/shifter_platform/tests/cms/raes/test_dispatch.py index 811c816e0..592006524 100644 --- a/shifter/shifter_platform/tests/cms/raes/test_dispatch.py +++ b/shifter/shifter_platform/tests/cms/raes/test_dispatch.py @@ -284,7 +284,6 @@ def test_an_admitted_launch_mints_vpn_bounded_by_the_lease_ceiling(self, user): assert range_obj.remote_access_capability["target_ref"] == f"{self._NODE}#0" assert range_obj.remote_access_capability["teardown_at"] == ceiling.isoformat().replace("+00:00", "Z") - assert range_obj.vpn_gateway_pool_slot is not None def test_a_launch_without_a_lease_ceiling_mints_nothing(self, user): range_obj = self._launch(user, lease_ceiling=None, backend_admission=self._gce()) diff --git a/shifter/shifter_platform/tests/cms/test_range_vpn.py b/shifter/shifter_platform/tests/cms/test_range_vpn.py index d82f81925..7e8ab36a5 100644 --- a/shifter/shifter_platform/tests/cms/test_range_vpn.py +++ b/shifter/shifter_platform/tests/cms/test_range_vpn.py @@ -17,7 +17,8 @@ User = get_user_model() PROFILE = ( "client\ndev tun\nproto udp\nremote vpn.example.test 1194\nnobind\npersist-key\npersist-tun\n" - "remote-cert-tls server\nauth-nocache\nverb 3\n\nCA\n\n\nCERT\n\n" + "remote-cert-tls server\nverify-x509-name shifter-openvpn-server name\n" + "auth-nocache\nverb 3\n\nCA\n\n\nCERT\n\n" "\nKEY\n\n\nTLS\n\n" ) diff --git a/shifter/shifter_platform/tests/cms/test_vpn_control_api.py b/shifter/shifter_platform/tests/cms/test_vpn_control_api.py new file mode 100644 index 000000000..955d9aafb --- /dev/null +++ b/shifter/shifter_platform/tests/cms/test_vpn_control_api.py @@ -0,0 +1,174 @@ +"""The shared OpenVPN pool controller's private API (#2480). + +Only the configured pool service account may call it. Google's token +verification is the external boundary and is the only thing replaced here; +ranges and sessions are real rows. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient + +from engine.models import Range, Request, VpnSession, VpnSessionState +from shared.remote_access import bind_openvpn_realization, build_openvpn_capability + +pytestmark = pytest.mark.django_db + +_AUDIENCE = "https://portal.example.com/vpn-control" +_EMAIL = "vpn-pool@example-project.iam.gserviceaccount.com" +_SUBJECT = "123456789012345678901" +_TARGET = "provision.node.kali#0" +_SIGNED_ASSERTION = "signed-token" +_POOL_IDENTITY = { + "VPN_CONTROL_AUDIENCE": _AUDIENCE, + "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL": _EMAIL, + "VPN_CONTROLLER_SERVICE_ACCOUNT_ID": _SUBJECT, +} + + +@pytest.fixture +def pool_identity(settings): + """Configure the pool service account the API admits.""" + for name, value in _POOL_IDENTITY.items(): + setattr(settings, name, value) + + +@pytest.fixture +def google_claims(monkeypatch, pool_identity): + """Replace only Google's signature check; the claims it returns are under test control.""" + claims = {"email": _EMAIL, "email_verified": True, "sub": _SUBJECT} + seen = [] + + def verify(token, request, audience=None): + seen.append((token, audience)) + if token != "signed-token": + raise ValueError("bad signature") + return dict(claims) + + monkeypatch.setattr("google.oauth2.id_token.verify_oauth2_token", verify) + return claims, seen + + +def _ready_range() -> Range: + user = get_user_model().objects.create_user(username=f"{uuid4()}@example.com") + request = Request.objects.create(request_id=uuid4(), request_type="range", user=user) + realization = { + "generation": str(request.request_id), + "target_ref": _TARGET, + "endpoint": "203.0.113.7", + "port": 1194, + "secret_ref": "projects/p/secrets/profile", + } + return Range.objects.create( + workspace_id=1, + request=request, + user=user, + status=Range.Status.READY, + range_backend="gce", + remote_access_capability=build_openvpn_capability(_TARGET, datetime.now(UTC) + timedelta(days=2)), + vpn_access_binding=bind_openvpn_realization(realization, user.id), + provisioned_instances=[ + {"uuid": _TARGET, "role": "raes-node", "private_ip": "10.50.3.3", "participant_access_channels": ["ssh"]} + ], + ) + + +def _post(name: str, body: dict, *, credential: str | None = _SIGNED_ASSERTION): + client = APIClient() + if credential is not None: + client.credentials(HTTP_AUTHORIZATION=f"Bearer {credential}") + return client.post(reverse(f"v1:cms:{name}"), body, format="json") + + +class TestControllerIdentity: + @pytest.mark.parametrize( + "claim", + [ + {"email": "other@example-project.iam.gserviceaccount.com"}, + {"sub": "100000000000000000001"}, + {"email_verified": False}, + ], + ids=["other-email", "other-subject", "unverified"], + ) + def test_any_other_identity_is_refused(self, google_claims, claim): + claims, _seen = google_claims + claims.update(claim) + + response = _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": []}) + + assert response.status_code in (401, 403) + + def test_a_bad_signature_or_missing_token_is_refused(self, google_claims): + assert _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": []}, credential="forged").status_code in ( + 401, + 403, + ) + assert _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": []}, credential=None).status_code in ( + 401, + 403, + ) + + def test_a_logged_in_administrator_is_not_the_controller(self, google_claims): + admin = get_user_model().objects.create_superuser(username="admin@example.com", password="x" * 16) + client = APIClient() + client.force_login(admin) + + response = client.post( + reverse("v1:cms:vpn-control-heartbeat"), {"server": "vpn-a", "sessions": []}, format="json" + ) + + assert response.status_code in (401, 403) + + def test_the_token_is_checked_against_the_configured_audience(self, google_claims): + _claims, seen = google_claims + _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": []}) + assert seen == [("signed-token", _AUDIENCE)] + + +def test_an_unconfigured_installation_refuses_every_controller_request(google_claims, settings): + for name in _POOL_IDENTITY: + setattr(settings, name, "") + response = _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": []}) + assert response.status_code in (401, 403) + + +class TestSessionLifecycle: + def test_authorize_heartbeat_and_disconnect(self, google_claims): + range_obj = _ready_range() + + granted = _post( + "vpn-control-authorize", + { + "common_name": f"participant-{range_obj.request.request_id}", + "server": "vpn-a", + "client_id": 4, + "client_address": "198.51.100.20", + }, + ) + assert granted.status_code == 201 + body = granted.json() + assert (body["target"], body["ports"]) == ("10.50.3.3", [22]) + assert body["heartbeat_seconds"] < body["lease_seconds"] + + beat = _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": [body["session"]]}) + assert beat.status_code == 200 + assert beat.json()["disconnect"] == [] + + ended = _post("vpn-control-end", {"server": "vpn-a", "session": body["session"]}) + assert ended.status_code == 204 + assert VpnSession.objects.get(pk=body["session"]).state == VpnSessionState.ENDED + + def test_refusals_disclose_only_a_closed_code(self, google_claims): + unknown = _post( + "vpn-control-authorize", {"common_name": f"participant-{uuid4()}", "server": "vpn-a", "client_id": 1} + ) + assert (unknown.status_code, unknown.json()) == (403, {"error": "vpn.unknown_client"}) + + malformed = _post("vpn-control-heartbeat", {"server": "vpn-a", "sessions": "x"}) + assert (malformed.status_code, malformed.json()) == (400, {"error": "vpn.invalid_request"}) diff --git a/shifter/shifter_platform/tests/ctf/test_vpn_profile_api.py b/shifter/shifter_platform/tests/ctf/test_vpn_profile_api.py index 65ca605fe..d2e90793e 100644 --- a/shifter/shifter_platform/tests/ctf/test_vpn_profile_api.py +++ b/shifter/shifter_platform/tests/ctf/test_vpn_profile_api.py @@ -30,7 +30,7 @@ "nobind\n" "persist-key\n" "persist-tun\n" - "remote-cert-tls server\n" + "remote-cert-tls server\nverify-x509-name shifter-openvpn-server name\n" "auth-nocache\n" "verb 3\n" "\nTEST-CA\n\n" diff --git a/shifter/shifter_platform/tests/engine/services/test_raes_openvpn_access_2030.py b/shifter/shifter_platform/tests/engine/services/test_raes_openvpn_access_2030.py index 273d1ebbc..02b1bc073 100644 --- a/shifter/shifter_platform/tests/engine/services/test_raes_openvpn_access_2030.py +++ b/shifter/shifter_platform/tests/engine/services/test_raes_openvpn_access_2030.py @@ -2,8 +2,8 @@ Covers the Engine seams end to end: -* create-time minting against the persisted participant access, the gateway pool - slot, the GCE-only fail-closed gate, and the idempotent-replay guard; +* create-time minting against the persisted participant access, the GCE-only + fail-closed gate, and the idempotent-replay guard; * the warm-claim grant; * the operation-input projection the provisioner realizes from; * binding the owner-free realization from the terminal result to the range owner; @@ -87,13 +87,11 @@ def _create(*, deadline: datetime | None, access=None, backend: BackendAdmission class TestCreateMinting: - def test_an_admitted_launch_mints_the_sole_target_and_reserves_distinct_slots(self): + def test_an_admitted_launch_mints_the_sole_target(self): first = _create(deadline=_deadline()) - second = _create(deadline=_deadline()) assert first.remote_access_capability["target_ref"] == _TARGET assert first.remote_access_capability["channel"] == "openvpn" - assert {first.vpn_gateway_pool_slot, second.vpn_gateway_pool_slot} == {0, 1} @pytest.mark.parametrize( "access", @@ -105,12 +103,10 @@ def test_an_admitted_launch_mints_the_sole_target_and_reserves_distinct_slots(se def test_a_launch_without_a_single_target_mints_nothing(self, access): range_obj = _create(deadline=_deadline(), access=access) assert range_obj.remote_access_capability is None - assert range_obj.vpn_gateway_pool_slot is None def test_a_launch_without_a_deadline_mints_nothing(self): range_obj = _create(deadline=None) assert range_obj.remote_access_capability is None - assert range_obj.vpn_gateway_pool_slot is None def test_a_requested_deadline_on_a_backend_without_a_gateway_fails_closed(self): request_id = uuid4() @@ -148,7 +144,6 @@ def test_a_claimed_generation_receives_the_claimants_authority_once(self): assert grant_raes_remote_access(range_obj.request.request_id, _deadline()) is True range_obj.refresh_from_db() assert range_obj.remote_access_capability["target_ref"] == _TARGET - assert range_obj.vpn_gateway_pool_slot is not None with pytest.raises(ValueError, match="before its claim"): grant_raes_remote_access(range_obj.request.request_id, _deadline()) @@ -157,7 +152,7 @@ def test_no_deadline_grants_nothing(self): class _Range: - """A launched RAES range holding a capability, a slot, and declared access.""" + """A launched RAES range holding a capability and declared access.""" def __init__(self, *, capability: bool = True, owner=None, status=ResourceStatus.PENDING.value): self.request_id = uuid4() @@ -173,7 +168,6 @@ def __init__(self, *, capability: bool = True, owner=None, status=ResourceStatus range_backend="gce", provisioner_operation_id=self.operation_id, remote_access_capability=build_openvpn_capability(_TARGET, _deadline()) if capability else None, - vpn_gateway_pool_slot=3 if capability else None, ) for binding in _access(): RaesParticipantAccessBinding.objects.create( @@ -251,11 +245,11 @@ def _disposition(row: OperationResultInbox) -> str: class TestOperationInputProjection: - def test_provision_and_destroy_carry_the_capability_and_slot(self): + def test_provision_and_destroy_carry_the_capability(self): fx = _Range() provision = fx.input("provision").remote_access - assert (provision.target_ref, provision.gateway_pool_slot) == (_TARGET, 3) + assert provision.target_ref == _TARGET fx.range.status = ResourceStatus.DESTROYING.value fx.range.save(update_fields=["status"]) assert fx.input("destroy").remote_access == provision @@ -282,12 +276,6 @@ def test_a_warm_prepare_provision_is_quarantined_without_vpn(self): assert payload.remote_access is None assert payload.access_bindings == () - def test_a_capability_without_a_reserved_slot_fails_closed(self): - fx = _Range() - Range.objects.filter(pk=fx.range.pk).update(vpn_gateway_pool_slot=None) - with pytest.raises(ValueError, match="gateway pool slot"): - fx.input() - class TestRealizationBinding: def test_ready_binds_the_realization_to_the_range_owner(self): diff --git a/shifter/shifter_platform/tests/engine/services/test_vpn_profile.py b/shifter/shifter_platform/tests/engine/services/test_vpn_profile.py index 453dbd73c..e47525d17 100644 --- a/shifter/shifter_platform/tests/engine/services/test_vpn_profile.py +++ b/shifter/shifter_platform/tests/engine/services/test_vpn_profile.py @@ -26,7 +26,7 @@ "nobind\n" "persist-key\n" "persist-tun\n" - "remote-cert-tls server\n" + "remote-cert-tls server\nverify-x509-name shifter-openvpn-server name\n" "auth-nocache\n" "verb 3\n" "\nTEST-CA\n\n" diff --git a/shifter/shifter_platform/tests/engine/services/test_vpn_sessions.py b/shifter/shifter_platform/tests/engine/services/test_vpn_sessions.py new file mode 100644 index 000000000..75b4039f5 --- /dev/null +++ b/shifter/shifter_platform/tests/engine/services/test_vpn_sessions.py @@ -0,0 +1,219 @@ +"""Connect-time authorization and leases for the shared OpenVPN pool (#2480). + +Real Range and VpnSession rows; no first-party mocks. Each case is one +scenario the pool controller drives: admit, refuse, supersede, renew, revoke, +disconnect, and lease expiry after a server stops reporting. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from engine.models import Range, Request, VpnSession, VpnSessionEndReason, VpnSessionState +from engine.services import VpnSessionDenied, authorize_vpn_session, end_vpn_session, renew_vpn_sessions +from shared.remote_access import bind_openvpn_realization, build_openvpn_capability + +pytestmark = pytest.mark.django_db + +_TARGET = "provision.node.kali#0" + + +def _ready_range(*, target_ip: str = "10.50.3.3", channels=("ssh", "rdp"), days: int = 2) -> Range: + user = get_user_model().objects.create_user(username=f"{uuid4()}@example.com") + request = Request.objects.create(request_id=uuid4(), request_type="range", user=user) + realization = { + "generation": str(request.request_id), + "target_ref": _TARGET, + "endpoint": "203.0.113.7", + "port": 1194, + "secret_ref": "projects/p/secrets/profile", + } + return Range.objects.create( + workspace_id=1, + request=request, + user=user, + status=Range.Status.READY, + range_backend="gce", + remote_access_capability=build_openvpn_capability(_TARGET, datetime.now(UTC) + timedelta(days=days)), + vpn_access_binding=bind_openvpn_realization(realization, user.id), + provisioned_instances=[ + { + "uuid": _TARGET, + "role": "raes-node", + "private_ip": target_ip, + "participant_access_channels": list(channels), + }, + { + "uuid": "provision.node.dc#0", + "role": "raes-node", + "private_ip": "10.50.3.4", + "participant_access_channels": [], + }, + ], + ) + + +def _connect(range_obj: Range, *, server: str = "vpn-a", client_id: int = 1): + return authorize_vpn_session( + common_name=f"participant-{range_obj.request.request_id}", + server=server, + client_id=client_id, + client_address="198.51.100.20", + ) + + +class TestAuthorize: + def test_a_current_generation_reaches_only_its_target_and_declared_ports(self): + range_obj = _ready_range() + + grant = _connect(range_obj) + + assert (grant.target_ip, grant.ports) == ("10.50.3.3", (22, 3389)) + session = VpnSession.objects.get(pk=grant.session_id) + assert (session.state, session.server, session.client_id) == (VpnSessionState.ACTIVE, "vpn-a", 1) + assert session.generation == range_obj.request.request_id + assert session.lease_expires_at > timezone.now() + + def test_a_newer_connection_supersedes_the_older_one(self): + range_obj = _ready_range() + first = _connect(range_obj, server="vpn-a", client_id=1) + + second = _connect(range_obj, server="vpn-b", client_id=9) + + old = VpnSession.objects.get(pk=first.session_id) + assert (old.state, old.end_reason) == (VpnSessionState.ENDED, VpnSessionEndReason.SUPERSEDED) + assert VpnSession.objects.filter(range=range_obj, state=VpnSessionState.ACTIVE).get().pk == second.session_id + # The old server learns of it on its next heartbeat. + assert renew_vpn_sessions(server="vpn-a", session_ids=[str(first.session_id)]) == (first.session_id,) + + @pytest.mark.parametrize( + "change", + [ + pytest.param( + lambda r: Range.objects.filter(pk=r.pk).update(status=Range.Status.DESTROYING), id="not-ready" + ), + pytest.param( + lambda r: Range.objects.filter(pk=r.pk).update( + user=get_user_model().objects.create_user(username=f"{uuid4()}@example.com") + ), + id="new-owner", + ), + pytest.param(lambda r: Range.objects.filter(pk=r.pk).update(vpn_access_binding=None), id="no-binding"), + pytest.param(lambda r: Range.objects.filter(pk=r.pk).update(provisioned_instances=[]), id="target-gone"), + ], + ) + def test_access_that_is_no_longer_current_is_refused(self, change): + range_obj = _ready_range() + change(range_obj) + + with pytest.raises(VpnSessionDenied) as denied: + _connect(range_obj) + + assert denied.value.code == "vpn.access_revoked" + assert not VpnSession.objects.exists() + + def test_an_expired_access_deadline_is_refused(self): + range_obj = _ready_range() + expired = build_openvpn_capability(_TARGET, datetime.now(UTC) + timedelta(days=1)) + expired["teardown_at"] = "2020-01-01T00:00:00Z" + Range.objects.filter(pk=range_obj.pk).update(remote_access_capability=expired) + + with pytest.raises(VpnSessionDenied, match=r"vpn\.access_revoked"): + _connect(range_obj) + + @pytest.mark.parametrize("target_ip", ["8.8.8.8", "not-an-ip"]) + def test_a_non_private_target_is_refused(self, target_ip): + with pytest.raises(VpnSessionDenied, match=r"vpn\.access_revoked"): + _connect(_ready_range(target_ip=target_ip)) + + @pytest.mark.parametrize( + ("kwargs", "code"), + [ + ({"common_name": "participant-not-a-uuid"}, "vpn.unknown_client"), + ({"common_name": f"participant-{uuid4()}"}, "vpn.unknown_client"), + ({"server": "Bad_Name"}, "vpn.invalid_request"), + ({"client_id": -1}, "vpn.invalid_request"), + ({"client_id": True}, "vpn.invalid_request"), + ], + ) + def test_malformed_or_unknown_requests_are_refused(self, kwargs, code): + range_obj = _ready_range() + request = { + "common_name": f"participant-{range_obj.request.request_id}", + "server": "vpn-a", + "client_id": 1, + **kwargs, + } + with pytest.raises(VpnSessionDenied) as denied: + authorize_vpn_session(**request) + assert denied.value.code == code + + +class TestRenewAndEnd: + def test_a_valid_session_is_renewed_and_kept(self): + range_obj = _ready_range() + grant = _connect(range_obj) + VpnSession.objects.filter(pk=grant.session_id).update(lease_expires_at=timezone.now() + timedelta(seconds=5)) + + assert renew_vpn_sessions(server="vpn-a", session_ids=[str(grant.session_id)]) == () + + assert VpnSession.objects.get(pk=grant.session_id).lease_expires_at > timezone.now() + timedelta(seconds=30) + + def test_destroying_the_range_revokes_the_live_session(self): + range_obj = _ready_range() + grant = _connect(range_obj) + Range.objects.filter(pk=range_obj.pk).update(status=Range.Status.DESTROYING) + + assert renew_vpn_sessions(server="vpn-a", session_ids=[str(grant.session_id)]) == (grant.session_id,) + + session = VpnSession.objects.get(pk=grant.session_id) + assert (session.state, session.end_reason) == (VpnSessionState.ENDED, VpnSessionEndReason.REVOKED) + + def test_a_server_cannot_keep_sessions_it_does_not_own_or_that_do_not_exist(self): + range_obj = _ready_range() + grant = _connect(range_obj, server="vpn-a") + unknown = uuid4() + + kill = renew_vpn_sessions(server="vpn-b", session_ids=[str(grant.session_id), str(unknown)]) + + assert set(kill) == {grant.session_id, unknown} + assert VpnSession.objects.get(pk=grant.session_id).state == VpnSessionState.ACTIVE + + def test_an_unreported_session_has_left_the_server(self): + range_obj = _ready_range() + grant = _connect(range_obj, server="vpn-a") + + assert renew_vpn_sessions(server="vpn-a", session_ids=[]) == () + + session = VpnSession.objects.get(pk=grant.session_id) + assert (session.state, session.end_reason) == (VpnSessionState.ENDED, VpnSessionEndReason.DISCONNECTED) + + def test_a_crashed_servers_sessions_expire_on_another_servers_heartbeat(self): + lapsed = _connect(_ready_range(), server="vpn-dead") + VpnSession.objects.filter(pk=lapsed.session_id).update(lease_expires_at=timezone.now() - timedelta(seconds=1)) + + renew_vpn_sessions(server="vpn-a", session_ids=[]) + + session = VpnSession.objects.get(pk=lapsed.session_id) + assert (session.state, session.end_reason) == (VpnSessionState.ENDED, VpnSessionEndReason.LEASE_EXPIRED) + + def test_a_disconnect_ends_only_the_reporting_servers_session(self): + range_obj = _ready_range() + grant = _connect(range_obj, server="vpn-a") + + end_vpn_session(server="vpn-b", session_id=str(grant.session_id)) + assert VpnSession.objects.get(pk=grant.session_id).state == VpnSessionState.ACTIVE + + end_vpn_session(server="vpn-a", session_id=str(grant.session_id)) + session = VpnSession.objects.get(pk=grant.session_id) + assert (session.state, session.end_reason) == (VpnSessionState.ENDED, VpnSessionEndReason.DISCONNECTED) + + @pytest.mark.parametrize("ids", [["not-a-uuid"], [str(uuid4()) for _ in range(1001)], "not-a-list"]) + def test_malformed_heartbeats_are_refused(self, ids): + with pytest.raises(VpnSessionDenied, match=r"vpn\.invalid_request"): + renew_vpn_sessions(server="vpn-a", session_ids=ids) diff --git a/shifter/shifter_platform/tests/integration/engine/test_range_lifecycle.py b/shifter/shifter_platform/tests/integration/engine/test_range_lifecycle.py index 699917aa4..bd42413a7 100644 --- a/shifter/shifter_platform/tests/integration/engine/test_range_lifecycle.py +++ b/shifter/shifter_platform/tests/integration/engine/test_range_lifecycle.py @@ -10,7 +10,6 @@ import pytest from django.contrib.auth import get_user_model -from django.test import override_settings from engine.models import Range, Request from engine.services import ( @@ -429,73 +428,6 @@ def test_fills_gap_in_indices(self, user): assert index == 2 -# ============================================================================= -# Range.allocate_vpn_gateway_slot integration tests (ADR-008-R7) -# ============================================================================= - - -@pytest.mark.django_db(transaction=True) -class TestVpnGatewaySlotAllocationIntegration: - """Integration tests for Range.allocate_vpn_gateway_slot with a real DB.""" - - def test_allocates_first_slot_zero(self, user): - assert Range.allocate_vpn_gateway_slot() == 0 - - def test_allocates_next_free_slot(self, user): - Range.objects.create( - workspace_id=_WORKSPACE_ID, uuid=uuid.uuid4(), user=user, status=Range.Status.READY, vpn_gateway_pool_slot=0 - ) - assert Range.allocate_vpn_gateway_slot() == 1 - - def test_reuses_slot_from_destroyed_range(self, user): - Range.objects.create( - workspace_id=_WORKSPACE_ID, - uuid=uuid.uuid4(), - user=user, - status=Range.Status.DESTROYED, - vpn_gateway_pool_slot=0, - ) - assert Range.allocate_vpn_gateway_slot() == 0 - - def test_reuses_slot_from_failed_range(self, user): - Range.objects.create( - workspace_id=_WORKSPACE_ID, - uuid=uuid.uuid4(), - user=user, - status=Range.Status.FAILED, - vpn_gateway_pool_slot=0, - ) - assert Range.allocate_vpn_gateway_slot() == 0 - - def test_ignores_ranges_without_a_slot(self, user): - Range.objects.create( - workspace_id=_WORKSPACE_ID, - uuid=uuid.uuid4(), - user=user, - status=Range.Status.READY, - vpn_gateway_pool_slot=None, - ) - assert Range.allocate_vpn_gateway_slot() == 0 - - @override_settings(VPN_GATEWAY_POOL_SIZE=2) - def test_raises_when_pool_exhausted(self, user): - for slot in range(2): - Range.objects.create( - workspace_id=_WORKSPACE_ID, - uuid=uuid.uuid4(), - user=user, - status=Range.Status.READY, - vpn_gateway_pool_slot=slot, - ) - with pytest.raises(ValueError, match="pool exhausted"): - Range.allocate_vpn_gateway_slot() - - @override_settings(VPN_GATEWAY_POOL_SIZE=0) - def test_raises_when_pool_unset(self, user): - with pytest.raises(ValueError, match="VPN_GATEWAY_POOL_SIZE"): - Range.allocate_vpn_gateway_slot() - - # ============================================================================= # Range.get_active_for_user integration tests # ============================================================================= diff --git a/shifter/shifter_platform/tests/mission_control/test_vpn_profile_api.py b/shifter/shifter_platform/tests/mission_control/test_vpn_profile_api.py index 94c34dc4e..fd5efca16 100644 --- a/shifter/shifter_platform/tests/mission_control/test_vpn_profile_api.py +++ b/shifter/shifter_platform/tests/mission_control/test_vpn_profile_api.py @@ -26,7 +26,8 @@ URL = "/api/v1/mission-control/range/vpn-profile/" PROFILE = ( "client\ndev tun\nproto udp\nremote vpn.example.test 1194\nnobind\npersist-key\npersist-tun\n" - "remote-cert-tls server\nauth-nocache\nverb 3\n\nCA\n\n\nCERT\n\n" + "remote-cert-tls server\nverify-x509-name shifter-openvpn-server name\n" + "auth-nocache\nverb 3\n\nCA\n\n\nCERT\n\n" "\nKEY\n\n\nTLS\n\n" ) diff --git a/shifter/shifter_platform/tests/shared/raes/test_raes_operation_input.py b/shifter/shifter_platform/tests/shared/raes/test_raes_operation_input.py index 75921b807..f57892480 100644 --- a/shifter/shifter_platform/tests/shared/raes/test_raes_operation_input.py +++ b/shifter/shifter_platform/tests/shared/raes/test_raes_operation_input.py @@ -278,7 +278,7 @@ def test_duplicate_target_fails_closed(self): class TestRemoteAccess: - """The range's OpenVPN capability and gateway slot ride the input (#2030).""" + """The range's OpenVPN capability rides the input (#2030, #2480).""" _NODE = "provision.node.kali" @@ -289,9 +289,9 @@ def _access(self) -> tuple[ParticipantAccessBinding, ...]: ), ) - def _remote(self, target: str | None = None, slot: int = 2) -> RaesRemoteAccess: + def _remote(self, target: str | None = None) -> RaesRemoteAccess: capability = build_openvpn_capability(target or f"{self._NODE}#0", datetime.now(UTC) + timedelta(days=1)) - return RaesRemoteAccess(capability=capability, gateway_pool_slot=slot) + return RaesRemoteAccess(capability=capability) def test_absent_without_a_capability_and_round_trips_with_one(self): assert "remote_access" not in _built() @@ -306,24 +306,16 @@ def test_a_target_outside_the_declared_participant_access_is_rejected(self): with pytest.raises(RaesOperationInputError, match="declared participant-access target"): _built(access_bindings=self._access(), remote_access=self._remote(target="provision.node.other#0")) - @pytest.mark.parametrize( - ("field", "value"), - [ - ("gateway_pool_slot", -1), - ("gateway_pool_slot", True), - ("gateway_pool_slot", "2"), - ("capability", {"version": "openvpn-capability-v1"}), - ], - ) - def test_a_tampered_projection_is_rejected(self, field, value): + def test_a_tampered_projection_is_rejected(self): payload = _built(access_bindings=self._access(), remote_access=self._remote()) - payload["remote_access"][field] = value + payload["remote_access"]["capability"] = {"version": "openvpn-capability-v1"} with pytest.raises(RaesOperationInputError, match="remote access"): parse_raes_operation_input(payload) - def test_an_extra_remote_access_key_is_rejected(self): + @pytest.mark.parametrize("key", ["owner_user_id", "gateway_pool_slot"]) + def test_an_extra_remote_access_key_is_rejected(self, key): payload = _built(access_bindings=self._access(), remote_access=self._remote()) - payload["remote_access"]["owner_user_id"] = 7 + payload["remote_access"][key] = 7 with pytest.raises(RaesOperationInputError, match="remote access"): parse_raes_operation_input(payload) diff --git a/shifter/shifter_platform/tests/shared/test_remote_access.py b/shifter/shifter_platform/tests/shared/test_remote_access.py index c5d176865..095d1838a 100644 --- a/shifter/shifter_platform/tests/shared/test_remote_access.py +++ b/shifter/shifter_platform/tests/shared/test_remote_access.py @@ -114,6 +114,7 @@ def _profile(endpoint="vpn.example.test", port=1194): "persist-key\n" "persist-tun\n" "remote-cert-tls server\n" + "verify-x509-name shifter-openvpn-server name\n" "auth-nocache\n" "verb 3\n" "\nTEST-CA\n\n" @@ -168,6 +169,17 @@ def test_profile_validator_rejects_client_code_execution_and_route_expansion(uns validate_openvpn_profile(profile, binding) +@pytest.mark.parametrize( + "pin", + ["", "verify-x509-name some-other-server name\n", "verify-x509-name shifter-openvpn-server subject\n"], + ids=["missing", "other-name", "other-type"], +) +def test_profile_validator_requires_the_pool_server_name_pin(pin): + profile = _profile().replace("verify-x509-name shifter-openvpn-server name\n", pin) + with pytest.raises(OpenVpnBindingError): + validate_openvpn_profile(profile, parse_openvpn_binding(_binding())) + + def test_profile_validator_rejects_an_endpoint_that_does_not_match_the_binding(): binding = parse_openvpn_binding(_binding()) profile = _profile(endpoint="other.example.test") From 8c9583211ecc43a61eb2ec79e009bffe390d5cf5 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 06:25:54 +0000 Subject: [PATCH 02/14] feat(range): add the shared OpenVPN pool server 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 --- .github/dependabot.yml | 16 + .github/quality-path-filters.yaml | 9 + .github/workflows/_quality.yml | 80 +++ shifter/engine/openvpn/.dockerignore | 6 + shifter/engine/openvpn/Dockerfile | 17 + shifter/engine/openvpn/pyproject.toml | 49 ++ .../openvpn/shifter_openvpn/__init__.py | 0 .../openvpn/shifter_openvpn/__main__.py | 90 ++++ .../engine/openvpn/shifter_openvpn/config.py | 65 +++ .../openvpn/shifter_openvpn/controller.py | 181 +++++++ .../openvpn/shifter_openvpn/firewall.py | 72 +++ shifter/engine/openvpn/shifter_openvpn/gcp.py | 44 ++ .../openvpn/shifter_openvpn/management.py | 117 +++++ .../engine/openvpn/shifter_openvpn/portal.py | 136 +++++ .../openvpn/shifter_openvpn/server_config.py | 101 ++++ .../engine/openvpn/tests/test_controller.py | 176 +++++++ shifter/engine/openvpn/tests/test_parts.py | 236 +++++++++ shifter/engine/openvpn/tests/test_runtime.py | 105 ++++ shifter/engine/openvpn/uv.lock | 475 ++++++++++++++++++ sonar-project.properties | 6 +- 20 files changed, 1978 insertions(+), 3 deletions(-) create mode 100644 shifter/engine/openvpn/.dockerignore create mode 100644 shifter/engine/openvpn/Dockerfile create mode 100644 shifter/engine/openvpn/pyproject.toml create mode 100644 shifter/engine/openvpn/shifter_openvpn/__init__.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/__main__.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/config.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/controller.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/firewall.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/gcp.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/management.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/portal.py create mode 100644 shifter/engine/openvpn/shifter_openvpn/server_config.py create mode 100644 shifter/engine/openvpn/tests/test_controller.py create mode 100644 shifter/engine/openvpn/tests/test_parts.py create mode 100644 shifter/engine/openvpn/tests/test_runtime.py create mode 100644 shifter/engine/openvpn/uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dcecae205..38d3cc854 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -152,6 +152,21 @@ updates: - "minor" - "patch" + - package-ecosystem: "uv" + directory: "/shifter/engine/openvpn" + target-branch: "dev" + commit-message: + prefix: "chore" + include: "scope" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + python-minor-and-patch: + update-types: + - "minor" + - "patch" + - package-ecosystem: "uv" directory: "/scripts/bootstrap" target-branch: "dev" @@ -397,6 +412,7 @@ updates: - "/shifter/engine/provisioner" - "/shifter/engine/guacd" - "/shifter/engine/guacamole" + - "/shifter/engine/openvpn" target-branch: "dev" commit-message: prefix: "chore" diff --git a/.github/quality-path-filters.yaml b/.github/quality-path-filters.yaml index c338964e3..9812de674 100644 --- a/.github/quality-path-filters.yaml +++ b/.github/quality-path-filters.yaml @@ -86,6 +86,15 @@ quality_units: security: [packer-sast] test: [packer-tests] + - id: openvpn + paths: + - shifter/engine/openvpn/** + sonar: true + responsibilities: + lint: [openvpn-lint] + security: [openvpn-sast] + test: [openvpn-tests] + - id: bootstrap paths: - scripts/bootstrap/** diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index c72455d13..1dca31a0a 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -54,6 +54,7 @@ jobs: shifter_platform: ${{ steps.detect.outputs.shifter_platform }} provisioner: ${{ steps.detect.outputs.provisioner }} packer: ${{ steps.detect.outputs.packer }} + openvpn: ${{ steps.detect.outputs.openvpn }} bootstrap: ${{ steps.detect.outputs.bootstrap }} gcp_scripts: ${{ steps.detect.outputs.gcp_scripts }} check_layer_imports: ${{ steps.detect.outputs.check_layer_imports }} @@ -863,6 +864,27 @@ jobs: uv run ruff check . uv run ruff format --check . + openvpn-lint: + permissions: + contents: read + name: Lint (openvpn) + needs: [paths] + if: ${{ (needs.paths.outputs.run_all == 'true' || needs.paths.outputs.openvpn == 'true') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Lint and type-check Python + working-directory: shifter/engine/openvpn + run: | + uv sync --group dev + uv run ruff check . + uv run ruff format --check . + uv run mypy + bootstrap-lint: permissions: contents: read @@ -1338,6 +1360,26 @@ jobs: uv tool install bandit bandit -r shifter/packer/ --skip B101 --exclude shifter/packer/tests,shifter/packer/.venv + openvpn-sast: + permissions: + contents: read + name: SAST (openvpn) + needs: [paths] + if: ${{ (needs.paths.outputs.run_all == 'true' || needs.paths.outputs.openvpn == 'true') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Run Bandit + # B404/B603: the controller runs the fixed openvpn and nft binaries with + # a literal argv and no shell; nft input is built from validated values. + run: | + uv tool install bandit + bandit -r shifter/engine/openvpn/ --skip B101,B404,B603 --exclude shifter/engine/openvpn/tests,shifter/engine/openvpn/.venv + bootstrap-sast: permissions: contents: read @@ -1748,6 +1790,35 @@ jobs: if-no-files-found: error retention-days: 1 + openvpn-tests: + permissions: + contents: read + name: Tests (openvpn) + needs: [paths, openvpn-lint] + if: ${{ !inputs.skip_tests && ((needs.paths.outputs.run_all == 'true' || needs.paths.outputs.openvpn == 'true')) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install dependencies + working-directory: shifter/engine/openvpn + run: uv sync --group dev + + - name: Run tests + working-directory: shifter/engine/openvpn + run: uv run pytest tests/ --cov --cov-report=xml:coverage.xml + + - name: Upload coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-openvpn + path: shifter/engine/openvpn/coverage.xml + if-no-files-found: error + retention-days: 1 + bootstrap-tests: permissions: contents: read @@ -2492,6 +2563,7 @@ jobs: - shifter-platform-spa - shifter-engine-tests - packer-tests + - openvpn-tests - bootstrap-tests - check-layer-imports-tests - installation-tests @@ -2548,6 +2620,14 @@ jobs: with: name: coverage-packer path: shifter/packer + - name: Download openvpn coverage + if: >- + ${{ (needs.paths.outputs.run_all == 'true' || needs.paths.outputs.openvpn == 'true') + && needs.openvpn-tests.result == 'success' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-openvpn + path: shifter/engine/openvpn - name: Download bootstrap coverage if: >- ${{ (needs.paths.outputs.run_all == 'true' || needs.paths.outputs.bootstrap == 'true') diff --git a/shifter/engine/openvpn/.dockerignore b/shifter/engine/openvpn/.dockerignore new file mode 100644 index 000000000..a62e916fd --- /dev/null +++ b/shifter/engine/openvpn/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +tests +uv.lock +pyproject.toml diff --git a/shifter/engine/openvpn/Dockerfile b/shifter/engine/openvpn/Dockerfile new file mode 100644 index 000000000..decaf40ff --- /dev/null +++ b/shifter/engine/openvpn/Dockerfile @@ -0,0 +1,17 @@ +# Shared participant OpenVPN pool server (#2480): OpenVPN plus its authorizing +# controller. The container runs in its own network namespace on a pool VM: +# NET_ADMIN (tunnel, routes, nftables) applies to that namespace only, and +# OpenVPN drops to nobody after it opens the tunnel and reads its credentials. +FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 + +# Minor-version constraints: CI rebuilds without cache pick up patch releases. +RUN apk add --no-cache "openvpn~2.6" "nftables~1.1" "python3~3.12" + +WORKDIR /opt/shifter-openvpn +COPY shifter_openvpn ./shifter_openvpn + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +EXPOSE 1194/udp 8080/tcp +ENTRYPOINT ["python3", "-m", "shifter_openvpn"] diff --git a/shifter/engine/openvpn/pyproject.toml b/shifter/engine/openvpn/pyproject.toml new file mode 100644 index 000000000..77051a44c --- /dev/null +++ b/shifter/engine/openvpn/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "shifter-openvpn" +version = "0.1.0" +description = "Shared participant OpenVPN server pool: OpenVPN plus its authorizing controller (#2480)" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = [ + "mypy>=1.18.0", + "pytest-cov>=7.0.0", + "pytest>=9.0.2", + "ruff>=0.15.13", +] + +[tool.ruff] +target-version = "py312" +line-length = 120 +exclude = [".venv", "__pycache__"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "C4", "UP", "S", "T20", "SIM", "RUF", "C901"] +ignore = ["S101"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "S105", "S106", "S108"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.12" +strict = true +files = ["shifter_openvpn"] + +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.coverage.run] +source = ["shifter_openvpn"] +omit = ["tests/*"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true diff --git a/shifter/engine/openvpn/shifter_openvpn/__init__.py b/shifter/engine/openvpn/shifter_openvpn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/shifter/engine/openvpn/shifter_openvpn/__main__.py b/shifter/engine/openvpn/shifter_openvpn/__main__.py new file mode 100644 index 000000000..0f38e98c9 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/__main__.py @@ -0,0 +1,90 @@ +"""Start one pool server: credentials, firewall, OpenVPN, then the controller loop. + +Any failure of OpenVPN, the management connection or the controller ends the +process; the VM restarts the container, and clients reconnect to a healthy +server. A restarted controller starts with no sessions and a fresh ruleset. +""" + +from __future__ import annotations + +import logging +import os +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from . import gcp +from .config import load_config +from .controller import Controller +from .firewall import Firewall +from .management import Management +from .portal import PortalClient +from .server_config import write_config, write_material + +_OPENVPN = "/usr/sbin/openvpn" + + +_LOGGER = logging.getLogger("shifter_openvpn") + + +def _log(message: str) -> None: + _LOGGER.info(message) + + +def _wait_for_socket(path: str, process: subprocess.Popen[bytes], timeout: float = 30) -> None: + deadline = time.monotonic() + timeout + while not os.path.exists(path): + if process.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("OpenVPN did not open its management socket") + time.sleep(0.2) + + +def _serve_health(port: int, healthy: threading.Event) -> None: + class Health(BaseHTTPRequestHandler): + def do_GET(self) -> None: + ok = self.path == "/healthz" and healthy.is_set() + self.send_response(200 if ok else 503) + self.end_headers() + self.wfile.write(b"ok\n" if ok else b"unavailable\n") + + def log_message(self, *_args: object) -> None: + return + + # Bind the container's own interface only: tunnel clients never reach the health endpoint. + address = socket.gethostbyname(socket.gethostname()) + ThreadingHTTPServer((address, port), Health).serve_forever() + + +def _heartbeat(controller: Controller) -> None: + while True: + time.sleep(controller.heartbeat()) + + +def main() -> int: + logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(message)s") + config = load_config() + write_material(config.runtime_dir, gcp.read_secret(config.server_secret)) + paths = write_config(config) + Firewall().install(config.tunnel_network) + process = subprocess.Popen([_OPENVPN, "--config", paths.config]) # noqa: S603 (fixed argv, no shell, no external input) + _wait_for_socket(paths.management_socket, process) + management = Management(paths.management_socket) + portal = PortalClient(config.portal_url, config.control_audience, config.server_name, gcp.identity_token) + controller = Controller(management, portal, Firewall(), log=_log) + healthy = threading.Event() + threading.Thread(target=_serve_health, args=(config.health_port, healthy), daemon=True).start() + threading.Thread(target=_heartbeat, args=(controller,), daemon=True, name="heartbeat").start() + threading.Thread(target=lambda: (process.wait(), os._exit(1)), daemon=True, name="openvpn-watch").start() + healthy.set() + _log(f"pool server {config.server_name} ready") + while (event := management.events.get()) is not None: + controller.handle(event) + _log("management connection closed") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/shifter/engine/openvpn/shifter_openvpn/config.py b/shifter/engine/openvpn/shifter_openvpn/config.py new file mode 100644 index 000000000..4eff28980 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/config.py @@ -0,0 +1,65 @@ +"""Runtime configuration for one pool server, read once from the environment.""" + +from __future__ import annotations + +import ipaddress +import os +import re +from dataclasses import dataclass + +_SERVER_NAME = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?", re.ASCII) +_SECRET_NAME = re.compile(r"projects/[a-z0-9-]+/secrets/[A-Za-z0-9_-]+", re.ASCII) + + +class ConfigError(ValueError): + """The server cannot start with this configuration.""" + + +@dataclass(frozen=True) +class Config: + """Everything one pool server needs; nothing here is secret.""" + + portal_url: str + control_audience: str + server_secret: str + server_name: str + tunnel_network: ipaddress.IPv4Network + max_clients: int + health_port: int + runtime_dir: str + + +def _require(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise ConfigError(f"{name} is required") + return value + + +def load_config() -> Config: + """Read and validate the server configuration, failing closed on any defect.""" + portal_url = _require("PORTAL_URL").rstrip("/") + if not re.fullmatch(r"https://[A-Za-z0-9.-]+", portal_url): + raise ConfigError("PORTAL_URL must be an https origin") + server_secret = _require("SERVER_SECRET") + if not _SECRET_NAME.fullmatch(server_secret): + raise ConfigError("SERVER_SECRET must name a Secret Manager secret") + server_name = _require("SERVER_NAME") + if not _SERVER_NAME.fullmatch(server_name): + raise ConfigError("SERVER_NAME must be the Compute Engine instance name") + network = ipaddress.ip_network(os.environ.get("TUNNEL_NETWORK", "100.96.0.0/22"), strict=True) + if not isinstance(network, ipaddress.IPv4Network) or not 16 <= network.prefixlen <= 24: + raise ConfigError("TUNNEL_NETWORK must be an IPv4 network between /16 and /24") + max_clients = int(os.environ.get("MAX_CLIENTS", "250")) + if not 1 <= max_clients <= network.num_addresses - 3: + raise ConfigError("MAX_CLIENTS must fit inside TUNNEL_NETWORK") + return Config( + portal_url=portal_url, + control_audience=_require("CONTROL_AUDIENCE"), + server_secret=server_secret, + server_name=server_name, + tunnel_network=network, + max_clients=max_clients, + health_port=int(os.environ.get("HEALTH_PORT", "8080")), + runtime_dir=os.environ.get("RUNTIME_DIR", "/run/openvpn"), + ) diff --git a/shifter/engine/openvpn/shifter_openvpn/controller.py b/shifter/engine/openvpn/shifter_openvpn/controller.py new file mode 100644 index 000000000..6310c3db0 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/controller.py @@ -0,0 +1,181 @@ +"""Authorize, track and revoke the clients of one pool server. + +* CONNECT: ask the portal; admit with a route to the single granted target, or + refuse. A portal that cannot answer means refuse. +* ESTABLISHED: allow the client's tunnel address to the target's ports only. +* DISCONNECT: remove the allowance and tell the portal. +* Heartbeat: report live sessions; disconnect every session the portal returns. + When the portal cannot be reached for longer than the grace period, every + session ends: revocation that cannot be confirmed is not assumed. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Protocol + +from .management import ClientEvent +from .portal import Grant, PortalRefused, PortalUnavailable + +RETRY_SECONDS = 5 +_LOGGER = logging.getLogger("shifter_openvpn") + + +class ManagementPort(Protocol): + def allow(self, cid: int, kid: int, config_lines: list[str]) -> None: ... + def allow_unchanged(self, cid: int, kid: int) -> None: ... + def deny(self, cid: int, kid: int, reason: str) -> None: ... + def kill(self, cid: int) -> None: ... + + +class PortalPort(Protocol): + def authorize(self, common_name: str, client_id: int, client_address: str) -> Grant: ... + def heartbeat(self, sessions: list[str]) -> tuple[set[str], int]: ... + def end(self, session: str) -> None: ... + + +class FirewallPort(Protocol): + def allow(self, client: str, target: str, ports: tuple[int, ...]) -> None: ... + def revoke(self, client: str, target: str, ports: tuple[int, ...]) -> None: ... + + +@dataclass +class Session: + """One admitted client and what it may reach.""" + + session: str + target: str + ports: tuple[int, ...] + address: str | None = None + + +class Controller: + """The server-side policy for every client of this OpenVPN process.""" + + def __init__( + self, + management: ManagementPort, + portal: PortalPort, + firewall: FirewallPort, + *, + grace_seconds: int = 90, + clock: Callable[[], float] = time.monotonic, + log: Callable[[str], None] | None = None, + ) -> None: + self._management = management + self._portal = portal + self._firewall = firewall + self._grace = grace_seconds + self._clock = clock + self._log = log or _LOGGER.info + self._lock = threading.Lock() + self._sessions: dict[int, Session] = {} + self._last_heartbeat = clock() + self._authorizers = ThreadPoolExecutor(max_workers=8, thread_name_prefix="authorize") + + def handle(self, event: ClientEvent) -> None: + """Dispatch one management notification; authorization runs off the event thread.""" + if event.kind == "CONNECT": + self._authorizers.submit(self._connect, event) + elif event.kind == "REAUTH": + self._reauth(event) + elif event.kind == "ESTABLISHED": + self._established(event) + elif event.kind == "DISCONNECT": + self._disconnect(event) + + def _connect(self, event: ClientEvent) -> None: + kid = event.kid if event.kid is not None else 0 + try: + grant = self._portal.authorize( + event.env.get("common_name", ""), event.cid, event.env.get("untrusted_ip", "") + ) + except PortalRefused as refused: + self._log(f"deny cid={event.cid} reason={refused.code}") + self._management.deny(event.cid, kid, refused.code) + return + except PortalUnavailable as unavailable: + self._log(f"deny cid={event.cid} reason=portal-unavailable ({unavailable})") + self._management.deny(event.cid, kid, "portal unavailable") + return + with self._lock: + self._sessions[event.cid] = Session(grant.session, grant.target, grant.ports) + self._log(f"allow cid={event.cid} session={grant.session} target={grant.target} ports={list(grant.ports)}") + self._management.allow(event.cid, kid, [f'push "route {grant.target} 255.255.255.255"']) + + def _reauth(self, event: ClientEvent) -> None: + kid = event.kid if event.kid is not None else 0 + with self._lock: + known = event.cid in self._sessions + # A renegotiating client keeps its session; the heartbeat decides whether it may. + if known: + self._management.allow_unchanged(event.cid, kid) + else: + self._management.deny(event.cid, kid, "unknown session") + + def _established(self, event: ClientEvent) -> None: + address = event.env.get("ifconfig_pool_remote_ip", "") + with self._lock: + session = self._sessions.get(event.cid) + if session is None or not address: + self._management.kill(event.cid) + return + try: + self._firewall.allow(address, session.target, session.ports) + except Exception as error: # an allowance that cannot be installed is a session that cannot work + self._log(f"kill cid={event.cid} reason=firewall ({type(error).__name__})") + self._management.kill(event.cid) + return + with self._lock: + session.address = address + + def _disconnect(self, event: ClientEvent) -> None: + with self._lock: + session = self._sessions.pop(event.cid, None) + if session is None: + return + if session.address: + try: + self._firewall.revoke(session.address, session.target, session.ports) + except Exception as error: + self._log(f"revoke failed cid={event.cid} ({type(error).__name__})") + try: + self._portal.end(session.session) + except (PortalRefused, PortalUnavailable) as error: + self._log(f"end not recorded session={session.session} ({error}); the heartbeat reconciles it") + self._log(f"disconnect cid={event.cid} session={session.session}") + + def _kill(self, cid: int, reason: str) -> None: + self._log(f"kill cid={cid} reason={reason}") + try: + self._management.kill(cid) + except Exception as error: + self._log(f"kill failed cid={cid} ({type(error).__name__})") + + def heartbeat(self) -> int: + """Renew live sessions, apply revocations, and return the seconds until the next beat.""" + with self._lock: + live = {cid: session.session for cid, session in self._sessions.items()} + try: + disconnect, interval = self._portal.heartbeat(sorted(live.values())) + except (PortalRefused, PortalUnavailable) as error: + silent = self._clock() - self._last_heartbeat + self._log(f"heartbeat failed ({error}); {silent:.0f}s since the last confirmation") + if silent > self._grace: + for cid in live: + self._kill(cid, "revocation-unconfirmed") + return RETRY_SECONDS + self._last_heartbeat = self._clock() + for cid, session in live.items(): + if session in disconnect: + self._kill(cid, "revoked") + return max(RETRY_SECONDS, interval) + + def session_count(self) -> int: + with self._lock: + return len(self._sessions) diff --git a/shifter/engine/openvpn/shifter_openvpn/firewall.py b/shifter/engine/openvpn/shifter_openvpn/firewall.py new file mode 100644 index 000000000..e5f436565 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/firewall.py @@ -0,0 +1,72 @@ +"""Per-client forwarding policy with nftables, inside the server's network namespace. + +Forwarding drops by default. Each authorized client gets set elements +``(tunnel address, target address, port)`` for its declared ports only, added +after OpenVPN reports the client's tunnel address and removed when it leaves. +Clients can never reach the server itself, each other, the metadata server, or +any other address. Client traffic leaves NATed to the server's address. +""" + +from __future__ import annotations + +import ipaddress +import subprocess +from collections.abc import Callable + +TABLE = "shifter_vpn" +_NFT = "/usr/sbin/nft" + + +def base_ruleset(tunnel_network: ipaddress.IPv4Network, tunnel_device: str = "tun0") -> str: + """Return the complete ruleset this server starts from (replacing any previous one).""" + return f"""table inet {TABLE} +delete table inet {TABLE} +table inet {TABLE} {{ + set allowed {{ + type ipv4_addr . ipv4_addr . inet_service + }} + chain input {{ + type filter hook input priority filter; policy accept; + iifname "{tunnel_device}" drop + }} + chain forward {{ + type filter hook forward priority filter; policy drop; + ct state established,related accept + iifname "{tunnel_device}" ct state new ip saddr . ip daddr . tcp dport @allowed accept + }} + chain postrouting {{ + type nat hook postrouting priority srcnat; policy accept; + ip saddr {tunnel_network} oifname != "{tunnel_device}" masquerade + }} +}} +""" + + +def _elements(client: str, target: str, ports: tuple[int, ...]) -> str: + """Render validated set elements; addresses and ports never reach nft unchecked.""" + client_ip = ipaddress.IPv4Address(client) + target_ip = ipaddress.IPv4Address(target) + if not ports or any(isinstance(port, bool) or not 1 <= port <= 65535 for port in ports): + raise ValueError("invalid port list") + return ", ".join(f"{client_ip} . {target_ip} . {port}" for port in sorted(set(ports))) + + +def _nft(script: str) -> None: + """Apply one nft script atomically.""" + subprocess.run([_NFT, "-f", "-"], input=script, text=True, check=True, capture_output=True) # noqa: S603 (fixed argv, no shell; script built from validated values) + + +class Firewall: + """Apply the base ruleset and per-client allowances through ``nft -f -``.""" + + def __init__(self, runner: Callable[[str], None] = _nft) -> None: + self._run = runner + + def install(self, tunnel_network: ipaddress.IPv4Network) -> None: + self._run(base_ruleset(tunnel_network)) + + def allow(self, client: str, target: str, ports: tuple[int, ...]) -> None: + self._run(f"add element inet {TABLE} allowed {{ {_elements(client, target, ports)} }}\n") + + def revoke(self, client: str, target: str, ports: tuple[int, ...]) -> None: + self._run(f"delete element inet {TABLE} allowed {{ {_elements(client, target, ports)} }}\n") diff --git a/shifter/engine/openvpn/shifter_openvpn/gcp.py b/shifter/engine/openvpn/shifter_openvpn/gcp.py new file mode 100644 index 000000000..4c33879e2 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/gcp.py @@ -0,0 +1,44 @@ +"""Compute Engine metadata identity and Secret Manager access, standard library only. + +The VM's attached service account is the server's only credential: an access +token reads the server material, and a Google-signed identity token for the +portal audience authenticates every control call. +""" + +from __future__ import annotations + +import base64 +import json +import urllib.parse +import urllib.request + +_METADATA = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default" +_HEADERS = {"Metadata-Flavor": "Google"} +_TIMEOUT = 5 + + +def _metadata(path: str) -> str: + request = urllib.request.Request(f"{_METADATA}/{path}", headers=_HEADERS) # noqa: S310 (fixed metadata URL) + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: # noqa: S310 (fixed metadata URL) + return str(response.read().decode("utf-8")) + + +def identity_token(audience: str) -> str: + """Return a Google-signed identity token for ``audience`` (includes the email claim).""" + query = urllib.parse.urlencode({"audience": audience, "format": "full"}) + return _metadata(f"identity?{query}").strip() + + +def access_token() -> str: + """Return a short-lived OAuth access token for the attached service account.""" + payload = json.loads(_metadata("token")) + return str(payload["access_token"]) + + +def read_secret(name: str) -> str: + """Return the latest version of a Secret Manager secret.""" + url = f"https://secretmanager.googleapis.com/v1/{name}/versions/latest:access" + request = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token()}"}) + with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 (fixed https API URL) + payload = json.loads(response.read()) + return base64.b64decode(payload["payload"]["data"]).decode("utf-8") diff --git a/shifter/engine/openvpn/shifter_openvpn/management.py b/shifter/engine/openvpn/shifter_openvpn/management.py new file mode 100644 index 000000000..c37b5a004 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/management.py @@ -0,0 +1,117 @@ +"""OpenVPN management interface over a local unix socket (management-notes.txt). + +With ``--management-client-auth`` OpenVPN verifies the client certificate, then +announces each connection as a ``>CLIENT:CONNECT`` block and waits for the +controller to answer ``client-auth`` or ``client-deny``. ``ESTABLISHED`` carries +the client's tunnel address and ``DISCONNECT`` its departure. + +One reader thread owns the socket input: notification blocks go to the event +queue, command replies (``SUCCESS:``/``ERROR:``) to the reply queue. Commands +are serialized, so each reply belongs to the command that is waiting for it. +""" + +from __future__ import annotations + +import queue +import socket +import threading +from dataclasses import dataclass, field + +_REPLY_TIMEOUT = 10 + + +class ManagementError(RuntimeError): + """The management interface refused a command or went away.""" + + +@dataclass(frozen=True) +class ClientEvent: + """One ``>CLIENT:`` notification with its environment.""" + + kind: str + cid: int + kid: int | None = None + env: dict[str, str] = field(default_factory=dict) + + +def parse_event_header(line: str) -> tuple[str, int, int | None] | None: + """Parse ``>CLIENT:KIND,CID[,KID]``; return None for anything else.""" + if not line.startswith(">CLIENT:") or line.startswith(">CLIENT:ENV,"): + return None + kind, _, rest = line[len(">CLIENT:") :].partition(",") + parts = rest.split(",") + try: + cid = int(parts[0]) + kid = int(parts[1]) if len(parts) > 1 and kind in {"CONNECT", "REAUTH"} else None + except (ValueError, IndexError): + return None + return kind, cid, kid + + +def _quote(text: str) -> str: + """Quote a reason string for the management command line.""" + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +class Management: + """A connected management client.""" + + def __init__(self, path: str) -> None: + self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._socket.connect(path) + self._file = self._socket.makefile("rw", encoding="utf-8", newline="\n") + self.events: queue.Queue[ClientEvent | None] = queue.Queue() + self._replies: queue.Queue[str] = queue.Queue() + self._lock = threading.Lock() + self._reader = threading.Thread(target=self._read, name="management-reader", daemon=True) + self._reader.start() + + def _read(self) -> None: + pending: tuple[str, int, int | None] | None = None + env: dict[str, str] = {} + for raw in self._file: + line = raw.rstrip("\r\n") + header = parse_event_header(line) + if header is not None: + pending, env = header, {} + elif line.startswith(">CLIENT:ENV,") and pending is not None: + entry = line[len(">CLIENT:ENV,") :] + if entry == "END": + kind, cid, kid = pending + self.events.put(ClientEvent(kind=kind, cid=cid, kid=kid, env=env)) + pending = None + else: + key, _, value = entry.partition("=") + env[key] = value + elif line.startswith(("SUCCESS:", "ERROR:")): + self._replies.put(line) + self.events.put(None) # the socket closed + + def _command(self, *lines: str) -> str: + with self._lock: + for line in lines: + self._file.write(line + "\n") + self._file.flush() + try: + reply = self._replies.get(timeout=_REPLY_TIMEOUT) + except queue.Empty: + raise ManagementError("no reply from OpenVPN") from None + if reply.startswith("ERROR:"): + raise ManagementError(reply) + return reply + + def allow(self, cid: int, kid: int, config_lines: list[str]) -> None: + """Admit a pending client with per-client configuration (for example its route).""" + self._command(f"client-auth {cid} {kid}", *config_lines, "END") + + def allow_unchanged(self, cid: int, kid: int) -> None: + """Admit a renegotiating client without new configuration.""" + self._command(f"client-auth-nt {cid} {kid}") + + def deny(self, cid: int, kid: int, reason: str) -> None: + """Refuse a pending client; the reason is logged by OpenVPN, not sent to the client.""" + self._command(f"client-deny {cid} {kid} {_quote(reason)}") + + def kill(self, cid: int) -> None: + """Disconnect a client.""" + self._command(f"client-kill {cid}") diff --git a/shifter/engine/openvpn/shifter_openvpn/portal.py b/shifter/engine/openvpn/shifter_openvpn/portal.py new file mode 100644 index 000000000..9b8bee556 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/portal.py @@ -0,0 +1,136 @@ +"""The portal's private VPN control API, as seen from one pool server. + +Every call carries a fresh Google identity token for the configured audience. +Redirects are refused and responses are bounded, so a misdirected or hostile +endpoint cannot steer the server elsewhere or exhaust it. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from collections.abc import Callable +from dataclasses import dataclass + +_MAX_RESPONSE_BYTES = 65_536 +_TIMEOUT = 10 + + +class PortalUnavailable(RuntimeError): + """The portal could not be reached or answered outside its contract.""" + + +class PortalRefused(RuntimeError): + """The portal refused the request with a closed reason code.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class Grant: + """The single destination one authorized client may reach.""" + + session: str + target: str + ports: tuple[int, ...] + heartbeat_seconds: int + + +def _is_port(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 + + +def _grant(payload: dict[str, object]) -> Grant: + """Validate a grant strictly; anything outside the contract is treated as unavailable.""" + session, target = payload.get("session"), payload.get("target") + ports, interval = payload.get("ports"), payload.get("heartbeat_seconds") + if not ( + isinstance(session, str) + and isinstance(target, str) + and isinstance(ports, list) + and ports + and all(_is_port(port) for port in ports) + and isinstance(interval, int) + and not isinstance(interval, bool) + ): + raise PortalUnavailable("grant outside the contract") + return Grant(session=session, target=target, ports=tuple(ports), heartbeat_seconds=interval) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args: object, **_kwargs: object) -> None: + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +class PortalClient: + """Authorize, renew and end sessions for this server.""" + + def __init__(self, portal_url: str, audience: str, server: str, token: Callable[[str], str]) -> None: + self._base = f"{portal_url}/api/v1/cms/vpn-control" + self._audience = audience + self._server = server + self._token = token + + def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, object]]: + request = urllib.request.Request( # noqa: S310 (base is validated as an https origin at startup) + f"{self._base}/{path}", + data=json.dumps(body).encode(), + headers={"Authorization": f"Bearer {self._token(self._audience)}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with _OPENER.open(request, timeout=_TIMEOUT) as response: + status, raw = response.status, response.read(_MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as error: + status, raw = error.code, error.read(_MAX_RESPONSE_BYTES + 1) + except (urllib.error.URLError, OSError, TimeoutError) as error: + raise PortalUnavailable(type(error).__name__) from None + if len(raw) > _MAX_RESPONSE_BYTES: + raise PortalUnavailable("response too large") + try: + payload = json.loads(raw) if raw else {} + except ValueError: + raise PortalUnavailable("response is not JSON") from None + if not isinstance(payload, dict): + raise PortalUnavailable("response is not an object") + return status, payload + + def _refusal(self, status: int, payload: dict[str, object]) -> Exception: + if status in (400, 403) and isinstance(payload.get("error"), str): + return PortalRefused(str(payload["error"])) + return PortalUnavailable(f"status {status}") + + def authorize(self, common_name: str, client_id: int, client_address: str) -> Grant: + status, payload = self._post( + "sessions/", + { + "common_name": common_name, + "server": self._server, + "client_id": client_id, + "client_address": client_address, + }, + ) + if status != 201: + raise self._refusal(status, payload) + return _grant(payload) + + def heartbeat(self, sessions: list[str]) -> tuple[set[str], int]: + status, payload = self._post("heartbeat/", {"server": self._server, "sessions": sessions}) + if status != 200: + raise self._refusal(status, payload) + disconnect = payload.get("disconnect") + interval = payload.get("heartbeat_seconds") + if not isinstance(disconnect, list) or not isinstance(interval, int): + raise PortalUnavailable("heartbeat outside the contract") + return {str(value) for value in disconnect}, interval + + def end(self, session: str) -> None: + status, payload = self._post("sessions/end/", {"server": self._server, "session": session}) + if status != 204: + raise self._refusal(status, payload) diff --git a/shifter/engine/openvpn/shifter_openvpn/server_config.py b/shifter/engine/openvpn/shifter_openvpn/server_config.py new file mode 100644 index 000000000..2a2fade93 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/server_config.py @@ -0,0 +1,101 @@ +"""The OpenVPN server configuration and its credential files. + +Options follow the OpenVPN 2.6 reference manual: + +* ``tls-crypt``: only holders of a profile can reach the TLS handshake. +* ``remote-cert-tls client``, ``verify-client-cert require``, + ``tls-version-min 1.2``, ``tls-cert-profile preferred``. +* AEAD data ciphers only. +* ``management-client-auth``: the controller decides every connection after + OpenVPN verifies the certificate. ``auth-user-pass-optional`` lets + certificate-only clients reach that decision; the controller identifies the + client by its verified certificate common name only. ``username-as-common-name`` + must never be set: it would let a client choose its own identity. +* ``max-clients`` and ``connect-freq`` bound one server's load. +* No ``client-to-client``, no pushed default gateway: each client is pushed a + route to its single target by the controller. +* ``user``/``group nobody`` with ``persist-key``/``persist-tun``: OpenVPN drops + privileges after it opens the tunnel and reads its credentials. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + +from .config import Config + +_MATERIAL_KEYS = frozenset({"ca", "certificate", "private_key", "tls_crypt"}) + + +@dataclass(frozen=True) +class ServerPaths: + """Where the rendered configuration and credentials live (a tmpfs).""" + + config: str + management_socket: str + + +def write_material(runtime_dir: str, payload: str) -> None: + """Write the server credentials with owner-only permissions.""" + material = json.loads(payload) + if not isinstance(material, dict) or set(material) != _MATERIAL_KEYS: + raise ValueError("server material has an invalid shape") + os.makedirs(runtime_dir, mode=0o700, exist_ok=True) + for name, key in ( + ("ca.crt", "ca"), + ("server.crt", "certificate"), + ("server.key", "private_key"), + ("tls-crypt.key", "tls_crypt"), + ): + path = os.path.join(runtime_dir, name) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(str(material[key])) + + +def render(config: Config) -> str: + """Return the server configuration text.""" + network = config.tunnel_network + run = config.runtime_dir + return f"""port 1194 +proto udp4 +dev tun0 +dev-type tun +topology subnet +server {network.network_address} {network.netmask} +max-clients {config.max_clients} +connect-freq 20 10 +keepalive 10 60 +explicit-exit-notify 1 +ca {run}/ca.crt +cert {run}/server.crt +key {run}/server.key +dh none +tls-crypt {run}/tls-crypt.key +tls-version-min 1.2 +tls-cert-profile preferred +remote-cert-tls client +verify-client-cert require +data-ciphers AES-256-GCM:AES-128-GCM +auth SHA256 +user nobody +group nobody +persist-key +persist-tun +management {run}/management.sock unix +management-client-user root +management-client-auth +auth-user-pass-optional +verb 3 +""" + + +def write_config(config: Config) -> ServerPaths: + """Render the configuration next to the credentials.""" + path = os.path.join(config.runtime_dir, "server.conf") + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(render(config)) + return ServerPaths(config=path, management_socket=os.path.join(config.runtime_dir, "management.sock")) diff --git a/shifter/engine/openvpn/tests/test_controller.py b/shifter/engine/openvpn/tests/test_controller.py new file mode 100644 index 000000000..98726ce7d --- /dev/null +++ b/shifter/engine/openvpn/tests/test_controller.py @@ -0,0 +1,176 @@ +"""The pool server's client policy (#2480): admit, scope, revoke, fail closed.""" + +from __future__ import annotations + +import pytest + +from shifter_openvpn.controller import RETRY_SECONDS, Controller +from shifter_openvpn.management import ClientEvent +from shifter_openvpn.portal import Grant, PortalRefused, PortalUnavailable + + +class FakeManagement: + def __init__(self) -> None: + self.calls: list[tuple] = [] + + def allow(self, cid, kid, config_lines): + self.calls.append(("allow", cid, kid, tuple(config_lines))) + + def allow_unchanged(self, cid, kid): + self.calls.append(("allow_unchanged", cid, kid)) + + def deny(self, cid, kid, reason): + self.calls.append(("deny", cid, kid, reason)) + + def kill(self, cid): + self.calls.append(("kill", cid)) + + +class FakePortal: + def __init__(self) -> None: + self.authorize_result: Grant | Exception = Grant("s-1", "10.50.3.3", (22, 3389), 15) + self.heartbeat_result: tuple[set[str], int] | Exception = (set(), 15) + self.reported: list[list[str]] = [] + self.ended: list[str] = [] + + def authorize(self, common_name, client_id, client_address): + if isinstance(self.authorize_result, Exception): + raise self.authorize_result + return self.authorize_result + + def heartbeat(self, sessions): + self.reported.append(sessions) + if isinstance(self.heartbeat_result, Exception): + raise self.heartbeat_result + return self.heartbeat_result + + def end(self, session): + self.ended.append(session) + + +class FakeFirewall: + def __init__(self, *, fail: bool = False) -> None: + self.allowed: set[tuple] = set() + self.fail = fail + + def allow(self, client, target, ports): + if self.fail: + raise RuntimeError("nft failed") + self.allowed.add((client, target, ports)) + + def revoke(self, client, target, ports): + self.allowed.discard((client, target, ports)) + + +class Clock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +@pytest.fixture +def parts(): + management, portal, firewall, clock = FakeManagement(), FakePortal(), FakeFirewall(), Clock() + controller = Controller(management, portal, firewall, grace_seconds=90, clock=clock, log=lambda _m: None) + # Authorization normally runs on a pool; run it inline so the test is deterministic. + controller._authorizers.submit = lambda fn, *args: fn(*args) # type: ignore[method-assign] + return controller, management, portal, firewall, clock + + +def _connect(cid=7, kid=1): + return ClientEvent("CONNECT", cid, kid, {"common_name": "participant-x", "untrusted_ip": "198.51.100.2"}) + + +def _established(cid=7, address="100.96.0.2"): + return ClientEvent("ESTABLISHED", cid, None, {"ifconfig_pool_remote_ip": address}) + + +def test_an_authorized_client_reaches_only_its_target_ports(parts): + controller, management, _portal, firewall, _clock = parts + + controller.handle(_connect()) + controller.handle(_established()) + + assert management.calls == [("allow", 7, 1, ('push "route 10.50.3.3 255.255.255.255"',))] + assert firewall.allowed == {("100.96.0.2", "10.50.3.3", (22, 3389))} + + +@pytest.mark.parametrize( + ("result", "reason"), + [(PortalRefused("vpn.access_revoked"), "vpn.access_revoked"), (PortalUnavailable("timeout"), "portal unavailable")], +) +def test_a_refused_or_unanswered_connection_is_denied(parts, result, reason): + controller, management, portal, firewall, _clock = parts + portal.authorize_result = result + + controller.handle(_connect()) + + assert management.calls == [("deny", 7, 1, reason)] + assert controller.session_count() == 0 + assert firewall.allowed == set() + + +def test_disconnect_removes_the_allowance_and_tells_the_portal(parts): + controller, _management, portal, firewall, _clock = parts + controller.handle(_connect()) + controller.handle(_established()) + + controller.handle(ClientEvent("DISCONNECT", 7)) + + assert firewall.allowed == set() + assert portal.ended == ["s-1"] + assert controller.session_count() == 0 + + +def test_an_unknown_or_addressless_establishment_is_killed(parts): + controller, management, _portal, firewall, _clock = parts + controller.handle(_established(cid=99)) + controller.handle(_connect(cid=8)) + controller.handle(_established(cid=8, address="")) + + assert ("kill", 99) in management.calls and ("kill", 8) in management.calls + assert firewall.allowed == set() + + +def test_a_firewall_failure_kills_the_session_rather_than_leaving_it_half_open(parts): + controller, management, _portal, _firewall, _clock = parts + controller._firewall = FakeFirewall(fail=True) + controller.handle(_connect()) + controller.handle(_established()) + assert management.calls[-1] == ("kill", 7) + + +def test_renegotiation_keeps_a_known_session_and_refuses_an_unknown_one(parts): + controller, management, *_ = parts + controller.handle(_connect()) + + controller.handle(ClientEvent("REAUTH", 7, 2)) + controller.handle(ClientEvent("REAUTH", 8, 2)) + + assert management.calls[-2:] == [("allow_unchanged", 7, 2), ("deny", 8, 2, "unknown session")] + + +def test_the_heartbeat_reports_live_sessions_and_kills_revoked_ones(parts): + controller, management, portal, _firewall, _clock = parts + controller.handle(_connect()) + portal.heartbeat_result = ({"s-1"}, 15) + + assert controller.heartbeat() == 15 + assert portal.reported == [["s-1"]] + assert management.calls[-1] == ("kill", 7) + + +def test_unconfirmed_revocation_ends_every_session_after_the_grace_period(parts): + controller, management, portal, _firewall, clock = parts + controller.handle(_connect()) + portal.heartbeat_result = PortalUnavailable("down") + + clock.now = 60 + assert controller.heartbeat() == RETRY_SECONDS + assert ("kill", 7) not in management.calls + + clock.now = 91 + controller.heartbeat() + assert management.calls[-1] == ("kill", 7) diff --git a/shifter/engine/openvpn/tests/test_parts.py b/shifter/engine/openvpn/tests/test_parts.py new file mode 100644 index 000000000..5ec89cd92 --- /dev/null +++ b/shifter/engine/openvpn/tests/test_parts.py @@ -0,0 +1,236 @@ +"""Management parsing, nft rendering, configuration, server files, and the portal client (#2480).""" + +from __future__ import annotations + +import ipaddress +import json +import os +import socket +import threading +import urllib.request + +import pytest + +from shifter_openvpn import portal as portal_module +from shifter_openvpn.config import ConfigError, load_config +from shifter_openvpn.firewall import Firewall, base_ruleset +from shifter_openvpn.management import Management, parse_event_header +from shifter_openvpn.portal import PortalClient, PortalRefused, PortalUnavailable +from shifter_openvpn.server_config import render, write_config, write_material + +_MATERIAL = {"ca": "C", "certificate": "S", "private_key": "K", "tls_crypt": "T"} +_GRANT = {"session": "s", "target": "10.50.3.3", "ports": [22], "heartbeat_seconds": 15} +_ENV = { + "PORTAL_URL": "https://portal.example.com", + "CONTROL_AUDIENCE": "https://portal.example.com/vpn-control", + "SERVER_SECRET": "projects/p/secrets/shifter-test-vpn-server", + "SERVER_NAME": "shifter-test-vpn-abcd", +} + + +class TestManagement: + def test_event_headers(self): + assert parse_event_header(">CLIENT:CONNECT,4,1") == ("CONNECT", 4, 1) + assert parse_event_header(">CLIENT:ESTABLISHED,4") == ("ESTABLISHED", 4, None) + assert parse_event_header(">CLIENT:ENV,common_name=x") is None + assert parse_event_header(">INFO:OpenVPN Management Interface") is None + assert parse_event_header(">CLIENT:CONNECT,x,1") is None + + def test_a_connect_block_becomes_one_event_and_commands_get_their_reply(self, tmp_path): + path = str(tmp_path / "m.sock") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(path) + server.listen(1) + received: list[str] = [] + + def openvpn(): + conn, _ = server.accept() + stream = conn.makefile("rw", encoding="utf-8", newline="\n") + stream.write(">INFO:hello\n>CLIENT:CONNECT,4,1\n>CLIENT:ENV,common_name=participant-x\n>CLIENT:ENV,END\n") + stream.flush() + for line in stream: + received.append(line.strip()) + if line.strip() == "END": + stream.write("SUCCESS: client-auth command succeeded\n") + stream.flush() + if line.startswith("client-kill"): + stream.write("ERROR: client not found\n") + stream.flush() + break + conn.close() + + thread = threading.Thread(target=openvpn, daemon=True) + thread.start() + management = Management(path) + + event = management.events.get(timeout=5) + assert (event.kind, event.cid, event.kid, event.env) == ("CONNECT", 4, 1, {"common_name": "participant-x"}) + management.allow(4, 1, ['push "route 10.50.3.3 255.255.255.255"']) + assert received == ["client-auth 4 1", 'push "route 10.50.3.3 255.255.255.255"', "END"] + with pytest.raises(Exception, match="client not found"): + management.kill(9) + assert management.events.get(timeout=5) is None # the socket closed + + +class TestFirewall: + def test_the_base_ruleset_drops_forwarding_and_tunnel_input_by_default(self): + ruleset = base_ruleset(ipaddress.ip_network("100.96.0.0/22")) + assert "type filter hook forward priority filter; policy drop;" in ruleset + assert 'iifname "tun0" drop' in ruleset + assert "ip saddr . ip daddr . tcp dport @allowed accept" in ruleset + assert "ip saddr 100.96.0.0/22" in ruleset + + def test_allowances_are_validated_before_reaching_nft(self): + scripts: list[str] = [] + firewall = Firewall(runner=scripts.append) + firewall.allow("100.96.0.2", "10.50.3.3", (3389, 22)) + firewall.revoke("100.96.0.2", "10.50.3.3", (22,)) + assert scripts == [ + "add element inet shifter_vpn allowed { 100.96.0.2 . 10.50.3.3 . 22, 100.96.0.2 . 10.50.3.3 . 3389 }\n", + "delete element inet shifter_vpn allowed { 100.96.0.2 . 10.50.3.3 . 22 }\n", + ] + for client, target, ports in [ + ("x; flush", "10.50.3.3", (22,)), + ("100.96.0.2", "10.50.3.3", ()), + ("100.96.0.2", "10.50.3.3", (0,)), + ]: + with pytest.raises(ValueError): + firewall.allow(client, target, ports) + + +class TestConfig: + def test_a_complete_environment_loads(self, monkeypatch): + for key, value in _ENV.items(): + monkeypatch.setenv(key, value) + config = load_config() + assert config.tunnel_network == ipaddress.ip_network("100.96.0.0/22") + assert config.max_clients == 250 + + @pytest.mark.parametrize( + ("key", "value"), + [ + ("PORTAL_URL", "http://portal.example.com"), + ("SERVER_SECRET", "not-a-secret-name"), + ("SERVER_NAME", "Bad_Name"), + ("TUNNEL_NETWORK", "100.96.0.0/28"), + ("MAX_CLIENTS", "5000"), + ], + ) + def test_a_defective_environment_is_refused(self, monkeypatch, key, value): + for name, setting in _ENV.items(): + monkeypatch.setenv(name, setting) + monkeypatch.setenv(key, value) + with pytest.raises((ConfigError, ValueError)): + load_config() + + +class TestServerFiles: + def test_material_is_owner_only_and_the_config_is_hardened(self, monkeypatch, tmp_path): + for key, value in {**_ENV, "RUNTIME_DIR": str(tmp_path / "run")}.items(): + monkeypatch.setenv(key, value) + config = load_config() + write_material( + config.runtime_dir, json.dumps({"ca": "C", "certificate": "S", "private_key": "K", "tls_crypt": "T"}) + ) + paths = write_config(config) + assert oct(os.stat(os.path.join(config.runtime_dir, "server.key")).st_mode & 0o777) == "0o600" + text = render(config) + for directive in ( + "tls-crypt", + "remote-cert-tls client", + "verify-client-cert require", + "management-client-auth", + "auth-user-pass-optional", + "user nobody", + "max-clients 250", + ): + assert directive in text + for forbidden in ("client-to-client", "redirect-gateway", "username-as-common-name", "duplicate-cn"): + assert forbidden not in text + assert paths.management_socket.endswith("management.sock") + + def test_material_with_extra_keys_is_refused(self, tmp_path): + with pytest.raises(ValueError): + write_material( + str(tmp_path), json.dumps({"ca": "C", "certificate": "S", "private_key": "K", "tls_crypt": "T", "x": 1}) + ) + + +class _Response: + def __init__(self, status, body): + self.status, self._body = status, body + + def read(self, _limit=None): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +class TestPortalClient: + @pytest.fixture + def client(self, monkeypatch): + sent: list[urllib.request.Request] = [] + replies: list[tuple[int, bytes]] = [] + + class Opener: + def open(self, request, timeout): + sent.append(request) + status, body = replies.pop(0) + if status >= 400: + import io + import urllib.error + + raise urllib.error.HTTPError(request.full_url, status, "x", {}, io.BytesIO(body)) + return _Response(status, body) + + monkeypatch.setattr(portal_module, "_OPENER", Opener()) + client = PortalClient("https://portal.example.com", "aud", "vpn-a", lambda audience: f"token-for-{audience}") + return client, sent, replies + + def test_authorize_sends_the_server_identity_and_returns_a_strict_grant(self, client): + portal, sent, replies = client + replies.append( + (201, json.dumps({"session": "s", "target": "10.50.3.3", "ports": [22], "heartbeat_seconds": 15}).encode()) + ) + + grant = portal.authorize("participant-x", 3, "198.51.100.2") + + assert (grant.session, grant.target, grant.ports) == ("s", "10.50.3.3", (22,)) + assert sent[0].full_url == "https://portal.example.com/api/v1/cms/vpn-control/sessions/" + assert sent[0].get_header("Authorization") == "Bearer token-for-aud" + assert json.loads(sent[0].data)["server"] == "vpn-a" + + @pytest.mark.parametrize( + ("status", "body", "error"), + [ + (403, b'{"error": "vpn.access_revoked"}', PortalRefused), + (500, b"oops", PortalUnavailable), + ( + 201, + b'{"session": "s", "target": "10.50.3.3", "ports": ["22"], "heartbeat_seconds": 15}', + PortalUnavailable, + ), + (201, b"x" * 70_000, PortalUnavailable), + ], + ) + def test_anything_outside_the_contract_is_not_a_grant(self, client, status, body, error): + portal, _sent, replies = client + replies.append((status, body)) + with pytest.raises(error): + portal.authorize("participant-x", 3, "198.51.100.2") + + def test_heartbeat_and_end(self, client): + portal, sent, replies = client + replies.extend([(200, b'{"disconnect": ["s"], "heartbeat_seconds": 15}'), (204, b"")]) + + assert portal.heartbeat(["s"]) == ({"s"}, 15) + portal.end("s") + assert [request.full_url.rsplit("/vpn-control/", 1)[1] for request in sent] == ["heartbeat/", "sessions/end/"] + + def test_redirects_are_refused(self): + handler = portal_module._NoRedirect() + assert handler.redirect_request() is None diff --git a/shifter/engine/openvpn/tests/test_runtime.py b/shifter/engine/openvpn/tests/test_runtime.py new file mode 100644 index 000000000..5324da461 --- /dev/null +++ b/shifter/engine/openvpn/tests/test_runtime.py @@ -0,0 +1,105 @@ +"""The server's metadata identity, Secret Manager reads, health endpoint and startup wait (#2480).""" + +from __future__ import annotations + +import base64 +import json +import socket +import threading +import time +import urllib.request + +import pytest + +from shifter_openvpn import __main__ as entry +from shifter_openvpn import gcp + + +class _Response: + def __init__(self, body: bytes) -> None: + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +@pytest.fixture +def network(monkeypatch): + """Replace only urllib's network call; record every request.""" + seen: list[urllib.request.Request] = [] + + def urlopen(request, timeout): + seen.append(request) + url = request.full_url + if url.endswith("/token"): + return _Response(json.dumps({"access_token": "ya29.test"}).encode()) + if "/identity?" in url: + return _Response(b"eyJ.identity.token\n") + if url.startswith("https://secretmanager.googleapis.com/"): + return _Response(json.dumps({"payload": {"data": base64.b64encode(b"material").decode()}}).encode()) + raise AssertionError(url) + + monkeypatch.setattr(gcp.urllib.request, "urlopen", urlopen) + return seen + + +def test_identity_tokens_are_full_format_for_the_audience(network): + assert gcp.identity_token("https://portal.example.com/vpn-control") == "eyJ.identity.token" + url = network[0].full_url + assert "audience=https%3A%2F%2Fportal.example.com%2Fvpn-control" in url and "format=full" in url + assert network[0].get_header("Metadata-flavor") == "Google" + + +def test_secrets_are_read_with_the_attached_identity(network): + assert gcp.read_secret("projects/p/secrets/server") == "material" + access = network[-1] + assert access.full_url == "https://secretmanager.googleapis.com/v1/projects/p/secrets/server/versions/latest:access" + assert access.get_header("Authorization") == "Bearer ya29.test" + + +def test_the_health_endpoint_reflects_readiness(monkeypatch): + monkeypatch.setattr(entry.socket, "gethostbyname", lambda _name: "127.0.0.1") + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + healthy = threading.Event() + threading.Thread(target=entry._serve_health, args=(port, healthy), daemon=True).start() + + def status(path: str = "/healthz") -> int: + for _ in range(50): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=2) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + except OSError: + time.sleep(0.05) + raise AssertionError("health server did not start") + + assert status() == 503 + healthy.set() + assert status() == 200 + assert status("/other") == 503 + + +class _Process: + def __init__(self, exited: bool) -> None: + self.exited = exited + + def poll(self): + return 1 if self.exited else None + + +def test_startup_waits_for_the_management_socket(tmp_path): + path = tmp_path / "management.sock" + threading.Timer(0.3, path.touch).start() + entry._wait_for_socket(str(path), _Process(exited=False), timeout=5) + + with pytest.raises(RuntimeError, match="management socket"): + entry._wait_for_socket(str(tmp_path / "never"), _Process(exited=True), timeout=5) diff --git a/shifter/engine/openvpn/uv.lock b/shifter/engine/openvpn/uv.lock new file mode 100644 index 000000000..565d274ef --- /dev/null +++ b/shifter/engine/openvpn/uv.lock @@ -0,0 +1,475 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "shifter-openvpn" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.15.13" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/sonar-project.properties b/sonar-project.properties index 8af7e7f93..80346294d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -18,8 +18,8 @@ sonar.projectVersion=2.3.2 # complementary non-regression metric; see docs/dev/testing.md. # Source paths -sonar.sources=shifter/shifter_platform,shifter/engine/provisioner,shifter/packer,shifter/installation,scripts/bootstrap,scripts/check_layer_imports,scripts/adr_guard,mcp/ngfw,mcp/ops,mcp/planner,mcp/shared -sonar.tests=shifter/shifter_platform/tests,shifter/engine/provisioner/tests,shifter/packer/tests,shifter/installation/tests,scripts/bootstrap/tests,scripts/check_layer_imports/tests +sonar.sources=shifter/shifter_platform,shifter/engine/provisioner,shifter/engine/openvpn,shifter/packer,shifter/installation,scripts/bootstrap,scripts/check_layer_imports,scripts/adr_guard,mcp/ngfw,mcp/ops,mcp/planner,mcp/shared +sonar.tests=shifter/shifter_platform/tests,shifter/engine/provisioner/tests,shifter/engine/openvpn/tests,shifter/packer/tests,shifter/installation/tests,scripts/bootstrap/tests,scripts/check_layer_imports/tests # Exclusions (including test files - don't need bug/smell analysis on tests) # scenario-dev/** holds intentionally-vulnerable CTF target content; temp/** is scratch @@ -74,7 +74,7 @@ sonar.coverage.exclusions=**/tests/**,**/conftest.py,**/dev_auth.py,mcp/**,**/st # lcov.info are generated by the _quality.yml test jobs and passed to the # sonarcloud job as artifacts; the paths below are where that job # restores them. -sonar.python.coverage.reportPaths=shifter/shifter_platform/coverage.xml,shifter/engine/provisioner/coverage.xml,shifter/packer/coverage.xml,shifter/installation/coverage.xml,scripts/bootstrap/coverage.xml,scripts/check_layer_imports/coverage.xml,scripts/adr_guard/coverage.xml +sonar.python.coverage.reportPaths=shifter/shifter_platform/coverage.xml,shifter/engine/provisioner/coverage.xml,shifter/engine/openvpn/coverage.xml,shifter/packer/coverage.xml,shifter/installation/coverage.xml,scripts/bootstrap/coverage.xml,scripts/check_layer_imports/coverage.xml,scripts/adr_guard/coverage.xml sonar.javascript.lcov.reportPaths=shifter/shifter_platform/coverage/lcov.info,shifter/shifter_platform/frontend/coverage/lcov.info # Encoding From 6f8309acf4d0f867b42a27dd6518f5d3904f6242 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 06:26:08 +0000 Subject: [PATCH 03/14] feat(infra): deploy the shared OpenVPN pool on GCP 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 --- .github/workflows/_gcp-dev.yml | 123 +++++ .../shifter/templates/networkpolicies.yaml | 2 +- ...atingadmissionpolicy-provisioner-jobs.yaml | 2 +- .../shifter/tests/test_chart_contract.py | 4 +- platform/charts/shifter/values.yaml | 6 +- ...atingadmissionpolicy-provisioner-jobs.yaml | 2 +- .../terraform/gcp/environments/balrog/main.tf | 6 + .../gcp/environments/balrog/outputs.tf | 5 + .../gcp/environments/balrog/variables.tf | 38 ++ .../gcp/environments/gcp-dev/main.tf | 6 + .../gcp/environments/gcp-dev/outputs.tf | 5 + .../gcp/environments/gcp-dev/variables.tf | 38 ++ .../terraform/gcp/environments/nazgul/main.tf | 6 + .../gcp/environments/nazgul/outputs.tf | 5 + .../gcp/environments/nazgul/variables.tf | 38 ++ .../gcp/environments/orthanc/main.tf | 6 + .../gcp/environments/orthanc/outputs.tf | 5 + .../gcp/environments/orthanc/variables.tf | 38 ++ .../terraform/gcp/environments/sauron/main.tf | 6 + .../gcp/environments/sauron/outputs.tf | 5 + .../gcp/environments/sauron/variables.tf | 38 ++ .../gcp/modules/cicd-oidc-identity/main.tf | 57 +++ .../modules/cicd-oidc-identity/variables.tf | 44 ++ .../gcp/modules/platform-core/main.tf | 30 ++ .../gcp/modules/platform-core/outputs.tf | 18 + .../gcp/modules/platform-core/variables.tf | 38 ++ .../terraform/gcp/modules/portal/iam/main.tf | 28 -- .../gcp/modules/portal/iam/variables.tf | 13 - .../modules/vpn-pool/cloud-init.yaml.tftpl | 58 +++ .../terraform/gcp/modules/vpn-pool/main.tf | 424 ++++++++++++++++++ .../terraform/gcp/modules/vpn-pool/outputs.tf | 54 +++ .../gcp/modules/vpn-pool/variables.tf | 120 +++++ .../cloud_identifiers_public_ip_allowlist.txt | 1 + scripts/adr_guard/tests/test_adr_guard.py | 2 +- .../adr_guard/tests/test_deploy_workflow.py | 13 + scripts/gcp/check_event_capacity_drift.py | 38 +- scripts/gcp/ensure_vpn_pki.py | 221 +++++++++ scripts/gcp/probe_range_secret_permissions.py | 96 ---- scripts/gcp/pyproject.toml | 2 +- scripts/gcp/render_private_service_netpol.py | 4 - scripts/gcp/render_runtime_env.py | 40 +- .../tests/test_check_event_capacity_drift.py | 54 +++ scripts/gcp/tests/test_ensure_vpn_pki.py | 139 ++++++ .../test_probe_range_secret_permissions.py | 12 - .../test_render_private_service_netpol.py | 5 +- scripts/gcp/tests/test_render_runtime_env.py | 41 +- scripts/gcp/uv.lock | 163 ++++++- .../provisioner/tests/test_vpn_secrets.py | 6 +- shifter/engine/provisioner/vpn_secrets.py | 2 +- shifter/installation/capacity_profiles_gcp.py | 43 ++ .../backend-bundle-contract.json | 81 ++++ shifter/installation/runtime_inventory_gcp.py | 9 + .../tests/test_capacity_profiles_gcp.py | 19 + shifter/shifter_platform/engine/ecs/_env.py | 5 + 54 files changed, 2086 insertions(+), 178 deletions(-) create mode 100644 platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl create mode 100644 platform/terraform/gcp/modules/vpn-pool/main.tf create mode 100644 platform/terraform/gcp/modules/vpn-pool/outputs.tf create mode 100644 platform/terraform/gcp/modules/vpn-pool/variables.tf create mode 100644 scripts/gcp/ensure_vpn_pki.py create mode 100644 scripts/gcp/tests/test_ensure_vpn_pki.py diff --git a/.github/workflows/_gcp-dev.yml b/.github/workflows/_gcp-dev.yml index 55c4f4928..e960d189c 100644 --- a/.github/workflows/_gcp-dev.yml +++ b/.github/workflows/_gcp-dev.yml @@ -130,6 +130,7 @@ jobs: provisioner_digest: ${{ steps.build-provisioner.outputs.digest }} guacd_digest: ${{ steps.build-guacd.outputs.digest }} guacamole_client_digest: ${{ steps.build-guacamole-client.outputs.digest }} + openvpn_digest: ${{ steps.build-openvpn.outputs.digest }} env: GCP_ENVIRONMENT: ${{ inputs.environment }} USE_GKE_GCLOUD_AUTH_PLUGIN: "True" @@ -245,6 +246,8 @@ jobs: GCP_BOOTSTRAP_ADMIN_EMAIL: ${{ secrets.GCP_BOOTSTRAP_ADMIN_EMAIL }} GCP_BOOTSTRAP_ADMIN_PASSWORD: ${{ secrets.GCP_BOOTSTRAP_ADMIN_PASSWORD }} GCP_CTF_CONTENT_BUCKET: ${{ vars.GCP_CTF_CONTENT_BUCKET }} + # Participant OpenVPN opt-in also deploys the shared pool (#2480). + RANGE_OPENVPN_ENABLED: ${{ vars.RANGE_OPENVPN_ENABLED }} # JSON tfvars gives Terraform exact escaping without interpolating # secret values into HCL or command arguments. The file lives outside # the persistent checkout and is explicitly removed at job end (#2084). @@ -284,6 +287,10 @@ jobs: "ctf_content_bucket_name": os.environ.get("GCP_CTF_CONTENT_BUCKET", ""), } values.update({key: value for key, value in optional.items() if value}) + openvpn = os.environ.get("RANGE_OPENVPN_ENABLED", "").strip().lower() or "false" + if openvpn not in {"true", "false"}: + raise SystemExit("::error::RANGE_OPENVPN_ENABLED must be true or false") + values["openvpn_pool_enabled"] = openvpn == "true" descriptor = os.open( os.environ["TF_VARS_FILE"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, @@ -547,6 +554,7 @@ jobs: env_file.write(f"GUACD_IMAGE_ROOT={image_roots['guacd']}\n") env_file.write(f"GUACAMOLE_CLIENT_IMAGE_ROOT={image_roots['guacamole-client']}\n") env_file.write(f"PROVISIONER_IMAGE_ROOT={image_roots['pulumi-provisioner']}\n") + env_file.write(f"OPENVPN_IMAGE_ROOT={image_roots['openvpn']}\n") env_file.write(f"ARTIFACT_REGISTRY_HOST={image_roots['portal'].split('/')[0]}\n") env_file.write(f"PUBLIC_HOSTNAME={value('public_hostname')}\n") PY @@ -606,6 +614,17 @@ jobs: no-cache: true provenance: mode=max sbom: true + - name: Build and push OpenVPN pool image + id: build-openvpn + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 + with: + context: ./shifter/engine/openvpn + push: true + tags: | + ${{ env.OPENVPN_IMAGE_ROOT }}:${{ env.SHORT_SHA }} + no-cache: true + provenance: mode=max + sbom: true - name: Attest build provenance (portal) uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -630,6 +649,12 @@ jobs: subject-name: ${{ env.GUACAMOLE_CLIENT_IMAGE_ROOT }} subject-digest: ${{ steps.build-guacamole-client.outputs.digest }} push-to-registry: false + - name: Attest build provenance (openvpn) + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.OPENVPN_IMAGE_ROOT }} + subject-digest: ${{ steps.build-openvpn.outputs.digest }} + push-to-registry: false - name: Remove ephemeral preparation inputs if: always() run: | @@ -683,6 +708,7 @@ jobs: PROVISIONER_IMAGE_DIGEST: ${{ needs.prepare.outputs.provisioner_digest }} GUACD_IMAGE_DIGEST: ${{ needs.prepare.outputs.guacd_digest }} GUACAMOLE_CLIENT_IMAGE_DIGEST: ${{ needs.prepare.outputs.guacamole_client_digest }} + OPENVPN_IMAGE_DIGEST: ${{ needs.prepare.outputs.openvpn_digest }} run: | set -euo pipefail prefix="shifter-${GCP_ENVIRONMENT}" @@ -693,10 +719,12 @@ jobs: echo "PROVISIONER_IMAGE_ROOT=${registry}/${GCP_PROJECT_ID}/${prefix}-pulumi-provisioner/pulumi-provisioner" echo "GUACD_IMAGE_ROOT=${registry}/${GCP_PROJECT_ID}/${prefix}-guacd/guacd" echo "GUACAMOLE_CLIENT_IMAGE_ROOT=${registry}/${GCP_PROJECT_ID}/${prefix}-guacamole-client/guacamole-client" + echo "OPENVPN_IMAGE_ROOT=${registry}/${GCP_PROJECT_ID}/${prefix}-openvpn/openvpn" echo "PORTAL_IMAGE_DIGEST=${PORTAL_IMAGE_DIGEST}" echo "PROVISIONER_IMAGE_DIGEST=${PROVISIONER_IMAGE_DIGEST}" echo "GUACD_IMAGE_DIGEST=${GUACD_IMAGE_DIGEST}" echo "GUACAMOLE_CLIENT_IMAGE_DIGEST=${GUACAMOLE_CLIENT_IMAGE_DIGEST}" + echo "OPENVPN_IMAGE_DIGEST=${OPENVPN_IMAGE_DIGEST}" } >> "${GITHUB_ENV}" - name: Login to Artifact Registry with scanner identity uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 @@ -732,12 +760,14 @@ jobs: "provisioner": os.environ["PROVISIONER_IMAGE_ROOT"], "guacd": os.environ["GUACD_IMAGE_ROOT"], "guacamole-client": os.environ["GUACAMOLE_CLIENT_IMAGE_ROOT"], + "openvpn": os.environ["OPENVPN_IMAGE_ROOT"], } digests = { "portal": os.environ["PORTAL_IMAGE_DIGEST"], "provisioner": os.environ["PROVISIONER_IMAGE_DIGEST"], "guacd": os.environ["GUACD_IMAGE_DIGEST"], "guacamole-client": os.environ["GUACAMOLE_CLIENT_IMAGE_DIGEST"], + "openvpn": os.environ["OPENVPN_IMAGE_DIGEST"], } invalid = [name for name, value in digests.items() if len(value) != 71 or not value.startswith("sha256:")] if invalid: @@ -769,6 +799,7 @@ jobs: scan_image provisioner "${PROVISIONER_IMAGE_ROOT}" "${PROVISIONER_IMAGE_DIGEST}" scan_image guacd "${GUACD_IMAGE_ROOT}" "${GUACD_IMAGE_DIGEST}" scan_image guacamole-client "${GUACAMOLE_CLIENT_IMAGE_ROOT}" "${GUACAMOLE_CLIENT_IMAGE_DIGEST}" + scan_image openvpn "${OPENVPN_IMAGE_ROOT}" "${OPENVPN_IMAGE_DIGEST}" exit "${scan_status}" - name: Store raw scan evidence privately id: store_scan_evidence @@ -826,6 +857,7 @@ jobs: "${RUNNER_TEMP}/gcp-release-security/provisioner-trivy.json" \ "${RUNNER_TEMP}/gcp-release-security/guacd-trivy.json" \ "${RUNNER_TEMP}/gcp-release-security/guacamole-client-trivy.json" \ + "${RUNNER_TEMP}/gcp-release-security/openvpn-trivy.json" \ "${RUNNER_TEMP}/gcp-release-scan-verdict.json" rmdir "${RUNNER_TEMP}/trivy-bin" 2>/dev/null || true rmdir "${RUNNER_TEMP}/gcp-release-security" 2>/dev/null || true @@ -963,6 +995,7 @@ jobs: env_file.write(f"GUACD_IMAGE_ROOT={image_roots['guacd']}\n") env_file.write(f"GUACAMOLE_CLIENT_IMAGE_ROOT={image_roots['guacamole-client']}\n") env_file.write(f"PROVISIONER_IMAGE_ROOT={image_roots['pulumi-provisioner']}\n") + env_file.write(f"OPENVPN_IMAGE_ROOT={image_roots['openvpn']}\n") env_file.write(f"PUBLIC_HOSTNAME={value('public_hostname')}\n") PY - name: Rewrite overlay image project and service accounts @@ -1119,6 +1152,7 @@ jobs: PROVISIONER_DIGEST: ${{ needs.prepare.outputs.provisioner_digest }} GUACD_DIGEST: ${{ needs.prepare.outputs.guacd_digest }} GUACAMOLE_CLIENT_DIGEST: ${{ needs.prepare.outputs.guacamole_client_digest }} + OPENVPN_DIGEST: ${{ needs.prepare.outputs.openvpn_digest }} run: | # ADR-037-R6: verify the GitHub OIDC-signed provenance attestation for # each built image@digest and the fixed Brad-Edwards/shifter identity @@ -1145,6 +1179,28 @@ jobs: verify "${PROVISIONER_IMAGE_ROOT}" "${PROVISIONER_DIGEST}" "provisioner" verify "${GUACD_IMAGE_ROOT}" "${GUACD_DIGEST}" "guacd" verify "${GUACAMOLE_CLIENT_IMAGE_ROOT}" "${GUACAMOLE_CLIENT_DIGEST}" "guacamole-client" + verify "${OPENVPN_IMAGE_ROOT}" "${OPENVPN_DIGEST}" "openvpn" + + - name: Ensure OpenVPN pool PKI + id: vpn_pki + run: | + # #2480: the tenant CA and the pool server identity live only in Secret + # Manager (never Terraform state). Runs before the control plane + # restarts so the provisioner can sign participant profiles at once. + set -euo pipefail + pool="$(jq -c '.openvpn_pool.value // empty' /tmp/gcp-terraform-outputs.json)" + if [ -z "${pool}" ]; then + echo "enabled=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + issuer="$(jq -r '.issuer_secret' <<< "${pool}")" + server="$(jq -r '.server_secret' <<< "${pool}")" + uv run --project scripts/gcp python scripts/gcp/ensure_vpn_pki.py \ + --project "${PROJECT_ID}" \ + --issuer-secret "${issuer##*/}" \ + --server-secret "${server##*/}" \ + --changed-output "${GITHUB_OUTPUT}" + echo "enabled=true" >> "${GITHUB_OUTPUT}" - name: Render and apply optional model broker from the canonical chart env: @@ -1392,6 +1448,73 @@ jobs: fi sleep 5 done + - name: Roll out the OpenVPN pool release + if: steps.vpn_pki.outputs.enabled == 'true' + env: + OPENVPN_DIGEST: ${{ needs.prepare.outputs.openvpn_digest }} + PKI_CHANGED: ${{ steps.vpn_pki.outputs.changed }} + run: | + # #2480: pool servers run only the image named by the release record, + # and only this job writes it, after the exact-digest scan and the + # provenance check above. A new record or server identity replaces + # every server, surging new ones in before old ones leave. + set -euo pipefail + if [[ ! "${OPENVPN_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::openvpn build did not produce a valid image digest" + exit 1 + fi + pool="$(jq -c '.openvpn_pool.value' /tmp/gcp-terraform-outputs.json)" + field() { jq -r --arg key "$1" '.[$key]' <<< "${pool}"; } + if [ "$(field image_root)" != "${OPENVPN_IMAGE_ROOT}" ]; then + echo "::error::the pool image root does not match the built openvpn image root" + exit 1 + fi + release_secret="$(field release_secret)" + release_secret="${release_secret##*/}" + group="$(field instance_group)" + region="$(field region)" + release="${OPENVPN_IMAGE_ROOT}@${OPENVPN_DIGEST}" + errors="${RUNNER_TEMP}/openvpn-release.err" + if current="$(gcloud secrets versions access latest --secret="${release_secret}" --project="${PROJECT_ID}" 2>"${errors}")"; then + : + elif grep -q NOT_FOUND "${errors}"; then + current="" + else + cat "${errors}" >&2 + exit 1 + fi + replace="${PKI_CHANGED:-false}" + if [ "${current}" != "${release}" ]; then + printf '%s' "${release}" | gcloud secrets versions add "${release_secret}" --project="${PROJECT_ID}" --data-file=- + replace=true + fi + if [ "${replace}" = true ]; then + gcloud compute instance-groups managed rolling-action replace "${group}" \ + --region="${region}" --project="${PROJECT_ID}" --max-unavailable=0 + fi + gcloud compute instance-groups managed wait-until "${group}" \ + --version-target-reached --region="${region}" --project="${PROJECT_ID}" --timeout=1800 + gcloud compute instance-groups managed wait-until "${group}" \ + --stable --region="${region}" --project="${PROJECT_ID}" --timeout=1800 + backend="$(field backend_service)" + deadline=$((SECONDS + 600)) + until health="$(gcloud compute backend-services get-health "${backend}" --region="${region}" \ + --project="${PROJECT_ID}" --format=json)" \ + && jq -e '[.[].status.healthStatus[]?.healthState] | length > 0 and all(. == "HEALTHY")' <<< "${health}" >/dev/null; do + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "::error::OpenVPN pool servers did not all report healthy within 10 minutes." + exit 1 + fi + sleep 15 + done + # The pool reads only the latest record; superseded ones are history. + gcloud secrets versions list "${release_secret}" --project="${PROJECT_ID}" \ + --filter=state:ENABLED --sort-by=~createTime --format='value(name)' \ + | tail -n +2 | while read -r version; do + gcloud secrets versions destroy "${version##*/}" --secret="${release_secret}" \ + --project="${PROJECT_ID}" --quiet + done + rm -f "${errors}" - name: Record running workload image IDs env: PORTAL_IMAGE_DIGEST: ${{ needs.prepare.outputs.portal_digest }} diff --git a/platform/charts/shifter/templates/networkpolicies.yaml b/platform/charts/shifter/templates/networkpolicies.yaml index 91b8821dd..65fe1056b 100644 --- a/platform/charts/shifter/templates/networkpolicies.yaml +++ b/platform/charts/shifter/templates/networkpolicies.yaml @@ -330,7 +330,7 @@ metadata: {{- include "shifter.partOfLabels" . | nindent 4 }} spec: # The provisioner Job reaches range guests during provisioning (host/guest SSH - # setup, WinRM, and the OpenVPN gateway health probe). Range VMs are + # setup and WinRM). Range VMs are # private-only, so without this the default-deny blocks the provisioner -> range # path even though the per-range GCE firewall permits it. Kustomize parity: # render_private_service_netpol.py emits the same rule. diff --git a/platform/charts/shifter/templates/validatingadmissionpolicy-provisioner-jobs.yaml b/platform/charts/shifter/templates/validatingadmissionpolicy-provisioner-jobs.yaml index c6a8bef13..0da1dfc03 100644 --- a/platform/charts/shifter/templates/validatingadmissionpolicy-provisioner-jobs.yaml +++ b/platform/charts/shifter/templates/validatingadmissionpolicy-provisioner-jobs.yaml @@ -98,7 +98,7 @@ spec: {{- else }} - name: allowedLiteralEnv expression: >- - ['CLOUD_PROVIDER', 'ENVIRONMENT', 'CLOUD_REGION', 'AWS_REGION', 'GCP_REGION', 'GCP_PROJECT_ID', 'MODEL_BROKER_GUEST_URL', 'MODEL_BROKER_GUEST_VIP', 'MODEL_ENROLLMENT_CONTROL_URL', 'MODEL_ENROLLMENT_CA_PEM_B64', 'GCP_DYNAMIC_SECRET_PROJECT_ID', 'GOOGLE_CLOUD_PROJECT', 'CLOUD_PROJECT_ID', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'STORAGE_BUCKET_NAME', 'AGENT_STORAGE_BUCKET', 'AGENT_S3_BUCKET', 'RANGE_NETWORK_ID', 'RANGE_NETWORK_CIDR', 'RANGE_NETWORK_REGION', 'RANGE_NETWORK_ZONE', 'PORTAL_NETWORK_CIDRS', 'ACCESS_NETWORK_CIDRS', 'GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_BACKEND', 'GCP_RANGE_PLANE', 'GCP_RANGE_CELL_NETWORK_MODE', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES', 'GCP_RANGE_HOST_IDENTITY_POOL_SIZE', 'GCP_RANGE_LINUX_IMAGE', 'GCP_RANGE_LINUX_MACHINE_TYPE', 'GCP_RANGE_LINUX_DISK_SIZE_GB', 'GCP_RANGE_LINUX_DISK_TYPE', 'GCP_RANGE_KALI_IMAGE', 'GCP_RANGE_KALI_MACHINE_TYPE', 'GCP_RANGE_KALI_DISK_SIZE_GB', 'GCP_RANGE_KALI_DISK_TYPE', 'GCP_RANGE_IMAGE_KEY_PROFILES_JSON', 'GCP_RANGE_WINDOWS_IMAGE', 'GCP_RANGE_WINDOWS_MACHINE_TYPE', 'GCP_RANGE_WINDOWS_DISK_SIZE_GB', 'GCP_RANGE_WINDOWS_DISK_TYPE', 'GCP_RANGE_DC_IMAGE', 'GCP_RANGE_DC_MACHINE_TYPE', 'GCP_RANGE_DC_DISK_SIZE_GB', 'GCP_RANGE_DC_DISK_TYPE', 'GCP_RANGE_EGRESS_ALLOW_CIDRS', 'GCP_RANGE_PRIVATE_GOOGLE_ACCESS', 'GCP_RANGE_HOST_MGMT_SSH_PORT', 'GCP_RANGE_PREPROVISIONED_FIREWALLS', 'GDC_ACCESS_SECRET_ID', 'GDC_RANGE_NAMESPACE_PREFIX', 'GDC_NETWORK_INTERFACE', 'GDC_NETWORK_DNS_NAMESERVERS', 'GDC_STATIC_IP_RESERVATION_COUNT', 'GDC_VM_STORAGE_CLASS', 'GDC_VM_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_IMAGE_URL', 'GDC_VMSERIES_BOOTSTRAP_BUCKET', 'GDC_VMSERIES_STORAGE_CLASS', 'GDC_VMSERIES_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_NAMESPACE_PREFIX', 'GDC_VMSERIES_MGMT_NETWORK_NAME', 'GDC_VMSERIES_MGMT_IP_CIDR', 'GDC_VMSERIES_DATA_NETWORK_NAME', 'GDC_VMSERIES_DATA_IP_CIDR', 'GDC_VMSERIES_ROUTE_NEXT_HOP_IP', 'GDC_VMSERIES_VCPUS', 'GDC_VMSERIES_MEMORY', 'GDC_VMSERIES_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_XML_TEMPLATE_SECRET_ID', 'GDC_KALI_IMAGE_URL', 'GDC_KALI_VCPUS', 'GDC_KALI_MEMORY', 'GDC_KALI_DISK_SIZE_GIB', 'GDC_UBUNTU_IMAGE_URL', 'GDC_UBUNTU_VCPUS', 'GDC_UBUNTU_MEMORY', 'GDC_UBUNTU_DISK_SIZE_GIB', 'GDC_WINDOWS_IMAGE_URL', 'GDC_WINDOWS_VCPUS', 'GDC_WINDOWS_MEMORY', 'GDC_WINDOWS_DISK_SIZE_GIB', 'GDC_DC_IMAGE_URL', 'GDC_DC_VCPUS', 'GDC_DC_MEMORY', 'GDC_DC_DISK_SIZE_GIB', 'GDC_SCENARIO_POD_IMAGE_PULL_POLICY', 'GDC_SCENARIO_POD_KALI_IMAGE', 'GDC_SCENARIO_POD_UBUNTU_IMAGE', 'GDC_SETUP_RUNNER_IMAGE', 'ENGINE_TASK_IMAGE', 'RANGE_VPC_ID', 'RANGE_VPC_CIDR', 'RANGE_AVAILABILITY_ZONE', 'AVAILABILITY_ZONE'] + ['CLOUD_PROVIDER', 'ENVIRONMENT', 'CLOUD_REGION', 'AWS_REGION', 'GCP_REGION', 'GCP_PROJECT_ID', 'MODEL_BROKER_GUEST_URL', 'MODEL_BROKER_GUEST_VIP', 'MODEL_ENROLLMENT_CONTROL_URL', 'MODEL_ENROLLMENT_CA_PEM_B64', 'GCP_DYNAMIC_SECRET_PROJECT_ID', 'GOOGLE_CLOUD_PROJECT', 'CLOUD_PROJECT_ID', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'STORAGE_BUCKET_NAME', 'AGENT_STORAGE_BUCKET', 'AGENT_S3_BUCKET', 'RANGE_NETWORK_ID', 'RANGE_NETWORK_CIDR', 'RANGE_NETWORK_REGION', 'RANGE_NETWORK_ZONE', 'PORTAL_NETWORK_CIDRS', 'ACCESS_NETWORK_CIDRS', 'RANGE_OPENVPN_ENDPOINT', 'RANGE_OPENVPN_ISSUER_SECRET_ID', 'RANGE_OPENVPN_POOL_CIDRS', 'GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_BACKEND', 'GCP_RANGE_PLANE', 'GCP_RANGE_CELL_NETWORK_MODE', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES', 'GCP_RANGE_HOST_IDENTITY_POOL_SIZE', 'GCP_RANGE_LINUX_IMAGE', 'GCP_RANGE_LINUX_MACHINE_TYPE', 'GCP_RANGE_LINUX_DISK_SIZE_GB', 'GCP_RANGE_LINUX_DISK_TYPE', 'GCP_RANGE_KALI_IMAGE', 'GCP_RANGE_KALI_MACHINE_TYPE', 'GCP_RANGE_KALI_DISK_SIZE_GB', 'GCP_RANGE_KALI_DISK_TYPE', 'GCP_RANGE_IMAGE_KEY_PROFILES_JSON', 'GCP_RANGE_WINDOWS_IMAGE', 'GCP_RANGE_WINDOWS_MACHINE_TYPE', 'GCP_RANGE_WINDOWS_DISK_SIZE_GB', 'GCP_RANGE_WINDOWS_DISK_TYPE', 'GCP_RANGE_DC_IMAGE', 'GCP_RANGE_DC_MACHINE_TYPE', 'GCP_RANGE_DC_DISK_SIZE_GB', 'GCP_RANGE_DC_DISK_TYPE', 'GCP_RANGE_EGRESS_ALLOW_CIDRS', 'GCP_RANGE_PRIVATE_GOOGLE_ACCESS', 'GCP_RANGE_HOST_MGMT_SSH_PORT', 'GCP_RANGE_PREPROVISIONED_FIREWALLS', 'GDC_ACCESS_SECRET_ID', 'GDC_RANGE_NAMESPACE_PREFIX', 'GDC_NETWORK_INTERFACE', 'GDC_NETWORK_DNS_NAMESERVERS', 'GDC_STATIC_IP_RESERVATION_COUNT', 'GDC_VM_STORAGE_CLASS', 'GDC_VM_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_IMAGE_URL', 'GDC_VMSERIES_BOOTSTRAP_BUCKET', 'GDC_VMSERIES_STORAGE_CLASS', 'GDC_VMSERIES_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_NAMESPACE_PREFIX', 'GDC_VMSERIES_MGMT_NETWORK_NAME', 'GDC_VMSERIES_MGMT_IP_CIDR', 'GDC_VMSERIES_DATA_NETWORK_NAME', 'GDC_VMSERIES_DATA_IP_CIDR', 'GDC_VMSERIES_ROUTE_NEXT_HOP_IP', 'GDC_VMSERIES_VCPUS', 'GDC_VMSERIES_MEMORY', 'GDC_VMSERIES_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_XML_TEMPLATE_SECRET_ID', 'GDC_KALI_IMAGE_URL', 'GDC_KALI_VCPUS', 'GDC_KALI_MEMORY', 'GDC_KALI_DISK_SIZE_GIB', 'GDC_UBUNTU_IMAGE_URL', 'GDC_UBUNTU_VCPUS', 'GDC_UBUNTU_MEMORY', 'GDC_UBUNTU_DISK_SIZE_GIB', 'GDC_WINDOWS_IMAGE_URL', 'GDC_WINDOWS_VCPUS', 'GDC_WINDOWS_MEMORY', 'GDC_WINDOWS_DISK_SIZE_GIB', 'GDC_DC_IMAGE_URL', 'GDC_DC_VCPUS', 'GDC_DC_MEMORY', 'GDC_DC_DISK_SIZE_GIB', 'GDC_SCENARIO_POD_IMAGE_PULL_POLICY', 'GDC_SCENARIO_POD_KALI_IMAGE', 'GDC_SCENARIO_POD_UBUNTU_IMAGE', 'GDC_SETUP_RUNNER_IMAGE', 'ENGINE_TASK_IMAGE', 'RANGE_VPC_ID', 'RANGE_VPC_CIDR', 'RANGE_AVAILABILITY_ZONE', 'AVAILABILITY_ZONE'] - name: allowedSecretEnv expression: "['DB_PASSWORD', 'FIELD_ENCRYPTION_KEY', 'DC_DOMAIN_PASSWORD']" - name: requiredLiteralEnv diff --git a/platform/charts/shifter/tests/test_chart_contract.py b/platform/charts/shifter/tests/test_chart_contract.py index 2bb283457..886ac65e3 100644 --- a/platform/charts/shifter/tests/test_chart_contract.py +++ b/platform/charts/shifter/tests/test_chart_contract.py @@ -47,8 +47,8 @@ # Regenerated for #2305 after granting namespace-scoped pod listing for cancellation. # Regenerated after raising provisioner launcher memory for burst requests. GCP_RENDER_SHA256 = { - "gcp-dev": "28a10854e15f7d4f307901a462b2ea7e47e16f6fad1d91b4f87f91d2497b5f67", - "gcp-prod": "169d18f669b415eac87c90290265c91dc4d74cf2fa165fd2d2e782e9fa2570a4", + "gcp-dev": "0c85feed84ee648c61593ab50af90553b328331bd92d8a7dc90cfee176386635", + "gcp-prod": "6c56b2f5b1f3a9a6b077ad8a030b47ef3965704392a1add294d7bedf1c13fffe", } diff --git a/platform/charts/shifter/values.yaml b/platform/charts/shifter/values.yaml index fd7379d5b..d7fdd0e86 100644 --- a/platform/charts/shifter/values.yaml +++ b/platform/charts/shifter/values.yaml @@ -390,9 +390,9 @@ network: - 22 - 3389 # Ports the provisioner Jobs dial on range guests: guest SSH (22), the - # container-host management sshd (2222), RDP (3389), WinRM (5985/5986), and the - # participant OpenVPN gateway health responder (1195, #2030). Kept in step with - # scripts/gcp/render_private_service_netpol.py for the kustomize deploy path. + # container-host management sshd (2222), RDP (3389), and WinRM (5985/5986). + # Kept in step with scripts/gcp/render_private_service_netpol.py for the + # kustomize deploy path. rangeProvisionerPorts: - 22 - 2222 diff --git a/platform/k8s/gcp/base/validatingadmissionpolicy-provisioner-jobs.yaml b/platform/k8s/gcp/base/validatingadmissionpolicy-provisioner-jobs.yaml index 96b6a8132..73bd714f2 100644 --- a/platform/k8s/gcp/base/validatingadmissionpolicy-provisioner-jobs.yaml +++ b/platform/k8s/gcp/base/validatingadmissionpolicy-provisioner-jobs.yaml @@ -61,7 +61,7 @@ spec: expression: "('ENGINE_TASK_IMAGE_PULL_POLICY' in variables.paramData) ? variables.paramData['ENGINE_TASK_IMAGE_PULL_POLICY'] : ''" - name: allowedLiteralEnv expression: >- - ['CLOUD_PROVIDER', 'ENVIRONMENT', 'CLOUD_REGION', 'AWS_REGION', 'GCP_REGION', 'GCP_PROJECT_ID', 'MODEL_BROKER_GUEST_URL', 'MODEL_BROKER_GUEST_VIP', 'MODEL_ENROLLMENT_CONTROL_URL', 'MODEL_ENROLLMENT_CA_PEM_B64', 'GCP_DYNAMIC_SECRET_PROJECT_ID', 'GOOGLE_CLOUD_PROJECT', 'CLOUD_PROJECT_ID', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'STORAGE_BUCKET_NAME', 'AGENT_STORAGE_BUCKET', 'AGENT_S3_BUCKET', 'RANGE_NETWORK_ID', 'RANGE_NETWORK_CIDR', 'RANGE_NETWORK_REGION', 'RANGE_NETWORK_ZONE', 'PORTAL_NETWORK_CIDRS', 'ACCESS_NETWORK_CIDRS', 'GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_BACKEND', 'GCP_RANGE_PLANE', 'GCP_RANGE_CELL_NETWORK_MODE', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES', 'GCP_RANGE_HOST_IDENTITY_POOL_SIZE', 'GCP_RANGE_LINUX_IMAGE', 'GCP_RANGE_LINUX_MACHINE_TYPE', 'GCP_RANGE_LINUX_DISK_SIZE_GB', 'GCP_RANGE_LINUX_DISK_TYPE', 'GCP_RANGE_KALI_IMAGE', 'GCP_RANGE_KALI_MACHINE_TYPE', 'GCP_RANGE_KALI_DISK_SIZE_GB', 'GCP_RANGE_KALI_DISK_TYPE', 'GCP_RANGE_IMAGE_KEY_PROFILES_JSON', 'GCP_RANGE_WINDOWS_IMAGE', 'GCP_RANGE_WINDOWS_MACHINE_TYPE', 'GCP_RANGE_WINDOWS_DISK_SIZE_GB', 'GCP_RANGE_WINDOWS_DISK_TYPE', 'GCP_RANGE_DC_IMAGE', 'GCP_RANGE_DC_MACHINE_TYPE', 'GCP_RANGE_DC_DISK_SIZE_GB', 'GCP_RANGE_DC_DISK_TYPE', 'GCP_RANGE_EGRESS_ALLOW_CIDRS', 'GCP_RANGE_PRIVATE_GOOGLE_ACCESS', 'GCP_RANGE_HOST_MGMT_SSH_PORT', 'GCP_RANGE_PREPROVISIONED_FIREWALLS', 'GDC_ACCESS_SECRET_ID', 'GDC_RANGE_NAMESPACE_PREFIX', 'GDC_NETWORK_INTERFACE', 'GDC_NETWORK_DNS_NAMESERVERS', 'GDC_STATIC_IP_RESERVATION_COUNT', 'GDC_VM_STORAGE_CLASS', 'GDC_VM_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_IMAGE_URL', 'GDC_VMSERIES_BOOTSTRAP_BUCKET', 'GDC_VMSERIES_STORAGE_CLASS', 'GDC_VMSERIES_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_NAMESPACE_PREFIX', 'GDC_VMSERIES_MGMT_NETWORK_NAME', 'GDC_VMSERIES_MGMT_IP_CIDR', 'GDC_VMSERIES_DATA_NETWORK_NAME', 'GDC_VMSERIES_DATA_IP_CIDR', 'GDC_VMSERIES_ROUTE_NEXT_HOP_IP', 'GDC_VMSERIES_VCPUS', 'GDC_VMSERIES_MEMORY', 'GDC_VMSERIES_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_XML_TEMPLATE_SECRET_ID', 'GDC_KALI_IMAGE_URL', 'GDC_KALI_VCPUS', 'GDC_KALI_MEMORY', 'GDC_KALI_DISK_SIZE_GIB', 'GDC_UBUNTU_IMAGE_URL', 'GDC_UBUNTU_VCPUS', 'GDC_UBUNTU_MEMORY', 'GDC_UBUNTU_DISK_SIZE_GIB', 'GDC_WINDOWS_IMAGE_URL', 'GDC_WINDOWS_VCPUS', 'GDC_WINDOWS_MEMORY', 'GDC_WINDOWS_DISK_SIZE_GIB', 'GDC_DC_IMAGE_URL', 'GDC_DC_VCPUS', 'GDC_DC_MEMORY', 'GDC_DC_DISK_SIZE_GIB', 'GDC_SCENARIO_POD_IMAGE_PULL_POLICY', 'GDC_SCENARIO_POD_KALI_IMAGE', 'GDC_SCENARIO_POD_UBUNTU_IMAGE', 'GDC_SETUP_RUNNER_IMAGE', 'ENGINE_TASK_IMAGE', 'RANGE_VPC_ID', 'RANGE_VPC_CIDR', 'RANGE_AVAILABILITY_ZONE', 'AVAILABILITY_ZONE'] + ['CLOUD_PROVIDER', 'ENVIRONMENT', 'CLOUD_REGION', 'AWS_REGION', 'GCP_REGION', 'GCP_PROJECT_ID', 'MODEL_BROKER_GUEST_URL', 'MODEL_BROKER_GUEST_VIP', 'MODEL_ENROLLMENT_CONTROL_URL', 'MODEL_ENROLLMENT_CA_PEM_B64', 'GCP_DYNAMIC_SECRET_PROJECT_ID', 'GOOGLE_CLOUD_PROJECT', 'CLOUD_PROJECT_ID', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'STORAGE_BUCKET_NAME', 'AGENT_STORAGE_BUCKET', 'AGENT_S3_BUCKET', 'RANGE_NETWORK_ID', 'RANGE_NETWORK_CIDR', 'RANGE_NETWORK_REGION', 'RANGE_NETWORK_ZONE', 'PORTAL_NETWORK_CIDRS', 'ACCESS_NETWORK_CIDRS', 'RANGE_OPENVPN_ENDPOINT', 'RANGE_OPENVPN_ISSUER_SECRET_ID', 'RANGE_OPENVPN_POOL_CIDRS', 'GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_BACKEND', 'GCP_RANGE_PLANE', 'GCP_RANGE_CELL_NETWORK_MODE', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL', 'GCP_RANGE_HOST_SERVICE_ACCOUNT_SCOPES', 'GCP_RANGE_HOST_IDENTITY_POOL_SIZE', 'GCP_RANGE_LINUX_IMAGE', 'GCP_RANGE_LINUX_MACHINE_TYPE', 'GCP_RANGE_LINUX_DISK_SIZE_GB', 'GCP_RANGE_LINUX_DISK_TYPE', 'GCP_RANGE_KALI_IMAGE', 'GCP_RANGE_KALI_MACHINE_TYPE', 'GCP_RANGE_KALI_DISK_SIZE_GB', 'GCP_RANGE_KALI_DISK_TYPE', 'GCP_RANGE_IMAGE_KEY_PROFILES_JSON', 'GCP_RANGE_WINDOWS_IMAGE', 'GCP_RANGE_WINDOWS_MACHINE_TYPE', 'GCP_RANGE_WINDOWS_DISK_SIZE_GB', 'GCP_RANGE_WINDOWS_DISK_TYPE', 'GCP_RANGE_DC_IMAGE', 'GCP_RANGE_DC_MACHINE_TYPE', 'GCP_RANGE_DC_DISK_SIZE_GB', 'GCP_RANGE_DC_DISK_TYPE', 'GCP_RANGE_EGRESS_ALLOW_CIDRS', 'GCP_RANGE_PRIVATE_GOOGLE_ACCESS', 'GCP_RANGE_HOST_MGMT_SSH_PORT', 'GCP_RANGE_PREPROVISIONED_FIREWALLS', 'GDC_ACCESS_SECRET_ID', 'GDC_RANGE_NAMESPACE_PREFIX', 'GDC_NETWORK_INTERFACE', 'GDC_NETWORK_DNS_NAMESERVERS', 'GDC_STATIC_IP_RESERVATION_COUNT', 'GDC_VM_STORAGE_CLASS', 'GDC_VM_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_IMAGE_URL', 'GDC_VMSERIES_BOOTSTRAP_BUCKET', 'GDC_VMSERIES_STORAGE_CLASS', 'GDC_VMSERIES_IMAGE_GCS_SECRET_ID', 'GDC_VMSERIES_NAMESPACE_PREFIX', 'GDC_VMSERIES_MGMT_NETWORK_NAME', 'GDC_VMSERIES_MGMT_IP_CIDR', 'GDC_VMSERIES_DATA_NETWORK_NAME', 'GDC_VMSERIES_DATA_IP_CIDR', 'GDC_VMSERIES_ROUTE_NEXT_HOP_IP', 'GDC_VMSERIES_VCPUS', 'GDC_VMSERIES_MEMORY', 'GDC_VMSERIES_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_DISK_SIZE_GIB', 'GDC_VMSERIES_BOOTSTRAP_XML_TEMPLATE_SECRET_ID', 'GDC_KALI_IMAGE_URL', 'GDC_KALI_VCPUS', 'GDC_KALI_MEMORY', 'GDC_KALI_DISK_SIZE_GIB', 'GDC_UBUNTU_IMAGE_URL', 'GDC_UBUNTU_VCPUS', 'GDC_UBUNTU_MEMORY', 'GDC_UBUNTU_DISK_SIZE_GIB', 'GDC_WINDOWS_IMAGE_URL', 'GDC_WINDOWS_VCPUS', 'GDC_WINDOWS_MEMORY', 'GDC_WINDOWS_DISK_SIZE_GIB', 'GDC_DC_IMAGE_URL', 'GDC_DC_VCPUS', 'GDC_DC_MEMORY', 'GDC_DC_DISK_SIZE_GIB', 'GDC_SCENARIO_POD_IMAGE_PULL_POLICY', 'GDC_SCENARIO_POD_KALI_IMAGE', 'GDC_SCENARIO_POD_UBUNTU_IMAGE', 'GDC_SETUP_RUNNER_IMAGE', 'ENGINE_TASK_IMAGE', 'RANGE_VPC_ID', 'RANGE_VPC_CIDR', 'RANGE_AVAILABILITY_ZONE', 'AVAILABILITY_ZONE'] - name: allowedSecretEnv expression: "['DB_PASSWORD', 'FIELD_ENCRYPTION_KEY', 'DC_DOMAIN_PASSWORD']" - name: requiredLiteralEnv diff --git a/platform/terraform/gcp/environments/balrog/main.tf b/platform/terraform/gcp/environments/balrog/main.tf index 06ada9c0e..b44e90364 100644 --- a/platform/terraform/gcp/environments/balrog/main.tf +++ b/platform/terraform/gcp/environments/balrog/main.tf @@ -98,6 +98,12 @@ module "platform_core" { provisioner_node_count = var.provisioner_node_count access_node_count = var.access_node_count access_node_max_count = var.access_node_max_count + openvpn_pool_enabled = var.openvpn_pool_enabled + openvpn_pool_subnet_cidr = var.openvpn_pool_subnet_cidr + openvpn_pool_machine_type = var.vpn_pool_machine_type + openvpn_pool_min_vms = var.vpn_pool_min_vms + openvpn_pool_max_vms = var.vpn_pool_max_vms + openvpn_pool_cpu_target_pct = var.vpn_pool_cpu_target_pct shared_service_capacity_profile = var.shared_service_capacity_profile cloud_sql_database_version = var.cloud_sql_database_version cloud_sql_tier = var.cloud_sql_tier diff --git a/platform/terraform/gcp/environments/balrog/outputs.tf b/platform/terraform/gcp/environments/balrog/outputs.tf index 7ee4fa5ee..1be4d3f2d 100644 --- a/platform/terraform/gcp/environments/balrog/outputs.tf +++ b/platform/terraform/gcp/environments/balrog/outputs.tf @@ -204,3 +204,8 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = module.platform_core.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when not deployed." + value = module.platform_core.openvpn_pool +} diff --git a/platform/terraform/gcp/environments/balrog/variables.tf b/platform/terraform/gcp/environments/balrog/variables.tf index 5fdb9bb96..94c54d5c7 100644 --- a/platform/terraform/gcp/environments/balrog/variables.tf +++ b/platform/terraform/gcp/environments/balrog/variables.tf @@ -447,3 +447,41 @@ variable "gcp_bootstrap_admin_password" { default = "" sensitive = true } + +# Shared participant OpenVPN server pool (#2480). openvpn_pool_enabled comes from +# RANGE_OPENVPN_ENABLED; the vpn_pool_* sizing comes from the capacity profile. +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC." + type = string + default = "10.49.0.0/24" +} + +variable "vpn_pool_machine_type" { + description = "Pool server machine type; the selected capacity profile owns this value." + type = string + default = "e2-standard-2" +} + +variable "vpn_pool_min_vms" { + description = "Minimum pool servers; the selected capacity profile owns this bound." + type = number + default = 2 +} + +variable "vpn_pool_max_vms" { + description = "Maximum pool servers; the selected capacity profile owns this bound." + type = number + default = 4 +} + +variable "vpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent; the selected capacity profile owns this value." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/environments/gcp-dev/main.tf b/platform/terraform/gcp/environments/gcp-dev/main.tf index 06ada9c0e..b44e90364 100644 --- a/platform/terraform/gcp/environments/gcp-dev/main.tf +++ b/platform/terraform/gcp/environments/gcp-dev/main.tf @@ -98,6 +98,12 @@ module "platform_core" { provisioner_node_count = var.provisioner_node_count access_node_count = var.access_node_count access_node_max_count = var.access_node_max_count + openvpn_pool_enabled = var.openvpn_pool_enabled + openvpn_pool_subnet_cidr = var.openvpn_pool_subnet_cidr + openvpn_pool_machine_type = var.vpn_pool_machine_type + openvpn_pool_min_vms = var.vpn_pool_min_vms + openvpn_pool_max_vms = var.vpn_pool_max_vms + openvpn_pool_cpu_target_pct = var.vpn_pool_cpu_target_pct shared_service_capacity_profile = var.shared_service_capacity_profile cloud_sql_database_version = var.cloud_sql_database_version cloud_sql_tier = var.cloud_sql_tier diff --git a/platform/terraform/gcp/environments/gcp-dev/outputs.tf b/platform/terraform/gcp/environments/gcp-dev/outputs.tf index 7ee4fa5ee..1be4d3f2d 100644 --- a/platform/terraform/gcp/environments/gcp-dev/outputs.tf +++ b/platform/terraform/gcp/environments/gcp-dev/outputs.tf @@ -204,3 +204,8 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = module.platform_core.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when not deployed." + value = module.platform_core.openvpn_pool +} diff --git a/platform/terraform/gcp/environments/gcp-dev/variables.tf b/platform/terraform/gcp/environments/gcp-dev/variables.tf index eab7ead5a..1871c0a9d 100644 --- a/platform/terraform/gcp/environments/gcp-dev/variables.tf +++ b/platform/terraform/gcp/environments/gcp-dev/variables.tf @@ -447,3 +447,41 @@ variable "gcp_bootstrap_admin_password" { default = "" sensitive = true } + +# Shared participant OpenVPN server pool (#2480). openvpn_pool_enabled comes from +# RANGE_OPENVPN_ENABLED; the vpn_pool_* sizing comes from the capacity profile. +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC." + type = string + default = "10.49.0.0/24" +} + +variable "vpn_pool_machine_type" { + description = "Pool server machine type; the selected capacity profile owns this value." + type = string + default = "e2-standard-2" +} + +variable "vpn_pool_min_vms" { + description = "Minimum pool servers; the selected capacity profile owns this bound." + type = number + default = 2 +} + +variable "vpn_pool_max_vms" { + description = "Maximum pool servers; the selected capacity profile owns this bound." + type = number + default = 4 +} + +variable "vpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent; the selected capacity profile owns this value." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/environments/nazgul/main.tf b/platform/terraform/gcp/environments/nazgul/main.tf index 06ada9c0e..b44e90364 100644 --- a/platform/terraform/gcp/environments/nazgul/main.tf +++ b/platform/terraform/gcp/environments/nazgul/main.tf @@ -98,6 +98,12 @@ module "platform_core" { provisioner_node_count = var.provisioner_node_count access_node_count = var.access_node_count access_node_max_count = var.access_node_max_count + openvpn_pool_enabled = var.openvpn_pool_enabled + openvpn_pool_subnet_cidr = var.openvpn_pool_subnet_cidr + openvpn_pool_machine_type = var.vpn_pool_machine_type + openvpn_pool_min_vms = var.vpn_pool_min_vms + openvpn_pool_max_vms = var.vpn_pool_max_vms + openvpn_pool_cpu_target_pct = var.vpn_pool_cpu_target_pct shared_service_capacity_profile = var.shared_service_capacity_profile cloud_sql_database_version = var.cloud_sql_database_version cloud_sql_tier = var.cloud_sql_tier diff --git a/platform/terraform/gcp/environments/nazgul/outputs.tf b/platform/terraform/gcp/environments/nazgul/outputs.tf index 7ee4fa5ee..1be4d3f2d 100644 --- a/platform/terraform/gcp/environments/nazgul/outputs.tf +++ b/platform/terraform/gcp/environments/nazgul/outputs.tf @@ -204,3 +204,8 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = module.platform_core.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when not deployed." + value = module.platform_core.openvpn_pool +} diff --git a/platform/terraform/gcp/environments/nazgul/variables.tf b/platform/terraform/gcp/environments/nazgul/variables.tf index 5fdb9bb96..94c54d5c7 100644 --- a/platform/terraform/gcp/environments/nazgul/variables.tf +++ b/platform/terraform/gcp/environments/nazgul/variables.tf @@ -447,3 +447,41 @@ variable "gcp_bootstrap_admin_password" { default = "" sensitive = true } + +# Shared participant OpenVPN server pool (#2480). openvpn_pool_enabled comes from +# RANGE_OPENVPN_ENABLED; the vpn_pool_* sizing comes from the capacity profile. +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC." + type = string + default = "10.49.0.0/24" +} + +variable "vpn_pool_machine_type" { + description = "Pool server machine type; the selected capacity profile owns this value." + type = string + default = "e2-standard-2" +} + +variable "vpn_pool_min_vms" { + description = "Minimum pool servers; the selected capacity profile owns this bound." + type = number + default = 2 +} + +variable "vpn_pool_max_vms" { + description = "Maximum pool servers; the selected capacity profile owns this bound." + type = number + default = 4 +} + +variable "vpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent; the selected capacity profile owns this value." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/environments/orthanc/main.tf b/platform/terraform/gcp/environments/orthanc/main.tf index 06ada9c0e..b44e90364 100644 --- a/platform/terraform/gcp/environments/orthanc/main.tf +++ b/platform/terraform/gcp/environments/orthanc/main.tf @@ -98,6 +98,12 @@ module "platform_core" { provisioner_node_count = var.provisioner_node_count access_node_count = var.access_node_count access_node_max_count = var.access_node_max_count + openvpn_pool_enabled = var.openvpn_pool_enabled + openvpn_pool_subnet_cidr = var.openvpn_pool_subnet_cidr + openvpn_pool_machine_type = var.vpn_pool_machine_type + openvpn_pool_min_vms = var.vpn_pool_min_vms + openvpn_pool_max_vms = var.vpn_pool_max_vms + openvpn_pool_cpu_target_pct = var.vpn_pool_cpu_target_pct shared_service_capacity_profile = var.shared_service_capacity_profile cloud_sql_database_version = var.cloud_sql_database_version cloud_sql_tier = var.cloud_sql_tier diff --git a/platform/terraform/gcp/environments/orthanc/outputs.tf b/platform/terraform/gcp/environments/orthanc/outputs.tf index 7ee4fa5ee..1be4d3f2d 100644 --- a/platform/terraform/gcp/environments/orthanc/outputs.tf +++ b/platform/terraform/gcp/environments/orthanc/outputs.tf @@ -204,3 +204,8 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = module.platform_core.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when not deployed." + value = module.platform_core.openvpn_pool +} diff --git a/platform/terraform/gcp/environments/orthanc/variables.tf b/platform/terraform/gcp/environments/orthanc/variables.tf index 5fdb9bb96..94c54d5c7 100644 --- a/platform/terraform/gcp/environments/orthanc/variables.tf +++ b/platform/terraform/gcp/environments/orthanc/variables.tf @@ -447,3 +447,41 @@ variable "gcp_bootstrap_admin_password" { default = "" sensitive = true } + +# Shared participant OpenVPN server pool (#2480). openvpn_pool_enabled comes from +# RANGE_OPENVPN_ENABLED; the vpn_pool_* sizing comes from the capacity profile. +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC." + type = string + default = "10.49.0.0/24" +} + +variable "vpn_pool_machine_type" { + description = "Pool server machine type; the selected capacity profile owns this value." + type = string + default = "e2-standard-2" +} + +variable "vpn_pool_min_vms" { + description = "Minimum pool servers; the selected capacity profile owns this bound." + type = number + default = 2 +} + +variable "vpn_pool_max_vms" { + description = "Maximum pool servers; the selected capacity profile owns this bound." + type = number + default = 4 +} + +variable "vpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent; the selected capacity profile owns this value." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/environments/sauron/main.tf b/platform/terraform/gcp/environments/sauron/main.tf index 06ada9c0e..b44e90364 100644 --- a/platform/terraform/gcp/environments/sauron/main.tf +++ b/platform/terraform/gcp/environments/sauron/main.tf @@ -98,6 +98,12 @@ module "platform_core" { provisioner_node_count = var.provisioner_node_count access_node_count = var.access_node_count access_node_max_count = var.access_node_max_count + openvpn_pool_enabled = var.openvpn_pool_enabled + openvpn_pool_subnet_cidr = var.openvpn_pool_subnet_cidr + openvpn_pool_machine_type = var.vpn_pool_machine_type + openvpn_pool_min_vms = var.vpn_pool_min_vms + openvpn_pool_max_vms = var.vpn_pool_max_vms + openvpn_pool_cpu_target_pct = var.vpn_pool_cpu_target_pct shared_service_capacity_profile = var.shared_service_capacity_profile cloud_sql_database_version = var.cloud_sql_database_version cloud_sql_tier = var.cloud_sql_tier diff --git a/platform/terraform/gcp/environments/sauron/outputs.tf b/platform/terraform/gcp/environments/sauron/outputs.tf index 7ee4fa5ee..1be4d3f2d 100644 --- a/platform/terraform/gcp/environments/sauron/outputs.tf +++ b/platform/terraform/gcp/environments/sauron/outputs.tf @@ -204,3 +204,8 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = module.platform_core.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when not deployed." + value = module.platform_core.openvpn_pool +} diff --git a/platform/terraform/gcp/environments/sauron/variables.tf b/platform/terraform/gcp/environments/sauron/variables.tf index 5fdb9bb96..94c54d5c7 100644 --- a/platform/terraform/gcp/environments/sauron/variables.tf +++ b/platform/terraform/gcp/environments/sauron/variables.tf @@ -447,3 +447,41 @@ variable "gcp_bootstrap_admin_password" { default = "" sensitive = true } + +# Shared participant OpenVPN server pool (#2480). openvpn_pool_enabled comes from +# RANGE_OPENVPN_ENABLED; the vpn_pool_* sizing comes from the capacity profile. +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC." + type = string + default = "10.49.0.0/24" +} + +variable "vpn_pool_machine_type" { + description = "Pool server machine type; the selected capacity profile owns this value." + type = string + default = "e2-standard-2" +} + +variable "vpn_pool_min_vms" { + description = "Minimum pool servers; the selected capacity profile owns this bound." + type = number + default = 2 +} + +variable "vpn_pool_max_vms" { + description = "Maximum pool servers; the selected capacity profile owns this bound." + type = number + default = 4 +} + +variable "vpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent; the selected capacity profile owns this value." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/modules/cicd-oidc-identity/main.tf b/platform/terraform/gcp/modules/cicd-oidc-identity/main.tf index d86534f2b..9614e7c16 100644 --- a/platform/terraform/gcp/modules/cicd-oidc-identity/main.tf +++ b/platform/terraform/gcp/modules/cicd-oidc-identity/main.tf @@ -49,6 +49,17 @@ locals { [for bucket in local.platform_storage_bucket_names : "resource.name == 'projects/_/buckets/${bucket}'"], [for bucket in local.platform_storage_bucket_names : "resource.name.startsWith('projects/_/buckets/${bucket}/objects/')"], )) + # Shared participant OpenVPN pool (#2480). Compute supports IAM Conditions on + # instances, disks and instance templates, so those are limited to the pool's + # -vpn- names: deploy can run the pool but cannot create, re-image or + # inject metadata into any other VM (runners, range hosts) or template. + vpn_pool_name_prefix = "${var.name_prefix}-vpn-" + vpn_pool_condition = join(" || ", [ + "(resource.type != 'compute.googleapis.com/Instance' && resource.type != 'compute.googleapis.com/Disk' && resource.type != 'compute.googleapis.com/InstanceTemplate')", + "resource.name.extract('/instances/{name}').startsWith('${local.vpn_pool_name_prefix}')", + "resource.name.extract('/disks/{name}').startsWith('${local.vpn_pool_name_prefix}')", + "resource.name.extract('/instanceTemplates/{name}').startsWith('${local.vpn_pool_name_prefix}')", + ]) lifecycle_iam_bucket_names = setunion( toset([local.terraform_state_bucket_name]), var.platform_external_bucket_names, @@ -313,6 +324,52 @@ resource "google_project_iam_member" "destroy_storage" { } } +resource "google_project_iam_custom_role" "deploy_vpn_pool" { + count = local.deploy_enabled ? 1 : 0 + project = var.project_id + role_id = "${replace(var.name_prefix, "-", "_")}_deploy_vpn_pool" + title = "Shifter platform deploy OpenVPN pool" + description = "Instance template, regional instance group and autoscaler lifecycle for the shared OpenVPN pool only." + permissions = var.deploy_vpn_pool_permissions +} + +resource "google_project_iam_member" "deploy_vpn_pool" { + depends_on = [google_service_account.deploy, google_project_iam_custom_role.deploy_vpn_pool] + count = local.deploy_enabled ? 1 : 0 + project = var.project_id + role = "projects/${var.project_id}/roles/${replace(var.name_prefix, "-", "_")}_deploy_vpn_pool" + member = "serviceAccount:${local.service_account_emails.deploy}" + + condition { + title = "openvpn-pool-vms-only" + description = "VM, disk and template permissions apply only to the shared OpenVPN pool's names." + expression = local.vpn_pool_condition + } +} + +resource "google_project_iam_custom_role" "destroy_vpn_pool" { + count = local.destroy_enabled ? 1 : 0 + project = var.project_id + role_id = "${replace(var.name_prefix, "-", "_")}_destroy_vpn_pool" + title = "Shifter platform destroy OpenVPN pool" + description = "Teardown of the shared OpenVPN pool's instance templates, regional instance group and autoscaler." + permissions = var.destroy_vpn_pool_permissions +} + +resource "google_project_iam_member" "destroy_vpn_pool" { + depends_on = [google_service_account.destroy, google_project_iam_custom_role.destroy_vpn_pool] + count = local.destroy_enabled ? 1 : 0 + project = var.project_id + role = "projects/${var.project_id}/roles/${replace(var.name_prefix, "-", "_")}_destroy_vpn_pool" + member = "serviceAccount:${local.service_account_emails.destroy}" + + condition { + title = "openvpn-pool-vms-only" + description = "Template permissions apply only to the shared OpenVPN pool's names." + expression = local.vpn_pool_condition + } +} + # Terraform maintains per-workload object grants on the pre-existing state and # explicitly declared content buckets. Bucket Owner is attached to each exact # bucket resource, never inherited from the project and never applied to the diff --git a/platform/terraform/gcp/modules/cicd-oidc-identity/variables.tf b/platform/terraform/gcp/modules/cicd-oidc-identity/variables.tf index 682c0b33e..482e7600c 100644 --- a/platform/terraform/gcp/modules/cicd-oidc-identity/variables.tf +++ b/platform/terraform/gcp/modules/cicd-oidc-identity/variables.tf @@ -210,6 +210,50 @@ variable "destroy_storage_permissions" { ] } +variable "deploy_vpn_pool_permissions" { + description = "Compute permissions for the shared OpenVPN pool (#2480); VM, disk and template permissions are conditioned to the pool's names." + type = list(string) + default = [ + "compute.autoscalers.create", + "compute.autoscalers.delete", + "compute.autoscalers.get", + "compute.autoscalers.update", + # regionInstanceGroupManagers.insert/patch check these on the caller; the + # Compute service agent then creates the VMs from the template. + "compute.disks.create", + "compute.instances.create", + "compute.instances.setLabels", + "compute.instances.setMetadata", + "compute.instances.setServiceAccount", + "compute.instances.setTags", + "compute.instanceGroupManagers.create", + "compute.instanceGroupManagers.delete", + "compute.instanceGroupManagers.get", + "compute.instanceGroupManagers.update", + "compute.instanceGroupManagers.use", + "compute.instanceGroups.delete", + "compute.instanceTemplates.create", + "compute.instanceTemplates.delete", + "compute.instanceTemplates.get", + "compute.instanceTemplates.list", + "compute.instanceTemplates.useReadOnly", + ] +} + +variable "destroy_vpn_pool_permissions" { + description = "Compute permissions to tear down the shared OpenVPN pool (#2480), conditioned to the pool's names." + type = list(string) + default = [ + "compute.autoscalers.delete", + "compute.autoscalers.get", + "compute.instanceGroupManagers.delete", + "compute.instanceGroupManagers.get", + "compute.instanceGroups.delete", + "compute.instanceTemplates.delete", + "compute.instanceTemplates.get", + ] +} + variable "promotion_reader_service_account_email" { description = "Prod promote SA email granted read-only access to source images by the source-project root." type = string diff --git a/platform/terraform/gcp/modules/platform-core/main.tf b/platform/terraform/gcp/modules/platform-core/main.tf index 035749ed3..c127df648 100644 --- a/platform/terraform/gcp/modules/platform-core/main.tf +++ b/platform/terraform/gcp/modules/platform-core/main.tf @@ -28,6 +28,7 @@ locals { access_pods = var.gke_access_pods_cidr control_plane = var.gke_master_ipv4_cidr range_network = var.range_network_cidr + openvpn_pool = var.openvpn_pool_subnet_cidr } topology_cidr_names = keys(local.topology_cidrs) topology_cidr_bounds = { @@ -69,6 +70,7 @@ locals { "guacd", "guacamole-client", "pulumi-provisioner", + "openvpn", ]) platform_event_subscriptions = toset([ @@ -400,6 +402,34 @@ module "portal_iam" { depends_on = [module.portal_secrets, module.portal_gcs, module.dynamic_secret_project_services] } +# Shared participant OpenVPN server pool (#2480): deployed only when the tenant +# opts in to participant OpenVPN access. +module "vpn_pool" { + source = "../vpn-pool" + count = var.openvpn_pool_enabled ? 1 : 0 + + project_id = var.project_id + region = var.region + name_prefix = local.name_prefix + common_labels = local.common_labels + network_id = module.range_vpc.range_network_id + network_name = module.range_vpc.range_network_name + subnet_cidr = var.openvpn_pool_subnet_cidr + range_network_cidr = var.range_network_cidr + public_hostname = local.normalized_public_hostname + portal_ingress_ip = module.portal_ingress.public_ingress_ip_address + artifact_registry_location = var.artifact_registry_location + artifact_repository = module.portal_artifact_registry.artifact_registry_repositories["openvpn"] + provisioner_service_account_email = module.portal_iam.workload_service_accounts["provisioner"] + deploy_service_account_email = var.deploy_service_account_email + machine_type = var.openvpn_pool_machine_type + min_vms = var.openvpn_pool_min_vms + max_vms = var.openvpn_pool_max_vms + cpu_target_pct = var.openvpn_pool_cpu_target_pct + + depends_on = [module.project_services] +} + module "portal_gke" { source = "../portal/gke" diff --git a/platform/terraform/gcp/modules/platform-core/outputs.tf b/platform/terraform/gcp/modules/platform-core/outputs.tf index bcce2e2a8..2e5a76a91 100644 --- a/platform/terraform/gcp/modules/platform-core/outputs.tf +++ b/platform/terraform/gcp/modules/platform-core/outputs.tf @@ -222,3 +222,21 @@ output "shared_service_capacity_profile" { description = "Applied immutable shared-service capacity profile identity." value = var.shared_service_capacity_profile } + +output "openvpn_pool" { + description = "Shared participant OpenVPN pool wiring (#2480); null when the pool is not deployed." + value = var.openvpn_pool_enabled ? { + endpoint = module.vpn_pool[0].endpoint + subnet_cidr = module.vpn_pool[0].subnet_cidr + service_account_email = module.vpn_pool[0].service_account_email + service_account_id = module.vpn_pool[0].service_account_id + control_audience = module.vpn_pool[0].control_audience + issuer_secret = module.vpn_pool[0].issuer_secret + server_secret = module.vpn_pool[0].server_secret + release_secret = module.vpn_pool[0].release_secret + image_root = module.vpn_pool[0].image_root + instance_group = module.vpn_pool[0].instance_group + backend_service = module.vpn_pool[0].backend_service + region = var.region + } : null +} diff --git a/platform/terraform/gcp/modules/platform-core/variables.tf b/platform/terraform/gcp/modules/platform-core/variables.tf index d13def2f4..4cda5f528 100644 --- a/platform/terraform/gcp/modules/platform-core/variables.tf +++ b/platform/terraform/gcp/modules/platform-core/variables.tf @@ -613,3 +613,41 @@ variable "messaging_notification_channels" { type = list(string) default = [] } + +# Shared participant OpenVPN server pool (#2480). Sizing comes from the shared +# service capacity profile (vpn_pool_* projection). +variable "openvpn_pool_enabled" { + description = "Deploy the shared participant OpenVPN server pool (RANGE_OPENVPN_ENABLED)." + type = bool + default = false +} + +variable "openvpn_pool_subnet_cidr" { + description = "Pool subnet in the range VPC; must not overlap any other platform or range network." + type = string + default = "10.49.0.0/24" +} + +variable "openvpn_pool_machine_type" { + description = "Pool server machine type (capacity profile)." + type = string + default = "e2-standard-2" +} + +variable "openvpn_pool_min_vms" { + description = "Minimum pool servers (capacity profile)." + type = number + default = 2 +} + +variable "openvpn_pool_max_vms" { + description = "Maximum pool servers (capacity profile)." + type = number + default = 4 +} + +variable "openvpn_pool_cpu_target_pct" { + description = "Pool autoscaler CPU target in percent (capacity profile)." + type = number + default = 30 +} diff --git a/platform/terraform/gcp/modules/portal/iam/main.tf b/platform/terraform/gcp/modules/portal/iam/main.tf index a5618ca73..b0a547fd6 100644 --- a/platform/terraform/gcp/modules/portal/iam/main.tf +++ b/platform/terraform/gcp/modules/portal/iam/main.tf @@ -386,34 +386,6 @@ resource "google_project_iam_member" "portal_legacy_dynamic_secret_accessor" { } } -# OpenVPN gateway identity pool (ADR-008-R7). Each active range that requests -# OpenVPN reserves one member of this pre-provisioned, no-role service-account -# pool (Range.vpn_gateway_pool_slot -> sh-vpn-pool-) and the range VM runs -# as it, isolated to that range's server secret. The provisioner holds -# serviceAccountUser on each *specific* pool member (a resource-scoped binding), -# so it can attach a pool identity WITHOUT any project-wide -# create/delete/setIamPolicy grant. This deletes the former project-level -# `vpnGatewayIdentityAdmin` custom role, which let the runtime provisioner call -# setIamPolicy against any service account in the project (e.g. the build SA) and -# escalate cross-identity -- GCP IAM cannot condition setIamPolicy on a service -# account's resource name, so a dynamic-creation grant could not be name-scoped. -# The pool assumes a single isolated tenant/project (no cross-project SA usage, -# no org-policy change); `vpn_gateway_pool_size` bounds concurrent OpenVPN ranges -# and must match VPN_GATEWAY_POOL_SIZE in the engine runtime env. -resource "google_service_account" "vpn_gateway_pool" { - count = var.vpn_gateway_pool_size - project = var.project_id - account_id = "sh-vpn-pool-${count.index}" - display_name = "Shifter OpenVPN gateway pool member ${count.index}" -} - -resource "google_service_account_iam_member" "provisioner_vpn_gateway_pool_act_as" { - count = var.vpn_gateway_pool_size - service_account_id = google_service_account.vpn_gateway_pool[count.index].name - role = "roles/iam.serviceAccountUser" - member = "serviceAccount:${google_service_account.workload["provisioner"].email}" -} - resource "google_service_account" "range_host_pool" { count = var.range_host_identity_pool_size project = var.project_id diff --git a/platform/terraform/gcp/modules/portal/iam/variables.tf b/platform/terraform/gcp/modules/portal/iam/variables.tf index e71d242fe..ac0820b26 100644 --- a/platform/terraform/gcp/modules/portal/iam/variables.tf +++ b/platform/terraform/gcp/modules/portal/iam/variables.tf @@ -26,19 +26,6 @@ variable "name_prefix" { type = string } -# ADR-008-R7: size of the pre-provisioned OpenVPN gateway service-account pool -# (sh-vpn-pool-0 .. N-1). Bounds concurrent OpenVPN ranges and must match -# VPN_GATEWAY_POOL_SIZE in the engine runtime env, which reserves slots into it. -variable "vpn_gateway_pool_size" { - type = number - default = 24 - - validation { - condition = var.vpn_gateway_pool_size >= 0 && floor(var.vpn_gateway_pool_size) == var.vpn_gateway_pool_size - error_message = "vpn_gateway_pool_size must be a non-negative integer." - } -} - variable "range_host_identity_pool_size" { type = number default = 0 diff --git a/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl b/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl new file mode 100644 index 000000000..8c3672a40 --- /dev/null +++ b/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl @@ -0,0 +1,58 @@ +#cloud-config +# Shared participant OpenVPN pool server (#2480). Container-Optimized OS runs the +# image the deploy recorded in the release secret, after checking it names this +# tenant's repository by digest. The container keeps its own network namespace: +# NET_ADMIN applies there, never to the host. +write_files: + - path: /etc/systemd/system/shifter-openvpn.service + permissions: "0644" + owner: root + content: | + [Unit] + Description=Shifter shared OpenVPN pool server + Wants=network-online.target + After=network-online.target docker.service + Requires=docker.service + + [Service] + Environment=HOME=/var/lib/shifter-openvpn + Restart=always + RestartSec=5 + ExecStart=/bin/bash /var/lib/shifter-openvpn/run.sh + ExecStop=/usr/bin/docker stop -t 20 shifter-openvpn + - path: /var/lib/shifter-openvpn/run.sh + permissions: "0700" + owner: root + content: | + set -euo pipefail + metadata() { + curl -fsS -H 'Metadata-Flavor: Google' "http://metadata.google.internal/computeMetadata/v1/$1" + } + token=$(metadata instance/service-accounts/default/token | sed -n 's/.*"access_token" *: *"\([^"]*\)".*/\1/p') + image=$(curl -fsS -H "Authorization: Bearer $${token}" \ + "https://secretmanager.googleapis.com/v1/${release_secret}/versions/latest:access" \ + | sed -n 's/.*"data" *: *"\([^"]*\)".*/\1/p' | base64 -d) + if ! [[ "$${image}" =~ ^${image_repository_regex}@sha256:[0-9a-f]{64}$ ]]; then + echo "refusing image reference outside ${image_repository}" >&2 + exit 1 + fi + # The load balancer delivers client packets to the forwarding-rule address. + ip route replace local ${endpoint}/32 dev eth0 table local + docker-credential-gcr configure-docker --registries="${registry}" + docker rm -f shifter-openvpn >/dev/null 2>&1 || true + exec docker run --rm --name shifter-openvpn \ + --cap-drop ALL --cap-add NET_ADMIN --cap-add SETUID --cap-add SETGID \ + --device /dev/net/tun --sysctl net.ipv4.ip_forward=1 \ + --read-only --tmpfs /run:rw,nosuid,size=16m --tmpfs /tmp:rw,nosuid,size=16m \ + --security-opt no-new-privileges --pids-limit 256 \ + -p 1194:1194/udp -p ${health_port}:${health_port}/tcp \ + -e PORTAL_URL="${portal_url}" \ + -e CONTROL_AUDIENCE="${control_audience}" \ + -e SERVER_SECRET="${server_secret}" \ + -e SERVER_NAME="$(metadata instance/name)" \ + -e MAX_CLIENTS="${max_clients}" \ + -e HEALTH_PORT="${health_port}" \ + "$${image}" +runcmd: + - systemctl daemon-reload + - systemctl start shifter-openvpn.service diff --git a/platform/terraform/gcp/modules/vpn-pool/main.tf b/platform/terraform/gcp/modules/vpn-pool/main.tf new file mode 100644 index 000000000..748660550 --- /dev/null +++ b/platform/terraform/gcp/modules/vpn-pool/main.tf @@ -0,0 +1,424 @@ +# Shared participant OpenVPN server pool (#2480, ADR-039-R10). +# +# A regional managed instance group of Container-Optimized OS VMs in their own +# subnet of the range VPC, behind an external passthrough UDP load balancer on +# a reserved address. Each VM runs one OpenVPN process with the controller that +# asks the portal to authorize every connection. The VMs have no public +# addresses; a NAT scoped to the pool subnet carries only the portal call. +# +# Private keys never enter Terraform state: this module creates the empty +# secrets and their IAM, and the deploy workflow writes the PKI (scripts/gcp/ +# ensure_vpn_pki.py) and the release record (image digest) directly. + +data "google_compute_zones" "available" { + project = var.project_id + region = var.region + status = "UP" +} + +locals { + zones = length(var.zones) > 0 ? var.zones : slice(sort(data.google_compute_zones.available.names), 0, min(3, length(data.google_compute_zones.available.names))) + name = "${var.name_prefix}-vpn" + tag = "${var.name_prefix}-vpn-pool" + image_root = "${var.artifact_registry_location}-docker.pkg.dev/${var.project_id}/${var.artifact_repository}/openvpn" + registry = "${var.artifact_registry_location}-docker.pkg.dev" + portal_url = "https://${var.public_hostname}" + control_audience = "https://${var.public_hostname}/vpn-control" + # Autohealing probes (35.191.0.0/16, 130.211.0.0/22) and regional external + # passthrough load-balancer probes (35.191.0.0/16, 209.85.204.0/22). + health_check_cidrs = ["35.191.0.0/16", "130.211.0.0/22", "209.85.204.0/22"] + private_api_vip = "199.36.153.8/30" +} + +resource "google_compute_subnetwork" "pool" { + name = local.name + project = var.project_id + region = var.region + network = var.network_id + ip_cidr_range = var.subnet_cidr + private_ip_google_access = true + + log_config { + aggregation_interval = "INTERVAL_5_SEC" + flow_sampling = 0.5 + metadata = "INCLUDE_ALL_METADATA" + } +} + +resource "google_compute_router" "pool" { + name = "${local.name}-nat" + project = var.project_id + region = var.region + network = var.network_id +} + +resource "google_compute_address" "pool_nat" { + name = "${local.name}-nat-egress" + project = var.project_id + region = var.region + address_type = "EXTERNAL" +} + +# Only the pool subnet is translated; range subnets keep their own egress posture. +resource "google_compute_router_nat" "pool" { + name = "${local.name}-nat" + project = var.project_id + region = var.region + router = google_compute_router.pool.name + nat_ip_allocate_option = "MANUAL_ONLY" + nat_ips = [google_compute_address.pool_nat.self_link] + source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS" + + subnetwork { + name = google_compute_subnetwork.pool.id + source_ip_ranges_to_nat = ["ALL_IP_RANGES"] + } +} + +# Artifact Registry over Private Google Access, like *.googleapis.com already is. +resource "google_dns_managed_zone" "pkg_dev" { + name = "${local.name}-pkg-dev" + project = var.project_id + dns_name = "pkg.dev." + description = "Private Google Access: resolve *.pkg.dev to the private.googleapis.com VIP for the OpenVPN pool." + visibility = "private" + + private_visibility_config { + networks { + network_url = var.network_id + } + } +} + +resource "google_dns_record_set" "pkg_dev_wildcard" { + project = var.project_id + managed_zone = google_dns_managed_zone.pkg_dev.name + name = "*.pkg.dev." + type = "CNAME" + ttl = 300 + rrdatas = ["private.googleapis.com."] +} + +resource "google_service_account" "pool" { + project = var.project_id + account_id = local.name + display_name = "Shifter shared OpenVPN pool servers" +} + +# The deploy identity attaches the pool SA to the servers it creates. Scoped to +# this one SA (not a project-wide serviceAccountUser), like the GKE node SA. +resource "google_service_account_iam_member" "deploy_act_as_pool" { + service_account_id = google_service_account.pool.name + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${var.deploy_service_account_email}" +} + +resource "google_secret_manager_secret" "pool" { + for_each = toset(["issuer", "server", "release"]) + + project = var.project_id + secret_id = "${local.name}-${each.key}" + labels = var.common_labels + + replication { + auto {} + } +} + +# The pool reads its server identity and the release record; never the issuer. +resource "google_secret_manager_secret_iam_member" "pool_reads" { + for_each = toset(["server", "release"]) + + project = var.project_id + secret_id = google_secret_manager_secret.pool[each.key].secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.pool.email}" +} + +# Only the provisioner signs participant certificates. +resource "google_secret_manager_secret_iam_member" "provisioner_reads_issuer" { + project = var.project_id + secret_id = google_secret_manager_secret.pool["issuer"].secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${var.provisioner_service_account_email}" +} + +resource "google_artifact_registry_repository_iam_member" "pool_pulls" { + project = var.project_id + location = var.artifact_registry_location + repository = var.artifact_repository + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.pool.email}" +} + +resource "google_project_iam_member" "pool_telemetry" { + for_each = toset(["roles/logging.logWriter", "roles/monitoring.metricWriter"]) + + project = var.project_id + role = each.key + member = "serviceAccount:${google_service_account.pool.email}" +} + +resource "google_compute_address" "endpoint" { + name = "${local.name}-endpoint" + project = var.project_id + region = var.region + address_type = "EXTERNAL" + network_tier = "PREMIUM" +} + +resource "google_compute_region_health_check" "pool" { + name = local.name + project = var.project_id + region = var.region + check_interval_sec = 5 + timeout_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + + http_health_check { + port = var.health_port + request_path = "/healthz" + } + + log_config { + enable = true + } +} + +resource "google_compute_region_instance_template" "pool" { + name_prefix = "${local.name}-" + project = var.project_id + region = var.region + machine_type = var.machine_type + tags = [local.tag] + labels = merge(var.common_labels, { role = "vpn-pool" }) + + disk { + boot = true + auto_delete = true + source_image = "projects/cos-cloud/global/images/family/cos-stable" + disk_size_gb = 20 + disk_type = "pd-balanced" + } + + network_interface { + subnetwork = google_compute_subnetwork.pool.id + } + + service_account { + email = google_service_account.pool.email + scopes = ["cloud-platform"] + } + + shielded_instance_config { + enable_secure_boot = true + enable_vtpm = true + enable_integrity_monitoring = true + } + + metadata = { + block-project-ssh-keys = "true" + enable-oslogin = "TRUE" + google-logging-enabled = "true" + cos-update-strategy = "update_disabled" + user-data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + release_secret = google_secret_manager_secret.pool["release"].id + server_secret = google_secret_manager_secret.pool["server"].id + image_repository = local.image_root + image_repository_regex = replace(local.image_root, ".", "\\.") + registry = local.registry + endpoint = google_compute_address.endpoint.address + portal_url = local.portal_url + control_audience = local.control_audience + max_clients = var.max_clients_per_vm + health_port = var.health_port + }) + } + + lifecycle { + create_before_destroy = true + } +} + +resource "google_compute_region_instance_group_manager" "pool" { + depends_on = [google_service_account_iam_member.deploy_act_as_pool] + + name = local.name + project = var.project_id + region = var.region + base_instance_name = local.name + distribution_policy_zones = local.zones + + version { + instance_template = google_compute_region_instance_template.pool.self_link + } + + named_port { + name = "health" + port = var.health_port + } + + auto_healing_policies { + health_check = google_compute_region_health_check.pool.id + initial_delay_sec = 300 + } + + # Replacements surge in before old servers leave, so capacity never dips. + update_policy { + type = "PROACTIVE" + minimal_action = "REPLACE" + replacement_method = "SUBSTITUTE" + max_surge_fixed = length(local.zones) + max_unavailable_fixed = 0 + instance_redistribution_type = "PROACTIVE" + } +} + +resource "google_compute_region_autoscaler" "pool" { + name = local.name + project = var.project_id + region = var.region + target = google_compute_region_instance_group_manager.pool.id + + autoscaling_policy { + min_replicas = var.min_vms + max_replicas = var.max_vms + cooldown_period = 180 + + # One OpenVPN process uses one core: a busy server is half of a 2-vCPU VM. + cpu_utilization { + target = var.cpu_target_pct / 100 + } + + # Scale in slowly; removing a server disconnects its participants once. + scale_in_control { + max_scaled_in_replicas { + fixed = 1 + } + time_window_sec = 900 + } + } +} + +resource "google_compute_region_backend_service" "pool" { + name = local.name + project = var.project_id + region = var.region + protocol = "UDP" + load_balancing_scheme = "EXTERNAL" + session_affinity = "CLIENT_IP" + health_checks = [google_compute_region_health_check.pool.id] + connection_draining_timeout_sec = 30 + + backend { + group = google_compute_region_instance_group_manager.pool.instance_group + balancing_mode = "CONNECTION" + } + + log_config { + enable = true + } +} + +resource "google_compute_forwarding_rule" "pool" { + name = local.name + project = var.project_id + region = var.region + ip_address = google_compute_address.endpoint.address + ip_protocol = "UDP" + ports = ["1194"] + load_balancing_scheme = "EXTERNAL" + backend_service = google_compute_region_backend_service.pool.id +} + +# Firewall envelope. Allows sit above the denies, and both above the range-wide +# rules (provisioner management ingress and allowlist egress at 1000), so the +# pool's traffic is exactly what is listed here. +resource "google_compute_firewall" "pool_vpn_ingress" { + name = "${local.name}-in" + project = var.project_id + network = var.network_name + direction = "INGRESS" + priority = 900 + target_tags = [local.tag] + source_ranges = ["0.0.0.0/0"] + + allow { + protocol = "udp" + ports = ["1194"] + } +} + +resource "google_compute_firewall" "pool_health_ingress" { + name = "${local.name}-health" + project = var.project_id + network = var.network_name + direction = "INGRESS" + priority = 900 + target_tags = [local.tag] + source_ranges = local.health_check_cidrs + + allow { + protocol = "tcp" + ports = [tostring(var.health_port)] + } +} + +resource "google_compute_firewall" "pool_ingress_deny" { + name = "${local.name}-in-deny" + project = var.project_id + network = var.network_name + direction = "INGRESS" + priority = 950 + target_tags = [local.tag] + source_ranges = ["0.0.0.0/0"] + + deny { + protocol = "all" + } +} + +resource "google_compute_firewall" "pool_range_egress" { + name = "${local.name}-to-range" + project = var.project_id + network = var.network_name + direction = "EGRESS" + priority = 900 + target_tags = [local.tag] + destination_ranges = [var.range_network_cidr] + + allow { + protocol = "tcp" + ports = ["22", "3389"] + } +} + +resource "google_compute_firewall" "pool_control_egress" { + name = "${local.name}-to-control" + project = var.project_id + network = var.network_name + direction = "EGRESS" + priority = 900 + target_tags = [local.tag] + destination_ranges = ["${var.portal_ingress_ip}/32", local.private_api_vip] + + allow { + protocol = "tcp" + ports = ["443"] + } +} + +resource "google_compute_firewall" "pool_egress_deny" { + name = "${local.name}-out-deny" + project = var.project_id + network = var.network_name + direction = "EGRESS" + priority = 950 + target_tags = [local.tag] + destination_ranges = ["0.0.0.0/0"] + + deny { + protocol = "all" + } +} diff --git a/platform/terraform/gcp/modules/vpn-pool/outputs.tf b/platform/terraform/gcp/modules/vpn-pool/outputs.tf new file mode 100644 index 000000000..ab9318552 --- /dev/null +++ b/platform/terraform/gcp/modules/vpn-pool/outputs.tf @@ -0,0 +1,54 @@ +output "endpoint" { + description = "Static address every participant profile connects to (UDP 1194)." + value = google_compute_address.endpoint.address +} + +output "subnet_cidr" { + description = "Pool source network; each OpenVPN range admits it to its target node." + value = google_compute_subnetwork.pool.ip_cidr_range +} + +output "service_account_email" { + description = "Pool identity the portal admits on the VPN control API." + value = google_service_account.pool.email +} + +output "service_account_id" { + description = "Immutable numeric subject of the pool identity." + value = google_service_account.pool.unique_id +} + +output "control_audience" { + description = "Audience of the pool's identity tokens." + value = local.control_audience +} + +output "issuer_secret" { + description = "Tenant CA secret (provisioner only)." + value = google_secret_manager_secret.pool["issuer"].id +} + +output "server_secret" { + description = "Pool server identity secret (pool only)." + value = google_secret_manager_secret.pool["server"].id +} + +output "release_secret" { + description = "Release record: the image digest pool servers run." + value = google_secret_manager_secret.pool["release"].id +} + +output "image_root" { + description = "Image root the release record must name." + value = local.image_root +} + +output "instance_group" { + description = "Regional instance group name, for rolling replacement at deploy." + value = google_compute_region_instance_group_manager.pool.name +} + +output "backend_service" { + description = "Load balancer backend service, for the deploy-time health check." + value = google_compute_region_backend_service.pool.name +} diff --git a/platform/terraform/gcp/modules/vpn-pool/variables.tf b/platform/terraform/gcp/modules/vpn-pool/variables.tf new file mode 100644 index 000000000..02ba714bc --- /dev/null +++ b/platform/terraform/gcp/modules/vpn-pool/variables.tf @@ -0,0 +1,120 @@ +variable "project_id" { + description = "Project that hosts the pool." + type = string +} + +variable "region" { + description = "Region of the pool, its load balancer and its subnet." + type = string +} + +variable "zones" { + description = "Zones the regional instance group spreads servers across. Empty uses up to three available zones of the region." + type = list(string) + default = [] +} + +variable "name_prefix" { + description = "Resource name prefix, for example shifter-." + type = string +} + +variable "common_labels" { + description = "Labels applied to labelable resources." + type = map(string) + default = {} +} + +variable "network_id" { + description = "Self link or id of the range VPC the pool joins." + type = string +} + +variable "network_name" { + description = "Name of the range VPC (firewall rules attach by name)." + type = string +} + +variable "subnet_cidr" { + description = "Pool subnet. Ranges admit this network to their participant target only." + type = string +} + +variable "range_network_cidr" { + description = "Range network the pool may reach on the participant channels." + type = string +} + +variable "public_hostname" { + description = "Portal hostname; the controller calls https:// with audience https:///vpn-control." + type = string + + validation { + condition = length(trimspace(var.public_hostname)) > 0 + error_message = "The OpenVPN pool needs the portal public hostname." + } +} + +variable "portal_ingress_ip" { + description = "Portal public ingress address, the only HTTPS destination outside Google APIs." + type = string +} + +variable "artifact_registry_location" { + description = "Artifact Registry location of the openvpn repository." + type = string +} + +variable "artifact_repository" { + description = "Artifact Registry repository id that holds the openvpn image." + type = string +} + +variable "deploy_service_account_email" { + description = "Deploy identity that runs Terraform; it may act as the pool SA only." + type = string +} + +variable "provisioner_service_account_email" { + description = "Provisioner identity, the only reader of the tenant CA." + type = string +} + +variable "machine_type" { + description = "Server machine type. One OpenVPN process uses one core." + type = string + default = "e2-standard-2" +} + +variable "min_vms" { + description = "Minimum servers (capacity profile)." + type = number + + validation { + condition = var.min_vms >= 2 + error_message = "min_vms must be at least 2 so one server can fail or be replaced." + } +} + +variable "max_vms" { + description = "Maximum servers (capacity profile)." + type = number +} + +variable "cpu_target_pct" { + description = "Autoscaler CPU target in percent of the VM." + type = number + default = 30 +} + +variable "max_clients_per_vm" { + description = "OpenVPN max-clients on each server." + type = number + default = 250 +} + +variable "health_port" { + description = "Controller health endpoint port." + type = number + default = 8080 +} diff --git a/scripts/adr_guard/_guard/checks/cloud_identifiers_public_ip_allowlist.txt b/scripts/adr_guard/_guard/checks/cloud_identifiers_public_ip_allowlist.txt index 2edcd3337..eaac5fbf4 100644 --- a/scripts/adr_guard/_guard/checks/cloud_identifiers_public_ip_allowlist.txt +++ b/scripts/adr_guard/_guard/checks/cloud_identifiers_public_ip_allowlist.txt @@ -11,6 +11,7 @@ 1.0.0.1/32 # Cloudflare DNS 130.211.0.0/22 # GCP load-balancer health-check range 35.191.0.0/16 # GCP load-balancer health-check range +209.85.204.0/22 # GCP regional external passthrough load-balancer health-check range 35.235.240.0/20 # GCP Identity-Aware Proxy 199.36.153.4/30 # GCP private googleapis VIP range 199.36.153.8/30 # GCP restricted googleapis VIP range diff --git a/scripts/adr_guard/tests/test_adr_guard.py b/scripts/adr_guard/tests/test_adr_guard.py index b5c5365a6..60526c401 100644 --- a/scripts/adr_guard/tests/test_adr_guard.py +++ b/scripts/adr_guard/tests/test_adr_guard.py @@ -5554,7 +5554,7 @@ def test_allows_wellknown_and_documentation_ips_in_iac(self) -> None: repo_root, "platform/terraform/x/net.tf", 'dns = ["8.8.8.8", "8.8.4.4", "1.1.1.1"]\n' - 'gcp_health = ["130.211.0.0/22", "35.191.0.0/16"]\n' + 'gcp_health = ["130.211.0.0/22", "35.191.0.0/16", "209.85.204.0/22"]\n' 'gcp_iap = "35.235.240.0/20"\n' 'googleapis = "199.36.153.8/30"\n' 'doc = "203.0.113.10/32"\n' diff --git a/scripts/adr_guard/tests/test_deploy_workflow.py b/scripts/adr_guard/tests/test_deploy_workflow.py index 73bdd114a..2813d6503 100644 --- a/scripts/adr_guard/tests/test_deploy_workflow.py +++ b/scripts/adr_guard/tests/test_deploy_workflow.py @@ -630,6 +630,7 @@ def test_gcp_deploy_scans_each_exact_oci_digest(self): "PROVISIONER_IMAGE_DIGEST", "GUACD_IMAGE_DIGEST", "GUACAMOLE_CLIENT_IMAGE_DIGEST", + "OPENVPN_IMAGE_DIGEST", ): self.assertIn(digest, workflow) self.assertIn("--exit-code 1", workflow) @@ -767,6 +768,18 @@ def test_platform_lifecycle_roles_cannot_mutate_release_images_or_evidence(self) self.assertIn('resource "google_storage_bucket_iam_member" "destroy_bucket_iam_admin"', identity) self.assert_hcl_assignment(identity, "role", '"roles/storage.legacyBucketOwner"') self.assertIn("platform_external_bucket_names", identity) + # The OpenVPN pool's VM permissions (#2480) never reach images and are + # conditioned to the pool's own instance, disk and template names. + for marker in ('variable "deploy_vpn_pool_permissions"', 'variable "destroy_vpn_pool_permissions"'): + start = variables.index(marker) + block = variables[start : variables.index("\n}", start)] + self.assertNotIn("compute.images.", block) + self.assertNotIn("compute.snapshots.", block) + for name in ("deploy_vpn_pool", "destroy_vpn_pool"): + start = identity.index(f'resource "google_project_iam_member" "{name}"') + block = identity[start : identity.index("\n}\n", start)] + self.assert_hcl_assignment(block, "expression", "local.vpn_pool_condition") + self.assertIn("resource.name.extract('/instances/{name}').startsWith", identity) condition_start = identity.index("platform_storage_bucket_names") condition_end = identity.index("\n}", condition_start) self.assertNotIn("release-evidence", identity[condition_start:condition_end]) diff --git a/scripts/gcp/check_event_capacity_drift.py b/scripts/gcp/check_event_capacity_drift.py index ad69e221b..3cca17732 100644 --- a/scripts/gcp/check_event_capacity_drift.py +++ b/scripts/gcp/check_event_capacity_drift.py @@ -30,6 +30,9 @@ class Drift: _NON_SHRINKING_FLOOR_FIELDS = frozenset({("terraform", "capacity.cloud_sql_disk_size_gb")}) +# The shared OpenVPN pool (#2480) exists only where the tenant opted in; an +# installation without it carries no pool evidence to compare. +_VPN_POOL_FIELD_PREFIX = "capacity.vpn_pool_" def compare_capacity_state(desired: dict[str, object], observed: dict[str, object]) -> list[Drift]: @@ -44,6 +47,8 @@ def compare_capacity_state(desired: dict[str, object], observed: dict[str, objec wanted = _mapping(desired, section, "desired") actual = _mapping(observed, section, "observed") for field, wanted_value in sorted(wanted.items()): + if field.startswith(_VPN_POOL_FIELD_PREFIX) and observed.get("vpn_pool") == "absent": + continue if field not in actual: raise DriftInputError(f"missing observed field {section}.{field}") actual_value = actual[field] @@ -85,6 +90,7 @@ def collect_live_state( redis_instance: str, cluster: str, namespace: str, + vpn_pool: str | None = None, ) -> dict[str, object]: """Read effective provider and Kubernetes state with fixed, read-only argv.""" sql = _run_json(["gcloud", "sql", "instances", "describe", sql_instance, "--project", project, "--format=json"]) @@ -175,7 +181,35 @@ def collect_live_state( if any(value in (None, "") for value in terraform.values()): raise DriftInputError("provider resources have incomplete capacity evidence") kubernetes = _extract_kubernetes(workloads, autoscalers, backends, runtime) - return {"profile_id": profile_labels[0], "terraform": terraform, "kubernetes": kubernetes} + observed: dict[str, object] = {"profile_id": profile_labels[0], "terraform": terraform, "kubernetes": kubernetes} + if vpn_pool: + terraform.update(_vpn_pool_state(vpn_pool, project=project, region=region)) + else: + observed["vpn_pool"] = "absent" + return observed + + +def _vpn_pool_state(name: str, *, project: str, region: str) -> dict[str, object]: + """Read the pool autoscaler bounds and server machine type.""" + location = ["--project", project, "--region", region, "--format=json"] + group = _run_json(["gcloud", "compute", "instance-groups", "managed", "describe", name, *location]) + policy = _run_json(["gcloud", "compute", "autoscalers", "describe", name, *location]).get("autoscalingPolicy", {}) + template = str(group.get("instanceTemplate", "")).rsplit("/", 1)[-1] + machine = _dig( + _run_json(["gcloud", "compute", "instance-templates", "describe", template, *location]), + "properties", + "machineType", + ) + target = _dig(policy, "cpuUtilization", "utilizationTarget") + state = { + "capacity.vpn_pool_machine_type": str(machine).rsplit("/", 1)[-1] if machine else None, + "capacity.vpn_pool_min_vms": _as_int(_dig(policy, "minNumReplicas")), + "capacity.vpn_pool_max_vms": _as_int(_dig(policy, "maxNumReplicas")), + "capacity.vpn_pool_cpu_target_pct": round(float(target) * 100) if isinstance(target, (int, float)) else None, + } + if any(value in (None, "") for value in state.values()): + raise DriftInputError("the OpenVPN pool has incomplete capacity evidence") + return state def _extract_kubernetes( @@ -310,6 +344,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--redis-instance") parser.add_argument("--cluster") parser.add_argument("--namespace", default="shifter-platform") + parser.add_argument("--vpn-pool", help="OpenVPN pool instance group name, when the tenant deploys the pool") return parser @@ -333,6 +368,7 @@ def main(argv: list[str] | None = None) -> int: redis_instance=args.redis_instance, cluster=args.cluster, namespace=args.namespace, + vpn_pool=args.vpn_pool, ) drifts = compare_capacity_state(desired, observed) except DriftInputError as exc: diff --git a/scripts/gcp/ensure_vpn_pki.py b/scripts/gcp/ensure_vpn_pki.py new file mode 100644 index 000000000..cde35c385 --- /dev/null +++ b/scripts/gcp/ensure_vpn_pki.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Create and renew the shared OpenVPN pool's PKI directly in Secret Manager (#2480). + +Terraform creates the empty secrets and their IAM; private keys never enter +Terraform state. This deploy step: + +* creates the tenant CA and the pool tls-crypt key once, in the issuer secret + (readable only by the provisioner, which signs participant certificates); +* issues the pool server certificate from that CA, in the server secret + (readable only by the pool), and renews it before it expires or when the CA + changes. Superseded server versions are destroyed. + +Key material is passed to ``gcloud`` on stdin, never argv, and is never printed. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +#: Must equal ``shared.remote_access.OPENVPN_SERVER_NAME``: clients pin it. +SERVER_NAME = "shifter-openvpn-server" +CA_NAME = "shifter-openvpn-ca" +CA_LIFETIME = timedelta(days=3650) +SERVER_LIFETIME = timedelta(days=365) +SERVER_RENEW_BEFORE = timedelta(days=30) +#: A participant certificate may live 397 days; the CA must outlive every one. +CA_MINIMUM_REMAINING = timedelta(days=398) + +Runner = Callable[[list[str], str | None], subprocess.CompletedProcess[str]] + + +def _gcloud(args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(["gcloud", *args], input=stdin, text=True, capture_output=True, check=False) + + +@dataclass(frozen=True) +class SecretStore: + """Latest-version reads and version writes for one project.""" + + project: str + run: Runner = _gcloud + + def latest(self, secret: str) -> str | None: + result = self.run( + ["secrets", "versions", "access", "latest", f"--secret={secret}", f"--project={self.project}"], None + ) + if result.returncode == 0: + return result.stdout + if "NOT_FOUND" in result.stderr or "not found" in result.stderr.lower(): + return None + raise RuntimeError(f"cannot read {secret}: gcloud exited {result.returncode}") + + def add(self, secret: str, payload: str) -> None: + result = self.run(["secrets", "versions", "add", secret, f"--project={self.project}", "--data-file=-"], payload) + if result.returncode != 0: + raise RuntimeError(f"cannot write {secret}: gcloud exited {result.returncode}") + + def destroy_superseded(self, secret: str) -> None: + listed = self.run( + [ + "secrets", "versions", "list", secret, f"--project={self.project}", + "--filter=state:ENABLED", "--sort-by=~createTime", "--format=value(name)", + ], + None, + ) # fmt: skip + if listed.returncode != 0: + raise RuntimeError(f"cannot list {secret}: gcloud exited {listed.returncode}") + for version in listed.stdout.split()[1:]: + self.run( + [ + "secrets", + "versions", + "destroy", + version.rsplit("/", 1)[-1], + f"--secret={secret}", + f"--project={self.project}", + "--quiet", + ], + None, + ) + + +def _pem_key(key: ec.EllipticCurvePrivateKey) -> str: + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + + +def _pem_certificate(certificate: x509.Certificate) -> str: + return certificate.public_bytes(serialization.Encoding.PEM).decode() + + +def _tls_crypt_key() -> str: + import secrets as random + + body = random.token_hex(256) + lines = "\n".join(body[index : index + 32] for index in range(0, len(body), 32)) + return f"-----BEGIN OpenVPN Static key V1-----\n{lines}\n-----END OpenVPN Static key V1-----\n" + + +def _usage(*, ca: bool) -> x509.KeyUsage: + return x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=not ca, + key_cert_sign=ca, + crl_sign=ca, + encipher_only=False, + decipher_only=False, + ) + + +def new_issuer(now: datetime) -> dict[str, str]: + """Return a new tenant CA, its key and the pool tls-crypt key.""" + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, CA_NAME)]) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + CA_LIFETIME) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension(_usage(ca=True), critical=True) + .sign(key, hashes.SHA256()) + ) + return {"ca": _pem_certificate(certificate), "ca_private_key": _pem_key(key), "tls_crypt": _tls_crypt_key()} + + +def new_server(issuer: dict[str, str], now: datetime) -> dict[str, str]: + """Return a pool server identity signed by the tenant CA.""" + ca = x509.load_pem_x509_certificate(issuer["ca"].encode()) + ca_key = serialization.load_pem_private_key(issuer["ca_private_key"].encode(), password=None) + if not isinstance(ca_key, ec.EllipticCurvePrivateKey): + raise ValueError("the tenant CA key must be an EC key") + key = ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, SERVER_NAME)])) + .issuer_name(ca.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + SERVER_LIFETIME) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(_usage(ca=False), critical=True) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + return { + "ca": issuer["ca"], + "certificate": _pem_certificate(certificate), + "private_key": _pem_key(key), + "tls_crypt": issuer["tls_crypt"], + } + + +def _server_is_current(server: dict[str, str] | None, issuer: dict[str, str], now: datetime) -> bool: + """Return whether the stored server identity is from this CA and not due for renewal.""" + if not server or server.get("ca") != issuer["ca"] or server.get("tls_crypt") != issuer["tls_crypt"]: + return False + certificate = x509.load_pem_x509_certificate(server["certificate"].encode()) + return certificate.not_valid_after_utc - now > SERVER_RENEW_BEFORE + + +def ensure(store: SecretStore, issuer_secret: str, server_secret: str, now: datetime | None = None) -> list[str]: + """Create or renew the PKI; return the actions taken (no key material).""" + moment = now or datetime.now(UTC) + actions: list[str] = [] + stored = store.latest(issuer_secret) + if stored is None: + issuer = new_issuer(moment) + store.add(issuer_secret, json.dumps(issuer)) + actions.append("created tenant CA") + else: + issuer = json.loads(stored) + ca = x509.load_pem_x509_certificate(issuer["ca"].encode()) + if ca.not_valid_after_utc - moment < CA_MINIMUM_REMAINING: + raise RuntimeError("the tenant OpenVPN CA expires within 398 days; rotate it before minting more profiles") + stored_server = store.latest(server_secret) + if not _server_is_current(json.loads(stored_server) if stored_server else None, issuer, moment): + store.add(server_secret, json.dumps(new_server(issuer, moment))) + store.destroy_superseded(server_secret) + actions.append("issued pool server certificate") + return actions + + +def main(argv: list[str] | None = None, run: Runner = _gcloud) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--project", required=True) + parser.add_argument("--issuer-secret", required=True) + parser.add_argument("--server-secret", required=True) + parser.add_argument( + "--changed-output", help="append changed=true|false here (a GitHub step output) so the pool can be rolled" + ) + args = parser.parse_args(argv) + actions = ensure(SecretStore(args.project, run), args.issuer_secret, args.server_secret) + print("OpenVPN PKI: " + ("; ".join(actions) if actions else "current")) + if args.changed_output: + with open(args.changed_output, "a", encoding="utf-8") as output: + output.write(f"changed={'true' if actions else 'false'}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gcp/probe_range_secret_permissions.py b/scripts/gcp/probe_range_secret_permissions.py index 8aff50740..630e1f6e7 100755 --- a/scripts/gcp/probe_range_secret_permissions.py +++ b/scripts/gcp/probe_range_secret_permissions.py @@ -30,8 +30,6 @@ class ProbeConfig: portal_service_account: str range_host_service_account: str peer_range_host_service_account: str - gateway_service_account: str - peer_gateway_service_account: str workers_service_account: str launcher_service_account: str node_service_account: str @@ -61,7 +59,6 @@ def build_probe_steps(config: ProbeConfig, payload_path: Path, suffix: str) -> l canonical_root = f"shifter-{config.environment}-dynamic" participant = f"{canonical_root}-participant-probe-range-{suffix}-credential" host_secret = f"{canonical_root}-workload-probe-host-range-{suffix}-credential" - gateway_secret = f"{canonical_root}-workload-probe-vpn-range-{suffix}-server" unrelated = f"shifter-permission-probe-unrelated-{suffix}" platform_unrelated = f"shifter-permission-probe-platform-{suffix}" external_unrelated = f"shifter-permission-probe-external-{suffix}" @@ -337,85 +334,6 @@ def version(command: str, secret_id: str, project: str, *args: str) -> list[str] ), False, ), - ProbeStep( - "provisioner-create-gateway-secret", - _impersonate( - secret("create", gateway_secret, config.dynamic_project, "--replication-policy=automatic"), - provisioner, - ), - True, - ), - ProbeStep( - "provisioner-add-gateway-version", - _impersonate( - version("add", gateway_secret, config.dynamic_project, f"--data-file={payload_path}"), - provisioner, - ), - True, - ), - ProbeStep( - "range-host-read-gateway-denied", - _impersonate( - version("access", gateway_secret, config.dynamic_project, "latest"), - config.range_host_service_account, - ), - False, - ), - ProbeStep( - "gateway-read-before-grant-denied", - _impersonate( - version("access", gateway_secret, config.dynamic_project, "latest"), - config.gateway_service_account, - ), - False, - ), - ProbeStep( - "provisioner-grant-gateway", - _impersonate( - secret( - "add-iam-policy-binding", - gateway_secret, - config.dynamic_project, - f"--member=serviceAccount:{config.gateway_service_account}", - "--role=roles/secretmanager.secretAccessor", - ), - provisioner, - ), - True, - ), - ProbeStep( - "gateway-read-after-grant", - _impersonate( - version("access", gateway_secret, config.dynamic_project, "latest"), - config.gateway_service_account, - ), - True, - attempts=5, - ), - ProbeStep( - "gateway-read-host-denied", - _impersonate( - version("access", host_secret, config.dynamic_project, "latest"), - config.gateway_service_account, - ), - False, - ), - ProbeStep( - "peer-gateway-read-denied", - _impersonate( - version("access", gateway_secret, config.dynamic_project, "latest"), - config.peer_gateway_service_account, - ), - False, - ), - ProbeStep( - "gateway-project-wide-read-denied", - _impersonate( - version("access", unrelated, config.dynamic_project, "latest"), - config.gateway_service_account, - ), - False, - ), ] ) for role, service_account in ( @@ -447,11 +365,6 @@ def version(command: str, secret_id: str, project: str, *args: str) -> list[str] _impersonate(secret("delete", host_secret, config.dynamic_project), provisioner), True, ), - ProbeStep( - "provisioner-delete-gateway-secret", - _impersonate(secret("delete", gateway_secret, config.dynamic_project), provisioner), - True, - ), ] ) return steps @@ -516,8 +429,6 @@ def _step_evidence(step: ProbeStep, correlation_id: str, elapsed_seconds: float, resource_class = "participant" elif "probe-host" in secret_id: resource_class = "host-workload" - elif "probe-vpn" in secret_id: - resource_class = "gateway-workload" else: resource_class = "unrelated" resource_fingerprint = hashlib.sha256(f"{project}/{secret_id}".encode()).hexdigest()[:16] @@ -562,7 +473,6 @@ def _cleanup(config: ProbeConfig, suffix: str, runner: Runner) -> None: f"{root}-participant-probe-range-{suffix}-credential", f"{root}-participant-probe-range-{suffix}-portal-create", f"{root}-workload-probe-host-range-{suffix}-credential", - f"{root}-workload-probe-vpn-range-{suffix}-server", f"shifter-permission-probe-unrelated-{suffix}", ) ] @@ -623,8 +533,6 @@ def run_probe(config: ProbeConfig, *, runner: Runner = _subprocess_runner) -> No "portal": config.portal_service_account, "range host": config.range_host_service_account, "peer range host": config.peer_range_host_service_account, - "gateway": config.gateway_service_account, - "peer gateway": config.peer_gateway_service_account, "workers": config.workers_service_account, "launcher": config.launcher_service_account, "node": config.node_service_account, @@ -675,8 +583,6 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--portal-service-account", required=True) parser.add_argument("--range-host-service-account", required=True) parser.add_argument("--peer-range-host-service-account", required=True) - parser.add_argument("--gateway-service-account", required=True) - parser.add_argument("--peer-gateway-service-account", required=True) parser.add_argument("--workers-service-account", required=True) parser.add_argument("--launcher-service-account", required=True) parser.add_argument("--node-service-account", required=True) @@ -695,8 +601,6 @@ def main(argv: Sequence[str] | None = None) -> int: portal_service_account=args.portal_service_account, range_host_service_account=args.range_host_service_account, peer_range_host_service_account=args.peer_range_host_service_account, - gateway_service_account=args.gateway_service_account, - peer_gateway_service_account=args.peer_gateway_service_account, workers_service_account=args.workers_service_account, launcher_service_account=args.launcher_service_account, node_service_account=args.node_service_account, diff --git a/scripts/gcp/pyproject.toml b/scripts/gcp/pyproject.toml index e1cba2810..d032ca3f6 100644 --- a/scripts/gcp/pyproject.toml +++ b/scripts/gcp/pyproject.toml @@ -3,7 +3,7 @@ name = "gcp-scripts" version = "0.1.0" description = "Shifter GCP deploy-time rendering scripts" requires-python = ">=3.12" -dependencies = ["installation"] +dependencies = ["cryptography>=48.0.1", "installation"] [tool.uv.sources] installation = { path = "../../shifter/installation", editable = true } diff --git a/scripts/gcp/render_private_service_netpol.py b/scripts/gcp/render_private_service_netpol.py index 8318e214e..1219bac96 100644 --- a/scripts/gcp/render_private_service_netpol.py +++ b/scripts/gcp/render_private_service_netpol.py @@ -174,10 +174,6 @@ def render_netpol(outputs: dict[str, object]) -> str: port: 5985 - protocol: TCP port: 5986 - - protocol: TCP - # Participant OpenVPN gateway health responder (#2030, ADR-039-R10): - # the provisioner proves the gateway ready before publishing a profile. - port: 1195 """ # YAML is hand-formatted (rather than via PyYAML) for two reasons: diff --git a/scripts/gcp/render_runtime_env.py b/scripts/gcp/render_runtime_env.py index 44c47989c..9f159ef71 100644 --- a/scripts/gcp/render_runtime_env.py +++ b/scripts/gcp/render_runtime_env.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse +import ipaddress import json import os import re @@ -267,16 +268,45 @@ def _model_access_runtime_values() -> dict[str, str]: } -def _openvpn_runtime_values() -> dict[str, str]: - """Render the deployment's participant OpenVPN opt-in (#2030, ADR-039-R10). +_OPENVPN_POOL_KEYS = ( + "RANGE_OPENVPN_ENDPOINT", + "RANGE_OPENVPN_POOL_CIDRS", + "RANGE_OPENVPN_ISSUER_SECRET_ID", + "VPN_CONTROL_AUDIENCE", + "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", + "VPN_CONTROLLER_SERVICE_ACCOUNT_ID", +) + + +def _openvpn_runtime_values(outputs: dict[str, object]) -> dict[str, str]: + """Render participant OpenVPN access and its shared pool wiring (#2030, #2480, ADR-039-R10). Always emitted, so a deploy that turns the opt-in off also revokes a stale - ``true`` from an earlier rollout. + ``true`` and blanks the pool wiring from an earlier rollout. With the opt-in + on, the Terraform ``openvpn_pool`` output must exist: a range must never be + promised a VPN that no pool serves. """ enabled = os.environ.get("RANGE_OPENVPN_ENABLED", "").strip().lower() or "false" if enabled not in {"true", "false"}: raise ValueError("RANGE_OPENVPN_ENABLED must be true or false") - return {"RANGE_OPENVPN_ENABLED": enabled} + values = {"RANGE_OPENVPN_ENABLED": enabled, **dict.fromkeys(_OPENVPN_POOL_KEYS, "")} + if enabled == "false": + return values + pool = outputs.get("openvpn_pool", {}) + pool = pool.get("value") if isinstance(pool, dict) else None + if not isinstance(pool, dict): + raise ValueError("RANGE_OPENVPN_ENABLED=true requires the Terraform openvpn_pool output") + values.update( + { + "RANGE_OPENVPN_ENDPOINT": str(ipaddress.ip_address(str(pool["endpoint"]))), + "RANGE_OPENVPN_POOL_CIDRS": str(ipaddress.ip_network(str(pool["subnet_cidr"]), strict=True)), + "RANGE_OPENVPN_ISSUER_SECRET_ID": str(pool["issuer_secret"]), + "VPN_CONTROL_AUDIENCE": str(pool["control_audience"]), + "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL": str(pool["service_account_email"]), + "VPN_CONTROLLER_SERVICE_ACCOUNT_ID": str(pool["service_account_id"]), + } + ) + return values def _mission_control_lease_runtime_values() -> dict[str, str]: @@ -558,7 +588,7 @@ def render_env(outputs: dict[str, object], *, engine_image: str) -> str: values["MODEL_ENROLLMENT_CONTROL_URL"] = "" values["MODEL_ENROLLMENT_CA_PEM_B64"] = "" values.update(_model_access_runtime_values()) - values.update(_openvpn_runtime_values()) + values.update(_openvpn_runtime_values(outputs)) values.update(_mission_control_lease_runtime_values()) # These references originate in the same validated shifter.yaml map that # drives per-secret Terraform IAM. Apply them last so a process-local env diff --git a/scripts/gcp/tests/test_check_event_capacity_drift.py b/scripts/gcp/tests/test_check_event_capacity_drift.py index 208c0cc2a..d4189c507 100644 --- a/scripts/gcp/tests/test_check_event_capacity_drift.py +++ b/scripts/gcp/tests/test_check_event_capacity_drift.py @@ -151,3 +151,57 @@ def test_live_state_uses_effective_autoscaling_minimum_for_access_capacity(monke ) assert observed["terraform"]["capacity.access_node_count"] == 3 + + +def _live(monkeypatch, extra: list[dict], **kwargs) -> dict: + responses = iter(_live_responses() + extra) + monkeypatch.setattr(_MODULE, "_run_json", lambda _argv: next(responses)) + return collect_live_state( + {}, + project="project", + region="region", + sql_instance="sql", + redis_instance="redis", + cluster="cluster", + namespace="namespace", + **kwargs, + ) + + +def test_a_deployed_openvpn_pool_reports_its_sizing(monkeypatch): + pool = [ + {"instanceTemplate": "https://compute.googleapis.com/compute/v1/projects/p/regions/r/instanceTemplates/t-1"}, + {"autoscalingPolicy": {"minNumReplicas": 3, "maxNumReplicas": 6, "cpuUtilization": {"utilizationTarget": 0.3}}}, + {"properties": {"machineType": "e2-standard-2"}}, + ] + + observed = _live(monkeypatch, pool, vpn_pool="shifter-x-vpn") + + assert {key: value for key, value in observed["terraform"].items() if "vpn_pool" in key} == { + "capacity.vpn_pool_machine_type": "e2-standard-2", + "capacity.vpn_pool_min_vms": 3, + "capacity.vpn_pool_max_vms": 6, + "capacity.vpn_pool_cpu_target_pct": 30, + } + assert "vpn_pool" not in observed + + +def test_an_installation_without_the_pool_skips_only_the_pool_fields(monkeypatch): + observed = _live(monkeypatch, []) + desired = { + "profile_id": "gcp-shared-v1-p30", + "terraform": {**observed["terraform"], "capacity.vpn_pool_min_vms": 3}, + "kubernetes": {}, + } + + assert observed["vpn_pool"] == "absent" + assert compare_capacity_state(desired, observed) == [] + observed.pop("vpn_pool") + with pytest.raises(DriftInputError, match="vpn_pool_min_vms"): + compare_capacity_state(desired, observed) + + +def test_incomplete_pool_evidence_fails_closed(monkeypatch): + pool = [{"instanceTemplate": "t"}, {"autoscalingPolicy": {}}, {"properties": {}}] + with pytest.raises(DriftInputError, match="OpenVPN pool"): + _live(monkeypatch, pool, vpn_pool="shifter-x-vpn") diff --git a/scripts/gcp/tests/test_ensure_vpn_pki.py b/scripts/gcp/tests/test_ensure_vpn_pki.py new file mode 100644 index 000000000..8eecb5d78 --- /dev/null +++ b/scripts/gcp/tests/test_ensure_vpn_pki.py @@ -0,0 +1,139 @@ +"""The shared OpenVPN pool PKI bootstrap and renewal step (#2480).""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +_SPEC = importlib.util.spec_from_file_location("ensure_vpn_pki", Path(__file__).parents[1] / "ensure_vpn_pki.py") +assert _SPEC is not None and _SPEC.loader is not None +pki = importlib.util.module_from_spec(_SPEC) +sys.modules["ensure_vpn_pki"] = pki +_SPEC.loader.exec_module(pki) + +_NOW = datetime(2026, 10, 5, tzinfo=UTC) + + +class FakeGcloud: + """Secret Manager as seen through the gcloud CLI, in memory.""" + + def __init__(self) -> None: + self.versions: dict[str, list[tuple[str, str]]] = {"issuer": [], "server": []} + self.calls: list[list[str]] = [] + + def __call__(self, args: list[str], stdin: str | None) -> subprocess.CompletedProcess[str]: + self.calls.append(args) + verb = args[2] + secret = next((a.split("=", 1)[1] for a in args if a.startswith("--secret=")), args[3] if len(args) > 3 else "") + if verb == "access": + live = [payload for _name, payload in self.versions[secret] if payload is not None] + if not live: + return subprocess.CompletedProcess(args, 1, "", "ERROR: NOT_FOUND: Secret Version [latest] not found") + return subprocess.CompletedProcess(args, 0, live[-1], "") + if verb == "add": + name = f"projects/p/secrets/{secret}/versions/{len(self.versions[secret]) + 1}" + self.versions[secret].append((name, stdin)) + return subprocess.CompletedProcess(args, 0, "", "") + if verb == "list": + names = [name for name, payload in reversed(self.versions[secret]) if payload is not None] + return subprocess.CompletedProcess(args, 0, "\n".join(names), "") + if verb == "destroy": + number = args[3] + self.versions[secret] = [ + (name, None if name.endswith(f"/{number}") else payload) for name, payload in self.versions[secret] + ] + return subprocess.CompletedProcess(args, 0, "", "") + raise AssertionError(args) + + +def _store(fake: FakeGcloud): + return pki.SecretStore(project="p", run=fake) + + +def _latest(fake: FakeGcloud, secret: str) -> dict: + return json.loads([payload for _name, payload in fake.versions[secret] if payload is not None][-1]) + + +def test_first_run_creates_a_ca_and_a_server_identity_it_signs(): + fake = FakeGcloud() + + assert pki.ensure(_store(fake), "issuer", "server", _NOW) == ["created tenant CA", "issued pool server certificate"] + + issuer, server = _latest(fake, "issuer"), _latest(fake, "server") + assert set(issuer) == {"ca", "ca_private_key", "tls_crypt"} + assert set(server) == {"ca", "certificate", "private_key", "tls_crypt"} + assert "ca_private_key" not in json.dumps(server) + ca = x509.load_pem_x509_certificate(issuer["ca"].encode()) + certificate = x509.load_pem_x509_certificate(server["certificate"].encode()) + assert certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == pki.SERVER_NAME + ca.public_key().verify( + certificate.signature, certificate.tbs_certificate_bytes, ec.ECDSA(certificate.signature_hash_algorithm) + ) + assert list(certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value) == [ + ExtendedKeyUsageOID.SERVER_AUTH + ] + assert server["tls_crypt"] == issuer["tls_crypt"] + # Key material reaches gcloud on stdin only. + assert not any("PRIVATE KEY" in arg for call in fake.calls for arg in call) + + +def test_a_current_pki_is_left_alone(): + fake = FakeGcloud() + pki.ensure(_store(fake), "issuer", "server", _NOW) + + assert pki.ensure(_store(fake), "issuer", "server", _NOW + timedelta(days=100)) == [] + assert len(fake.versions["issuer"]) == 1 + + +def test_the_server_identity_is_renewed_before_expiry_and_old_versions_destroyed(): + fake = FakeGcloud() + pki.ensure(_store(fake), "issuer", "server", _NOW) + + actions = pki.ensure(_store(fake), "issuer", "server", _NOW + timedelta(days=340)) + + assert actions == ["issued pool server certificate"] + enabled = [name for name, payload in fake.versions["server"] if payload is not None] + assert enabled == ["projects/p/secrets/server/versions/2"] + + +def test_a_ca_too_close_to_expiry_refuses_to_continue(): + fake = FakeGcloud() + pki.ensure(_store(fake), "issuer", "server", _NOW) + + with pytest.raises(RuntimeError, match="rotate"): + pki.ensure(_store(fake), "issuer", "server", _NOW + pki.CA_LIFETIME - timedelta(days=100)) + + +def test_an_unreadable_secret_fails_instead_of_minting_a_new_ca(): + def denied(args, _stdin): + return subprocess.CompletedProcess(args, 1, "", "ERROR: PERMISSION_DENIED") + + with pytest.raises(RuntimeError, match="cannot read"): + pki.ensure(pki.SecretStore(project="p", run=denied), "issuer", "server", _NOW) + + +def test_the_server_name_matches_the_name_clients_pin(): + shared = Path(__file__).parents[3] / "shifter/shifter_platform/shared/remote_access.py" + match = re.search(r'^OPENVPN_SERVER_NAME = "([^"]+)"', shared.read_text(), re.M) + assert match and match.group(1) == pki.SERVER_NAME + + +def test_the_cli_reports_whether_the_pool_must_reload(tmp_path): + fake = FakeGcloud() + output = tmp_path / "github-output" + args = ["--project", "p", "--issuer-secret", "issuer", "--server-secret", "server", "--changed-output", str(output)] + + assert pki.main(args, run=fake) == 0 + assert pki.main(args, run=fake) == 0 + + assert output.read_text().splitlines() == ["changed=true", "changed=false"] diff --git a/scripts/gcp/tests/test_probe_range_secret_permissions.py b/scripts/gcp/tests/test_probe_range_secret_permissions.py index 0907cfd20..6a25e4294 100644 --- a/scripts/gcp/tests/test_probe_range_secret_permissions.py +++ b/scripts/gcp/tests/test_probe_range_secret_permissions.py @@ -31,8 +31,6 @@ def _config(module): portal_service_account="portal@platform-project.iam.gserviceaccount.com", range_host_service_account="range-host@platform-project.iam.gserviceaccount.com", peer_range_host_service_account="peer-host@platform-project.iam.gserviceaccount.com", - gateway_service_account="gateway@platform-project.iam.gserviceaccount.com", - peer_gateway_service_account="peer-gateway@platform-project.iam.gserviceaccount.com", workers_service_account="workers@platform-project.iam.gserviceaccount.com", launcher_service_account="launcher@platform-project.iam.gserviceaccount.com", node_service_account="node@platform-project.iam.gserviceaccount.com", @@ -58,9 +56,6 @@ def test_probe_matrix_covers_positive_and_negative_real_identity_edges(tmp_path) assert expectations["range-host-read-after-grant"] is True assert expectations["peer-range-host-read-denied"] is False assert expectations["range-host-project-wide-read-denied"] is False - assert expectations["gateway-read-after-grant"] is True - assert expectations["peer-gateway-read-denied"] is False - assert expectations["gateway-project-wide-read-denied"] is False assert expectations["workers-read-participant-denied"] is False assert expectations["launcher-read-participant-denied"] is False assert expectations["node-read-participant-denied"] is False @@ -70,7 +65,6 @@ def test_probe_matrix_covers_positive_and_negative_real_identity_edges(tmp_path) assert "--impersonate-service-account=provisioner@platform-project.iam.gserviceaccount.com" in commands assert "--impersonate-service-account=portal@platform-project.iam.gserviceaccount.com" in commands assert "--impersonate-service-account=range-host@platform-project.iam.gserviceaccount.com" in commands - assert "--impersonate-service-account=gateway@platform-project.iam.gserviceaccount.com" in commands assert "--impersonate-service-account=node@platform-project.iam.gserviceaccount.com" in commands @@ -166,8 +160,6 @@ def test_probe_refuses_same_project_compatibility_posture(): portal_service_account="portal@example.test", range_host_service_account="host@example.test", peer_range_host_service_account="peer-host@example.test", - gateway_service_account="gateway@example.test", - peer_gateway_service_account="peer-gateway@example.test", workers_service_account="workers@example.test", launcher_service_account="launcher@example.test", node_service_account="node@example.test", @@ -198,10 +190,6 @@ def test_cli_is_plan_only_without_execute(capsys): "host@example.test", "--peer-range-host-service-account", "peer-host@example.test", - "--gateway-service-account", - "gateway@example.test", - "--peer-gateway-service-account", - "peer-gateway@example.test", "--workers-service-account", "workers@example.test", "--launcher-service-account", diff --git a/scripts/gcp/tests/test_render_private_service_netpol.py b/scripts/gcp/tests/test_render_private_service_netpol.py index 4fa98a170..eb25b1d61 100644 --- a/scripts/gcp/tests/test_render_private_service_netpol.py +++ b/scripts/gcp/tests/test_render_private_service_netpol.py @@ -186,9 +186,8 @@ def test_render_emits_range_access_egress_scoped_to_range_dialers(): if doc["metadata"]["name"] == "allow-jobs-range-access-egress-generated" ) jobs_ports = {entry["port"] for entry in jobs["spec"]["egress"][0]["ports"]} - # The provisioner, and only the provisioner, probes the OpenVPN gateway health - # responder before publishing a participant profile (#2030). - assert 1195 in jobs_ports + # Range VPN servers moved to the shared pool (#2480): no per-range health probe port. + assert 1195 not in jobs_ports assert {entry["port"] for entry in policy["spec"]["egress"][0]["ports"]} == {22, 3389} diff --git a/scripts/gcp/tests/test_render_runtime_env.py b/scripts/gcp/tests/test_render_runtime_env.py index 1fca4c91e..954c40d6a 100644 --- a/scripts/gcp/tests/test_render_runtime_env.py +++ b/scripts/gcp/tests/test_render_runtime_env.py @@ -346,6 +346,16 @@ def test_render_env_rejects_incomplete_model_access_projection(monkeypatch): module.render_env(_outputs(), engine_image=PINNED_ENGINE_DIGEST) +_OPENVPN_POOL = { + "endpoint": "203.0.113.7", + "subnet_cidr": "10.49.0.0/24", + "issuer_secret": "projects/p/secrets/shifter-test-vpn-issuer", + "control_audience": "https://portal.example.com/vpn-control", + "service_account_email": "shifter-test-vpn@p.iam.gserviceaccount.com", + "service_account_id": "123456789012345678901", +} + + @pytest.mark.parametrize(("configured", "rendered_value"), [(None, "false"), ("", "false"), ("TRUE", "true")]) def test_render_env_always_renders_the_openvpn_opt_in(monkeypatch, configured, rendered_value): """Unset renders false, so turning the opt-in off revokes a stale true (#2030).""" @@ -354,10 +364,39 @@ def test_render_env_always_renders_the_openvpn_opt_in(monkeypatch, configured, r monkeypatch.delenv("RANGE_OPENVPN_ENABLED", raising=False) else: monkeypatch.setenv("RANGE_OPENVPN_ENABLED", configured) - rendered = module.render_env(_outputs(), engine_image=PINNED_ENGINE_DIGEST) + outputs = {**_outputs(), "openvpn_pool": {"value": _OPENVPN_POOL}} + rendered = module.render_env(outputs, engine_image=PINNED_ENGINE_DIGEST) assert f"RANGE_OPENVPN_ENABLED={rendered_value}\n" in rendered +def test_render_env_wires_the_shared_pool_only_when_enabled(monkeypatch): + """The portal and provisioner learn the pool from Terraform; off blanks every key (#2480).""" + module = _load_module("render_runtime_env.py", "render_runtime_env") + monkeypatch.setenv("RANGE_OPENVPN_ENABLED", "true") + outputs = {**_outputs(), "openvpn_pool": {"value": _OPENVPN_POOL}} + rendered = module.render_env(outputs, engine_image=PINNED_ENGINE_DIGEST).splitlines() + for line in ( + "RANGE_OPENVPN_ENDPOINT=203.0.113.7", + "RANGE_OPENVPN_POOL_CIDRS=10.49.0.0/24", + "RANGE_OPENVPN_ISSUER_SECRET_ID=projects/p/secrets/shifter-test-vpn-issuer", + "VPN_CONTROL_AUDIENCE=https://portal.example.com/vpn-control", + "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL=shifter-test-vpn@p.iam.gserviceaccount.com", + "VPN_CONTROLLER_SERVICE_ACCOUNT_ID=123456789012345678901", + ): + assert line in rendered + + monkeypatch.setenv("RANGE_OPENVPN_ENABLED", "false") + disabled = module.render_env(outputs, engine_image=PINNED_ENGINE_DIGEST).splitlines() + assert "RANGE_OPENVPN_ENDPOINT=" in disabled and "VPN_CONTROLLER_SERVICE_ACCOUNT_ID=" in disabled + + +def test_render_env_refuses_an_opt_in_without_a_deployed_pool(monkeypatch): + module = _load_module("render_runtime_env.py", "render_runtime_env") + monkeypatch.setenv("RANGE_OPENVPN_ENABLED", "true") + with pytest.raises(ValueError, match="openvpn_pool"): + module.render_env({**_outputs(), "openvpn_pool": {"value": None}}, engine_image=PINNED_ENGINE_DIGEST) + + def test_render_env_rejects_a_non_boolean_openvpn_opt_in(monkeypatch): module = _load_module("render_runtime_env.py", "render_runtime_env") monkeypatch.setenv("RANGE_OPENVPN_ENABLED", "yes") diff --git a/scripts/gcp/uv.lock b/scripts/gcp/uv.lock index c046f0e7f..70ab4eeaf 100644 --- a/scripts/gcp/uv.lock +++ b/scripts/gcp/uv.lock @@ -20,6 +20,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -29,11 +114,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + [[package]] name = "gcp-scripts" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cryptography" }, { name = "installation" }, ] @@ -45,7 +194,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "installation", editable = "../../shifter/installation" }] +requires-dist = [ + { name = "cryptography", specifier = ">=48.0.1" }, + { name = "installation", editable = "../../shifter/installation" }, +] [package.metadata.requires-dev] dev = [ @@ -134,6 +286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" diff --git a/shifter/engine/provisioner/tests/test_vpn_secrets.py b/shifter/engine/provisioner/tests/test_vpn_secrets.py index 122e7ff6c..772ddfef2 100644 --- a/shifter/engine/provisioner/tests/test_vpn_secrets.py +++ b/shifter/engine/provisioner/tests/test_vpn_secrets.py @@ -63,7 +63,7 @@ def test_issuer_is_read_from_the_configured_tenant_secret_only(): assert client.access_secret_version.call_args.kwargs["request"] == {"name": f"{_ISSUER}/versions/latest"} client.create_secret.assert_not_called() - with pytest.raises(RuntimeError, match="RANGE_OPENVPN_ISSUER_SECRET"): + with pytest.raises(RuntimeError, match="RANGE_OPENVPN_ISSUER_SECRET_ID"): _gcp_adapter(MagicMock(), issuer_secret="").read_issuer() @@ -149,12 +149,12 @@ def test_pool_is_enabled_only_with_a_deployed_pool_on_shared_vpc_gce(monkeypatch monkeypatch.setenv("GCP_RANGE_BACKEND", "gce") monkeypatch.setenv("GCP_RANGE_CELL_NETWORK_MODE", "shared-vpc") monkeypatch.delenv("RANGE_OPENVPN_ENDPOINT", raising=False) - monkeypatch.delenv("RANGE_OPENVPN_ISSUER_SECRET", raising=False) + monkeypatch.delenv("RANGE_OPENVPN_ISSUER_SECRET_ID", raising=False) monkeypatch.delenv("RANGE_OPENVPN_POOL_CIDRS", raising=False) assert openvpn_access_enabled() is False monkeypatch.setenv("RANGE_OPENVPN_ENDPOINT", "203.0.113.7") - monkeypatch.setenv("RANGE_OPENVPN_ISSUER_SECRET", _ISSUER) + monkeypatch.setenv("RANGE_OPENVPN_ISSUER_SECRET_ID", _ISSUER) assert openvpn_access_enabled() is False monkeypatch.setenv("RANGE_OPENVPN_POOL_CIDRS", "10.49.0.0/24") assert openvpn_access_enabled() is True diff --git a/shifter/engine/provisioner/vpn_secrets.py b/shifter/engine/provisioner/vpn_secrets.py index ebe3e6a37..295d78f9f 100644 --- a/shifter/engine/provisioner/vpn_secrets.py +++ b/shifter/engine/provisioner/vpn_secrets.py @@ -25,7 +25,7 @@ ) from vpn_access import VpnSecretOps -_ISSUER_LOCATOR_ENV = "RANGE_OPENVPN_ISSUER_SECRET" +_ISSUER_LOCATOR_ENV = "RANGE_OPENVPN_ISSUER_SECRET_ID" class _GCPExceptions(Protocol): diff --git a/shifter/installation/capacity_profiles_gcp.py b/shifter/installation/capacity_profiles_gcp.py index bbca98e40..db930080b 100644 --- a/shifter/installation/capacity_profiles_gcp.py +++ b/shifter/installation/capacity_profiles_gcp.py @@ -140,6 +140,29 @@ def validate_bounds(self) -> AccessNodeCapacity: return self +class VpnPoolCapacity(_ClosedModel): + """Shared participant OpenVPN pool sizing (#2480). + + One OpenVPN 2.x process is single-threaded and uses one core. The plan is + 25 participants per 2-vCPU server: 25 x 3 Mbps (interactive RDP plus SSH) + is about 75 Mbps, roughly 40% of one core at OpenVPN's published 12 MHz per + Mbps. The minimum adds one spare server (and never drops below two) so a + server can fail or be replaced without exceeding the plan. The CPU target + is 30% of the VM because a saturated OpenVPN core shows as only 50%. + """ + + machine_type: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)+$") + minimum_vms: int = Field(ge=2) + maximum_vms: int = Field(ge=2) + cpu_utilization_pct: int = Field(ge=10, le=45) + + @model_validator(mode="after") + def validate_bounds(self) -> VpnPoolCapacity: + if self.maximum_vms < self.minimum_vms: + raise ValueError("VPN pool maximum must be at least its minimum") + return self + + class TimeoutCapacity(_ClosedModel): """Coordinated backend, WebSocket, process, pod, and drain timeouts.""" @@ -179,6 +202,7 @@ class GcpSharedServiceCapacityProfile(_ClosedModel): cloud_sql: CloudSqlCapacity redis: RedisCapacity access_nodes: AccessNodeCapacity + vpn_pool: VpnPoolCapacity timeouts: TimeoutCapacity gate: GateCapacity @@ -198,6 +222,10 @@ def terraform_projection(self) -> dict[str, object]: "access_machine_type": self.access_nodes.machine_type, "access_node_count": self.access_nodes.minimum_nodes, "access_node_max_count": self.access_nodes.maximum_nodes, + "vpn_pool_machine_type": self.vpn_pool.machine_type, + "vpn_pool_min_vms": self.vpn_pool.minimum_vms, + "vpn_pool_max_vms": self.vpn_pool.maximum_vms, + "vpn_pool_cpu_target_pct": self.vpn_pool.cpu_utilization_pct, "cloud_sql_tier": self.cloud_sql.tier, "cloud_sql_availability_type": self.cloud_sql.availability_type, "cloud_sql_disk_size_gb": self.cloud_sql.disk_size_gb, @@ -363,6 +391,20 @@ def _resources(request_cpu: str, request_memory: str, limit_cpu: str, limit_memo ) +_VPN_PARTICIPANTS_PER_SERVER = 25 + + +def _vpn_pool(count: int) -> VpnPoolCapacity: + """Size the OpenVPN pool for one participant tier (basis on VpnPoolCapacity).""" + minimum = max(2, -(-count // _VPN_PARTICIPANTS_PER_SERVER) + 1) + return VpnPoolCapacity( + machine_type="e2-standard-2", + minimum_vms=minimum, + maximum_vms=max(minimum + 2, minimum * 2), + cpu_utilization_pct=30, + ) + + def _build_profile(count: Literal[10, 30, 50, 100]) -> GcpSharedServiceCapacityProfile: """Build one immutable catalog entry from its supported participant tier.""" sizes: dict[int, tuple[int, int, int, int, str, Literal["ZONAL", "REGIONAL"], int, int]] = { @@ -420,6 +462,7 @@ def _build_profile(count: Literal[10, 30, 50, 100]) -> GcpSharedServiceCapacityP minimum_nodes=access_nodes, maximum_nodes=access_max, ), + vpn_pool=_vpn_pool(count), timeouts=TimeoutCapacity( portal_backend_seconds=3600, guacamole_backend_seconds=3600, diff --git a/shifter/installation/published_contract/backend-bundle-contract.json b/shifter/installation/published_contract/backend-bundle-contract.json index 114edcc1c..d5d35517a 100644 --- a/shifter/installation/published_contract/backend-bundle-contract.json +++ b/shifter/installation/published_contract/backend-bundle-contract.json @@ -3168,6 +3168,48 @@ "sensitivity": "public", "source": "rendered from validated Terraform outputs and the normalized root config" }, + { + "description": "Public GCP runtime configuration value (RANGE_OPENVPN_ENDPOINT) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "RANGE_OPENVPN_ENDPOINT", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker", + "provisioner" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, + { + "description": "Public GCP runtime configuration value (RANGE_OPENVPN_ISSUER_SECRET_ID) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "RANGE_OPENVPN_ISSUER_SECRET_ID", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker", + "provisioner" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, + { + "description": "Public GCP runtime configuration value (RANGE_OPENVPN_POOL_CIDRS) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "RANGE_OPENVPN_POOL_CIDRS", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker", + "provisioner" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, { "description": "Public GCP runtime configuration value (RANGE_VPC_CIDR) emitted into the platform runtime environment.", "destination": "runtime-env", @@ -3314,6 +3356,45 @@ "sensitivity": "public", "source": "rendered from validated Terraform outputs and the normalized root config" }, + { + "description": "Public GCP runtime configuration value (VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, + { + "description": "Public GCP runtime configuration value (VPN_CONTROLLER_SERVICE_ACCOUNT_ID) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "VPN_CONTROLLER_SERVICE_ACCOUNT_ID", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, + { + "description": "Public GCP runtime configuration value (VPN_CONTROL_AUDIENCE) emitted into the platform runtime environment.", + "destination": "runtime-env", + "kind": "runtime-env", + "name": "VPN_CONTROL_AUDIENCE", + "owner": "gcp backend runtime-env renderer (scripts/gcp/render_runtime_env.py)", + "process_roles": [ + "portal", + "worker" + ], + "sensitivity": "public", + "source": "rendered from validated Terraform outputs and the normalized root config" + }, { "description": "Public GCP runtime configuration value (DEFAULT_FROM_EMAIL) emitted into the platform runtime environment. Optional; emitted only when the deployment configures it.", "destination": "runtime-env", diff --git a/shifter/installation/runtime_inventory_gcp.py b/shifter/installation/runtime_inventory_gcp.py index a213f83a7..194488e8c 100644 --- a/shifter/installation/runtime_inventory_gcp.py +++ b/shifter/installation/runtime_inventory_gcp.py @@ -79,6 +79,9 @@ "QUEUE_MC_PUBLISHER_ID", "RANGE_EVENTS_TOPIC_ID", "RANGE_OPENVPN_ENABLED", + "RANGE_OPENVPN_ENDPOINT", + "RANGE_OPENVPN_ISSUER_SECRET_ID", + "RANGE_OPENVPN_POOL_CIDRS", "RANGE_NETWORK_CIDR", "RANGE_NETWORK_ID", "RANGE_NETWORK_REGION", @@ -93,6 +96,9 @@ "SITE_URL", "STORAGE_BUCKET_NAME", "TF_STATE_BUCKET", + "VPN_CONTROL_AUDIENCE", + "VPN_CONTROLLER_SERVICE_ACCOUNT_EMAIL", + "VPN_CONTROLLER_SERVICE_ACCOUNT_ID", } ) @@ -183,6 +189,9 @@ "MODEL_ENROLLMENT_CONTROL_URL", "MODEL_ENROLLMENT_CA_PEM_B64", "ACCESS_NETWORK_CIDRS", + "RANGE_OPENVPN_ENDPOINT", + "RANGE_OPENVPN_ISSUER_SECRET_ID", + "RANGE_OPENVPN_POOL_CIDRS", "AGENT_STORAGE_BUCKET", "CLOUD_PROJECT_ID", "CLOUD_PROVIDER", diff --git a/shifter/installation/tests/test_capacity_profiles_gcp.py b/shifter/installation/tests/test_capacity_profiles_gcp.py index c6c3c17aa..d203a5adf 100644 --- a/shifter/installation/tests/test_capacity_profiles_gcp.py +++ b/shifter/installation/tests/test_capacity_profiles_gcp.py @@ -169,6 +169,10 @@ def test_p30_terraform_and_gate_projections_are_complete(): "access_machine_type": "e2-standard-8", "access_node_count": 3, "access_node_max_count": 8, + "vpn_pool_machine_type": "e2-standard-2", + "vpn_pool_min_vms": 3, + "vpn_pool_max_vms": 6, + "vpn_pool_cpu_target_pct": 30, "cloud_sql_tier": "db-custom-4-15360", "cloud_sql_availability_type": "REGIONAL", "cloud_sql_disk_size_gb": 60, @@ -248,3 +252,18 @@ def test_p30_drift_projection_covers_capacity_bearing_kubernetes_fields(): assert state["hpa/guacd.spec.maxReplicas"] == 4 assert state["backendconfig/portal-web.spec.connectionDraining.drainingTimeoutSec"] == 300 assert state["configmap/platform-runtime.data.PORTAL_WEB_WS_PING_INTERVAL"] == "30" + + +@pytest.mark.parametrize( + ("profile_id", "minimum", "maximum"), + [ + ("gcp-shared-v1-p10", 2, 4), + ("gcp-shared-v1-p30", 3, 6), + ("gcp-shared-v1-p50", 3, 6), + ("gcp-shared-v1-p100", 5, 10), + ], +) +def test_the_openvpn_pool_keeps_a_spare_server_above_its_participant_plan(profile_id, minimum, maximum): + # 25 participants per single-threaded OpenVPN server, plus one spare, never fewer than two (#2480). + pool = resolve_capacity_profile(profile_id).vpn_pool + assert (pool.minimum_vms, pool.maximum_vms, pool.cpu_utilization_pct) == (minimum, maximum, 30) diff --git a/shifter/shifter_platform/engine/ecs/_env.py b/shifter/shifter_platform/engine/ecs/_env.py index e98900060..b121197a0 100644 --- a/shifter/shifter_platform/engine/ecs/_env.py +++ b/shifter/shifter_platform/engine/ecs/_env.py @@ -51,6 +51,11 @@ "RANGE_NETWORK_ZONE", "PORTAL_NETWORK_CIDRS", "ACCESS_NETWORK_CIDRS", + # Shared participant OpenVPN pool (#2480): profiles point at the pool + # endpoint and are signed by the tenant CA; ranges admit the pool networks. + "RANGE_OPENVPN_ENDPOINT", + "RANGE_OPENVPN_ISSUER_SECRET_ID", + "RANGE_OPENVPN_POOL_CIDRS", "GCP_PROVISIONER_SERVICE_ACCOUNT_EMAIL", "GCP_RANGE_BACKEND", "GCP_RANGE_PLANE", From db0612061a4f5efd4a9bb5a045272c252a47871a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 06:26:13 +0000 Subject: [PATCH 04/14] docs(adr): record the shared OpenVPN pool 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 --- docs/adr/exceptions.yaml | 9 --- docs/adr/index.yaml | 6 +- ...envpn-participant-access-preflight-1695.md | 7 ++ ...gcp-range-cell-containment-threat-model.md | 9 ++- ...ctf-openvpn-range-access-preflight-1696.md | 7 ++ docs/dev/deploy-secrets.md | 2 +- docs/dev/gcp-range-cell-deploy.md | 70 ++++++++++++------- docs/ops/gcp-event-capacity.md | 12 ++-- docs/technical/shifter_platform/ctf.md | 15 ++-- platform/terraform/gcp/README.md | 17 +++-- .../check_tf_gcp_iam_resource_scope.py | 7 +- .../engine/provisioner/raes_gcp_destroy.py | 2 +- shifter/engine/provisioner/raes_range_ops.py | 2 +- .../tests/test_warm_activation_ops.py | 2 +- .../cms/services/_range_remote_access.py | 4 +- .../engine/services/_operation_apply_vpn.py | 6 +- .../shared/cloud/gcp/task_runner.py | 3 +- .../shared/operation_result_payloads.py | 2 +- 18 files changed, 108 insertions(+), 74 deletions(-) diff --git a/docs/adr/exceptions.yaml b/docs/adr/exceptions.yaml index e081cc016..58b0f4d0b 100644 --- a/docs/adr/exceptions.yaml +++ b/docs/adr/exceptions.yaml @@ -373,15 +373,6 @@ "shifter/engine/provisioner/terraform/modules/range/vpn.tf" ] }, - { - "rule_id": "ADR-004-R11", - "owner": "@Brad-Edwards", - "reason": "The GCE OpenVPN gateway uses one deterministic sh-vpn-* service account per range generation, and Compute requires iam.serviceAccounts.actAs before attaching that identity. IAM service-account resources do not provide resource.name for an inherited project-level conditional serviceAccountUser grant, so the provisioner holds a custom role with service-account create/delete/getIamPolicy/setIamPolicy and the provisioner code writes serviceAccountUser only onto the generated sh-vpn-* account it owns for the current generation. The gateway account still receives no project roles, no keys, and only secretAccessor on its own server secret. Regression tests pin the generated identity, per-account actAs binding, and absence of a project-scoped serviceAccountUser literal (#1695).", - "expires_on": "2027-07-17", - "paths": [ - "platform/terraform/gcp/modules/portal/iam/main.tf" - ] - }, { "rule_id": "ADR-004-R24", "owner": "@Brad-Edwards", diff --git a/docs/adr/index.yaml b/docs/adr/index.yaml index 35dfe0820..e4a0476f9 100644 --- a/docs/adr/index.yaml +++ b/docs/adr/index.yaml @@ -530,7 +530,7 @@ }, { "id": "ADR-008-R7", - "description": "GCP application workload identities (portal, workers, CTF scheduler, and provisioner) must not receive project-level Secret Manager payload/admin, Cloud Storage object-admin, or service-account IAM-admin (`iam.serviceAccounts.create`/`delete`/`setIamPolicy`) roles, whether predefined or via an equivalent project-level custom role. Static runtime access is bound on each named secret or bucket from the resource-owning module's outputs (portal/secrets `runtime_secret_ids`, portal/gcs `assets_bucket_name`), driven by a single canonical workload/resource matrix in `platform/terraform/gcp/modules/portal/iam/main.tf`. Dynamic provisioner-created secret lifecycle is isolated in one dedicated range-secret project per Shifter deployment, never a project per range/workspace/user and never a project shared by unrelated deployments; a creation broker is not selected. The dedicated project contains only ephemeral dynamic secrets. The provisioner receives an unconditioned create-only custom role on that parent because Google authorizes `secretmanager.secrets.create` before the new resource exists. That grant can create an arbitrary empty Secret resource inside the dedicated project and is bounded by deployment isolation, monitoring, quota, and bootstrap/operator cleanup; it must not permit creation in the platform or any unrelated project. Every existing-secret lifecycle permission is limited by the closed Secret Manager-only role and the fully qualified canonical `shifter-{environment}-dynamic-` resource prefix. Secret and SecretVersion calls use the same name boundary without a resource.type predicate; live GKE qualification for #1583 rejected the additional type predicate despite correct audit resource attributes. Participant suffix checks extract the secret ID from the version resource name before matching, including numbered and latest versions. The portal's read-only permission is narrower: it is conditioned on a finite, centrally classified set of canonical participant-access name classes, excludes Vertex keys, VPN issuer/server and non-published bootstrap/directory material, and does not admit future families by default; the migration-only legacy condition composes each known legacy prefix with only its published credential shapes, explicitly excludes the `shifter-range-{range}-raes-domain-...` namespace through supported `resource.name.extract(...)` classification, never uses a broad suffix list, and must remain at or below Google IAM's hard limit of 12 logical operators per binding. Participant-controlled range hosts retain only per-secret access. Static runtime/operator secrets remain in their owning project with named bindings. The single `dynamic_secret_project_id` operator/Terraform/runtime seam may be explicitly set equal to the platform project only during migration, omission fails closed, and the value must become distinct after legacy drain; canonical naming also requires a non-empty lowercase single-hyphen `ENVIRONMENT` namespace and has no implicit `dev` fallback. Platform, Vertex API, shared-key source, compute-host, and dynamic-secret project IDs must not be conflated. Read-before-create reconciliation probes every exact legacy/canonical container after a missing latest version and waits boundedly on an existing empty container, so migration cannot mint a competing canonical credential. Repo-native Terraform guards parse and enforce the exact IAM-condition local inputs and compositions; static effective-permission tests and live API probes under the real workload identities must cover direct, binding/policy, local-map, IAM-condition, cross-project, equivalent custom-role, positive, and negative shapes; parent-scoped create inside the dedicated project must not be misreported as prefix-restricted. The check is implemented in `scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py` and wired as a blocking CI step in `.github/workflows/_quality.yml`, with its unittest + effective-permission oracle suite included in the `check_tf_*` checker tests. The implementation removes the former portal `roles/secretmanager.secretAccessor` and provisioner `roles/secretmanager.admin` platform-project residuals; the guard admits only the named migration and dedicated-project bindings and has no permanent residual exemption. The provisioner's former project-level `iam.serviceAccounts.setIamPolicy` custom role (used to self-grant `serviceAccountUser` on dynamically created OpenVPN gateway service accounts, which GCP cannot resource-name-scope for setIamPolicy) is replaced by a pre-provisioned single-project gateway SA pool: Terraform pre-creates `sh-vpn-pool-` no-role service accounts and grants the provisioner `serviceAccountUser` on each specific member, and each active OpenVPN range reserves a slot via `Range.allocate_vpn_gateway_slot` bounded by `VPN_GATEWAY_POOL_SIZE` (must equal the Terraform `vpn_gateway_pool_size`). Preconfigured GCE range hosts use the same bounded-identity rule through a separate no-role `sh-range-host-` pool selected by the range's existing allocation index; Terraform grants the provisioner `serviceAccountUser` only on each specific member, and `GCP_RANGE_HOST_IDENTITY_POOL_SIZE` must match the Terraform `range_host_identity_pool_size`. This removes all runtime service-account administration from the provisioner with no cross-project or org-policy dependency (single isolated tenant), so the guard also fails a project-level service-account IAM-admin custom role attached to a workload identity. Recorded by the architecture preflights for #1517, #1586, #1896, and #2083.", + "description": "GCP application workload identities (portal, workers, CTF scheduler, and provisioner) must not receive project-level Secret Manager payload/admin, Cloud Storage object-admin, or service-account IAM-admin (`iam.serviceAccounts.create`/`delete`/`setIamPolicy`) roles, whether predefined or via an equivalent project-level custom role. Static runtime access is bound on each named secret or bucket from the resource-owning module's outputs (portal/secrets `runtime_secret_ids`, portal/gcs `assets_bucket_name`), driven by a single canonical workload/resource matrix in `platform/terraform/gcp/modules/portal/iam/main.tf`. Dynamic provisioner-created secret lifecycle is isolated in one dedicated range-secret project per Shifter deployment, never a project per range/workspace/user and never a project shared by unrelated deployments; a creation broker is not selected. The dedicated project contains only ephemeral dynamic secrets. The provisioner receives an unconditioned create-only custom role on that parent because Google authorizes `secretmanager.secrets.create` before the new resource exists. That grant can create an arbitrary empty Secret resource inside the dedicated project and is bounded by deployment isolation, monitoring, quota, and bootstrap/operator cleanup; it must not permit creation in the platform or any unrelated project. Every existing-secret lifecycle permission is limited by the closed Secret Manager-only role and the fully qualified canonical `shifter-{environment}-dynamic-` resource prefix. Secret and SecretVersion calls use the same name boundary without a resource.type predicate; live GKE qualification for #1583 rejected the additional type predicate despite correct audit resource attributes. Participant suffix checks extract the secret ID from the version resource name before matching, including numbered and latest versions. The portal's read-only permission is narrower: it is conditioned on a finite, centrally classified set of canonical participant-access name classes, excludes Vertex keys, VPN issuer/server and non-published bootstrap/directory material, and does not admit future families by default; the migration-only legacy condition composes each known legacy prefix with only its published credential shapes, explicitly excludes the `shifter-range-{range}-raes-domain-...` namespace through supported `resource.name.extract(...)` classification, never uses a broad suffix list, and must remain at or below Google IAM's hard limit of 12 logical operators per binding. Participant-controlled range hosts retain only per-secret access. Static runtime/operator secrets remain in their owning project with named bindings. The single `dynamic_secret_project_id` operator/Terraform/runtime seam may be explicitly set equal to the platform project only during migration, omission fails closed, and the value must become distinct after legacy drain; canonical naming also requires a non-empty lowercase single-hyphen `ENVIRONMENT` namespace and has no implicit `dev` fallback. Platform, Vertex API, shared-key source, compute-host, and dynamic-secret project IDs must not be conflated. Read-before-create reconciliation probes every exact legacy/canonical container after a missing latest version and waits boundedly on an existing empty container, so migration cannot mint a competing canonical credential. Repo-native Terraform guards parse and enforce the exact IAM-condition local inputs and compositions; static effective-permission tests and live API probes under the real workload identities must cover direct, binding/policy, local-map, IAM-condition, cross-project, equivalent custom-role, positive, and negative shapes; parent-scoped create inside the dedicated project must not be misreported as prefix-restricted. The check is implemented in `scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py` and wired as a blocking CI step in `.github/workflows/_quality.yml`, with its unittest + effective-permission oracle suite included in the `check_tf_*` checker tests. The implementation removes the former portal `roles/secretmanager.secretAccessor` and provisioner `roles/secretmanager.admin` platform-project residuals; the guard admits only the named migration and dedicated-project bindings and has no permanent residual exemption. The provisioner holds no project-level `iam.serviceAccounts.setIamPolicy` custom role and creates no service accounts: participant OpenVPN terminates on the shared pool (ADR-039-R10, #2480), whose one Terraform-managed identity reads only its own server and release secrets, and the provisioner only reads the issuer secret. Preconfigured GCE range hosts follow the bounded-identity rule through a no-role `sh-range-host-` pool selected by the range's existing allocation index; Terraform grants the provisioner `serviceAccountUser` only on each specific member, and `GCP_RANGE_HOST_IDENTITY_POOL_SIZE` must match the Terraform `range_host_identity_pool_size`. This removes all runtime service-account administration from the provisioner with no cross-project or org-policy dependency (single isolated tenant), so the guard also fails a project-level service-account IAM-admin custom role attached to a workload identity. Recorded by the architecture preflights for #1517, #1586, #1896, and #2083.", "checks": [] }, { @@ -2279,7 +2279,7 @@ }, { "id": "ADR-039-R10", - "description": "User-held client VPN is an ADR-039 remote-access specialization, not a fifth operation, browser-workspace transport, or scenario-authored service. Only a trusted product launch path may mint the closed capability beside scenario intent; product provenance and ownership stay at the product/CMS boundary, while the capability binds one authoritative participant-access target and an enforced server-owned access deadline. CMS persists one lease for every new CTF and Mission Control range: the current deadline is the sole automatic-cleanup authority and the immutable generation ceiling bounds certificate `notAfter`. CTF derives both from event cleanup. Mission Control uses one typed, server-owned lease policy: the deployment-configured 30/30/365 default is the fallback, an audited runtime tenant policy may replace it, and every matching persisted Django RBAC-group policy composes restrictively beneath the tenant (see ADR-011-R9 and the #2169 preflight). Durations resolve inside the cold-create or warm-claim transaction and are snapshotted per generation; the tenant/group extension-admission switch is live. Owners extend only through the existing empty-body action and the generation's server-selected increment, never beyond its immutable maximum; HTTP callers cannot select a deadline, increment, maximum, group, or switch. The DB-authoritative reconciler locks due rows and reuses canonical system-attributed destroy and existing product projections rather than a CTF- or Mission-Control-specific cleanup workflow. Any future product must supply the same enforced lease semantics or introduce an explicitly versioned renewal/revocation lifecycle; caller-supplied, silently expiring, and unbounded deadlines are prohibited, and topology alone never activates a VPN. A required profile terminates on a request-owned logical gateway inside the range containment boundary (never a cross-range shared gateway or the user-controlled target), creates one owner/generation-scoped profile secret reference during provision, and enforces server-side reachability only to the authoritative member target. The only public range ingress exception is the audited per-range mutual-TLS OpenVPN UDP/1194 listener in the request-owned NLB resource; the CIDR guard must match that exact path/resource/protocol/port tuple and its readiness responder is private to the portal network. Infrastructure creation returns pending metadata, and a bounded service-and-policy probe must pass before the binding is published as ready. Download resolves the existing secret after product ownership/state checks; pause disables access, resume restores the current generation, and destroy deletes all generation material. Because a downloaded credential cannot be recalled from a former owner, in-place reassignment is refused while a VPN binding exists; recovery for a new owner must destroy the old generation and provision a new one before readiness. The immutable provisioner-only issuer is retained until generation teardown so retries reuse the exact server/profile material. GCE uses a distinct no-role gateway service account per range generation with secretAccessor only on its own server secret; the provisioner writes serviceAccountUser only on that generated gateway account before Compute attaches it. Profile/private-key/CA signing values are prohibited from public contracts, Django/adapter/Terraform state, events, task payloads, environment, argv, logs, metrics, and errors. Every live-fire adapter eligible for an enabled product profile must pass the same real-client handshake, isolation, lifecycle, secret-deletion, and capacity conformance before advertising the capability; no product or SPA provider branch may compensate for a missing adapter capability.", + "description": "User-held client VPN is an ADR-039 remote-access specialization, not a fifth operation, browser-workspace transport, or scenario-authored service. Only a trusted product launch path may mint the closed capability beside scenario intent; product provenance and ownership stay at the product/CMS boundary, while the capability binds one authoritative participant-access target and an enforced server-owned access deadline. CMS persists one lease for every new CTF and Mission Control range: the current deadline is the sole automatic-cleanup authority and the immutable generation ceiling bounds certificate `notAfter`. CTF derives both from event cleanup. Mission Control uses one typed, server-owned lease policy: the deployment-configured 30/30/365 default is the fallback, an audited runtime tenant policy may replace it, and every matching persisted Django RBAC-group policy composes restrictively beneath the tenant (see ADR-011-R9 and the #2169 preflight). Durations resolve inside the cold-create or warm-claim transaction and are snapshotted per generation; the tenant/group extension-admission switch is live. Owners extend only through the existing empty-body action and the generation's server-selected increment, never beyond its immutable maximum; HTTP callers cannot select a deadline, increment, maximum, group, or switch. The DB-authoritative reconciler locks due rows and reuses canonical system-attributed destroy and existing product projections rather than a CTF- or Mission-Control-specific cleanup workflow. Any future product must supply the same enforced lease semantics or introduce an explicitly versioned renewal/revocation lifecycle; caller-supplied, silently expiring, and unbounded deadlines are prohibited, and topology alone never activates a VPN. On GCE a required profile terminates on the deployment's shared participant OpenVPN pool (#2480), never on a per-range gateway or the user-controlled target: a regional, autoscaled managed instance group of hardened VMs outside the GKE cluster, in its own subnet of the range VPC, behind one external passthrough load balancer whose only listener is mutual-TLS OpenVPN on UDP/1194. The pool is sized from the shared-service capacity profile with a spare server and CPU headroom so one server loss or a scale-up wave does not saturate the rest. The portal authorizes every connection: the pool server presents the verified certificate common name over an exact-audience Google identity token, and the portal admits it only while the range is READY for that owner and generation before the access deadline, keeps one active session per range in PostgreSQL (a newer connection supersedes the older), and returns only the authoritative member target and its declared participant ports. The server confines each client with its own nftables allowance to that target and those ports, pushes only that host route, never enables client-to-client, and cannot reach the pool, the metadata server, or any other range address. Sessions renew every 15 seconds against a 60-second lease; destroy, pause, owner change, the deadline, or a newer session ends a live tunnel within one heartbeat, and a server that cannot confirm its sessions with the portal disconnects every client after 90 seconds (fail closed). Each OpenVPN range admits the pool subnet only to its target node and declared ports, and provision creates one owner/generation-scoped profile secret reference. EC2 launches receive no OpenVPN capability until #2481 moves AWS to the same pool model and retires the legacy per-range gateway Terraform (request-owned NLB with the audited UDP/1194 CIDR exception). Download resolves the existing secret after product ownership/state checks; pause disables access, resume restores the current generation, and destroy deletes all generation material. Because a downloaded credential cannot be recalled from a former owner, in-place reassignment is refused while a VPN binding exists; recovery for a new owner must destroy the old generation and provision a new one before readiness. The tenant CA and the pool tls-crypt key live in an issuer secret only the provisioner can read; the provisioner signs one client certificate per generation whose notAfter is the generation deadline, and profiles pin the pool server name with verify-x509-name. The pool identity reads only its own server secret and the release record, which only the deploy job writes after the exact-digest scan and provenance check; servers run that image by digest. Private keys are generated outside Terraform and never enter its state. Profile/private-key/CA signing values are prohibited from public contracts, Django/adapter/Terraform state, events, task payloads, environment, argv, logs, metrics, and errors. Every live-fire adapter eligible for an enabled product profile must pass the same real-client handshake, isolation, lifecycle, secret-deletion, and capacity conformance before advertising the capability; no product or SPA provider branch may compensate for a missing adapter capability.", "checks": [] }, { @@ -4099,7 +4099,7 @@ }, { "id": "ADR-063-R5", - "description": "Old-generation teardown MUST survive producer/configuration changes and pack retirement through a narrowly versioned, validated recovery read path with an explicit supported window, pinning enough non-secret ownership identity (provider/project, zone/region, network ownership, resource names/identifiers, dynamic-secret namespace and credential references, egress posture, plan/contract and relevant configuration/artifact versions) to recover the original resources; generated names alone, current registry entries, and current environment values are insufficient. It MUST reuse persisted placement/binding helpers and never reconstruct against a different configuration silently, never relabel stored plan bytes, never delete evidence to make a drain pass, and never require a retired pack or enabled image mapping merely to destroy an existing range, reconciling ADR-024/ADR-032/ADR-034 hard-cutover/drain rules with ADR-043-R7 retention. #1919 MUST be revalidated end to end through the CTF cancellation entry point (nested participant hosts and VPN gateways, dependency ordering, already-absent resources, partial cleanup); range/participant/generation/reservation linkage MUST be retained until terminal cleanup or an equally durable owned recovery link exists, logical admission quota MUST be distinguished from physical/event capacity, and reusable resource slots MUST NOT be released while unresolved resources can still own them. Retention pruning MUST NOT delete the sole retry or residual evidence for an unresolved operation.", + "description": "Old-generation teardown MUST survive producer/configuration changes and pack retirement through a narrowly versioned, validated recovery read path with an explicit supported window, pinning enough non-secret ownership identity (provider/project, zone/region, network ownership, resource names/identifiers, dynamic-secret namespace and credential references, egress posture, plan/contract and relevant configuration/artifact versions) to recover the original resources; generated names alone, current registry entries, and current environment values are insufficient. It MUST reuse persisted placement/binding helpers and never reconstruct against a different configuration silently, never relabel stored plan bytes, never delete evidence to make a drain pass, and never require a retired pack or enabled image mapping merely to destroy an existing range, reconciling ADR-024/ADR-032/ADR-034 hard-cutover/drain rules with ADR-043-R7 retention. #1919 MUST be revalidated end to end through the CTF cancellation entry point (nested participant hosts and OpenVPN access, dependency ordering, already-absent resources, partial cleanup); range/participant/generation/reservation linkage MUST be retained until terminal cleanup or an equally durable owned recovery link exists, logical admission quota MUST be distinguished from physical/event capacity, and reusable resource slots MUST NOT be released while unresolved resources can still own them. Retention pruning MUST NOT delete the sole retry or residual evidence for an unresolved operation.", "checks": [] }, { diff --git a/docs/architecture/ctf-openvpn-participant-access-preflight-1695.md b/docs/architecture/ctf-openvpn-participant-access-preflight-1695.md index 119d856de..0f8c8d2d0 100644 --- a/docs/architecture/ctf-openvpn-participant-access-preflight-1695.md +++ b/docs/architecture/ctf-openvpn-participant-access-preflight-1695.md @@ -3,6 +3,13 @@ Issue: GitHub #1695, "OVPN download button for CTF users to connect to their Kali box." +> **Superseded termination (#2480):** participant OpenVPN no longer terminates on +> a per-range gateway. GCE ranges use the deployment's shared, autoscaled +> OpenVPN pool, which asks the portal before admitting each client; see +> ADR-039-R10 and "Participant OpenVPN access" in +> [the range-cell deploy guide](../dev/gcp-range-cell-deploy.md). The capability, +> lease, ownership, and download rules below still apply. + This is requirement-free pre-implementation guidance. The issue title, body, and acceptance criteria are the shipping contract. This note does not implement the change and is not an implementation plan. diff --git a/docs/architecture/gcp-range-cell-containment-threat-model.md b/docs/architecture/gcp-range-cell-containment-threat-model.md index bb242e52d..c43683b7b 100644 --- a/docs/architecture/gcp-range-cell-containment-threat-model.md +++ b/docs/architecture/gcp-range-cell-containment-threat-model.md @@ -34,7 +34,9 @@ participant-controlled context, not by hiding an endpoint from a root guest. | Cell to management VPC or project | Higher-precedence deny of the complete deployment-owned management, range, and private-service inventory sits above any allow lane; egress defaults to deny; the public-web and operator allow lanes are validated against the denied-network inventory so neither can name an internal destination. | Closed. Proven by cell to platform pod/service/node, Cloud SQL, Redis, portal, API, and peer-range denial probes. Routing or peering is not authorization. | | Cell to provisioner resources | No provisioner credential, database credential, task payload, or cloud-management secret is placed in guest metadata, environment, argv, disks, or logs; the provisioner identity is never attached to a guest; bootstrap uses exact-object signed delivery with a per-range credential lifecycle. | Closed. The range host and range-Vertex service accounts are least-privilege and fail the IAM resource-scope checker if a project Storage or Secret role is added. | | Cell to internet, DNS, or Google APIs | The effective egress mode is the single policy vocabulary: `none` creates no NAT and no general Google-API lane. Sanctioned public-web egress targets the public-internet complement of the denied-network inventory; the Google private-API VIP is reachable only through the Private Google Access lane when enabled. | Closed for the configured mode. DNS is a separately tested transport and is not inferred from a name-resolution failure. | -| Internet or participant to cell | The dedicated access-workload source allows only SSH 22 and RDP 3389; management ingress is SSH-only from the provisioner or management range; the OpenVPN gateway keeps a closed ingress and egress envelope. | Closed. Platform peering and provisioner source CIDRs are never turned into participant ingress. | +| Internet or participant to cell | The dedicated access-workload source allows only SSH 22 and RDP 3389; management ingress is SSH-only from the provisioner or management range; the shared OpenVPN pool subnet is admitted only to each OpenVPN range's target node on its declared channel ports. | Closed. Platform peering and provisioner source CIDRs are never turned into participant ingress. | +| OpenVPN participant to anything but its target | The pool server asks the portal before admitting a certificate and receives one target address and its ports; a per-client nftables allowance (forward policy drop) admits only that tuple, only that route is pushed, client-to-client is off, and input from the tunnel to the server, the metadata server, and the health port is dropped. The pool VPC firewall allows egress only to the range network on 22/3389 and to the portal and Google API VIP on 443. One session per range; revocation within one heartbeat; fail closed after 90 seconds without portal confirmation (#2480). | Closed. Verified in a two-network lab: the target is reachable on its port only; other ports, peers, the server, the tunnel gateway address, and metadata are unreachable; heartbeat revocation disconnects, and a reconnect is authorized again. | +| Compromised OpenVPN pool server | The server runs one container in its own network namespace with only NET_ADMIN, SETUID, and SETGID, a read-only root, and no-new-privileges; OpenVPN drops to `nobody`. The VM has no public address, runs only the deploy-recorded image digest, and its identity reads only its server secret and the release record, never the CA. The portal still checks owner, generation, state, and deadline on every grant. | Accepted with rationale. A compromised server can reach range targets on 22/3389 like any participant client and can disrupt sessions it terminates, but it cannot mint profiles, reach the platform, or widen another range's firewall. | ## Protocol and address-family coverage @@ -48,8 +50,9 @@ participant-controlled context, not by hiding an endpoint from a root guest. - **TCP and UDP.** Internal same-cell allow and the default egress deny cover all protocols. Sanctioned lanes are protocol-scoped: management and access ingress are TCP (22, 3389, and the host management SSH port), public-web egress is TCP - 80 and 443, the Private Google Access lane is TCP 443, and the OpenVPN gateway - ingress is UDP 1194 with a TCP 1195 health port. Any protocol outside a + 80 and 443, the Private Google Access lane is TCP 443, and the shared OpenVPN + pool admits participants on UDP 1194 and reaches range targets only on TCP 22 + and 3389. Any protocol outside a sanctioned lane falls to the default deny. ## Verification diff --git a/docs/architecture/non-ctf-openvpn-range-access-preflight-1696.md b/docs/architecture/non-ctf-openvpn-range-access-preflight-1696.md index f38bce469..2660ead4f 100644 --- a/docs/architecture/non-ctf-openvpn-range-access-preflight-1696.md +++ b/docs/architecture/non-ctf-openvpn-range-access-preflight-1696.md @@ -2,6 +2,13 @@ Issue: GitHub #1696, "extend OVPN access to non-CTF range user types." +> **Superseded termination (#2480):** participant OpenVPN no longer terminates on +> a per-range gateway. GCE ranges use the deployment's shared, autoscaled +> OpenVPN pool, which asks the portal before admitting each client; see +> ADR-039-R10 and "Participant OpenVPN access" in +> [the range-cell deploy guide](../dev/gcp-range-cell-deploy.md). The capability, +> lease, ownership, and download rules below still apply. + This is requirement-free architecture guidance. The issue is the shipping contract. This note records the accepted lifecycle, access, and presentation boundaries for that implementation. diff --git a/docs/dev/deploy-secrets.md b/docs/dev/deploy-secrets.md index 4316ba24c..678d8c81b 100644 --- a/docs/dev/deploy-secrets.md +++ b/docs/dev/deploy-secrets.md @@ -751,7 +751,7 @@ a specific image outside that flow. | `GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL` | ranges | Keyless range host identity attached to range guests for default-on model access (ADR-064): a predict-only Vertex role plus host telemetry, delivered via Workload Identity with no key material. When the model broker is the guest path (`MODEL_BROKER_GUEST_VIP` set) guests stay identity-less instead. | | `GCP_RANGE_HOST_IDENTITY_POOL_SIZE` | machine-image hosts | Number of Terraform-created `sh-range-host-` identities. Must equal `range_host_identity_pool_size`; zero disables preconfigured machine-image hosts. | | `GCP_RANGE_PRIVATE_GOOGLE_ACCESS` | no | Set `true` so no-external-IP guests reach approved Google APIs (including Vertex for default-on model access) over Private Google Access. | -| `RANGE_OPENVPN_ENABLED` | no | Set `true` to give participants an OpenVPN profile for their range's single participant-access target (ADR-039-R10). Each such range gets a gateway VM, an external address, and per-range VPN secrets. Requires `GCP_RANGE_PRIVATE_GOOGLE_ACCESS=true` and the Terraform `sh-vpn-pool-*` gateway identity pool. Defaults to `false`. See "Participant OpenVPN access" in `docs/dev/gcp-range-cell-deploy.md`. | +| `RANGE_OPENVPN_ENABLED` | no | Set `true` to give participants an OpenVPN profile for their range's single participant-access target (ADR-039-R10). It also deploys the shared OpenVPN server pool (#2480): Terraform `openvpn_pool_enabled` follows this variable, and the deploy creates the pool PKI and release record. Apply `global/cicd-oidc` first so the deploy identity holds the conditioned pool role. Defaults to `false`. See "Participant OpenVPN access" in `docs/dev/gcp-range-cell-deploy.md`. | By default (ADR-064) range guests hold a keyless, predict-only Vertex identity via Workload Identity so they can reach models directly; enabling a specific model in diff --git a/docs/dev/gcp-range-cell-deploy.md b/docs/dev/gcp-range-cell-deploy.md index 933e309f1..ef98b8608 100644 --- a/docs/dev/gcp-range-cell-deploy.md +++ b/docs/dev/gcp-range-cell-deploy.md @@ -120,7 +120,7 @@ The current built-in support/evidence projection is in It is documentation only; do not reproduce it as a scenario-ID branch or CMS allowlist. Destroy renders with image and provision-capability checks disabled, so a newly unsupported composition can still be cleaned up deterministically. -That recovery also tolerates a missing CIDR or OpenVPN gateway-pool binding when +That recovery also tolerates a missing CIDR binding when provision failed before allocation: deletion uses deterministic owned-resource names and remains idempotent. If the launcher reports the canonical command grammar denial, verify that the base and Helm `restrict-provisioner-jobs` @@ -383,32 +383,54 @@ tunnel reaches exactly one range member, the scenario's participant-access target, and only on its declared participant channels (SSH 22, RDP 3389). Every other range member is reachable only by attacking the range from that target. +Every participant connects to one shared OpenVPN server pool per deployment +(#2480), not to a per-range gateway: + +- A regional managed instance group of Container-Optimized OS VMs runs outside + the GKE cluster, in its own subnet of the range VPC (`openvpn_pool_subnet_cidr`, + default `10.49.0.0/24`). The VMs have no public addresses. +- One external passthrough load balancer on a reserved address is the only + listener: UDP 1194, mutual TLS with tls-crypt. Every profile names that + address. Client-IP affinity keeps a client on one server. +- Before a server admits a client, it asks the portal. The portal admits the + certificate only while its range is `READY` for the same owner and generation + before the access deadline, keeps one active session per range (a newer + connection replaces the older one), and returns the target address and ports. + The server then allows that client to reach only that address and those ports + and pushes only that route. +- Servers renew their sessions every 15 seconds. Destroy, pause, owner change, + the deadline, or a newer session disconnects a tunnel within one heartbeat. A + server that cannot confirm its sessions with the portal disconnects every + client after 90 seconds. +- A range with OpenVPN admits the pool subnet only to its target node, on its + declared channel ports. + Enable it per deployment: 1. Set the deployment variable `RANGE_OPENVPN_ENABLED=true` (default `false`). -2. Keep `GCP_RANGE_PRIVATE_GOOGLE_ACCESS=true`; the gateway reads its identity - from Secret Manager over Private Google Access. -3. Keep the Terraform gateway identity pool (`vpn_gateway_pool_size`, default - 24, `sh-vpn-pool-`) equal to `VPN_GATEWAY_POOL_SIZE`. It bounds the - number of concurrent OpenVPN ranges; a launch fails while the pool is - exhausted. - -A launch then requests OpenVPN when the scenario's compiled participant access -names exactly one target node; other scenarios launch without it. The -credential window ends at the range lease ceiling. Each such range gets a -gateway VM in the target's subnet with one external address that accepts UDP -1194 from the internet, a health responder on TCP 1195 reachable only from the -portal network, and egress only to the target's channel ports and the Google -API VIP. The range becomes ready only after the gateway reports healthy, and -teardown deletes the gateway, its address and firewall rules, and every VPN -secret. - -Warm-pool generations carry no VPN; a claim mints the claimant's access and -activation realizes the gateway. A CTF spare is provisioned with its gateway for -its managed spare identity, and recovery hands the binding to the participant, -because that identity can never authenticate and so could never have downloaded -the profile. A range whose owner could have downloaded the profile never changes -owner in place. The EC2 range-cell gateway is tracked by #2443. + The deploy then creates the pool, its PKI, and its release record, and renders + the pool wiring into the runtime environment. +2. Apply `platform/terraform/gcp/global/cicd-oidc` for the deployment's identity + profile first. The deploy identity needs the conditioned pool role it adds + (instance templates, the regional instance group, and the autoscaler, limited + to the pool's own VM names). +3. Size the pool through `settings.shared_service_capacity_profile`. Each profile + plans 25 participants per `e2-standard-2` server, keeps one spare server above + the planned count, scales out at 30% CPU, and allows twice the minimum. Check + the live values with `scripts/gcp/check_event_capacity_drift.py --vpn-pool`. + +Each deploy rebuilds and scans the pool image, records its digest, and replaces +the servers by surging new ones in before old ones leave. Connected participants +reconnect once, like browser sessions during the control-plane restart. The +deploy also creates the tenant CA once and renews the pool server certificate +30 days before it expires; private keys never enter Terraform state. + +Warm-pool generations carry no VPN; a claim mints the claimant's profile. A CTF +spare is provisioned with a profile for its managed spare identity, and recovery +hands the binding to the participant, because that identity can never +authenticate and so could never have downloaded the profile. A range whose owner +could have downloaded the profile never changes owner in place. EC2 is tracked +by #2481. ## Baking a new pre-promoted DC image diff --git a/docs/ops/gcp-event-capacity.md b/docs/ops/gcp-event-capacity.md index a61c72ba9..670fc04ef 100644 --- a/docs/ops/gcp-event-capacity.md +++ b/docs/ops/gcp-event-capacity.md @@ -48,8 +48,8 @@ but it is not qualified until it has its own strict gate budget and evidence. instance, or Redis instance by hand. The p30 minimums themselves carry the event; autoscaling is supplemental headroom. -4. Wait for Terraform, GKE rollouts, HPAs, BackendConfigs, Cloud SQL, and Redis - to stabilize. Guacamole client replicas must remain exactly one; portal and +4. Wait for Terraform, GKE rollouts, HPAs, BackendConfigs, Cloud SQL, Redis, + and the OpenVPN pool (when enabled) to stabilize. Guacamole client replicas must remain exactly one; portal and guacd are the horizontally scaled workloads. 5. Prove the effective state matches the selected profile: @@ -58,10 +58,14 @@ but it is not qualified until it has its own strict gate budget and evidence. uv run python scripts/gcp/check_event_capacity_drift.py \ --desired /tmp/shifter-event-capacity.json \ --project --region --cluster \ - --sql-instance --redis-instance + --sql-instance --redis-instance \ + --vpn-pool ``` - Exit 0 and `capacity drift: none` are required. Exit 1 is real drift. Exit 2 + Pass `--vpn-pool` with the `openvpn_pool.instance_group` Terraform output when + participant OpenVPN is enabled; the check then compares the pool's machine + type, minimum and maximum servers, and CPU target. Without it the pool fields + are skipped. Exit 0 and `capacity drift: none` are required. Exit 1 is real drift. Exit 2 means evidence was missing or malformed and is also a stop condition. ## Run the p30 public-path gate diff --git a/docs/technical/shifter_platform/ctf.md b/docs/technical/shifter_platform/ctf.md index c4663ec6e..7a0d8fb5b 100644 --- a/docs/technical/shifter_platform/ctf.md +++ b/docs/technical/shifter_platform/ctf.md @@ -203,9 +203,10 @@ The CTF bridge mints a closed OpenVPN capability from the event cleanup deadline and the single participant-access Kali member, falling back to the unique hydrated Kali member for legacy templates without access declarations. Engine persists it beside the range; Mission Control ranges and topology without that capability do not create -a VPN edge. Provisioning may attach a request-owned OpenVPN gateway only when -that capability is present. The provider adapter enforces a `/32` path to that member and -stores the server identity and participant profile in the provider secret store. +a VPN edge. Provisioning admits the deployment's shared OpenVPN pool (#2480) to +that member only when the capability is present, and stores the participant +profile in the provider secret store. The pool asks the portal before it admits +a client and then allows only the member's address and declared ports. Django persists only a closed, non-secret binding containing the generation, owner, target, endpoint, profile version, and secret reference. Profile bytes are resolved and validated in memory only after CTF provenance, participant ownership, range @@ -219,9 +220,11 @@ exists because clearing a database reference cannot revoke a downloaded credenti recovery must destroy that generation and provision a new one for the replacement owner before it becomes ready. Certificates remain valid through the trusted teardown deadline, subject to a -397-day maximum capability window that fails before cloud mutation. On GCE, each -generation gets a distinct no-role gateway service account with read access only -to its own server secret; the shared range-host identity cannot read VPN secrets. +397-day maximum capability window that fails before cloud mutation. Each +participant holds one live session per range; a newer connection replaces the +older one, and destroy, pause, or the deadline disconnects it within one pool +heartbeat. On GCE only the provisioner reads the CA, the pool identity reads only +its server identity, and the shared range-host identity cannot read VPN secrets. See [the provider-neutral range substrate](../../architecture/provider-neutral-range-substrate) for the ADR-039 contract and [the issue 1695 architecture preflight](../../architecture/ctf-openvpn-participant-access-preflight-1695) for the threat model and containment decisions. diff --git a/platform/terraform/gcp/README.md b/platform/terraform/gcp/README.md index 26d271d2c..58be91e4f 100644 --- a/platform/terraform/gcp/README.md +++ b/platform/terraform/gcp/README.md @@ -69,7 +69,7 @@ and configure Secret Manager audit logging. Runtime authority is narrower: Secret resource type; - portal: read-only access conditioned on the narrower canonical `...-dynamic-participant-` prefix; -- range hosts and VPN gateways: accessor only on their individual secret. +- range hosts: accessor only on their individual secret. The create-only parent grant can create an arbitrary empty container in this single-purpose project, but it cannot add/read versions, change IAM, or delete @@ -112,7 +112,7 @@ apply; there is no project-wide fallback. project fails closed and never creates back in the platform project. 3. **Verify:** create and destroy a disposable range, verify participant access, and run `scripts/gcp/probe_range_secret_permissions.py` with the real - provisioner, portal, host/gateway pool, worker, launcher, and node identities. + provisioner, portal, range-host pool, worker, launcher, and node identities. The script is plan-only unless `--execute` is supplied and cleans its probe secrets with the operator identity. @@ -140,8 +140,6 @@ python3 scripts/gcp/probe_range_secret_permissions.py \ --portal-service-account PORTAL_GSA \ --range-host-service-account ASSIGNED_RANGE_HOST_GSA \ --peer-range-host-service-account PEER_RANGE_HOST_GSA \ - --gateway-service-account ASSIGNED_GATEWAY_GSA \ - --peer-gateway-service-account PEER_GATEWAY_GSA \ --workers-service-account WORKERS_GSA \ --launcher-service-account PROVISIONER_LAUNCHER_GSA \ --node-service-account GKE_NODE_GSA @@ -151,7 +149,7 @@ The probe suppresses provider stderr and payload output. It records a run correlation ID, principal, permission, resource class, fingerprinted resource, result, and elapsed propagation time. It proves allowed canonical lifecycle and participant reads; denied platform-project creation and unrelated-resource -lifecycle; portal read partition and mutation denial; assigned host/gateway +lifecycle; portal read partition and mutation denial; assigned host per-secret access with peer-secret negatives; and no dynamic-secret read by workers, launcher, or the GKE node identity. Its `finally` cleanup uses the operator identity in both projects, so even an unexpectedly allowed negative @@ -160,8 +158,8 @@ create does not leave a probe container. ### Capacity and cost envelope (checked 2026-09-07) Recalculate this section against the linked Google sources and the deployment's -actual metrics before cut-over. The checked-in defaults bound both the range-host -and VPN gateway identity pools at 24 concurrent slots. A conservative planning +actual metrics before cut-over. The checked-in default bounds the range-host +identity pool at 24 concurrent slots. A conservative planning case of 10 one-version dynamic secrets per active range therefore gives 240 active versions. A full 24-range create wave is approximately 528 Secret Manager writes (24 × (10 create + 10 add-version + 2 per-secret IAM writes)), leaving @@ -206,8 +204,9 @@ Secret deletion blocks fresh Secret Manager reads after IAM propagation, but already delivered guest credentials, cached portal values, downloaded VPN profiles, minted OAuth tokens, and service-account keys have separate revocation windows. The rollout owner must record the observed permission-probe propagation -time, allow five minutes for the portal cache, and revoke the guest/gateway -material. The extra project also consumes +time, allow five minutes for the portal cache, and revoke the guest material. +A downloaded VPN profile stops working when the portal refuses its session, +within one pool heartbeat (#2480). The extra project also consumes the organization's project quota and requires billing/API/audit-policy authority; verify those deployment-specific limits rather than assuming project creation is available. diff --git a/scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py b/scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py index 505e973ab..e6c347d73 100644 --- a/scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py +++ b/scripts/check_tf_gcp_iam_resource_scope/check_tf_gcp_iam_resource_scope.py @@ -64,10 +64,9 @@ # object-mutation, or service-account IAM-admin access. A project-scoped custom # role bound to a workload SA is a violation if it carries any of these (or a # matching wildcard). The service-account permissions close ADR-008-R7's -# gateway-identity escalation: project-level setIamPolicy/create/delete let a -# workload seize any service account (GCP cannot resource-name-scope -# setIamPolicy), so the OpenVPN gateway uses a pre-provisioned per-SA pool -# instead of a dynamic-creation custom role. +# identity escalation: project-level setIamPolicy/create/delete let a workload +# seize any service account (GCP cannot resource-name-scope setIamPolicy), so +# no workload holds a dynamic service-account custom role. FORBIDDEN_CUSTOM_PERMISSIONS = frozenset( { "secretmanager.versions.access", diff --git a/shifter/engine/provisioner/raes_gcp_destroy.py b/shifter/engine/provisioner/raes_gcp_destroy.py index 37a329c6a..2128facdd 100644 --- a/shifter/engine/provisioner/raes_gcp_destroy.py +++ b/shifter/engine/provisioner/raes_gcp_destroy.py @@ -54,7 +54,7 @@ class RaesGceDestroyOptions: directory_secret_ops: RaesDirectorySecretOps | None = None allocated_network_cidrs: Sequence[tuple[str, str]] | None = None reconstruct_without_allocation: bool = False - # The range's OpenVPN gateway names (#2030), when it holds a capability. + # The range's OpenVPN access names (#2480: pool firewall, profile secret), when it holds a capability. remote_access: RaesGceVpnAccess | None = None diff --git a/shifter/engine/provisioner/raes_range_ops.py b/shifter/engine/provisioner/raes_range_ops.py index 2bd6c7113..2c63da695 100644 --- a/shifter/engine/provisioner/raes_range_ops.py +++ b/shifter/engine/provisioner/raes_range_ops.py @@ -287,7 +287,7 @@ def _report_realized_provision( # PROVISIONING indefinitely and occupies the owner's active-range slot. The # cloud resources are realized, so allocations are not released here: the # failed range is torn down through the normal destroy operation, which also - # removes its OpenVPN gateway. + # removes its OpenVPN access. reason_code, diagnostic = _classify_failure(exc, "raes range provision result") logger.error("RAES range provision result was rejected for request_id=%s", request_id) _report_failure(ref, operation, diagnostic, reason_code) diff --git a/shifter/engine/provisioner/tests/test_warm_activation_ops.py b/shifter/engine/provisioner/tests/test_warm_activation_ops.py index a5864676a..22cf79fff 100644 --- a/shifter/engine/provisioner/tests/test_warm_activation_ops.py +++ b/shifter/engine/provisioner/tests/test_warm_activation_ops.py @@ -202,7 +202,7 @@ def test_malformed_verification_addresses_fail_before_snapshot(self, monkeypatch class TestRealizeClaimantOpenVpn: - """Activation realizes the claimant's OpenVPN gateway with the cell (#2030).""" + """Activation realizes the claimant's OpenVPN access with the cell (#2030, #2480).""" def _patch(self, monkeypatch, result: dict) -> list: seen: list = [] diff --git a/shifter/shifter_platform/cms/services/_range_remote_access.py b/shifter/shifter_platform/cms/services/_range_remote_access.py index 0ee7ce377..0b5eff5ef 100644 --- a/shifter/shifter_platform/cms/services/_range_remote_access.py +++ b/shifter/shifter_platform/cms/services/_range_remote_access.py @@ -15,8 +15,8 @@ if TYPE_CHECKING: from datetime import datetime -#: Realization backends whose RAES adapter implements the OpenVPN gateway. The -#: EC2 range-cell gateway is tracked by #2443. +#: Realization backends whose RAES adapter admits the shared OpenVPN pool (#2480). +#: EC2 is tracked by #2481. _OPENVPN_BACKENDS = frozenset({"gce"}) diff --git a/shifter/shifter_platform/engine/services/_operation_apply_vpn.py b/shifter/shifter_platform/engine/services/_operation_apply_vpn.py index 42678cda9..aec7bcaa7 100644 --- a/shifter/shifter_platform/engine/services/_operation_apply_vpn.py +++ b/shifter/shifter_platform/engine/services/_operation_apply_vpn.py @@ -1,4 +1,4 @@ -"""Bind a realized participant OpenVPN gateway to its range owner (#2030, ADR-039-R10). +"""Bind realized participant OpenVPN access to its range owner (#2030, #2480, ADR-039-R10). Split out of ``_operation_apply_raes`` (Sonar S104). The provisioner reports an owner-free realization on the terminal READY result (ADR-043: ownership never @@ -19,9 +19,9 @@ def bound_vpn_access( row: OperationResultInbox, realization: dict[str, Any] | None, range_obj: Range ) -> dict[str, object] | None: - """Return the owner-bound binding for this generation's realized gateway. + """Return the owner-bound binding for this generation's realized OpenVPN access. - A range holding a capability must report exactly its authorized gateway, and a + A range holding a capability must report exactly its authorized target, and a range without one must report none; anything else is a permanent contract violation, refused once rather than retried. """ diff --git a/shifter/shifter_platform/shared/cloud/gcp/task_runner.py b/shifter/shifter_platform/shared/cloud/gcp/task_runner.py index 000da554b..2a909a020 100644 --- a/shifter/shifter_platform/shared/cloud/gcp/task_runner.py +++ b/shifter/shifter_platform/shared/cloud/gcp/task_runner.py @@ -26,8 +26,7 @@ _SHIFTER_TASK_RUNNER_GCP = "gcp" # Exclusive provisioner node-pool placement (#1711). Provisioner Jobs SSH-drive -# range hosts and probe the OpenVPN gateway; pinning them to the tainted -# provisioner pool gives their pods alias IPs from the provisioner pod range, +# range hosts; pinning them to the tainted provisioner pool gives their pods alias IPs from the provisioner pod range, # which is the only source the range VPC's management ingress admits. The node # label matches the pool's ``node-restriction.kubernetes.io/shifter-pool`` label # and the toleration matches its ``dedicated=provisioner:NoSchedule`` taint (both diff --git a/shifter/shifter_platform/shared/operation_result_payloads.py b/shifter/shifter_platform/shared/operation_result_payloads.py index 06368e452..d75bee74c 100644 --- a/shifter/shifter_platform/shared/operation_result_payloads.py +++ b/shifter/shifter_platform/shared/operation_result_payloads.py @@ -449,7 +449,7 @@ def _parse_raes_ready(payload: dict[str, Any], spec: StepSpec) -> dict[str, Any] except ValueError: raise OperationResultError("invalid RAES completion evidence") from None if "vpn_access" in payload: - # The owner-free OpenVPN gateway realization (#2030); the Engine binds + # The owner-free OpenVPN access realization (#2030, #2480); the Engine binds # the owner from its locked range row before persisting it. from shared.remote_access import OpenVpnBindingError, parse_openvpn_realization From 181e5477cab05801a2b001141602afc3cace500e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 06:51:49 +0000 Subject: [PATCH 05/14] fix(range): close the pool server's HTTP client and pin the pool envelope 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 --- .pre-commit-config.yaml | 6 +- .../modules/vpn-pool/tests/main.tftest.hcl | 156 ++++++++++++++++++ platform/terraform/validation-inventory.yaml | 5 + scripts/gcp/ensure_vpn_pki.py | 8 +- shifter/engine/openvpn/shifter_openvpn/gcp.py | 8 +- .../engine/openvpn/shifter_openvpn/portal.py | 14 +- .../openvpn/shifter_openvpn/transport.py | 29 ++++ shifter/engine/openvpn/tests/test_parts.py | 6 +- shifter/engine/openvpn/tests/test_runtime.py | 17 +- 9 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl create mode 100644 shifter/engine/openvpn/shifter_openvpn/transport.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b4985039..e9ed64dd3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,8 +39,10 @@ repos: args: [--maxkb=500] - id: check-merge-conflict - id: detect-private-key - # Exclude: tests (check key format strings) + crypto.py (key generation module, not leaked secrets) - exclude: ^(shifter/engine/provisioner/tests/|shifter/shifter_platform/tests/engine/ssh/|scripts/bootstrap/tests/|shifter/engine/provisioner/utils/crypto\.py) + # Exclude: tests (check key format strings) + key-handling modules that hold only PEM armor + # labels, never key material: crypto.py (SSH key generation), ensure_vpn_pki.py (formats the + # generated OpenVPN tls-crypt key) and vpn_access.py (validates that key's armor) (#2480). + exclude: ^(shifter/engine/provisioner/tests/|shifter/shifter_platform/tests/engine/ssh/|scripts/bootstrap/tests/|shifter/engine/provisioner/utils/crypto\.py|scripts/gcp/ensure_vpn_pki\.py|shifter/engine/provisioner/vpn_access\.py) - repo: https://github.com/rhysd/actionlint rev: v1.7.12 diff --git a/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl b/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl new file mode 100644 index 000000000..011dea403 --- /dev/null +++ b/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl @@ -0,0 +1,156 @@ +# Shared OpenVPN pool network and identity envelope contract test (#2480). +# +# Proves the pool's exposure is exactly the reviewed envelope: one public +# listener (UDP 1194), health probes only from Google's probe ranges, egress only +# to range targets on 22/3389 and to the portal and Google API VIP on 443, and a +# deny-all below every allow. The servers have no public address, run Shielded +# VM, are replaced without losing capacity, and their identity reads only its own +# server secret and the release record, never the CA. +# +# Credential-free: mock_provider synthesizes the google provider; `command = plan` +# is sufficient because every asserted value is configuration-known. +# Run with: +# terraform -chdir=platform/terraform/gcp/modules/vpn-pool test + +mock_provider "google" {} + +variables { + project_id = "shifter-test" + region = "us-central1" + zones = ["us-central1-a", "us-central1-b", "us-central1-c"] + name_prefix = "shifter-test" + network_id = "projects/shifter-test/global/networks/shifter-test-range" + network_name = "shifter-test-range" + subnet_cidr = "10.49.0.0/24" + range_network_cidr = "10.50.0.0/16" + public_hostname = "portal.example.com" + portal_ingress_ip = "203.0.113.10" + artifact_registry_location = "us-central1" + artifact_repository = "shifter-test-openvpn" + deploy_service_account_email = "deploy@shifter-test.iam.gserviceaccount.com" + provisioner_service_account_email = "provisioner@shifter-test.iam.gserviceaccount.com" + min_vms = 2 + max_vms = 4 +} + +run "public_exposure_is_one_udp_listener_and_probe_ranges" { + command = plan + + assert { + condition = google_compute_forwarding_rule.pool.ip_protocol == "UDP" && google_compute_forwarding_rule.pool.ports == toset(["1194"]) + error_message = "The pool load balancer must expose only UDP 1194." + } + + assert { + condition = ( + length(google_compute_firewall.pool_vpn_ingress.allow) == 1 + && one(google_compute_firewall.pool_vpn_ingress.allow).protocol == "udp" + && one(google_compute_firewall.pool_vpn_ingress.allow).ports == tolist(["1194"]) + ) + error_message = "The only world-reachable pool ingress must be UDP 1194." + } + + assert { + condition = ( + toset(google_compute_firewall.pool_health_ingress.source_ranges) == toset(["35.191.0.0/16", "130.211.0.0/22", "209.85.204.0/22"]) + && one(google_compute_firewall.pool_health_ingress.allow).ports == tolist(["8080"]) + ) + error_message = "Health ingress must come only from Google's probe ranges to the health port." + } + + assert { + condition = alltrue([for nic in google_compute_region_instance_template.pool.network_interface : length(nic.access_config) == 0]) + error_message = "Pool servers must not have public addresses." + } +} + +run "every_allow_sits_above_a_deny_all" { + command = plan + + assert { + condition = alltrue([ + for rule in [ + google_compute_firewall.pool_vpn_ingress, + google_compute_firewall.pool_health_ingress, + google_compute_firewall.pool_range_egress, + google_compute_firewall.pool_control_egress, + ] : rule.priority < google_compute_firewall.pool_ingress_deny.priority && rule.priority < 1000 + ]) + error_message = "Pool allows must outrank the pool denies, and both must outrank the range-wide rules at 1000." + } + + assert { + condition = ( + google_compute_firewall.pool_ingress_deny.priority < 1000 + && google_compute_firewall.pool_egress_deny.priority < 1000 + && one(google_compute_firewall.pool_ingress_deny.deny).protocol == "all" + && one(google_compute_firewall.pool_egress_deny.deny).protocol == "all" + && google_compute_firewall.pool_egress_deny.destination_ranges == toset(["0.0.0.0/0"]) + ) + error_message = "The pool must deny all other ingress and egress above the range-wide rules." + } + + assert { + condition = ( + google_compute_firewall.pool_range_egress.destination_ranges == toset(["10.50.0.0/16"]) + && toset(one(google_compute_firewall.pool_range_egress.allow).ports) == toset(["22", "3389"]) + && one(google_compute_firewall.pool_range_egress.allow).protocol == "tcp" + ) + error_message = "Pool egress into the range must be TCP 22/3389 only." + } + + assert { + condition = ( + google_compute_firewall.pool_control_egress.destination_ranges == toset(["203.0.113.10/32", "199.36.153.8/30"]) + && one(google_compute_firewall.pool_control_egress.allow).ports == tolist(["443"]) + ) + error_message = "Pool control egress must reach only the portal ingress and the Google API VIP on 443." + } +} + +run "servers_are_hardened_and_replaced_without_losing_capacity" { + command = plan + + assert { + condition = ( + one(google_compute_region_instance_template.pool.shielded_instance_config).enable_secure_boot + && one(google_compute_region_instance_template.pool.shielded_instance_config).enable_integrity_monitoring + ) + error_message = "Pool servers must run Shielded VM with secure boot and integrity monitoring." + } + + assert { + condition = ( + google_compute_region_instance_template.pool.metadata["block-project-ssh-keys"] == "true" + && google_compute_region_instance_template.pool.metadata["enable-oslogin"] == "TRUE" + ) + error_message = "Pool servers must block project SSH keys and use OS Login." + } + + assert { + condition = one(google_compute_region_instance_group_manager.pool.update_policy).max_unavailable_fixed == 0 + error_message = "Server replacement must surge in new servers before removing old ones." + } +} + +run "the_pool_identity_never_reads_the_ca" { + command = plan + + assert { + condition = toset(keys(google_secret_manager_secret_iam_member.pool_reads)) == toset(["server", "release"]) + error_message = "The pool identity may read only its server secret and the release record." + } + + assert { + condition = google_secret_manager_secret_iam_member.provisioner_reads_issuer.member == "serviceAccount:provisioner@shifter-test.iam.gserviceaccount.com" + error_message = "Only the provisioner reads the issuer (CA) secret." + } + + assert { + condition = ( + google_service_account_iam_member.deploy_act_as_pool.role == "roles/iam.serviceAccountUser" + && google_service_account_iam_member.deploy_act_as_pool.member == "serviceAccount:deploy@shifter-test.iam.gserviceaccount.com" + ) + error_message = "The deploy identity may act as the pool identity only through the SA-scoped binding." + } +} diff --git a/platform/terraform/validation-inventory.yaml b/platform/terraform/validation-inventory.yaml index 5db6921c1..972af3467 100644 --- a/platform/terraform/validation-inventory.yaml +++ b/platform/terraform/validation-inventory.yaml @@ -382,6 +382,11 @@ modules: contract: terraform_test test: tests/main.tftest.hcl test_profile: gcp-1.16.1 + - path: platform/terraform/gcp/modules/vpn-pool + owner: "@Brad-Edwards" + contract: terraform_test + test: tests/main.tftest.hcl + test_profile: gcp-1.16.1 - path: platform/terraform/gcp/modules/github-runner-network owner: "@Brad-Edwards" contract: deferred diff --git a/scripts/gcp/ensure_vpn_pki.py b/scripts/gcp/ensure_vpn_pki.py index cde35c385..57e9f0fc8 100644 --- a/scripts/gcp/ensure_vpn_pki.py +++ b/scripts/gcp/ensure_vpn_pki.py @@ -17,7 +17,7 @@ import argparse import json -import subprocess +import subprocess # nosec B404 - only the fixed gcloud CLI is invoked, with argv and no shell. import sys from collections.abc import Callable from dataclasses import dataclass @@ -41,7 +41,11 @@ def _gcloud(args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run(["gcloud", *args], input=stdin, text=True, capture_output=True, check=False) + # Closed argv built in this module, no shell; key material only on stdin. gcloud + # comes from the operator/CI toolchain PATH (setup-gcloud), not from input. + return subprocess.run( # nosec B603 B607 + ["gcloud", *args], input=stdin, text=True, capture_output=True, check=False + ) @dataclass(frozen=True) diff --git a/shifter/engine/openvpn/shifter_openvpn/gcp.py b/shifter/engine/openvpn/shifter_openvpn/gcp.py index 4c33879e2..426927094 100644 --- a/shifter/engine/openvpn/shifter_openvpn/gcp.py +++ b/shifter/engine/openvpn/shifter_openvpn/gcp.py @@ -12,14 +12,16 @@ import urllib.parse import urllib.request +from . import transport + _METADATA = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default" _HEADERS = {"Metadata-Flavor": "Google"} _TIMEOUT = 5 def _metadata(path: str) -> str: - request = urllib.request.Request(f"{_METADATA}/{path}", headers=_HEADERS) # noqa: S310 (fixed metadata URL) - with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: # noqa: S310 (fixed metadata URL) + request = urllib.request.Request(f"{_METADATA}/{path}", headers=_HEADERS) # noqa: S310 (opened via transport: http/https only) + with transport.OPENER.open(request, timeout=_TIMEOUT) as response: return str(response.read().decode("utf-8")) @@ -39,6 +41,6 @@ def read_secret(name: str) -> str: """Return the latest version of a Secret Manager secret.""" url = f"https://secretmanager.googleapis.com/v1/{name}/versions/latest:access" request = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token()}"}) - with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 (fixed https API URL) + with transport.OPENER.open(request, timeout=10) as response: payload = json.loads(response.read()) return base64.b64decode(payload["payload"]["data"]).decode("utf-8") diff --git a/shifter/engine/openvpn/shifter_openvpn/portal.py b/shifter/engine/openvpn/shifter_openvpn/portal.py index 9b8bee556..45896a376 100644 --- a/shifter/engine/openvpn/shifter_openvpn/portal.py +++ b/shifter/engine/openvpn/shifter_openvpn/portal.py @@ -13,6 +13,8 @@ from collections.abc import Callable from dataclasses import dataclass +from . import transport + _MAX_RESPONSE_BYTES = 65_536 _TIMEOUT = 10 @@ -60,14 +62,6 @@ def _grant(payload: dict[str, object]) -> Grant: return Grant(session=session, target=target, ports=tuple(ports), heartbeat_seconds=interval) -class _NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, *_args: object, **_kwargs: object) -> None: - return None - - -_OPENER = urllib.request.build_opener(_NoRedirect) - - class PortalClient: """Authorize, renew and end sessions for this server.""" @@ -78,14 +72,14 @@ def __init__(self, portal_url: str, audience: str, server: str, token: Callable[ self._token = token def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, object]]: - request = urllib.request.Request( # noqa: S310 (base is validated as an https origin at startup) + request = urllib.request.Request( # noqa: S310 (https origin validated at startup; opened via transport) f"{self._base}/{path}", data=json.dumps(body).encode(), headers={"Authorization": f"Bearer {self._token(self._audience)}", "Content-Type": "application/json"}, method="POST", ) try: - with _OPENER.open(request, timeout=_TIMEOUT) as response: + with transport.OPENER.open(request, timeout=_TIMEOUT) as response: status, raw = response.status, response.read(_MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: status, raw = error.code, error.read(_MAX_RESPONSE_BYTES + 1) diff --git a/shifter/engine/openvpn/shifter_openvpn/transport.py b/shifter/engine/openvpn/shifter_openvpn/transport.py new file mode 100644 index 000000000..edba807f2 --- /dev/null +++ b/shifter/engine/openvpn/shifter_openvpn/transport.py @@ -0,0 +1,29 @@ +"""The pool server's only HTTP client. + +It opens ``http:`` and ``https:`` URLs only and never follows a redirect, so no +caller can be steered to a local file, another scheme, or another host. It +ignores proxy environment variables. +""" + +from __future__ import annotations + +import ssl +import urllib.request + + +def closed_opener() -> urllib.request.OpenerDirector: + """Return an opener with HTTP(S) handlers and error processing; other schemes raise.""" + opener = urllib.request.OpenerDirector() + for handler in ( + urllib.request.HTTPHandler(), + urllib.request.HTTPSHandler(context=ssl.create_default_context()), + urllib.request.HTTPDefaultErrorHandler(), + urllib.request.HTTPErrorProcessor(), + # Without this, an unsupported scheme silently returns None. + urllib.request.UnknownHandler(), + ): + opener.add_handler(handler) + return opener + + +OPENER = closed_opener() diff --git a/shifter/engine/openvpn/tests/test_parts.py b/shifter/engine/openvpn/tests/test_parts.py index 5ec89cd92..68d7a1b5b 100644 --- a/shifter/engine/openvpn/tests/test_parts.py +++ b/shifter/engine/openvpn/tests/test_parts.py @@ -187,7 +187,7 @@ def open(self, request, timeout): raise urllib.error.HTTPError(request.full_url, status, "x", {}, io.BytesIO(body)) return _Response(status, body) - monkeypatch.setattr(portal_module, "_OPENER", Opener()) + monkeypatch.setattr(portal_module.transport, "OPENER", Opener()) client = PortalClient("https://portal.example.com", "aud", "vpn-a", lambda audience: f"token-for-{audience}") return client, sent, replies @@ -230,7 +230,3 @@ def test_heartbeat_and_end(self, client): assert portal.heartbeat(["s"]) == ({"s"}, 15) portal.end("s") assert [request.full_url.rsplit("/vpn-control/", 1)[1] for request in sent] == ["heartbeat/", "sessions/end/"] - - def test_redirects_are_refused(self): - handler = portal_module._NoRedirect() - assert handler.redirect_request() is None diff --git a/shifter/engine/openvpn/tests/test_runtime.py b/shifter/engine/openvpn/tests/test_runtime.py index 5324da461..b833d7e94 100644 --- a/shifter/engine/openvpn/tests/test_runtime.py +++ b/shifter/engine/openvpn/tests/test_runtime.py @@ -7,6 +7,7 @@ import socket import threading import time +import urllib.error import urllib.request import pytest @@ -31,10 +32,10 @@ def __exit__(self, *_): @pytest.fixture def network(monkeypatch): - """Replace only urllib's network call; record every request.""" + """Replace only the opener's network call; record every request.""" seen: list[urllib.request.Request] = [] - def urlopen(request, timeout): + def open_(request, timeout): seen.append(request) url = request.full_url if url.endswith("/token"): @@ -45,7 +46,7 @@ def urlopen(request, timeout): return _Response(json.dumps({"payload": {"data": base64.b64encode(b"material").decode()}}).encode()) raise AssertionError(url) - monkeypatch.setattr(gcp.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(gcp.transport.OPENER, "open", open_) return seen @@ -103,3 +104,13 @@ def test_startup_waits_for_the_management_socket(tmp_path): with pytest.raises(RuntimeError, match="management socket"): entry._wait_for_socket(str(tmp_path / "never"), _Process(exited=True), timeout=5) + + +def test_the_http_client_opens_only_http_and_never_follows_redirects(): + from shifter_openvpn import transport + + opener = transport.closed_opener() + with pytest.raises(urllib.error.URLError, match="unknown url type"): + opener.open("file:///etc/passwd") + assert not any(isinstance(h, urllib.request.HTTPRedirectHandler) for h in opener.handlers) + assert not any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers) From 7a184da5e3642f5a86b8a16d138b433d19257e92 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 07:31:09 +0000 Subject: [PATCH 06/14] fix(range): run the pool server unprivileged and clear its Sonar findings 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 --- ...gcp-range-cell-containment-threat-model.md | 2 +- .../modules/vpn-pool/cloud-init.yaml.tftpl | 11 ++- .../terraform/gcp/modules/vpn-pool/main.tf | 8 ++- .../modules/vpn-pool/tests/main.tftest.hcl | 5 ++ .../gcp/modules/vpn-pool/variables.tf | 11 +++ shifter/engine/openvpn/Dockerfile | 29 ++++++-- .../openvpn/shifter_openvpn/__main__.py | 35 +++++----- .../engine/openvpn/shifter_openvpn/config.py | 3 +- .../openvpn/shifter_openvpn/controller.py | 19 ++++-- .../openvpn/shifter_openvpn/firewall.py | 3 +- shifter/engine/openvpn/shifter_openvpn/gcp.py | 4 +- .../openvpn/shifter_openvpn/management.py | 15 +++-- .../engine/openvpn/shifter_openvpn/portal.py | 36 ++++++---- .../openvpn/shifter_openvpn/server_config.py | 11 +-- .../openvpn/shifter_openvpn/transport.py | 4 +- shifter/engine/openvpn/tests/test_parts.py | 15 ++++- shifter/engine/openvpn/tests/test_runtime.py | 34 ++++------ .../capacity_profile_invariants_gcp.py | 67 +++++++++++++++++++ shifter/installation/capacity_profiles_gcp.py | 54 +-------------- .../shifter_platform/cms/api/vpn_control.py | 2 + .../engine/services/_vpn_sessions.py | 56 ++++++++++------ 21 files changed, 266 insertions(+), 158 deletions(-) create mode 100644 shifter/installation/capacity_profile_invariants_gcp.py diff --git a/docs/architecture/gcp-range-cell-containment-threat-model.md b/docs/architecture/gcp-range-cell-containment-threat-model.md index c43683b7b..aeb827dac 100644 --- a/docs/architecture/gcp-range-cell-containment-threat-model.md +++ b/docs/architecture/gcp-range-cell-containment-threat-model.md @@ -36,7 +36,7 @@ participant-controlled context, not by hiding an endpoint from a root guest. | Cell to internet, DNS, or Google APIs | The effective egress mode is the single policy vocabulary: `none` creates no NAT and no general Google-API lane. Sanctioned public-web egress targets the public-internet complement of the denied-network inventory; the Google private-API VIP is reachable only through the Private Google Access lane when enabled. | Closed for the configured mode. DNS is a separately tested transport and is not inferred from a name-resolution failure. | | Internet or participant to cell | The dedicated access-workload source allows only SSH 22 and RDP 3389; management ingress is SSH-only from the provisioner or management range; the shared OpenVPN pool subnet is admitted only to each OpenVPN range's target node on its declared channel ports. | Closed. Platform peering and provisioner source CIDRs are never turned into participant ingress. | | OpenVPN participant to anything but its target | The pool server asks the portal before admitting a certificate and receives one target address and its ports; a per-client nftables allowance (forward policy drop) admits only that tuple, only that route is pushed, client-to-client is off, and input from the tunnel to the server, the metadata server, and the health port is dropped. The pool VPC firewall allows egress only to the range network on 22/3389 and to the portal and Google API VIP on 443. One session per range; revocation within one heartbeat; fail closed after 90 seconds without portal confirmation (#2480). | Closed. Verified in a two-network lab: the target is reachable on its port only; other ports, peers, the server, the tunnel gateway address, and metadata are unreachable; heartbeat revocation disconnects, and a reconnect is authorized again. | -| Compromised OpenVPN pool server | The server runs one container in its own network namespace with only NET_ADMIN, SETUID, and SETGID, a read-only root, and no-new-privileges; OpenVPN drops to `nobody`. The VM has no public address, runs only the deploy-recorded image digest, and its identity reads only its server secret and the release record, never the CA. The portal still checks owner, generation, state, and deadline on every grant. | Accepted with rationale. A compromised server can reach range targets on 22/3389 like any participant client and can disrupt sessions it terminates, but it cannot mint profiles, reach the platform, or widen another range's firewall. | +| Compromised OpenVPN pool server | The server runs one container in its own network namespace with a read-only root and no-new-privileges. It starts as root only to switch to an unprivileged user whose sole capability is NET_ADMIN; OpenVPN and the controller both run as that user. The VM has no public address, runs only the deploy-recorded image digest, and its identity reads only its server secret and the release record, never the CA. The portal still checks owner, generation, state, and deadline on every grant. | Accepted with rationale. A compromised server can reach range targets on 22/3389 like any participant client and can disrupt sessions it terminates, but it cannot mint profiles, reach the platform, or widen another range's firewall. | ## Protocol and address-family coverage diff --git a/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl b/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl index 8c3672a40..19b2d4080 100644 --- a/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl +++ b/platform/terraform/gcp/modules/vpn-pool/cloud-init.yaml.tftpl @@ -2,7 +2,9 @@ # Shared participant OpenVPN pool server (#2480). Container-Optimized OS runs the # image the deploy recorded in the release secret, after checking it names this # tenant's repository by digest. The container keeps its own network namespace: -# NET_ADMIN applies there, never to the host. +# NET_ADMIN applies there, never to the host. It starts as root only so the +# image's setpriv entrypoint can switch to the unprivileged shifter-vpn user (uid +# 10001) keeping NET_ADMIN as its sole capability. write_files: - path: /etc/systemd/system/shifter-openvpn.service permissions: "0644" @@ -41,9 +43,11 @@ write_files: docker-credential-gcr configure-docker --registries="${registry}" docker rm -f shifter-openvpn >/dev/null 2>&1 || true exec docker run --rm --name shifter-openvpn \ - --cap-drop ALL --cap-add NET_ADMIN --cap-add SETUID --cap-add SETGID \ + --user 0:0 --cap-drop ALL --cap-add NET_ADMIN --cap-add SETUID --cap-add SETGID \ --device /dev/net/tun --sysctl net.ipv4.ip_forward=1 \ - --read-only --tmpfs /run:rw,nosuid,size=16m --tmpfs /tmp:rw,nosuid,size=16m \ + --read-only \ + --tmpfs /run:rw,nosuid,nodev,noexec,size=16m,uid=10001,gid=10001,mode=0700 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m,uid=10001,gid=10001,mode=0700 \ --security-opt no-new-privileges --pids-limit 256 \ -p 1194:1194/udp -p ${health_port}:${health_port}/tcp \ -e PORTAL_URL="${portal_url}" \ @@ -51,6 +55,7 @@ write_files: -e SERVER_SECRET="${server_secret}" \ -e SERVER_NAME="$(metadata instance/name)" \ -e MAX_CLIENTS="${max_clients}" \ + -e TUNNEL_NETWORK="${tunnel_network}" \ -e HEALTH_PORT="${health_port}" \ "$${image}" runcmd: diff --git a/platform/terraform/gcp/modules/vpn-pool/main.tf b/platform/terraform/gcp/modules/vpn-pool/main.tf index 748660550..cfdb8efac 100644 --- a/platform/terraform/gcp/modules/vpn-pool/main.tf +++ b/platform/terraform/gcp/modules/vpn-pool/main.tf @@ -176,9 +176,10 @@ resource "google_compute_region_health_check" "pool" { healthy_threshold = 2 unhealthy_threshold = 3 - http_health_check { - port = var.health_port - request_path = "/healthz" + # The server opens the health port only once it is serving; a completed TCP + # handshake is the health signal. + tcp_health_check { + port = var.health_port } log_config { @@ -232,6 +233,7 @@ resource "google_compute_region_instance_template" "pool" { portal_url = local.portal_url control_audience = local.control_audience max_clients = var.max_clients_per_vm + tunnel_network = var.tunnel_network health_port = var.health_port }) } diff --git a/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl b/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl index 011dea403..45c2f457d 100644 --- a/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl +++ b/platform/terraform/gcp/modules/vpn-pool/tests/main.tftest.hcl @@ -58,6 +58,11 @@ run "public_exposure_is_one_udp_listener_and_probe_ranges" { error_message = "Health ingress must come only from Google's probe ranges to the health port." } + assert { + condition = length(google_compute_region_health_check.pool.tcp_health_check) == 1 && length(google_compute_region_health_check.pool.http_health_check) == 0 + error_message = "Health is a TCP handshake on the health port; the server exposes no HTTP endpoint." + } + assert { condition = alltrue([for nic in google_compute_region_instance_template.pool.network_interface : length(nic.access_config) == 0]) error_message = "Pool servers must not have public addresses." diff --git a/platform/terraform/gcp/modules/vpn-pool/variables.tf b/platform/terraform/gcp/modules/vpn-pool/variables.tf index 02ba714bc..6cc759eb3 100644 --- a/platform/terraform/gcp/modules/vpn-pool/variables.tf +++ b/platform/terraform/gcp/modules/vpn-pool/variables.tf @@ -113,6 +113,17 @@ variable "max_clients_per_vm" { default = 250 } +variable "tunnel_network" { + description = "Client address pool inside each server's own network namespace; masqueraded, never routed in the VPC." + type = string + default = "100.96.0.0/22" + + validation { + condition = can(cidrhost(var.tunnel_network, 0)) && tonumber(split("/", var.tunnel_network)[1]) >= 16 && tonumber(split("/", var.tunnel_network)[1]) <= 24 + error_message = "tunnel_network must be an IPv4 CIDR between /16 and /24." + } +} + variable "health_port" { description = "Controller health endpoint port." type = number diff --git a/shifter/engine/openvpn/Dockerfile b/shifter/engine/openvpn/Dockerfile index decaf40ff..8d79c7fd0 100644 --- a/shifter/engine/openvpn/Dockerfile +++ b/shifter/engine/openvpn/Dockerfile @@ -1,11 +1,25 @@ # Shared participant OpenVPN pool server (#2480): OpenVPN plus its authorizing -# controller. The container runs in its own network namespace on a pool VM: -# NET_ADMIN (tunnel, routes, nftables) applies to that namespace only, and -# OpenVPN drops to nobody after it opens the tunnel and reads its credentials. -FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +# controller, in its own network namespace on a pool VM. +# +# The image's default user is unprivileged and cannot start the server. The pool +# VM starts it as root with NET_ADMIN, SETUID and SETGID only, and setpriv then +# switches to the shifter-vpn user keeping NET_ADMIN (tunnel, routes, nftables in +# this namespace) as its only capability. no-new-privileges stops it regaining +# any other. +# base image: alpine:3.22 +FROM alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 + +LABEL maintainer="Shifter maintainers" \ + org.opencontainers.image.title="shifter-openvpn" \ + org.opencontainers.image.description="Shifter shared participant OpenVPN pool server" \ + org.opencontainers.image.source="https://github.com/Brad-Edwards/shifter" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.authors="Shifter maintainers" # Minor-version constraints: CI rebuilds without cache pick up patch releases. -RUN apk add --no-cache "openvpn~2.6" "nftables~1.1" "python3~3.12" +RUN apk add --no-cache "nftables~1.1" "openvpn~2.6" "python3~3.12" "setpriv~2.41" \ + && addgroup -S -g 10001 shifter-vpn \ + && adduser -S -D -H -u 10001 -G shifter-vpn -s /sbin/nologin shifter-vpn WORKDIR /opt/shifter-openvpn COPY shifter_openvpn ./shifter_openvpn @@ -14,4 +28,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 EXPOSE 1194/udp 8080/tcp -ENTRYPOINT ["python3", "-m", "shifter_openvpn"] +USER shifter-vpn +ENTRYPOINT ["setpriv", "--reuid=10001", "--regid=10001", "--clear-groups", \ + "--inh-caps=-all,+net_admin", "--ambient-caps=-all,+net_admin", \ + "--", "python3", "-m", "shifter_openvpn"] diff --git a/shifter/engine/openvpn/shifter_openvpn/__main__.py b/shifter/engine/openvpn/shifter_openvpn/__main__.py index 0f38e98c9..ad5332cfa 100644 --- a/shifter/engine/openvpn/shifter_openvpn/__main__.py +++ b/shifter/engine/openvpn/shifter_openvpn/__main__.py @@ -14,7 +14,6 @@ import sys import threading import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import gcp from .config import load_config @@ -31,10 +30,12 @@ def _log(message: str) -> None: + """Write one operational line to the container log.""" _LOGGER.info(message) def _wait_for_socket(path: str, process: subprocess.Popen[bytes], timeout: float = 30) -> None: + """Wait for OpenVPN's management socket, failing if OpenVPN exits or stalls.""" deadline = time.monotonic() + timeout while not os.path.exists(path): if process.poll() is not None or time.monotonic() > deadline: @@ -42,43 +43,43 @@ def _wait_for_socket(path: str, process: subprocess.Popen[bytes], timeout: float time.sleep(0.2) -def _serve_health(port: int, healthy: threading.Event) -> None: - class Health(BaseHTTPRequestHandler): - def do_GET(self) -> None: - ok = self.path == "/healthz" and healthy.is_set() - self.send_response(200 if ok else 503) - self.end_headers() - self.wfile.write(b"ok\n" if ok else b"unavailable\n") +def _serve_health(port: int) -> None: + """Accept and close TCP connections on the health port for the load balancer. - def log_message(self, *_args: object) -> None: - return - - # Bind the container's own interface only: tunnel clients never reach the health endpoint. + The listener opens only once the server is ready and closes with the + process, so a completed handshake is the health signal; no data is + exchanged. It binds the container's own interface, which tunnel clients + cannot reach (their input is dropped by the firewall). + """ address = socket.gethostbyname(socket.gethostname()) - ThreadingHTTPServer((address, port), Health).serve_forever() + with socket.create_server((address, port), backlog=64) as listener: + while True: + connection, _peer = listener.accept() + connection.close() def _heartbeat(controller: Controller) -> None: + """Renew live sessions with the portal for the life of the process.""" while True: time.sleep(controller.heartbeat()) def main() -> int: + """Start the server; return only when the management connection closes.""" logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(message)s") config = load_config() write_material(config.runtime_dir, gcp.read_secret(config.server_secret)) paths = write_config(config) Firewall().install(config.tunnel_network) - process = subprocess.Popen([_OPENVPN, "--config", paths.config]) # noqa: S603 (fixed argv, no shell, no external input) + # Fixed argv with no shell; the only argument is the config path written above. + process = subprocess.Popen([_OPENVPN, "--config", paths.config]) # noqa: S603 _wait_for_socket(paths.management_socket, process) management = Management(paths.management_socket) portal = PortalClient(config.portal_url, config.control_audience, config.server_name, gcp.identity_token) controller = Controller(management, portal, Firewall(), log=_log) - healthy = threading.Event() - threading.Thread(target=_serve_health, args=(config.health_port, healthy), daemon=True).start() threading.Thread(target=_heartbeat, args=(controller,), daemon=True, name="heartbeat").start() threading.Thread(target=lambda: (process.wait(), os._exit(1)), daemon=True, name="openvpn-watch").start() - healthy.set() + threading.Thread(target=_serve_health, args=(config.health_port,), daemon=True, name="health").start() _log(f"pool server {config.server_name} ready") while (event := management.events.get()) is not None: controller.handle(event) diff --git a/shifter/engine/openvpn/shifter_openvpn/config.py b/shifter/engine/openvpn/shifter_openvpn/config.py index 4eff28980..4cd4f53ca 100644 --- a/shifter/engine/openvpn/shifter_openvpn/config.py +++ b/shifter/engine/openvpn/shifter_openvpn/config.py @@ -30,6 +30,7 @@ class Config: def _require(name: str) -> str: + """Return a non-empty environment value or fail closed.""" value = os.environ.get(name, "").strip() if not value: raise ConfigError(f"{name} is required") @@ -47,7 +48,7 @@ def load_config() -> Config: server_name = _require("SERVER_NAME") if not _SERVER_NAME.fullmatch(server_name): raise ConfigError("SERVER_NAME must be the Compute Engine instance name") - network = ipaddress.ip_network(os.environ.get("TUNNEL_NETWORK", "100.96.0.0/22"), strict=True) + network = ipaddress.ip_network(_require("TUNNEL_NETWORK"), strict=True) if not isinstance(network, ipaddress.IPv4Network) or not 16 <= network.prefixlen <= 24: raise ConfigError("TUNNEL_NETWORK must be an IPv4 network between /16 and /24") max_clients = int(os.environ.get("MAX_CLIENTS", "250")) diff --git a/shifter/engine/openvpn/shifter_openvpn/controller.py b/shifter/engine/openvpn/shifter_openvpn/controller.py index 6310c3db0..c4757ebc7 100644 --- a/shifter/engine/openvpn/shifter_openvpn/controller.py +++ b/shifter/engine/openvpn/shifter_openvpn/controller.py @@ -27,6 +27,8 @@ class ManagementPort(Protocol): + """The OpenVPN management commands the policy issues.""" + def allow(self, cid: int, kid: int, config_lines: list[str]) -> None: ... def allow_unchanged(self, cid: int, kid: int) -> None: ... def deny(self, cid: int, kid: int, reason: str) -> None: ... @@ -34,12 +36,16 @@ def kill(self, cid: int) -> None: ... class PortalPort(Protocol): + """The portal's session decisions.""" + def authorize(self, common_name: str, client_id: int, client_address: str) -> Grant: ... def heartbeat(self, sessions: list[str]) -> tuple[set[str], int]: ... def end(self, session: str) -> None: ... class FirewallPort(Protocol): + """Per-client forwarding allowances.""" + def allow(self, client: str, target: str, ports: tuple[int, ...]) -> None: ... def revoke(self, client: str, target: str, ports: tuple[int, ...]) -> None: ... @@ -48,7 +54,7 @@ def revoke(self, client: str, target: str, ports: tuple[int, ...]) -> None: ... class Session: """One admitted client and what it may reach.""" - session: str + session_id: str target: str ports: tuple[int, ...] address: str | None = None @@ -127,7 +133,8 @@ def _established(self, event: ClientEvent) -> None: return try: self._firewall.allow(address, session.target, session.ports) - except Exception as error: # an allowance that cannot be installed is a session that cannot work + except Exception as error: + # An allowance that cannot be installed is a session that cannot work. self._log(f"kill cid={event.cid} reason=firewall ({type(error).__name__})") self._management.kill(event.cid) return @@ -145,10 +152,10 @@ def _disconnect(self, event: ClientEvent) -> None: except Exception as error: self._log(f"revoke failed cid={event.cid} ({type(error).__name__})") try: - self._portal.end(session.session) + self._portal.end(session.session_id) except (PortalRefused, PortalUnavailable) as error: - self._log(f"end not recorded session={session.session} ({error}); the heartbeat reconciles it") - self._log(f"disconnect cid={event.cid} session={session.session}") + self._log(f"end not recorded session={session.session_id} ({error}); the heartbeat reconciles it") + self._log(f"disconnect cid={event.cid} session={session.session_id}") def _kill(self, cid: int, reason: str) -> None: self._log(f"kill cid={cid} reason={reason}") @@ -160,7 +167,7 @@ def _kill(self, cid: int, reason: str) -> None: def heartbeat(self) -> int: """Renew live sessions, apply revocations, and return the seconds until the next beat.""" with self._lock: - live = {cid: session.session for cid, session in self._sessions.items()} + live = {cid: session.session_id for cid, session in self._sessions.items()} try: disconnect, interval = self._portal.heartbeat(sorted(live.values())) except (PortalRefused, PortalUnavailable) as error: diff --git a/shifter/engine/openvpn/shifter_openvpn/firewall.py b/shifter/engine/openvpn/shifter_openvpn/firewall.py index e5f436565..967dafad0 100644 --- a/shifter/engine/openvpn/shifter_openvpn/firewall.py +++ b/shifter/engine/openvpn/shifter_openvpn/firewall.py @@ -53,7 +53,8 @@ def _elements(client: str, target: str, ports: tuple[int, ...]) -> str: def _nft(script: str) -> None: """Apply one nft script atomically.""" - subprocess.run([_NFT, "-f", "-"], input=script, text=True, check=True, capture_output=True) # noqa: S603 (fixed argv, no shell; script built from validated values) + # Fixed argv with no shell; the script on stdin is built from validated values. + subprocess.run([_NFT, "-f", "-"], input=script, text=True, check=True, capture_output=True) # noqa: S603 class Firewall: diff --git a/shifter/engine/openvpn/shifter_openvpn/gcp.py b/shifter/engine/openvpn/shifter_openvpn/gcp.py index 426927094..f8cbcbf27 100644 --- a/shifter/engine/openvpn/shifter_openvpn/gcp.py +++ b/shifter/engine/openvpn/shifter_openvpn/gcp.py @@ -20,7 +20,9 @@ def _metadata(path: str) -> str: - request = urllib.request.Request(f"{_METADATA}/{path}", headers=_HEADERS) # noqa: S310 (opened via transport: http/https only) + """Return one metadata-server value for the attached service account.""" + # Fixed metadata URL, opened only through the http/https-only transport. + request = urllib.request.Request(f"{_METADATA}/{path}", headers=_HEADERS) # noqa: S310 with transport.OPENER.open(request, timeout=_TIMEOUT) as response: return str(response.read().decode("utf-8")) diff --git a/shifter/engine/openvpn/shifter_openvpn/management.py b/shifter/engine/openvpn/shifter_openvpn/management.py index c37b5a004..2f599e61c 100644 --- a/shifter/engine/openvpn/shifter_openvpn/management.py +++ b/shifter/engine/openvpn/shifter_openvpn/management.py @@ -24,6 +24,10 @@ class ManagementError(RuntimeError): """The management interface refused a command or went away.""" +_CLIENT = ">CLIENT:" +_CLIENT_ENV = ">CLIENT:ENV," + + @dataclass(frozen=True) class ClientEvent: """One ``>CLIENT:`` notification with its environment.""" @@ -36,9 +40,9 @@ class ClientEvent: def parse_event_header(line: str) -> tuple[str, int, int | None] | None: """Parse ``>CLIENT:KIND,CID[,KID]``; return None for anything else.""" - if not line.startswith(">CLIENT:") or line.startswith(">CLIENT:ENV,"): + if not line.startswith(_CLIENT) or line.startswith(_CLIENT_ENV): return None - kind, _, rest = line[len(">CLIENT:") :].partition(",") + kind, _, rest = line[len(_CLIENT) :].partition(",") parts = rest.split(",") try: cid = int(parts[0]) @@ -74,8 +78,8 @@ def _read(self) -> None: header = parse_event_header(line) if header is not None: pending, env = header, {} - elif line.startswith(">CLIENT:ENV,") and pending is not None: - entry = line[len(">CLIENT:ENV,") :] + elif line.startswith(_CLIENT_ENV) and pending is not None: + entry = line[len(_CLIENT_ENV) :] if entry == "END": kind, cid, kid = pending self.events.put(ClientEvent(kind=kind, cid=cid, kid=kid, env=env)) @@ -85,7 +89,8 @@ def _read(self) -> None: env[key] = value elif line.startswith(("SUCCESS:", "ERROR:")): self._replies.put(line) - self.events.put(None) # the socket closed + # The socket closed: tell the consumer there will be no more events. + self.events.put(None) def _command(self, *lines: str) -> str: with self._lock: diff --git a/shifter/engine/openvpn/shifter_openvpn/portal.py b/shifter/engine/openvpn/shifter_openvpn/portal.py index 45896a376..3b48b2afa 100644 --- a/shifter/engine/openvpn/shifter_openvpn/portal.py +++ b/shifter/engine/openvpn/shifter_openvpn/portal.py @@ -19,11 +19,15 @@ _TIMEOUT = 10 -class PortalUnavailable(RuntimeError): +class PortalError(RuntimeError): + """A control call did not produce the contracted result.""" + + +class PortalUnavailable(PortalError): """The portal could not be reached or answered outside its contract.""" -class PortalRefused(RuntimeError): +class PortalRefused(PortalError): """The portal refused the request with a closed reason code.""" def __init__(self, code: str) -> None: @@ -42,9 +46,17 @@ class Grant: def _is_port(value: object) -> bool: + """Return whether ``value`` is a TCP/UDP port number (and not a bool).""" return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 +def _refusal(status: int, payload: dict[str, object]) -> PortalError: + """Map a non-success answer to a refusal with its reason code, or to unavailability.""" + if status in (400, 403) and isinstance(payload.get("error"), str): + return PortalRefused(str(payload["error"])) + return PortalUnavailable(f"status {status}") + + def _grant(payload: dict[str, object]) -> Grant: """Validate a grant strictly; anything outside the contract is treated as unavailable.""" session, target = payload.get("session"), payload.get("target") @@ -72,7 +84,9 @@ def __init__(self, portal_url: str, audience: str, server: str, token: Callable[ self._token = token def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, object]]: - request = urllib.request.Request( # noqa: S310 (https origin validated at startup; opened via transport) + """POST one JSON body and return the status and a bounded JSON object.""" + # The https origin is validated at startup; the transport opens http/https only. + request = urllib.request.Request( # noqa: S310 f"{self._base}/{path}", data=json.dumps(body).encode(), headers={"Authorization": f"Bearer {self._token(self._audience)}", "Content-Type": "application/json"}, @@ -83,7 +97,7 @@ def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, obje status, raw = response.status, response.read(_MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: status, raw = error.code, error.read(_MAX_RESPONSE_BYTES + 1) - except (urllib.error.URLError, OSError, TimeoutError) as error: + except OSError as error: # URLError and timeouts are OSErrors raise PortalUnavailable(type(error).__name__) from None if len(raw) > _MAX_RESPONSE_BYTES: raise PortalUnavailable("response too large") @@ -95,12 +109,8 @@ def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, obje raise PortalUnavailable("response is not an object") return status, payload - def _refusal(self, status: int, payload: dict[str, object]) -> Exception: - if status in (400, 403) and isinstance(payload.get("error"), str): - return PortalRefused(str(payload["error"])) - return PortalUnavailable(f"status {status}") - def authorize(self, common_name: str, client_id: int, client_address: str) -> Grant: + """Ask whether this verified client may connect; return its single grant.""" status, payload = self._post( "sessions/", { @@ -111,13 +121,14 @@ def authorize(self, common_name: str, client_id: int, client_address: str) -> Gr }, ) if status != 201: - raise self._refusal(status, payload) + raise _refusal(status, payload) return _grant(payload) def heartbeat(self, sessions: list[str]) -> tuple[set[str], int]: + """Renew live sessions; return those to disconnect and the next interval.""" status, payload = self._post("heartbeat/", {"server": self._server, "sessions": sessions}) if status != 200: - raise self._refusal(status, payload) + raise _refusal(status, payload) disconnect = payload.get("disconnect") interval = payload.get("heartbeat_seconds") if not isinstance(disconnect, list) or not isinstance(interval, int): @@ -125,6 +136,7 @@ def heartbeat(self, sessions: list[str]) -> tuple[set[str], int]: return {str(value) for value in disconnect}, interval def end(self, session: str) -> None: + """Report that a session's client disconnected.""" status, payload = self._post("sessions/end/", {"server": self._server, "session": session}) if status != 204: - raise self._refusal(status, payload) + raise _refusal(status, payload) diff --git a/shifter/engine/openvpn/shifter_openvpn/server_config.py b/shifter/engine/openvpn/shifter_openvpn/server_config.py index 2a2fade93..51c7ff8f4 100644 --- a/shifter/engine/openvpn/shifter_openvpn/server_config.py +++ b/shifter/engine/openvpn/shifter_openvpn/server_config.py @@ -14,14 +14,16 @@ * ``max-clients`` and ``connect-freq`` bound one server's load. * No ``client-to-client``, no pushed default gateway: each client is pushed a route to its single target by the controller. -* ``user``/``group nobody`` with ``persist-key``/``persist-tun``: OpenVPN drops - privileges after it opens the tunnel and reads its credentials. +* No ``user``/``group``: the whole server already runs as the unprivileged + ``shifter-vpn`` user with NET_ADMIN as its only capability (see the Dockerfile). + ``management-client-user`` admits only that same user to the control socket. """ from __future__ import annotations import json import os +import pwd from dataclasses import dataclass from .config import Config @@ -59,6 +61,7 @@ def render(config: Config) -> str: """Return the server configuration text.""" network = config.tunnel_network run = config.runtime_dir + user = pwd.getpwuid(os.geteuid()).pw_name return f"""port 1194 proto udp4 dev tun0 @@ -80,12 +83,10 @@ def render(config: Config) -> str: verify-client-cert require data-ciphers AES-256-GCM:AES-128-GCM auth SHA256 -user nobody -group nobody persist-key persist-tun management {run}/management.sock unix -management-client-user root +management-client-user {user} management-client-auth auth-user-pass-optional verb 3 diff --git a/shifter/engine/openvpn/shifter_openvpn/transport.py b/shifter/engine/openvpn/shifter_openvpn/transport.py index edba807f2..deadda1c0 100644 --- a/shifter/engine/openvpn/shifter_openvpn/transport.py +++ b/shifter/engine/openvpn/shifter_openvpn/transport.py @@ -7,7 +7,6 @@ from __future__ import annotations -import ssl import urllib.request @@ -16,7 +15,8 @@ def closed_opener() -> urllib.request.OpenerDirector: opener = urllib.request.OpenerDirector() for handler in ( urllib.request.HTTPHandler(), - urllib.request.HTTPSHandler(context=ssl.create_default_context()), + # The default context verifies certificates and host names. + urllib.request.HTTPSHandler(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPErrorProcessor(), # Without this, an unsupported scheme silently returns None. diff --git a/shifter/engine/openvpn/tests/test_parts.py b/shifter/engine/openvpn/tests/test_parts.py index 68d7a1b5b..dbf603d26 100644 --- a/shifter/engine/openvpn/tests/test_parts.py +++ b/shifter/engine/openvpn/tests/test_parts.py @@ -5,6 +5,7 @@ import ipaddress import json import os +import pwd import socket import threading import urllib.request @@ -25,6 +26,7 @@ "CONTROL_AUDIENCE": "https://portal.example.com/vpn-control", "SERVER_SECRET": "projects/p/secrets/shifter-test-vpn-server", "SERVER_NAME": "shifter-test-vpn-abcd", + "TUNNEL_NETWORK": "100.96.0.0/22", } @@ -114,6 +116,7 @@ def test_a_complete_environment_loads(self, monkeypatch): ("SERVER_NAME", "Bad_Name"), ("TUNNEL_NETWORK", "100.96.0.0/28"), ("MAX_CLIENTS", "5000"), + ("TUNNEL_NETWORK", ""), ], ) def test_a_defective_environment_is_refused(self, monkeypatch, key, value): @@ -141,11 +144,19 @@ def test_material_is_owner_only_and_the_config_is_hardened(self, monkeypatch, tm "verify-client-cert require", "management-client-auth", "auth-user-pass-optional", - "user nobody", + f"management-client-user {pwd.getpwuid(os.geteuid()).pw_name}", "max-clients 250", ): assert directive in text - for forbidden in ("client-to-client", "redirect-gateway", "username-as-common-name", "duplicate-cn"): + # The server is already unprivileged; a user/group switch would need CAP_SETUID/SETGID. + for forbidden in ( + "client-to-client", + "redirect-gateway", + "username-as-common-name", + "duplicate-cn", + "\nuser ", + "\ngroup ", + ): assert forbidden not in text assert paths.management_socket.endswith("management.sock") diff --git a/shifter/engine/openvpn/tests/test_runtime.py b/shifter/engine/openvpn/tests/test_runtime.py index b833d7e94..ae8071df0 100644 --- a/shifter/engine/openvpn/tests/test_runtime.py +++ b/shifter/engine/openvpn/tests/test_runtime.py @@ -1,4 +1,4 @@ -"""The server's metadata identity, Secret Manager reads, health endpoint and startup wait (#2480).""" +"""The server's metadata identity, Secret Manager reads, health listener and startup wait (#2480).""" from __future__ import annotations @@ -64,29 +64,23 @@ def test_secrets_are_read_with_the_attached_identity(network): assert access.get_header("Authorization") == "Bearer ya29.test" -def test_the_health_endpoint_reflects_readiness(monkeypatch): +def test_the_health_port_accepts_connections_once_serving(monkeypatch): monkeypatch.setattr(entry.socket, "gethostbyname", lambda _name: "127.0.0.1") with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] - healthy = threading.Event() - threading.Thread(target=entry._serve_health, args=(port, healthy), daemon=True).start() - - def status(path: str = "/healthz") -> int: - for _ in range(50): - try: - with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=2) as response: - return response.status - except urllib.error.HTTPError as error: - return error.code - except OSError: - time.sleep(0.05) - raise AssertionError("health server did not start") - - assert status() == 503 - healthy.set() - assert status() == 200 - assert status("/other") == 503 + with pytest.raises(OSError): + socket.create_connection(("127.0.0.1", port), timeout=1).close() + + threading.Thread(target=entry._serve_health, args=(port,), daemon=True).start() + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", port), timeout=2) as connection: + assert connection.recv(1) == b"" # accepted, then closed with no data + return + except ConnectionRefusedError: + time.sleep(0.05) + raise AssertionError("health listener did not start") class _Process: diff --git a/shifter/installation/capacity_profile_invariants_gcp.py b/shifter/installation/capacity_profile_invariants_gcp.py new file mode 100644 index 000000000..6be16aa96 --- /dev/null +++ b/shifter/installation/capacity_profile_invariants_gcp.py @@ -0,0 +1,67 @@ +"""Invariants that couple sections of a GCP shared-service capacity profile (#1816). + +Each check holds for every catalog entry; the profile model runs them all after +field validation, so an incoherent profile can never be resolved. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .capacity_profiles_gcp import GcpSharedServiceCapacityProfile + + +def validate_profile_invariants(profile: GcpSharedServiceCapacityProfile) -> None: + """Raise ``ValueError`` when any cross-section invariant does not hold.""" + _validate_profile_identity(profile) + _validate_ready_replica_floors(profile) + _validate_timeout_ordering(profile) + _validate_connection_budgets(profile) + _validate_gate_replica_floor(profile) + + +def _validate_profile_identity(profile: GcpSharedServiceCapacityProfile) -> None: + """Require the profile suffix, participant count, and gate target to agree.""" + expected_count = int(profile.profile_id.rsplit("p", 1)[1]) + if expected_count != profile.participant_count or profile.gate.concurrency != profile.participant_count: + raise ValueError("profile identity, participant count, and gate concurrency must agree") + + +def _validate_ready_replica_floors(profile: GcpSharedServiceCapacityProfile) -> None: + """Require ready replicas to carry the gate before autoscaling reacts.""" + if profile.portal.autoscaling.min_replicas != profile.portal.replicas: + raise ValueError("portal minimum replicas must carry the gate before autoscaling") + if profile.guacd.autoscaling.min_replicas != profile.guacd.replicas: + raise ValueError("guacd minimum replicas must carry the gate before autoscaling") + + +def _validate_timeout_ordering(profile: GcpSharedServiceCapacityProfile) -> None: + """Require heartbeat, drain, process, and pod timeouts to remain ordered.""" + timeouts = profile.timeouts + cadence = timeouts.websocket_ping_interval_seconds + timeouts.websocket_ping_timeout_seconds + if cadence >= min(timeouts.portal_backend_seconds, timeouts.guacamole_backend_seconds): + raise ValueError("WebSocket cadence must remain below both public backend timeouts") + if timeouts.pod_termination_grace_seconds < timeouts.connection_draining_seconds: + raise ValueError("pod termination grace must cover connection draining") + if timeouts.pod_termination_grace_seconds <= timeouts.process_graceful_timeout_seconds: + raise ValueError("pod termination grace must exceed the process graceful timeout") + + +def _validate_connection_budgets(profile: GcpSharedServiceCapacityProfile) -> None: + """Require SQL and Redis budgets to cover all configured process contexts.""" + portal_contexts = profile.portal.replicas * (profile.portal.web_workers + profile.portal.bootstrap_workers) + required_sql = ( + portal_contexts + profile.guacamole_client.jdbc_pool_active_connections + profile.cloud_sql.reserved_connections + ) + if profile.cloud_sql.connection_budget < required_sql: + raise ValueError("SQL connection budget does not cover portal, Guacamole, and reserve contexts") + required_redis = profile.portal.replicas * profile.portal.web_workers * 2 + if profile.redis.connection_budget < required_redis: + raise ValueError("Redis connection budget does not cover portal processes and reconnect headroom") + + +def _validate_gate_replica_floor(profile: GcpSharedServiceCapacityProfile) -> None: + """Require the public-path gate to demand no more guacd pods than ready.""" + if profile.gate.required_guacd_replicas > profile.guacd.replicas: + raise ValueError("gate requires more guacd replicas than the ready minimum") diff --git a/shifter/installation/capacity_profiles_gcp.py b/shifter/installation/capacity_profiles_gcp.py index db930080b..8962aa003 100644 --- a/shifter/installation/capacity_profiles_gcp.py +++ b/shifter/installation/capacity_profiles_gcp.py @@ -12,6 +12,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from .capacity_profile_invariants_gcp import validate_profile_invariants + CapacityProfileId = Literal[ "gcp-shared-v1-p10", "gcp-shared-v1-p30", @@ -209,11 +211,7 @@ class GcpSharedServiceCapacityProfile(_ClosedModel): @model_validator(mode="after") def validate_coupled_limits(self) -> GcpSharedServiceCapacityProfile: """Validate invariants that couple otherwise independent profile sections.""" - _validate_profile_identity(self) - _validate_ready_replica_floors(self) - _validate_timeout_ordering(self) - _validate_connection_budgets(self) - _validate_gate_replica_floor(self) + validate_profile_invariants(self) return self def terraform_projection(self) -> dict[str, object]: @@ -337,52 +335,6 @@ def desired_state(self) -> dict[str, object]: } -def _validate_profile_identity(profile: GcpSharedServiceCapacityProfile) -> None: - """Require the profile suffix, participant count, and gate target to agree.""" - expected_count = int(profile.profile_id.rsplit("p", 1)[1]) - if expected_count != profile.participant_count or profile.gate.concurrency != profile.participant_count: - raise ValueError("profile identity, participant count, and gate concurrency must agree") - - -def _validate_ready_replica_floors(profile: GcpSharedServiceCapacityProfile) -> None: - """Require ready replicas to carry the gate before autoscaling reacts.""" - if profile.portal.autoscaling.min_replicas != profile.portal.replicas: - raise ValueError("portal minimum replicas must carry the gate before autoscaling") - if profile.guacd.autoscaling.min_replicas != profile.guacd.replicas: - raise ValueError("guacd minimum replicas must carry the gate before autoscaling") - - -def _validate_timeout_ordering(profile: GcpSharedServiceCapacityProfile) -> None: - """Require heartbeat, drain, process, and pod timeouts to remain ordered.""" - timeouts = profile.timeouts - cadence = timeouts.websocket_ping_interval_seconds + timeouts.websocket_ping_timeout_seconds - if cadence >= min(timeouts.portal_backend_seconds, timeouts.guacamole_backend_seconds): - raise ValueError("WebSocket cadence must remain below both public backend timeouts") - if timeouts.pod_termination_grace_seconds < timeouts.connection_draining_seconds: - raise ValueError("pod termination grace must cover connection draining") - if timeouts.pod_termination_grace_seconds <= timeouts.process_graceful_timeout_seconds: - raise ValueError("pod termination grace must exceed the process graceful timeout") - - -def _validate_connection_budgets(profile: GcpSharedServiceCapacityProfile) -> None: - """Require SQL and Redis budgets to cover all configured process contexts.""" - portal_contexts = profile.portal.replicas * (profile.portal.web_workers + profile.portal.bootstrap_workers) - required_sql = ( - portal_contexts + profile.guacamole_client.jdbc_pool_active_connections + profile.cloud_sql.reserved_connections - ) - if profile.cloud_sql.connection_budget < required_sql: - raise ValueError("SQL connection budget does not cover portal, Guacamole, and reserve contexts") - required_redis = profile.portal.replicas * profile.portal.web_workers * 2 - if profile.redis.connection_budget < required_redis: - raise ValueError("Redis connection budget does not cover portal processes and reconnect headroom") - - -def _validate_gate_replica_floor(profile: GcpSharedServiceCapacityProfile) -> None: - """Require the public-path gate to demand no more guacd pods than ready.""" - if profile.gate.required_guacd_replicas > profile.guacd.replicas: - raise ValueError("gate requires more guacd replicas than the ready minimum") - - def _resources(request_cpu: str, request_memory: str, limit_cpu: str, limit_memory: str) -> WorkloadResources: """Build one workload resource request/limit pair.""" return WorkloadResources( diff --git a/shifter/shifter_platform/cms/api/vpn_control.py b/shifter/shifter_platform/cms/api/vpn_control.py index 5be8027d1..78c9e47f3 100644 --- a/shifter/shifter_platform/cms/api/vpn_control.py +++ b/shifter/shifter_platform/cms/api/vpn_control.py @@ -107,6 +107,8 @@ def _body(request: Request) -> dict[str, object]: class _VpnControlView(APIView): + """Base for the pool controller endpoints: Google identity of the pool SA only.""" + authentication_classes = [VpnControllerAuthentication] permission_classes = [IsVpnController] diff --git a/shifter/shifter_platform/engine/services/_vpn_sessions.py b/shifter/shifter_platform/engine/services/_vpn_sessions.py index c4b3dc986..105f0652d 100644 --- a/shifter/shifter_platform/engine/services/_vpn_sessions.py +++ b/shifter/shifter_platform/engine/services/_vpn_sessions.py @@ -41,6 +41,10 @@ _COMMON_NAME = re.compile(r"participant-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.ASCII) _SERVER_NAME = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?", re.ASCII) _CHANNEL_PORTS = {"ssh": 22, "rdp": 3389} +# Closed refusal codes the pool controller relays to OpenVPN. +_INVALID_REQUEST = "vpn.invalid_request" +_UNKNOWN_CLIENT = "vpn.unknown_client" +_ACCESS_REVOKED = "vpn.access_revoked" _MAX_CLIENT_ID = 2**63 - 1 _EXPIRY_BATCH = 200 @@ -65,7 +69,7 @@ class VpnSessionGrant: def _require_server(server: object) -> str: """Accept only a Compute Engine instance name for the reporting server.""" if not isinstance(server, str) or not _SERVER_NAME.fullmatch(server): - raise VpnSessionDenied("vpn.invalid_request") + raise VpnSessionDenied(_INVALID_REQUEST) return server @@ -73,14 +77,14 @@ def _require_generation(common_name: object) -> UUID: """Return the binding generation a participant certificate was minted for.""" match = _COMMON_NAME.fullmatch(common_name) if isinstance(common_name, str) else None if match is None: - raise VpnSessionDenied("vpn.unknown_client") + raise VpnSessionDenied(_UNKNOWN_CLIENT) return UUID(match.group(1)) def _require_client_id(client_id: object) -> int: """Accept only the non-negative OpenVPN client id of the server.""" if isinstance(client_id, bool) or not isinstance(client_id, int) or not 0 <= client_id <= _MAX_CLIENT_ID: - raise VpnSessionDenied("vpn.invalid_request") + raise VpnSessionDenied(_INVALID_REQUEST) return client_id @@ -98,12 +102,12 @@ def _current_target(range_obj: Range, generation: UUID) -> tuple[str, tuple[int, request = range_obj.request if range_obj.status != Range.Status.READY or request is None or request.request_id != generation: - raise VpnSessionDenied("vpn.access_revoked") + raise VpnSessionDenied(_ACCESS_REVOKED) try: binding = parse_openvpn_binding(range_obj.vpn_access_binding) capability = parse_openvpn_capability(range_obj.remote_access_capability) except OpenVpnBindingError as exc: - raise VpnSessionDenied("vpn.access_revoked") from exc + raise VpnSessionDenied(_ACCESS_REVOKED) from exc valid = ( binding.ready and binding.generation == generation @@ -113,12 +117,12 @@ def _current_target(range_obj: Range, generation: UUID) -> tuple[str, tuple[int, and is_raes_member_target(binding.target_ref) ) if not valid: - raise VpnSessionDenied("vpn.access_revoked") + raise VpnSessionDenied(_ACCESS_REVOKED) return _member_destination(range_obj, binding.target_ref) -def _member_destination(range_obj: Range, target_ref: str) -> tuple[str, tuple[int, ...]]: - """Resolve the realized participant target to one private address and its declared ports.""" +def _realized_member(range_obj: Range, target_ref: str) -> dict[str, object]: + """Return the one realized RAES member named ``target_ref``.""" members = range_obj.provisioned_instances if isinstance(range_obj.provisioned_instances, list) else [] matches = [ member @@ -126,19 +130,27 @@ def _member_destination(range_obj: Range, target_ref: str) -> tuple[str, tuple[i if isinstance(member, dict) and member.get("uuid") == target_ref and member.get("role") == "raes-node" ] if len(matches) != 1: - raise VpnSessionDenied("vpn.access_revoked") - channels = matches[0].get("participant_access_channels") - ports = ( - tuple(sorted({_CHANNEL_PORTS[c] for c in channels if c in _CHANNEL_PORTS})) - if isinstance(channels, list) - else () - ) + raise VpnSessionDenied(_ACCESS_REVOKED) + return matches[0] + + +def _channel_ports(channels: object) -> tuple[int, ...]: + """Return the sorted ports of the declared participant channels.""" + if not isinstance(channels, list): + return () + return tuple(sorted({_CHANNEL_PORTS[channel] for channel in channels if channel in _CHANNEL_PORTS})) + + +def _member_destination(range_obj: Range, target_ref: str) -> tuple[str, tuple[int, ...]]: + """Resolve the realized participant target to one private address and its declared ports.""" + member = _realized_member(range_obj, target_ref) + ports = _channel_ports(member.get("participant_access_channels")) try: - address = ipaddress.ip_address(str(matches[0].get("private_ip", ""))) + address = ipaddress.ip_address(str(member.get("private_ip", ""))) except ValueError as exc: - raise VpnSessionDenied("vpn.access_revoked") from exc + raise VpnSessionDenied(_ACCESS_REVOKED) from exc if not ports or address.version != 4 or not address.is_private: - raise VpnSessionDenied("vpn.access_revoked") + raise VpnSessionDenied(_ACCESS_REVOKED) return str(address), ports @@ -184,7 +196,7 @@ def authorize_vpn_session( .first() ) if range_obj is None: - raise VpnSessionDenied("vpn.unknown_client") + raise VpnSessionDenied(_UNKNOWN_CLIENT) target_ip, ports = _current_target(range_obj, generation) for previous in VpnSession.objects.select_for_update().filter(range=range_obj, state=VpnSessionState.ACTIVE): _end(previous, VpnSessionEndReason.SUPERSEDED) @@ -202,11 +214,11 @@ def authorize_vpn_session( def _require_session_ids(session_ids: object) -> set[UUID]: """Accept a bounded list of session identifiers from one heartbeat.""" if not isinstance(session_ids, list) or len(session_ids) > MAX_HEARTBEAT_SESSIONS: - raise VpnSessionDenied("vpn.invalid_request") + raise VpnSessionDenied(_INVALID_REQUEST) try: return {UUID(str(value)) for value in session_ids} except ValueError as exc: - raise VpnSessionDenied("vpn.invalid_request") from exc + raise VpnSessionDenied(_INVALID_REQUEST) from exc def _renewed(row: VpnSession, server_name: str, lease: datetime) -> bool: @@ -271,7 +283,7 @@ def end_vpn_session(*, server: object, session_id: object) -> None: try: identifier = UUID(str(session_id)) except ValueError as exc: - raise VpnSessionDenied("vpn.invalid_request") from exc + raise VpnSessionDenied(_INVALID_REQUEST) from exc with transaction.atomic(): row = ( VpnSession.objects.select_for_update() From a05e97fe5a91df5b9fabd2873a57da50a4b49b6d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 07:58:35 +0000 Subject: [PATCH 07/14] style(range): move a trailing comment in the pool portal client Refs #2480 --- shifter/engine/openvpn/shifter_openvpn/portal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shifter/engine/openvpn/shifter_openvpn/portal.py b/shifter/engine/openvpn/shifter_openvpn/portal.py index 3b48b2afa..8f6aaf3bf 100644 --- a/shifter/engine/openvpn/shifter_openvpn/portal.py +++ b/shifter/engine/openvpn/shifter_openvpn/portal.py @@ -97,7 +97,8 @@ def _post(self, path: str, body: dict[str, object]) -> tuple[int, dict[str, obje status, raw = response.status, response.read(_MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: status, raw = error.code, error.read(_MAX_RESPONSE_BYTES + 1) - except OSError as error: # URLError and timeouts are OSErrors + except OSError as error: + # URLError and socket timeouts are both OSError subclasses. raise PortalUnavailable(type(error).__name__) from None if len(raw) > _MAX_RESPONSE_BYTES: raise PortalUnavailable("response too large") From 24c03094ecbc1f680e62690382a1e2ce0c0130c4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 17:12:27 +0000 Subject: [PATCH 08/14] fix(range): tell pool clients to reconnect when a server stops 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 --- docs/dev/deploy-secrets.md | 2 +- docs/dev/gcp-range-cell-deploy.md | 12 +++--- docs/technical/dev/setup.md | 39 +++++++++++++++++++ .../openvpn/shifter_openvpn/__main__.py | 28 ++++++++++++- shifter/engine/openvpn/tests/test_runtime.py | 25 ++++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/docs/dev/deploy-secrets.md b/docs/dev/deploy-secrets.md index 678d8c81b..f84988fab 100644 --- a/docs/dev/deploy-secrets.md +++ b/docs/dev/deploy-secrets.md @@ -751,7 +751,7 @@ a specific image outside that flow. | `GCP_RANGE_HOST_SERVICE_ACCOUNT_EMAIL` | ranges | Keyless range host identity attached to range guests for default-on model access (ADR-064): a predict-only Vertex role plus host telemetry, delivered via Workload Identity with no key material. When the model broker is the guest path (`MODEL_BROKER_GUEST_VIP` set) guests stay identity-less instead. | | `GCP_RANGE_HOST_IDENTITY_POOL_SIZE` | machine-image hosts | Number of Terraform-created `sh-range-host-` identities. Must equal `range_host_identity_pool_size`; zero disables preconfigured machine-image hosts. | | `GCP_RANGE_PRIVATE_GOOGLE_ACCESS` | no | Set `true` so no-external-IP guests reach approved Google APIs (including Vertex for default-on model access) over Private Google Access. | -| `RANGE_OPENVPN_ENABLED` | no | Set `true` to give participants an OpenVPN profile for their range's single participant-access target (ADR-039-R10). It also deploys the shared OpenVPN server pool (#2480): Terraform `openvpn_pool_enabled` follows this variable, and the deploy creates the pool PKI and release record. Apply `global/cicd-oidc` first so the deploy identity holds the conditioned pool role. Defaults to `false`. See "Participant OpenVPN access" in `docs/dev/gcp-range-cell-deploy.md`. | +| `RANGE_OPENVPN_ENABLED` | no | Set `true` to give participants an OpenVPN profile for their range's single participant-access target (ADR-039-R10). It also deploys the shared OpenVPN server pool (#2480): Terraform `openvpn_pool_enabled` follows this variable, and the deploy creates the pool PKI and release record. Apply `global/cicd-oidc` first so the deploy identity holds the conditioned pool role. Only the CI deploy creates the pool; the local `gdc-bootstrap` path leaves it off. Defaults to `false`. See "Participant OpenVPN access" in `docs/dev/gcp-range-cell-deploy.md`. | By default (ADR-064) range guests hold a keyless, predict-only Vertex identity via Workload Identity so they can reach models directly; enabling a specific model in diff --git a/docs/dev/gcp-range-cell-deploy.md b/docs/dev/gcp-range-cell-deploy.md index ef98b8608..89534b59d 100644 --- a/docs/dev/gcp-range-cell-deploy.md +++ b/docs/dev/gcp-range-cell-deploy.md @@ -407,13 +407,15 @@ Every participant connects to one shared OpenVPN server pool per deployment Enable it per deployment: -1. Set the deployment variable `RANGE_OPENVPN_ENABLED=true` (default `false`). - The deploy then creates the pool, its PKI, and its release record, and renders - the pool wiring into the runtime environment. -2. Apply `platform/terraform/gcp/global/cicd-oidc` for the deployment's identity - profile first. The deploy identity needs the conditioned pool role it adds +1. Apply `platform/terraform/gcp/global/cicd-oidc` for the deployment's identity + profile. The deploy identity needs the conditioned pool role it adds (instance templates, the regional instance group, and the autoscaler, limited to the pool's own VM names). +2. Set the deployment variable `RANGE_OPENVPN_ENABLED=true` (default `false`) and + run the CI deploy. It creates the pool, its PKI, and its release record, and + renders the pool wiring into the runtime environment. The local + `gdc-bootstrap` path never deploys the pool; see step 6 of the GCP deployment + in [`docs/technical/dev/setup.md`](../technical/dev/setup.md). 3. Size the pool through `settings.shared_service_capacity_profile`. Each profile plans 25 participants per `e2-standard-2` server, keeps one spare server above the planned count, scales out at 30% CPU, and allows twice the minimum. Check diff --git a/docs/technical/dev/setup.md b/docs/technical/dev/setup.md index 146c5044b..cfcd694cc 100644 --- a/docs/technical/dev/setup.md +++ b/docs/technical/dev/setup.md @@ -668,3 +668,42 @@ With `--range-backend gdc`, the flow first builds or reconciles the GDC substrat The GCP path requires a real hostname and managed TLS. Point the configured hostname to the reserved global ingress IP so the Google-managed certificate can become active. + +### 6. Optional: participant OpenVPN access + +Participant OpenVPN (ADR-039-R10) is off by default. When it is on, every +deployment runs one shared, autoscaled OpenVPN server pool, and participants +download an `.ovpn` profile for their range. The local `gdc-bootstrap` path never +deploys the pool, so turn it on after bootstrap through the CI deploy: + +1. Apply `platform/terraform/gcp/global/cicd-oidc` for the tenant's identity + profile from a revision that includes the pool. The apply adds the deploy and + destroy roles `_deploy_vpn_pool` and `_destroy_vpn_pool`. Each + role covers the pool's instance templates, regional instance group, and + autoscaler, and its VM, disk, and template permissions apply only to the + pool's `shifter--vpn-` names. Without these roles the deploy + fails when it creates the pool's instance template. Confirm both bindings: + + ```bash + gcloud projects get-iam-policy --format=json \ + | jq -r '.bindings[] | select(.role | test("vpn_pool")) | .role' + ``` + +2. Set the GitHub Environment variable `RANGE_OPENVPN_ENABLED=true` for the + tenant. +3. Run the CI deploy: + `gh workflow run deploy.yml --ref -f environment=`. + It creates the pool, the tenant OpenVPN CA, and the pool server certificate. + It also records the scanned image digest, starts the servers, and waits until + every server is healthy behind the load balancer. +4. Plan quota for the pool: it adds `e2-standard-2` VMs (two vCPUs each) up to + the capacity profile's maximum, plus one external address for the endpoint + and one for the pool's NAT. + +Do not set `openvpn_pool_enabled = true` in `local.auto.tfvars` for a local +bootstrap. The local path does not build the server image or write the PKI and +release record, so those servers never start. Launches stay safe either way: the +runtime switch is off unless the CI deploy renders `RANGE_OPENVPN_ENABLED=true`, +and that render fails if no pool exists. See "Participant OpenVPN access" in +[`docs/dev/gcp-range-cell-deploy.md`](../../dev/gcp-range-cell-deploy.md) for +the network design, sizing, and session rules. diff --git a/shifter/engine/openvpn/shifter_openvpn/__main__.py b/shifter/engine/openvpn/shifter_openvpn/__main__.py index ad5332cfa..7e893a303 100644 --- a/shifter/engine/openvpn/shifter_openvpn/__main__.py +++ b/shifter/engine/openvpn/shifter_openvpn/__main__.py @@ -3,17 +3,24 @@ Any failure of OpenVPN, the management connection or the controller ends the process; the VM restarts the container, and clients reconnect to a healthy server. A restarted controller starts with no sessions and a fresh ruleset. + +On SIGTERM (``docker stop`` when the VM is replaced, scaled in or shut down) +the controller, which is PID 1, passes the signal to OpenVPN. OpenVPN's +``explicit-exit-notify`` then tells every client to reconnect at once, so a +planned removal costs participants seconds rather than a keepalive timeout. """ from __future__ import annotations import logging import os +import signal import socket import subprocess import sys import threading import time +from collections.abc import Callable from . import gcp from .config import load_config @@ -58,6 +65,22 @@ def _serve_health(port: int) -> None: connection.close() +def _forward_stop(process: subprocess.Popen[bytes], stopping: threading.Event) -> Callable[[int, object], None]: + """Return a signal handler that asks OpenVPN to notify its clients and exit.""" + + def handler(_signum: int, _frame: object) -> None: + stopping.set() + process.terminate() + + return handler + + +def _watch_openvpn(process: subprocess.Popen[bytes], stopping: threading.Event) -> None: + """End the server when OpenVPN exits: cleanly when asked to stop, as a failure otherwise.""" + process.wait() + os._exit(0 if stopping.is_set() else 1) + + def _heartbeat(controller: Controller) -> None: """Renew live sessions with the portal for the life of the process.""" while True: @@ -78,7 +101,10 @@ def main() -> int: portal = PortalClient(config.portal_url, config.control_audience, config.server_name, gcp.identity_token) controller = Controller(management, portal, Firewall(), log=_log) threading.Thread(target=_heartbeat, args=(controller,), daemon=True, name="heartbeat").start() - threading.Thread(target=lambda: (process.wait(), os._exit(1)), daemon=True, name="openvpn-watch").start() + stopping = threading.Event() + for stop_signal in (signal.SIGTERM, signal.SIGINT): + signal.signal(stop_signal, _forward_stop(process, stopping)) + threading.Thread(target=_watch_openvpn, args=(process, stopping), daemon=True, name="openvpn-watch").start() threading.Thread(target=_serve_health, args=(config.health_port,), daemon=True, name="health").start() _log(f"pool server {config.server_name} ready") while (event := management.events.get()) is not None: diff --git a/shifter/engine/openvpn/tests/test_runtime.py b/shifter/engine/openvpn/tests/test_runtime.py index ae8071df0..f4f55b20a 100644 --- a/shifter/engine/openvpn/tests/test_runtime.py +++ b/shifter/engine/openvpn/tests/test_runtime.py @@ -108,3 +108,28 @@ def test_the_http_client_opens_only_http_and_never_follows_redirects(): opener.open("file:///etc/passwd") assert not any(isinstance(h, urllib.request.HTTPRedirectHandler) for h in opener.handlers) assert not any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers) + + +class _Stoppable: + def __init__(self) -> None: + self.terminated = False + + def terminate(self) -> None: + self.terminated = True + + def wait(self) -> int: + return 0 + + +def test_a_stop_signal_is_passed_to_openvpn_and_ends_cleanly(monkeypatch): + stopping = threading.Event() + process = _Stoppable() + + entry._forward_stop(process, stopping)(15, None) + + assert process.terminated and stopping.is_set() + exits: list[int] = [] + monkeypatch.setattr(entry.os, "_exit", exits.append) + entry._watch_openvpn(process, stopping) + entry._watch_openvpn(process, threading.Event()) + assert exits == [0, 1] # asked to stop vs. OpenVPN died on its own From a4184165f6d468638b827eeab35043d3574168e0 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 21:33:02 +0000 Subject: [PATCH 09/14] fix(range): shorten the pool keepalive to 5/30 seconds Clients now ping every 5 seconds and reconnect after 30 seconds of silence (OpenVPN pushes ping 5 / ping-restart 30; the server drops a silent client after 60). An unplanned server loss costs participants about 30 seconds instead of about 60; planned removals are announced immediately by explicit-exit-notify. Shorter timeouts were rejected because brief Wi-Fi loss would become reconnects that drop open SSH/RDP sessions. Refs #2480 --- docs/dev/gcp-range-cell-deploy.md | 3 +++ shifter/engine/openvpn/shifter_openvpn/server_config.py | 6 +++++- shifter/engine/openvpn/tests/test_parts.py | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/dev/gcp-range-cell-deploy.md b/docs/dev/gcp-range-cell-deploy.md index 89534b59d..29a2fafd6 100644 --- a/docs/dev/gcp-range-cell-deploy.md +++ b/docs/dev/gcp-range-cell-deploy.md @@ -398,6 +398,9 @@ Every participant connects to one shared OpenVPN server pool per deployment connection replaces the older one), and returns the target address and ports. The server then allows that client to reach only that address and those ports and pushes only that route. +- Clients ping every 5 seconds and reconnect after 30 seconds of silence, so an + unplanned server loss costs about 30 seconds. A server that is stopped (deploy, + scale-in, replacement) tells its clients to reconnect immediately. - Servers renew their sessions every 15 seconds. Destroy, pause, owner change, the deadline, or a newer session disconnects a tunnel within one heartbeat. A server that cannot confirm its sessions with the portal disconnects every diff --git a/shifter/engine/openvpn/shifter_openvpn/server_config.py b/shifter/engine/openvpn/shifter_openvpn/server_config.py index 51c7ff8f4..91a347e82 100644 --- a/shifter/engine/openvpn/shifter_openvpn/server_config.py +++ b/shifter/engine/openvpn/shifter_openvpn/server_config.py @@ -12,6 +12,10 @@ client by its verified certificate common name only. ``username-as-common-name`` must never be set: it would let a client choose its own identity. * ``max-clients`` and ``connect-freq`` bound one server's load. +* ``keepalive 5 30``: clients ping every 5 seconds and reconnect after 30 seconds + of silence, so an unplanned server loss costs about 30 seconds; a planned + removal is announced at once by ``explicit-exit-notify``. Shorter timeouts + would turn brief Wi-Fi loss into reconnects that drop open SSH/RDP sessions. * No ``client-to-client``, no pushed default gateway: each client is pushed a route to its single target by the controller. * No ``user``/``group``: the whole server already runs as the unprivileged @@ -70,7 +74,7 @@ def render(config: Config) -> str: server {network.network_address} {network.netmask} max-clients {config.max_clients} connect-freq 20 10 -keepalive 10 60 +keepalive 5 30 explicit-exit-notify 1 ca {run}/ca.crt cert {run}/server.crt diff --git a/shifter/engine/openvpn/tests/test_parts.py b/shifter/engine/openvpn/tests/test_parts.py index dbf603d26..c51cb4823 100644 --- a/shifter/engine/openvpn/tests/test_parts.py +++ b/shifter/engine/openvpn/tests/test_parts.py @@ -146,6 +146,8 @@ def test_material_is_owner_only_and_the_config_is_hardened(self, monkeypatch, tm "auth-user-pass-optional", f"management-client-user {pwd.getpwuid(os.geteuid()).pw_name}", "max-clients 250", + "keepalive 5 30", + "explicit-exit-notify 1", ): assert directive in text # The server is already unprivileged; a user/group switch would need CAP_SETUID/SETGID. From e522f2d6cddacfa2eab07969a60be81863cfe058 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 21:58:37 +0000 Subject: [PATCH 10/14] style(range): document the pool server's stop handler Refs #2480 --- shifter/engine/openvpn/shifter_openvpn/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shifter/engine/openvpn/shifter_openvpn/__main__.py b/shifter/engine/openvpn/shifter_openvpn/__main__.py index 7e893a303..f73ce518f 100644 --- a/shifter/engine/openvpn/shifter_openvpn/__main__.py +++ b/shifter/engine/openvpn/shifter_openvpn/__main__.py @@ -69,6 +69,7 @@ def _forward_stop(process: subprocess.Popen[bytes], stopping: threading.Event) - """Return a signal handler that asks OpenVPN to notify its clients and exit.""" def handler(_signum: int, _frame: object) -> None: + """Mark the stop as intended, then send OpenVPN SIGTERM.""" stopping.set() process.terminate() From a17fa72d43a0ebd8f0b5d591de02376f0af1f1d1 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 23:47:44 +0000 Subject: [PATCH 11/14] fix(deps): bump postcss-selector-parser to 7.1.6 (GHSA-rj75-hqrm-r3gf) Quadratic-time parsing of flat selectors allowed CPU exhaustion. The package is a transitive dev dependency of stylelint in the platform frontend toolchain; 7.1.6 is the first fixed release and is within stylelint's range. Lockfile-only update; npm ci and osv-scanner 2.4.0 are clean. Refs #2480 --- shifter/shifter_platform/package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/shifter/shifter_platform/package-lock.json b/shifter/shifter_platform/package-lock.json index 25eee4c51..e152c13d0 100644 --- a/shifter/shifter_platform/package-lock.json +++ b/shifter/shifter_platform/package-lock.json @@ -8515,11 +8515,10 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" From 3694ff9b60a56dc461197770f3ed3a13c865ed22 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 23:47:44 +0000 Subject: [PATCH 12/14] test(engine): run the broker stream-fence test in the PostgreSQL lane The broker settles the reservation from its own thread while the test polls it. SQLite locks the table for that write, so the read failed intermittently with "database table is locked" and could fail any PR's platform lane. The concurrency it asserts needs PostgreSQL, like the file's existing refresh-race test, so it now carries the postgres marker and runs in that lane. Refs ADR-068 --- .../tests/engine/services/test_model_broker_http.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shifter/shifter_platform/tests/engine/services/test_model_broker_http.py b/shifter/shifter_platform/tests/engine/services/test_model_broker_http.py index ce6b1b425..6b5f6b3ec 100644 --- a/shifter/shifter_platform/tests/engine/services/test_model_broker_http.py +++ b/shifter/shifter_platform/tests/engine/services/test_model_broker_http.py @@ -260,6 +260,10 @@ async def test_slow_socket_consumer_cannot_delay_upstream_revocation(stack): writer.transport.abort() +# The broker settles the reservation from its own thread while this test polls it. +# SQLite locks the table for that write, so the read fails intermittently with +# "database table is locked"; the concurrency this asserts needs PostgreSQL. +@pytest.mark.postgres @pytest.mark.parametrize("fence", ["revoke", "control_loss", "disconnect", "drain", "deadline"]) async def test_live_stream_fences_within_ten_seconds_and_keeps_liability(stack, fence): if fence == "deadline": From 428e11564bca1693bc56bf10295f0bff928892a9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 23:47:44 +0000 Subject: [PATCH 13/14] docs(adr): park the model access broker (ADR-068 supersedes ADR-067) ADR-068 records that the broker is parked: no tenant runs it, it is not supported for use, and keyless-direct (ADR-064) is the only live guest model path. No work hardens or extends it beyond keeping CI reliable. Any future model access control path is a transparent proxy with optional controls, no controls by default, automatic credential renewal, and no manual bootstrap steps. ADR-067 and the parked broker design in ADR-059, ADR-060 and ADR-061 are marked superseded. Refs ADR-068 --- docs/adr/059-range-model-access-broker.md | 2 + .../060-model-access-allocation-accounting.md | 2 + ...1-model-access-operations-qualification.md | 2 + .../067-model-access-enforcement-optional.md | 2 + docs/adr/068-model-access-broker-parked.md | 75 +++++++++++++++++++ docs/adr/README.md | 6 ++ docs/adr/index.yaml | 58 ++++++++++++-- 7 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 docs/adr/068-model-access-broker-parked.md diff --git a/docs/adr/059-range-model-access-broker.md b/docs/adr/059-range-model-access-broker.md index 726e0bab5..46a20fbd0 100644 --- a/docs/adr/059-range-model-access-broker.md +++ b/docs/adr/059-range-model-access-broker.md @@ -2,6 +2,8 @@ ## Status +**Superseded by [ADR-068](068-model-access-broker-parked.md) (2026-10-05):** the broker is parked and not supported for use; keyless-direct (ADR-064) is the only live model path. This record is kept as history of the parked implementation. + Proposed for [#681](https://github.com/Brad-Edwards/shifter/issues/681), PLAT-202, 2026-09-06. This is an implementation decision for review; it does not advertise an installed capability or qualified provider. diff --git a/docs/adr/060-model-access-allocation-accounting.md b/docs/adr/060-model-access-allocation-accounting.md index 7c57637d1..1d210c35e 100644 --- a/docs/adr/060-model-access-allocation-accounting.md +++ b/docs/adr/060-model-access-allocation-accounting.md @@ -2,6 +2,8 @@ ## Status +**Superseded by [ADR-068](068-model-access-broker-parked.md) (2026-10-05):** the broker is parked and not supported for use; keyless-direct (ADR-064) is the only live model path. This record is kept as history of the parked implementation. + Proposed for [#681](https://github.com/Brad-Edwards/shifter/issues/681), PLAT-202, 2026-09-06. Depends on ADR-059; runtime implementation is pending. diff --git a/docs/adr/061-model-access-operations-qualification.md b/docs/adr/061-model-access-operations-qualification.md index c5bfdfbaa..ea20875e7 100644 --- a/docs/adr/061-model-access-operations-qualification.md +++ b/docs/adr/061-model-access-operations-qualification.md @@ -2,6 +2,8 @@ ## Status +**Superseded by [ADR-068](068-model-access-broker-parked.md) (2026-10-05):** the broker is parked and not supported for use; keyless-direct (ADR-064) is the only live model path. This record is kept as history of the parked implementation. + Proposed for [#681](https://github.com/Brad-Edwards/shifter/issues/681), PLAT-202, 2026-09-06. Depends on ADR-059 and ADR-060. diff --git a/docs/adr/067-model-access-enforcement-optional.md b/docs/adr/067-model-access-enforcement-optional.md index 48474aa08..76ab8cd8b 100644 --- a/docs/adr/067-model-access-enforcement-optional.md +++ b/docs/adr/067-model-access-enforcement-optional.md @@ -2,6 +2,8 @@ ## Status +**Superseded by [ADR-068](068-model-access-broker-parked.md) (2026-10-05).** Its third-party gateway selection and its rejection of a no-limit mode do not reflect the intended default of no controls. ADR-068 parks the broker, keeps keyless-direct as the only live path, and requires any future control path to default to no controls. + Accepted for [#2411](https://github.com/Brad-Edwards/shifter/issues/2411), 2026-09-30, and relates to the AI Gateway milestone [#2274](https://github.com/Brad-Edwards/shifter/issues/2274). Supersedes the diff --git a/docs/adr/068-model-access-broker-parked.md b/docs/adr/068-model-access-broker-parked.md new file mode 100644 index 000000000..6ce9aaabc --- /dev/null +++ b/docs/adr/068-model-access-broker-parked.md @@ -0,0 +1,75 @@ +# ADR-068: The model access broker is parked; keyless-direct is the only live model path + +## Status + +Accepted 2026-10-05. Supersedes ADR-067 and the parked broker design recorded in +ADR-059, ADR-060, and ADR-061. ADR-064 (keyless-direct default) stays in force. + +## Context + +The deployment-owned model access broker (ADR-059), its allocation and mandatory +budget accounting (ADR-060), and its operations qualification (ADR-061) are +implemented but not in use. On 2026-10-05: + +- The broker and model access are off by default in code. +- The live GCP tenants run no broker and set `MODEL_ACCESS_ENABLED=false`. The + last tenant that used it moved to keyless access on 2026-10-04. +- No tenant has a broker overlay template or overlay variable. + +Range guests reach models through the keyless, predict-only Workload Identity path +(ADR-064). + +Three defects make the broker unfit to run: + +1. Its TLS certificates are never renewed. The 30-day broker and control + certificates make every deploy fail once fewer than 14 days remain, and expire + at day 30. The 365-day signing certificate makes deploys fail once fewer than + 30 days remain. +2. A fresh tenant with a broker template fails its first deploy, because the trust + step runs before Terraform creates the cluster. The local bootstrap path needs + its trust objects created by hand. +3. Every model call requires finite spend, rate, concurrency, and token limits. + The schema states that omitting a limit never means unlimited. + +ADR-067 kept the broker as an opt-in path, selected a third-party gateway for +optional enforcement, and rejected adding a no-limit mode. That does not match the +intent for model access control. Control was meant to be a capability a +deployment can use, never a requirement. The default is a transparent proxy with +no controls. + +## Decision + +1. The broker is parked. It is not supported for use, and every deployment keeps + `settings.model_broker` and `settings.model_access` disabled. +2. Keyless Workload Identity direct access (ADR-064) is the only supported guest + model path while the broker is parked. +3. No work hardens or extends the parked broker. Changes to its code are limited + to keeping CI reliable for everyone until it is removed or replaced. Model + access control is taken up again under the AI Gateway milestone + ([#2274](https://github.com/Brad-Edwards/shifter/issues/2274)). +4. Any future model access control path is a transparent proxy by default: + - Spend, rate, concurrency, token limits, and detections are optional + controls a deployment can attach. The default is no controls, and a missing + limit means no limit. + - Certificates and other credentials renew automatically on established + practice. A deploy never fails only because a certificate is close to expiry. + - Enabling it works inside the existing bootstrap and deploy flows with no + manual steps. + + The intended end state is that guests reach models through that proxy rather + than keyless-direct; that switch needs its own decision. + +## Alternatives + +| Alternative | Disposition | +| --- | --- | +| Fix the broker now: certificate renewal, bootstrap integration, optional limits | Rejected for now. No tenant uses it, and the work would go into a design planned for replacement. | +| Keep ADR-067 as written | Rejected. Its gateway selection and its rejection of a no-limit mode conflict with the intended default of no controls. | +| Remove the broker code now | Not decided here. Removal is a separate change. | + +## Consequences + +The bootstrap guide and deploy reference mark the broker as not supported for use. +The broker's code, migrations, and tests stay in the repository and in CI until a +removal or replacement decision. Its tests must not make CI unreliable for other +work. Model calls from ranges are not metered or limited, consistent with ADR-064. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6bb69a79d..9f3c2df4a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -128,6 +128,12 @@ Accepted ADR-064 supersedes ADR-059's "no-service-account guest default" for GCP: range guests receive a default-on, keyless predict-only Vertex model identity via Workload Identity, mutually exclusive per range with the broker. +Accepted ADR-068 supersedes ADR-067 and the parked broker design in ADR-059, +ADR-060 and ADR-061. The broker is parked and not supported for use, and +keyless-direct (ADR-064) is the only live guest model path. Any future model +access control path is a transparent proxy whose controls are optional, with no +controls by default. + The enforcement entrypoint is: ```bash diff --git a/docs/adr/index.yaml b/docs/adr/index.yaml index 358f47d73..36134437d 100644 --- a/docs/adr/index.yaml +++ b/docs/adr/index.yaml @@ -3872,7 +3872,7 @@ { "id": "ADR-059", "title": "Range model and tool access crosses a deployment-owned broker", - "status": "proposed", + "status": "superseded", "scope": "shifter_platform", "decision": "PLAT-202 selects a deployment-owned participant model/tool broker with provider credentials outside participant control. Engine owns policy, allocation, grants and accounting; shared owns native contracts; CTF and CMS retain event/scenario/owner admission; the provisioner only realizes a generation-bound operation projection. GCP/Vertex is the first qualification target. This proposed design does not advertise an installed capability or provider parity.", "rules": [ @@ -3912,7 +3912,8 @@ "docs/architecture/model-access/security.md", "docs/architecture/model-access/delivery.md", "docs/architecture/native-ec2-runtime.md" - ] + ], + "superseded_by": "ADR-068" }, { "id": "ADR-064", @@ -3951,7 +3952,7 @@ { "id": "ADR-060", "title": "Model allocation and request budgets are Engine-owned commitments", - "status": "proposed", + "status": "superseded", "scope": "shifter_platform", "decision": "Model-required admission extends ADR-047's Engine-owned catalog and capacity seams while enforcing mandatory request, spend, token, rate and concurrency budgets. Engine persists stable generation-bound allocations and atomic integer-unit reservations before provider dispatch. Unknown outcomes retain maximum charges and cannot trigger blind retries. Proposed policy is not evidence that these runtime controls exist.", "rules": [ @@ -3993,12 +3994,13 @@ "shifter/shifter_platform/config/model_access_sharing.py", "shifter/shifter_platform/tests/engine/services/test_model_access_authority_postgres.py", "shifter/shifter_platform/tests/engine/services/test_model_access_range_pagination.py" - ] + ], + "superseded_by": "ADR-068" }, { "id": "ADR-061", "title": "Model access is released through revocation and operating evidence", - "status": "proposed", + "status": "superseded", "scope": "shifter_platform", "decision": "Model-access release requires measured grant revocation, conservative provider outcome handling, body-free audit, bounded operation, migration and restore evidence. Engine invalidation blocks new admissions; short dispatch/continuation leases fence broker transport. Provider cancellation and token revocation remain separately measured external behavior. GCP cohort qualification and future provider qualifications are distinct.", "rules": [ @@ -4031,7 +4033,8 @@ "docs/ops/model-access.md", "docs/architecture/model-access/security.md", "docs/architecture/model-access/delivery.md" - ] + ], + "superseded_by": "ADR-068" }, { "id": "ADR-062", @@ -4250,7 +4253,7 @@ { "id": "ADR-067", "title": "Model-access enforcement is optional; keyless-direct is the default; adopt a mature LLM gateway for enforcement", - "status": "accepted", + "status": "superseded", "scope": "shifter_platform", "decision": "Model-access enforcement (spend, rate, and request metering, and later detections) is an optional capability, not a requirement: no deployment must run an enforcing gateway to give ranges model access. Keyless Workload Identity direct access (ADR-064) is the default guest model path for all ranges including CTF ranges. The deployment-owned broker is an LLM gateway built in-house whose mandatory-enforcement posture, plus a bespoke zero-trust rotating-credential model, blocked basic range-to-model flows (GCE provisioning outlasts the membership freshness window; capacity, enrollment, and per-event bindings are not turnkey). The broker is no longer the strategic enforcement mechanism: investment in hardening its mandatory-enforcement, freshness, and membership machinery stops, and it is retained only as a compatibility and opt-in path pending migration. The strategic optional-enforcement path is a mature, self-hosted LLM gateway (LiteLLM primary candidate) evaluated and adopted under #2411 as part of the AI Gateway milestone #2274. This supersedes the mandatory-enforcement posture of ADR-059 and complements ADR-064.", "rules": [ @@ -4275,6 +4278,47 @@ "docs/adr/067-model-access-enforcement-optional.md", "docs/adr/064-default-range-model-access.md", "docs/adr/059-range-model-access-broker.md" + ], + "superseded_by": "ADR-068" + }, + { + "id": "ADR-068", + "title": "The model access broker is parked; keyless-direct is the only live model path (supersedes ADR-067)", + "status": "accepted", + "supersedes": "ADR-067", + "scope": "shifter_platform", + "decision": "The ADR-059 model access broker is parked: no tenant runs it, it is not supported for use, and every deployment keeps settings.model_broker and settings.model_access disabled. Keyless Workload Identity direct access (ADR-064) is the only supported guest model path while it is parked. No work hardens or extends the broker; changes are limited to keeping CI reliable until it is removed or replaced, and model access control is taken up again under the AI Gateway milestone (#2274). Any future model access control path is a transparent proxy whose controls (spend, rate, concurrency, token limits, detections) are optional, with no controls by default and a missing limit meaning no limit; its credentials renew automatically and enabling it needs no manual bootstrap steps. Supersedes ADR-067 and the parked broker design in ADR-059, ADR-060, and ADR-061.", + "rules": [ + { + "id": "ADR-068-R1", + "description": "The model access broker stays disabled on every deployment and is documented as not supported for use; no tenant configuration enables settings.model_broker or settings.model_access.", + "checks": [] + }, + { + "id": "ADR-068-R2", + "description": "While the broker is parked, keyless Workload Identity direct access (ADR-064) is the only supported guest model path.", + "checks": [] + }, + { + "id": "ADR-068-R3", + "description": "No new work hardens or extends the parked broker. Changes to its code are limited to keeping CI reliable for other work (for example, isolating a flaky test) until a removal or replacement decision.", + "checks": [] + }, + { + "id": "ADR-068-R4", + "description": "Any future model access control path defaults to no controls: every control is optional and a missing limit means no limit. Its certificates and credentials renew automatically, a deploy never fails only because a certificate is near expiry, and enabling it works in the existing bootstrap and deploy flows with no manual steps.", + "checks": [] + } + ], + "exceptions": [], + "enforcement": [ + "agent-policy" + ], + "evidence": [ + "docs/adr/068-model-access-broker-parked.md", + "docs/adr/064-default-range-model-access.md", + "docs/technical/dev/setup.md", + "https://github.com/Brad-Edwards/shifter/issues/2274" ] } ] From a40322f8cfea207494248ed42c491142314495cf Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 5 Oct 2026 23:54:48 +0000 Subject: [PATCH 14/14] fix(deps): bump proxy-addr to 2.0.8 and source-map-js to 1.2.2 GHSA-jqcg-44mw-7w3h (proxy-addr IP spoofing via IPv4-mapped IPv6 trust subnets; published 2026-10-05) in the three MCP packages via express, and GHSA-68fv-2mgg-jv7q (source-map-js event-loop DoS via indexed section offsets) in the platform and frontend toolchains. Lockfile-only updates to the first fixed releases within the parents' ranges; npm ci is clean in every root and osv-scanner 2.4.0 is clean across all repository lockfiles. Refs #2480 --- mcp/ngfw/package-lock.json | 10 +++++++--- mcp/ops/package-lock.json | 10 +++++++--- mcp/planner/package-lock.json | 10 +++++++--- shifter/shifter_platform/frontend/package-lock.json | 7 +++---- shifter/shifter_platform/package-lock.json | 7 +++---- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/mcp/ngfw/package-lock.json b/mcp/ngfw/package-lock.json index b4748ea79..cfe8e92f4 100644 --- a/mcp/ngfw/package-lock.json +++ b/mcp/ngfw/package-lock.json @@ -1477,15 +1477,19 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { diff --git a/mcp/ops/package-lock.json b/mcp/ops/package-lock.json index 8bd2e4bd1..902584721 100644 --- a/mcp/ops/package-lock.json +++ b/mcp/ops/package-lock.json @@ -1594,15 +1594,19 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { diff --git a/mcp/planner/package-lock.json b/mcp/planner/package-lock.json index f7dff5329..fb4af18f1 100644 --- a/mcp/planner/package-lock.json +++ b/mcp/planner/package-lock.json @@ -1476,15 +1476,19 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { diff --git a/shifter/shifter_platform/frontend/package-lock.json b/shifter/shifter_platform/frontend/package-lock.json index 257b4d81c..cedb70b1c 100644 --- a/shifter/shifter_platform/frontend/package-lock.json +++ b/shifter/shifter_platform/frontend/package-lock.json @@ -15120,10 +15120,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "engines": { "node": ">=0.10.0" } diff --git a/shifter/shifter_platform/package-lock.json b/shifter/shifter_platform/package-lock.json index e152c13d0..8bcdb54a2 100644 --- a/shifter/shifter_platform/package-lock.json +++ b/shifter/shifter_platform/package-lock.json @@ -8938,11 +8938,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" }