REST API — neutron-vpnaas — branch stable/2026.2
Branch
stable/2026.2@6ce5da102cf1— repository https://opendev.org/openstack/neutron-vpnaas — analysed on 2026-10-29. Cross-repository references:neutron-lib:= neutron-libstable/2026.2@1ee72052251a,neutron:= neutronstable/2026.2@eef1a0f7ee36.
neutron-vpnaas has no WSGI application of its own. It is a Neutron service plugin: the Neutron server loads it (service_plugins = vpnaas or ovn-vpnaas, pyproject.toml:57-60) and builds the routes at start-up from the extensions' attribute maps. The attribute maps live in neutron-lib (neutron-lib:neutron_lib/api/definitions/vpn.py:230, neutron-lib:neutron_lib/api/definitions/vpn_endpoint_groups.py:43); the extension descriptors in neutron_vpnaas/extensions/. Requests go through Neutron's legacy Routes ExtensionMiddleware and the generic controller (neutron:neutron/api/v2/base.py), which validates the body against the attribute map, checks the policy, then calls plugin.<action>_<resource>().
The route table below was extracted from the running server with _rewrite/tools/vpnaas_capture.py (files _rewrite/routes-legacy_stable-2026.2.json and _rewrite/routes-ovn_stable-2026.2.json), and every behaviour marked observed was replayed through the real WSGI pipeline (_rewrite/exchanges-*_stable-2026.2.json).
Two deployment profiles exist, and the API differs slightly between them:
| Profile | Service plugin | Service driver ([service_providers]) | Agent |
|---|---|---|---|
| legacy (ML2/OVS, L3 agent) | vpnaas → VPNDriverPlugin (neutron_vpnaas/services/vpn/plugin.py:56) | IPsecVPNDriver (neutron_vpnaas/services/vpn/service_drivers/ipsec.py:27) | L3 agent extension vpnaas (neutron_vpnaas/services/vpn/agent.py:108) |
| OVN (OpenKeel target) | ovn-vpnaas → VPNOVNDriverPlugin (neutron_vpnaas/services/vpn/ovn_plugin.py:77) | IPsecOvnVPNDriver (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:503) | neutron-ovn-vpn-agent (neutron_vpnaas/agent/ovn/vpn/agent.py:79) |
Summary table
All paths are under /v2.0. The vpn prefix comes from path_prefix = "/vpn" (neutron_vpnaas/services/vpn/plugin.py:53, neutron_vpnaas/services/vpn/ovn_plugin.py:74). Policies default to ADMIN_OR_PROJECT_MEMBER (project scope); the deprecated rules (rule:admin_or_owner, rule:regular_user for create) apply until enforce_new_defaults is set (neutron_vpnaas/policies/vpnservice.py:22-93).
| Method | Path | Handler | Policy | Success | Errors (observed or traced) |
|---|---|---|---|---|---|
| GET | /vpn/ikepolicies | neutron_vpnaas/db/vpn/vpn_db.py:380 get_ikepolicies | get_ikepolicy | 200 | 401, 403 |
| POST | /vpn/ikepolicies | neutron_vpnaas/db/vpn/vpn_db.py:318 create_ikepolicy | create_ikepolicy | 201 | 400, 401, 403 |
| GET | /vpn/ikepolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:374 get_ikepolicy | get_ikepolicy | 200 | 401, 404 |
| PUT | /vpn/ikepolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:344 update_ikepolicy | update_ikepolicy | 200 | 400, 401, 403, 404, 409 |
| DELETE | /vpn/ikepolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:364 delete_ikepolicy | delete_ikepolicy | 204 | 401, 403, 404, 409 |
| GET | /vpn/ipsecpolicies | neutron_vpnaas/db/vpn/vpn_db.py:466 get_ipsecpolicies | get_ipsecpolicy | 200 | 401, 403 |
| POST | /vpn/ipsecpolicies | neutron_vpnaas/db/vpn/vpn_db.py:404 create_ipsecpolicy | create_ipsecpolicy | 201 | 400, 401, 403 |
| GET | /vpn/ipsecpolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:460 get_ipsecpolicy | get_ipsecpolicy | 200 | 401, 404 |
| PUT | /vpn/ipsecpolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:428 update_ipsecpolicy | update_ipsecpolicy | 200 | 400, 401, 403, 404, 409 |
| DELETE | /vpn/ipsecpolicies/{id} | neutron_vpnaas/db/vpn/vpn_db.py:449 delete_ipsecpolicy | delete_ipsecpolicy | 204 | 401, 403, 404, 409 |
| GET | /vpn/endpoint-groups | neutron_vpnaas/db/vpn/vpn_db.py:680 get_endpoint_groups | get_endpoint_group | 200 | 401, 403 |
| POST | /vpn/endpoint-groups | neutron_vpnaas/db/vpn/vpn_db.py:635 create_endpoint_group | create_endpoint_group | 201 | 400, 401, 403 |
| GET | /vpn/endpoint-groups/{id} | neutron_vpnaas/db/vpn/vpn_db.py:674 get_endpoint_group | get_endpoint_group | 200 | 401, 404 |
| PUT | /vpn/endpoint-groups/{id} | neutron_vpnaas/db/vpn/vpn_db.py:655 update_endpoint_group | update_endpoint_group | 200 | 400, 401, 403, 404 |
| DELETE | /vpn/endpoint-groups/{id} | neutron_vpnaas/db/vpn/vpn_db.py:666 delete_endpoint_group | delete_endpoint_group | 204 | 400 (in use), 401, 403, 404 |
| GET | /vpn/vpnservices | neutron_vpnaas/db/vpn/vpn_db.py:560 get_vpnservices | get_vpnservice | 200 | 401, 403 |
| POST | /vpn/vpnservices | neutron_vpnaas/services/vpn/plugin.py:204 create_vpnservice | create_vpnservice | 201 | 400, 401, 403, 404, 409 (OVN), 503 |
| GET | /vpn/vpnservices/{id} | neutron_vpnaas/db/vpn/vpn_db.py:554 get_vpnservice | get_vpnservice | 200 | 401, 404 |
| PUT | /vpn/vpnservices/{id} | neutron_vpnaas/services/vpn/plugin.py:214 update_vpnservice | update_vpnservice | 200 | 400, 401, 403, 404 |
| DELETE | /vpn/vpnservices/{id} | neutron_vpnaas/services/vpn/plugin.py:222 delete_vpnservice | delete_vpnservice | 204 | 401, 403, 404, 409 |
| GET | /vpn/ipsec-site-connections | neutron_vpnaas/db/vpn/vpn_db.py:275 get_ipsec_site_connections | get_ipsec_site_connection | 200 | 401, 403 |
| POST | /vpn/ipsec-site-connections | neutron_vpnaas/services/vpn/plugin.py:166 create_ipsec_site_connection | create_ipsec_site_connection | 201 | 400, 401, 403, 404 |
| GET | /vpn/ipsec-site-connections/{id} | neutron_vpnaas/db/vpn/vpn_db.py:267 get_ipsec_site_connection | get_ipsec_site_connection | 200 | 401, 404 |
| PUT | /vpn/ipsec-site-connections/{id} | neutron_vpnaas/services/vpn/plugin.py:186 update_ipsec_site_connection | update_ipsec_site_connection | 200 | 400, 401, 403, 404 |
| DELETE | /vpn/ipsec-site-connections/{id} | neutron_vpnaas/services/vpn/plugin.py:177 delete_ipsec_site_connection | delete_ipsec_site_connection | 204 | 401, 403, 404 |
| GET | /agents/{agent_id}/vpn-routers | neutron_vpnaas/extensions/vpn_agentschedulers.py:50 index | get_vpn-routers (undefined → default) | 200 | 500 for non-admins |
| POST | /agents/{agent_id}/vpn-routers | neutron_vpnaas/extensions/vpn_agentschedulers.py:58 create | create_vpn-router (undefined → default) | 201 | 404, 409 |
| DELETE | /agents/{agent_id}/vpn-routers/{id} | neutron_vpnaas/extensions/vpn_agentschedulers.py:70 delete | delete_vpn-router (undefined → default) | 204 | 404 |
| GET | /routers/{router_id}/vpn-agents | neutron_vpnaas/extensions/vpn_agentschedulers.py:91 index | get_vpn-agents (undefined → default) | 200 | 404 |
The four vpn-routers/vpn-agents operations exist only in the OVN profile (extension alias vpn-agent-scheduler, neutron_vpnaas/services/vpn/ovn_plugin.py:70). Routes also generates GET/PUT …/vpn-routers/{id}, POST …/vpn-agents and GET/PUT/DELETE …/vpn-agents/{id}, but the controllers have no such methods: these six routes answer 500 (observed, sched-show-unimplemented, sched-post-vpn-agents-unimplemented).
Common Neutron behaviours apply to every collection: fields= selection, filters on every is_filter attribute, sorting on is_sort_key attributes, pagination (limit, marker), bulk create (emulated, native_bulk = false in the route table), and the response wraps the resource under its singular or plural name. Responses carry both project_id and the legacy tenant_id.
Effective attribute validation (observed)
Four extensions touch the IKE/IPsec policy attributes: vpnaas (base lists, neutron-lib:neutron_lib/api/definitions/vpn.py:71), vpn-aes-ccm-gcm (shim, no attributes, neutron-lib:neutron_lib/api/definitions/vpn_aes_ccm_gcm.py:27), vpn-aes-ctr (adds aes-128-ctr, aes-192-ctr, aes-256-ctr, neutron-lib:neutron_lib/api/definitions/vpn_aes_ctr.py:38) and vpn-no-sha1-3des (removes sha1 and 3des, defaults auth_algorithm to sha256, neutron-lib:neutron_lib/api/definitions/vpn_no_sha1_3des.py:33-89).
Neutron merges extended attributes by replacing the whole attribute definition, in extension processing order (neutron:neutron/api/extensions.py:353-364); extensions are loaded from their directory in sorted file-name order (neutron:neutron/api/extensions.py:447), so vpn_no_sha1_3des is applied after vpn_aes_ctr. Its VPN_SUPPORTED_ENCRYPTION_ALGORITHMS_V2 is computed from the base list, which has no CTR values (neutron-lib:neutron_lib/api/definitions/vpn_no_sha1_3des.py:38-41). Result, observed on both profiles: POST /vpn/ikepolicies {"encryption_algorithm": "aes-128-ctr"} → 400 Invalid input for encryption_algorithm. Reason: aes-128-ctr is not in valid_values. although vpn-aes-ctr is advertised in GET /v2.0/extensions and the database enum accepts CTR (neutron_vpnaas/db/vpn/vpn_models.py:32-41). See 00 § Points of attention.
The effective lists (from the live route table) are therefore:
| Attribute | Accepted values | Default |
|---|---|---|
auth_algorithm (IKE, IPsec) | sha256, sha384, sha512, aes-xcbc, aes-cmac | sha256 |
encryption_algorithm (IKE, IPsec) | aes-128, aes-192, aes-256, aes-{128,192,256}-ccm-{8,12,16}, aes-{128,192,256}-gcm-{8,12,16} | aes-128 |
pfs | group2, group5, group14–group31 | group5 |
ike_version | v1, v2 | v1 |
phase1_negotiation_mode | main, aggressive | main |
transform_protocol | esp, ah, ah-esp | esp |
encapsulation_mode | tunnel, transport | tunnel |
lifetime | {"units": "seconds", "value": >= 60} | {seconds, 3600} (applied in neutron_vpnaas/db/vpn/vpn_db.py:322-323) |
initiator | bi-directional, response-only | bi-directional |
dpd.action | clear, disabled, hold, restart, restart-by-peer | hold (neutron_vpnaas/db/vpn/vpn_validator.py:229-231) |
endpoint group type | cidr, network, router, subnet, vlan | — (required) |
IKE policies — /vpn/ikepolicies
Functional description
- Purpose: define the phase-1 (IKE) parameters a site-to-site connection negotiates: authentication hash, encryption, DH group (PFS), IKE version, phase-1 mode and SA lifetime.
- Actor: project member (
create_ikepolicy…get_ikepolicy,neutron_vpnaas/policies/ike_policy.py:22-90). A reader cannot see the policies (theget_*rules requiremember), and other projects get 404. - Preconditions: none; the policy is a pure database object.
- Business rules:
- values must be in the lists above (attribute map validation, 400);
lifetime.unitsdefaults toseconds,lifetime.valueto3600when absent (neutron_vpnaas/db/vpn/vpn_db.py:321-323);- an update or a delete is refused while any IPsec site connection uses the policy — even a rename (
IKEPolicyInUse, 409,neutron_vpnaas/db/vpn/vpn_db.py:349-351,neutron_vpnaas/db/vpn/vpn_db.py:366-368); - the driver validators accept every IKE policy (
neutron_vpnaas/db/vpn/vpn_validator.py:357-363).
- Flow: synchronous only — validation, one INSERT/UPDATE/DELETE on
ikepolicies, response. No agent is notified: a policy only reaches an agent through a connection. - Outcome: immediate; no status attribute.
- Limitations: there is no way to change a policy in use; create a new one and update each connection… which the API does not allow either (
ikepolicy_idis not updatable,neutron-lib:neutron_lib/api/definitions/vpn.py:467-473). In practice the connection must be recreated.
Technical sheet
- Request (
POST):{"ikepolicy": {"name", "description", "auth_algorithm", "encryption_algorithm", "phase1_negotiation_mode", "lifetime": {"units", "value"}, "ike_version", "pfs", "project_id"}}, all optional. Unknown attributes → 400Unrecognized attribute(s) 'foo'. - Response:
{"ikepolicy": {"id", "project_id", "tenant_id", "name", "description", "auth_algorithm", "encryption_algorithm", "phase1_negotiation_mode", "lifetime": {"units", "value"}, "ike_version", "pfs"}}(neutron_vpnaas/db/vpn/vpn_db.py:300-316). - Errors: 400 (validation), 403 (policy), 404
IKEPolicyNotFound, 409IKEPolicyInUse— see errors. - Effects: table
ikepoliciesonly. - Spec/code mismatches: the api-ref still shows
sha1and3desin samples and lists (neutron-lib:api-ref/source/v2/vpnaas.inc:62); the code rejects them sincevpn-no-sha1-3des. The api-ref documents no 403 for PUT/DELETE and no 409 for PUT; both happen.
IPsec policies — /vpn/ipsecpolicies
Same model as IKE policies (neutron_vpnaas/db/vpn/vpn_db.py:385-469), with transform_protocol and encapsulation_mode instead of phase1_negotiation_mode and ike_version.
- Business rules specific to IPsec policies:
ah-espis accepted at policy creation, because the reference validator accepts every IPsec policy (neutron_vpnaas/db/vpn/vpn_validator.py:297-303);- it is refused later, when a connection uses it, by the legacy driver only:
IpsecVpnValidatorraisesIpsecValidationFailure(400) forah-esp(neutron_vpnaas/services/vpn/service_drivers/ipsec_validator.py:30-50). The OVN driver uses the baseVpnDriverValidator, which validates nothing (neutron_vpnaas/services/vpn/service_drivers/__init__.py:31-35,neutron_vpnaas/services/vpn/service_drivers/driver_validator.py:17-29): on OVN the connection is created (observedconn-create-ah-esp: 400 legacy, 201 OVN) and strongSwan receives a configuration it cannot express (the template only knowsahand ESP,neutron_vpnaas/services/vpn/device_drivers/template/strongswan/ipsec.conf.template:29-35) [inferred: tunnel never comes up]; - update/delete refused while in use (
IPsecPolicyInUse, 409,neutron_vpnaas/db/vpn/vpn_db.py:433-436,neutron_vpnaas/db/vpn/vpn_db.py:451-454).
Endpoint groups — /vpn/endpoint-groups
Functional description
- Purpose: name a set of local subnets (
type: subnet) or remote CIDRs (type: cidr) that a connection protects. Endpoint groups are the modern way to describe traffic selectors; the legacy way isvpnservice.subnet_id+connection.peer_cidrs. - Actor: project member (
neutron_vpnaas/policies/endpoint_group.py:22-90). - Business rules (
neutron_vpnaas/db/vpn/vpn_validator.py:328-355, only at creation):- at least one endpoint (
MissingEndpointForEndpointGroup, 400; its message embeds the whole request body, observed); - no duplicates (
InvalidEndpointInEndpointGroup … Endpoints have to be unique, 400); cidr: each endpoint must be a valid CIDR (… Invalid CIDR, 400);subnet: each endpoint must be a UUID (… Invalid UUID) of an existing subnet visible to the caller (NonExistingSubnetInEndpointGroup, 400);vlan,network,router: no validation at all (observed:{"type": "vlan", "endpoints": ["not-a-vlan"]}→ 201); no driver uses them;typeandendpointsare read-only after creation (allow_put: false,neutron-lib:neutron_lib/api/definitions/vpn_endpoint_groups.py:77-90) → 400Cannot update read-only attribute endpoints;- delete refused while a connection references the group as local or peer group (
EndpointGroupInUse, 400 and not 409,neutron_vpnaas/db/vpn/vpn_db.py:685-693).
- at least one endpoint (
- Effects visible elsewhere: once a subnet is in a
subnetgroup, Neutron refuses to delete that subnet (subnet_callback,neutron_vpnaas/db/vpn/vpn_db.py:793-799; observed 409SubnetInUse … Callback neutron_vpnaas.db.vpn.vpn_db.subnet_callback-<n> failed with "Subnet … is used by endpoint group …"). - Tables:
vpn_endpoint_groups,vpn_endpoints.
VPN services — /vpn/vpnservices
POST /vpn/vpnservices
Functional description
- Purpose: attach a VPN capability to one of the project's routers. The service is the container of the site connections; it fixes the local tunnel endpoint (the router's external IP).
- Actor: project member (
create_vpnservice). - Preconditions:
- the router exists, is visible to the caller and has an external gateway (
RouterIsNotExternal, 400,neutron_vpnaas/db/vpn/vpn_validator.py:275-278); - if the legacy
subnet_idis given, that subnet must be attached to the router (SubnetIsNotConnectedToRouter, 400,neutron_vpnaas/db/vpn/vpn_validator.py:280-289); - if
flavor_idis given: the flavors plugin is loaded, the flavor exists, hasservice_typeVPN, is enabled, and its first provider is a configured driver (neutron_vpnaas/services/vpn/plugin.py:129-150; observed 404FlavorNotFoundfor an unknown flavor); - OVN only: a live VPN agent exists to host the router (see the flow).
- the router exists, is visible to the caller and has an external gateway (
- Business rules:
subnet_idis optional (uuid_or_none); when it is set, all connections of the service must usepeer_cidrs; when it is not, they must use endpoint groups (enforced at connection time, see below);- several services may exist on the same router (no uniqueness check; observed: two services on the same router);
- the provider (driver) is chosen from the flavor or the default provider, and recorded in Neutron's
providerresourceassociations(neutron_vpnaas/services/vpn/plugin.py:205-209).
- Flow — legacy profile:
- (sync) policy and attribute validation;
validate_vpnservice(neutron_vpnaas/db/vpn/vpn_db.py:485-502); INSERTvpnserviceswith statusPENDING_CREATE. - (sync) provider association (
neutron_vpnaas/services/vpn/plugin.py:208). - (sync) the driver stores the router gateway's first IPv4 and first IPv6 address in
external_v4_ip/external_v6_ip(neutron_vpnaas/services/vpn/service_drivers/base_ipsec.py:168-183). - No RPC is sent (observed): an L3 agent only receives services that have at least one connection (
neutron_vpnaas/services/vpn/service_drivers/base_ipsec.py:60-61).
- (sync) policy and attribute validation;
- Flow — OVN profile (
neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:373-435):- (sync) steps 1–2 above.
- (sync) schedule the router to a VPN agent (
schedule_router,neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:375): reuse the agent already hosting the router, else choose among liveVPN Agents with the configured scheduler (defaultLeastRoutersScheduler,neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py:39-41), INSERTroutervpnagentbindings. No live agent →RouterReschedulingFailed(409). - (sync) ensure one VPN gateway row per router (
vpn_ext_gws, statusPENDING_CREATE), retried while another request is creating it (RetryRequest(VPNGatewayNotReady),neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:350-370); a gateway inERROR→VPNGatewayInError(409). - (sync, first service of the router only) create, as admin with an empty project:
- the VPN gateway port
vpn-gw-<router_id>on the router's external network,device_owner network:vpn_router_gateway, bound to the agent host (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:258-273); - the transit network
vpn-transit-network-<router_id>and subnetvpn-transit-subnet-<router_id>169.254.0.0/28(gateway169.254.0.1, owned by the user's project), attached to the router as an interface, and the namespace portvpn-ns-<router_id>with169.254.0.2,device_owner network:vpn_namespace(neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:169-213); - then set the gateway
ACTIVEwith the created IDs (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:403-405). Any failure sets the gatewayERRORand re-raises.
- the VPN gateway port
- (sync) store
external_v4_ip/external_v6_ipfrom the VPN gateway port, not from the router gateway (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:323-343): on OVN the tunnel uses a dedicated public IP (observed: router gateway172.24.4.118,external_v4_ip172.24.4.16). - On any failure of steps 2–5, the service is set to
ERRORand the error is re-raised (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:425-434).
- Outcome:
201withstatus: PENDING_CREATE. The status becomesACTIVEorDOWNonly after a connection exists and the agent reports (see async processing §update_status). A service without connection staysPENDING_CREATEforever, and cannot be updated meanwhile (see PUT). - Effects visible elsewhere (OVN): new ports visible to admins in
GET /v2.0/ports; a new router interface on169.254.0.0/28and a new network owned by an empty project; the router is now bound to a VPN agent (GET /v2.0/routers/{id}/vpn-agents). - Limitations:
- OVN: a failed create leaves a row behind. The 409
RouterReschedulingFailedis returned, but the service was already committed withERROR(observedvpns-create-no-vpn-agentthenvpns-list-after-no-agent). The client must delete it. - OVN error path calls a missing attribute when the service is still pending and
updated_pending_statusis false:set_vpnservice_statusraisesvpnaas.VPNStateInvalidToUpdate, which does not exist inneutron_vpnaas.extensions.vpnaas(neutron_vpnaas/db/vpn/vpn_db.py:520-523) →AttributeError→ 500 [inferred: the only caller passesupdated_pending_status=True,neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:431-433, so the branch is unreachable today]. - The
no VPN agentexceptionNoVPNAgentAvailable(503,neutron_vpnaas/extensions/vpnaas.py:46-47) is only raised when the scheduler returnsNone(concurrent scheduling,neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:376-377); the usual "no agent" case is the 409 above.
- OVN: a failed create leaves a row behind. The 409
Technical sheet
- Request:
{"vpnservice": {"router_id" (required), "subnet_id" (null), "name", "description", "admin_state_up" (true), "flavor_id" (null), "project_id"}}. - Response:
{"vpnservice": {"id", "name", "description", "project_id", "tenant_id", "subnet_id", "router_id", "flavor_id", "admin_state_up", "external_v4_ip", "external_v6_ip", "status"}}(neutron_vpnaas/db/vpn/vpn_db.py:471-483). - Effects:
vpnservices,providerresourceassociations; OVN:routervpnagentbindings,vpn_ext_gws, Neutron ports, networks, subnets, router interfaces — see interactions.
PUT /vpn/vpnservices/{id}
- Purpose: rename, describe, or enable/disable (
admin_state_up) the service.router_id,subnet_id,flavor_idare immutable. - Rules: refused while the service is in a
PENDING_*status (VPNStateInvalidToUpdate, 400,neutron_vpnaas/db/vpn/vpn_db.py:88-92,neutron_vpnaas/db/vpn/vpn_db.py:532). Because a service without connection never leavesPENDING_CREATE, a fresh service cannot be renamed until an agent has reported it (observedvpns-update-pending). - Flow: UPDATE
vpnservices(status unchanged), thencast vpnservice_updatedto the agent(s) hosting the router (neutron_vpnaas/services/vpn/service_drivers/base_ipsec.py:185-186). Disabling the service makes the agent stop the IPsec daemon at its next sync (neutron_vpnaas/services/vpn/device_drivers/ipsec.py:315-327).
DELETE /vpn/vpnservices/{id}
- Rules: refused while connections exist (
VPNServiceInUse, 409,neutron_vpnaas/db/vpn/vpn_db.py:537-546). - Flow: DELETE
vpnservices, remove the provider association,cast vpnservice_updated(neutron_vpnaas/services/vpn/plugin.py:222-228). OVN: if no other service remains on the router, the VPN gateway is setPENDING_DELETE, its gateway port, namespace port, transit router interface, transit subnet and network are deleted, then thevpn_ext_gwsrow (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:407-443; observedovn-ports-after-delete→ no port left). A failure sets the gatewayERRORand re-raises (500) after the service row is gone [inferred]. - Note: the router stays bound to its VPN agent (
routervpnagentbindingsis not cleaned) [inferred from the absence of an unbind call inneutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:437-443].
IPsec site connections — /vpn/ipsec-site-connections
POST /vpn/ipsec-site-connections
Functional description
- Purpose: establish a site-to-site IPsec tunnel between the project's VPN service and a remote gateway, with a pre-shared key.
- Actor: project member (
create_ipsec_site_connection,neutron_vpnaas/policies/ipsec_site_connection.py:23). - Preconditions: the VPN service, IKE policy and IPsec policy exist and are visible to the caller (404 otherwise, checked in that order,
neutron_vpnaas/db/vpn/vpn_db.py:159-164); referenced endpoint groups exist; the router's external network has a subnet of the peer's IP version. - Business rules, in evaluation order:
- driver validation first, before any database read: legacy rejects an
ah-espIPsec policy (neutron_vpnaas/services/vpn/plugin.py:169-171,neutron_vpnaas/services/vpn/service_drivers/ipsec_validator.py:46-50); - defaults:
dpd→{hold, 30, 120}(neutron_vpnaas/db/vpn/vpn_validator.py:206-238);mtu1500,initiatorbi-directional,local_id'' (attribute map); - mode consistency (
neutron_vpnaas/db/vpn/vpn_validator.py:166-204):- service with
subnet_id→peer_cidrsrequired (MissingPeerCidrs) and no endpoint group allowed (InvalidEndpointGroup); - service without
subnet_id→ bothlocal_ep_group_idandpeer_ep_group_idrequired (MissingRequiredEndpointGroup) and nopeer_cidrs(PeerCidrsInvalid);
- service with
- endpoint-group mode: the local group must be of type
subnetand the peer group of typecidr(WrongEndpointGroupType); every local subnet must be attached to the service's router (SubnetIsNotConnectedToRouter); all local subnets share one IP version, all peer CIDRs share one IP version (MixedIPVersionsForIPSecEndpoints) (neutron_vpnaas/db/vpn/vpn_validator.py:252-262); - legacy mode: each peer CIDR is valid (
IPsecSiteConnectionPeerCidrError) and all share one IP version (MixedIPVersionsForPeerCidrs) (neutron_vpnaas/db/vpn/vpn_validator.py:263-266); - local and peer IP versions are equal (
MixedIPVersionsForIPSecConnection); dpd.timeout>dpd.interval(IPsecSiteConnectionDpdIntervalValueError);mtu≥ 68 for IPv4, ≥ 1280 for IPv6 (IPsecSiteConnectionMtuError,neutron_vpnaas/db/vpn/vpn_validator.py:38);peer_address: an IP, or an FQDN resolved synchronously by the API server withgetaddrinfo; the first address replaces the FQDN in the database (VPNPeerAddressNotResolved,neutron_vpnaas/db/vpn/vpn_validator.py:82-96);- the router's gateway port must have a fixed IP of the peer's IP version (
ExternalNetworkHasNoSubnet,neutron_vpnaas/db/vpn/vpn_validator.py:67-80). route_modeis forced tostaticandauth_modetopsk(neutron_vpnaas/db/vpn/vpn_db.py:180-182).
- driver validation first, before any database read: legacy rejects an
- Flow:
- (sync) validations above, INSERT
ipsec_site_connections(PENDING_CREATE) and oneipsecpeercidrsrow per legacy peer CIDR (neutron_vpnaas/db/vpn/vpn_db.py:153-203). - (sync, OVN only) recompute the router's extra routes: every peer CIDR of every connection on the router → next hop
169.254.0.2(the VPN namespace); add the missing ones, remove stale ones (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:297-321, observedovn-router-routes). - (sync)
cast vpnservice_updated {router: {id}}on topicipsec_agent, server = each live agent hosting the router (legacy: L3 agents,neutron_vpnaas/services/vpn/service_drivers/__init__.py:82-104; OVN: VPN agents,neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:477-500). - (async) the agent pulls the full configuration (
get_vpn_services_on_host), renders strongSwan/Libreswan files, starts or reloads the daemon in the router namespace (qrouter-<id>, orqvpn-<id>on OVN), then reports statuses withupdate_status. See async processing.
- (sync) validations above, INSERT
- Outcome:
201withstatus: PENDING_CREATE, thenACTIVE(tunnel established) orDOWNafter the first report. The client pollsGET. - Effects visible elsewhere: OVN router extra routes (visible in
GET /v2.0/routers/{id}); the subnets of the local group become undetachable from the router (SubnetInUseByIPsecSiteConnection,neutron_vpnaas/db/vpn/vpn_db.py:587-608); NAT-exemption rules in the router namespace (legacy). - Limitations:
- the PSK is stored and returned in clear in every
GET(is_visible: true,neutron-lib:neutron_lib/api/definitions/vpn.py:425-431;neutron_vpnaas/db/vpn/vpn_db.py:106) and sent in RPC payloads; - FQDN resolution happens once, on the API server, and the resolved IP replaces the FQDN in the database: a later DNS change is ignored (the Libreswan agent has a resolver,
_get_nexthop, but nothing calls it:neutron_vpnaas/services/vpn/device_drivers/libreswan_ipsec.py:267-281); - IPv6 peer CIDRs longer than 32 characters do not fit in
ipsecpeercidrs.cidr VARCHAR(32)(neutron_vpnaas/db/vpn/vpn_models.py:54) [inferred: DB error → 500 on MySQL strict mode]; endpoint groups (VARCHAR(255)) do not have this limit.
- the PSK is stored and returned in clear in every
Technical sheet
- Request:
{"ipsec_site_connection": {"vpnservice_id", "ikepolicy_id", "ipsecpolicy_id", "peer_address", "peer_id", "psk"(all required), "name", "description", "local_id", "peer_cidrs", "local_ep_group_id", "peer_ep_group_id", "mtu", "initiator", "dpd": {"action", "interval", "timeout"}, "admin_state_up", "project_id"}}. - Response: see
neutron_vpnaas/db/vpn/vpn_db.py:94-124:id, project_id, tenant_id, name, description, peer_address, peer_id, local_id, route_mode, mtu, auth_mode, psk, initiator, dpd{action, interval, timeout}, admin_state_up, status, vpnservice_id, ikepolicy_id, ipsecpolicy_id, peer_cidrs[], local_ep_group_id, peer_ep_group_id.
PUT /vpn/ipsec-site-connections/{id}
- Purpose: change tunnel parameters (peer, PSK, MTU, DPD, initiator, endpoint groups or peer CIDRs, admin state).
- Rules: the same validations run with the previous values as defaults (
neutron_vpnaas/db/vpn/vpn_db.py:217-225); refused whilePENDING_*(VPNStateInvalidToUpdate, 400,neutron_vpnaas/db/vpn/vpn_db.py:226) — note that the check comes after the validations, so an invalid body on a pending connection returns the validation error. The update does not change the status (noPENDING_UPDATE; observedconn-update: stillACTIVE).peer_cidrsare replaced as a set (neutron_vpnaas/db/vpn/vpn_db.py:228-244). - Flow: legacy driver validation (
neutron_vpnaas/services/vpn/plugin.py:193-195), UPDATE, OVN extra-route recomputation,cast vpnservice_updated.
DELETE /vpn/ipsec-site-connections/{id}
- No status check: a pending connection can be deleted. DELETE row (peer CIDRs cascade), OVN extra-route recomputation (routes removed),
cast vpnservice_updated(neutron_vpnaas/services/vpn/plugin.py:177-184). The agent's next sync stops or reconfigures the daemon.
VPN agent scheduler (OVN only) — /agents/{agent_id}/vpn-routers, /routers/{router_id}/vpn-agents
Functional description
- Purpose: let an administrator see and change which
neutron-ovn-vpn-agenthosts the VPN services of a router (one agent per router). - Actor: administrator. The controllers enforce
get_vpn-routers,create_vpn-router,delete_vpn-router,get_vpn-agentswith an empty target (neutron_vpnaas/extensions/vpn_agentschedulers.py:52-54,neutron_vpnaas/extensions/vpn_agentschedulers.py:93-95), and neutron-vpnaas registers none of these rules (neutron_vpnaas/policies/__init__.py:22-29), so Neutron'sdefaultrule (rule:admin_or_owner) applies. For a non-admin, the owner check cannot be evaluated on an empty target and oslo.policy raises → **500PolicyCheckError** (observedsched-list-routers-non-admin) instead of 403. - Business rules:
POST {"router_id"}: the agent must be of typeVPN Agent(InvalidVPNAgent, 404,neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py:132-139); a router already hosted by another agent →RouterHostedByVPNAgent(409); hosted by the same agent → no-op 201 with an empty body (observedsched-add-router-again); a binding failure →RouterSchedulingFailed(409) (neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py:187-214);- after binding: **RPC
call vpnservice_added_to_agent** to the new agent (2 attempts, blocking the API thread up to2 × rpc_response_timeout,neutron_vpnaas/api/rpc/agentnotifiers/vpn_rpc_agent_api.py:21-52), then the VPN gateway and namespace ports are rebound to the new host (neutron_vpnaas/services/vpn/ovn_plugin.py:78-84,neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:463-473); DELETE: removes the binding andcast vpnservice_removed_from_agent(neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py:216-230). The router then stays unhosted until the nextget_vpn_services_on_hostof any agent withvpn_auto_schedule = true(default), which re-schedules it (neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py:99-103);- an AMQP
notifyeventvpn_agent.router.add/vpn_agent.router.remove(publisherrouter) is emitted (neutron_vpnaas/extensions/vpn_agentschedulers.py:187-190).
- Response bodies:
GET …/vpn-routers→{"routers": [<router>]};GET …/vpn-agents→{"agents": [<agent>]}where the agent dict is Neutron's (on ML2/OVN it comes from theAgentCache,neutron:neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py:75-93).
Lifecycle: ipsec_site_connection
stateDiagram-v2 [*] --> PENDING_CREATE: POST (API) PENDING_CREATE --> ACTIVE: update_status (agent RPC, SA established) PENDING_CREATE --> DOWN: update_status (agent RPC, routed/connecting) ACTIVE --> DOWN: report_status (agent timer, tunnel lost / admin down / daemon stopped) DOWN --> ACTIVE: report_status (agent timer) ACTIVE --> ACTIVE: PUT (API, status unchanged) DOWN --> DOWN: PUT (API, status unchanged) ACTIVE --> [*]: DELETE (API) DOWN --> [*]: DELETE (API) PENDING_CREATE --> [*]: DELETE (API)
PENDING_UPDATE and PENDING_DELETE are never set by neutron-vpnaas for connections; the server only writes PENDING_CREATE (neutron_vpnaas/db/vpn/vpn_db.py:189) and the agent's statuses (neutron_vpnaas/db/vpn/vpn_db.py:285-298). While a connection is pending, the server accepts the agent's status only if the agent flags updated_pending_status (neutron_vpnaas/db/vpn/vpn_db.py:297).
Lifecycle: vpnservice
stateDiagram-v2 [*] --> PENDING_CREATE: POST (API) [*] --> ERROR: POST failed in the OVN driver (row kept, 409/500 returned) PENDING_CREATE --> ACTIVE: update_status (agent: daemon running with >= 1 connection status) PENDING_CREATE --> DOWN: update_status (agent) ACTIVE --> DOWN: report_status (agent timer) DOWN --> ACTIVE: report_status (agent timer) ACTIVE --> [*]: DELETE (API, no connection left) DOWN --> [*]: DELETE ERROR --> [*]: DELETE
A service is reported ACTIVE when its daemon answers ipsec status with at least one connection line, DOWN otherwise (neutron_vpnaas/services/vpn/device_drivers/ipsec.py:296-313).
Lifecycle: VPN gateway (vpn_ext_gws, OVN only, internal)
stateDiagram-v2 [*] --> PENDING_CREATE: first POST vpnservice on the router PENDING_CREATE --> ACTIVE: gateway port + transit network created PENDING_CREATE --> ERROR: port/network creation failed ACTIVE --> PENDING_DELETE: last vpnservice of the router deleted PENDING_DELETE --> [*]: ports, transit network and row deleted PENDING_DELETE --> ERROR: cleanup failed
A gateway in ERROR blocks every new VPN service on that router (VPNGatewayInError, 409) until an operator fixes the database: no API exposes or deletes it.
Source: reverse-spec-neutron-vpnaas/01-rest-api_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.