The Gateway trusts the WireGuard drone plane (10.71.0.0/16) as an identity boundary. Two independent mechanisms hang off that trust, and this page covers both:

Inbound: check_vpn_ip

A route decorator that authenticates drone-originated callbacks by source IP. No JWT, no per-device token — if the request arrives from a 10.71.* address (or a whitelisted X-Drone-IP), it is trusted as that drone.

Outbound: jumphost routing

The rosbridge Connection multiplexes many drones through a single nginx jumphost using x-drone-ip / x-drone-port headers, selected by JUMPHOST_IP.
Both live in the “trust the network” security model. That is deliberate and load-bearing — but it means the Gateway must never be reachable from outside the VPN/jumphost. See Authentication & Security Model for how this sits alongside the JWT (UI) and token/signature (activation, video, Stripe) auth models.

Inbound: the check_vpn_ip middleware

check_vpn_ip is a plain decorator (not Flask-JWT) defined in src/middleware/drone_vpn.py. It resolves the caller’s drone IP into request.vpn_ip and lets the handler through, or rejects with 400. The wrapped handler then identifies the drone with generic_drone_service.get_drone_by_ip(request.vpn_ip) (src/service/drone_service.py:116 — a simple Drone.ip == vpn_ip lookup).

IP resolution order

The decorator checks four sources in order and takes the first that matches (src/middleware/drone_vpn.py:32): If none match, the request is rejected:
The trusted prefix "10.71." is a hard-coded string literal in drone_vpn.py, not derived from the DRONE_NETWORK_CIDR env var (default 10.71.0.0/16). Changing DRONE_NETWORK_CIDR re-homes IP allocation in IPService but does not change what the middleware trusts. Keep the two in sync by hand, or a subnet change will silently 400 every drone callback.

The X-Drone-IP whitelist

When no 10.71.* source is present, the header X-Drone-IP is accepted only for the prefixes in _ALLOWED_LOCAL_IP_PREFIXES (src/middleware/drone_vpn.py:12):
src/middleware/drone_vpn.py
Two distinct gates apply to that header:
  • SKYHUB_SITL_* container names are trusted whenever ENABLE_SITL is true — including on non-local / production deployments (drone_vpn.py:56). This is how remote SITL drones, which have no 10.71.* VPN address, authenticate their callbacks. For SITL the “IP” column of the drones row literally stores the container name, so get_drone_by_ip still resolves.
  • All other local prefixes (127., 172.1x., 192.168., 10.223.) are trusted only when IS_LOCAL_ENVIRONMENT (i.e. DEPLOYMENT_ENVIRONMENT=local).

Routes protected by check_vpn_ip

These are the only endpoints with no JWT — the VPN source IP is their entire authentication. All are drone/agent → Gateway callbacks:
GET /api/v1/drone/activate is not in this list. Activation uses a separate 10-digit token header (bootstrap, before the drone is on the VPN); only the later self-update /drone/pull uses check_vpn_ip. See Drone Management & Control Actions and Executions, Assets & Reports for the full request/response shapes.

The spoofing caveat

check_vpn_ip is device authentication by network trust, not cryptographic identity. Any request whose remote_addr, X-Real-IP, or X-Forwarded-For begins with 10.71. is trusted as that drone, and any X-Drone-IP: SKYHUB_SITL_* is trusted whenever SITL is enabled. If the Gateway is ever exposed outside the VPN/jumphost, an attacker who can set those headers (or spoof a source IP behind a misconfigured proxy) can impersonate an arbitrary drone — pull its AWS credentials, upload assets against it, or forge executions. The security of every callback rests on the assumption that only the jumphost and the WireGuard plane can reach these routes.
Preserve these invariants when refactoring:
  • The Gateway must sit behind the jumphost / inside the VPC; in production the API is reached via nginx on the single WireGuard EC2 (TLS-terminated, port 5000 -> gateway API), never over a public ALB (no ALB/ELB exists in prod).
  • Only the jumphost nginx should be allowed to set X-Real-IP / X-Forwarded-For; do not let clients inject them.
  • The drone network isolation itself (per-user iptables from user_drone_access) is enforced by the User VPN service, not by this middleware.
Minor footgun to leave in place: the middleware package file is misnamed ___init__.py (three leading underscores) in src/middleware/, so it is not a real package __init__. Imports work only because drone_vpn is imported by explicit module path (from src.middleware.drone_vpn import check_vpn_ip).

Outbound: jumphost routing for rosbridge

Command dispatch and telemetry travel the other direction — Gateway → drone rosbridge (WebSocket, port 9090). A single nginx jumphost fronts every drone’s rosbridge and demultiplexes based on two request headers the Gateway injects. This lets one public/edge host serve an entire fleet without a per-drone port map. The connection object is built per drone in src/rosbridge/connection.py; see Rosbridge Connection & Reconnect for the socket internals and Connection Pool & Startup Wiring for pooling.

Direct vs jumphost selection

The mode is chosen by one line — an empty JUMPHOST_IP means direct (src/rosbridge/connection.py:27):
src/rosbridge/connection.py
In jumphost mode, the socket carries the target as headers so nginx knows where to proxy (connection.py:159):
src/rosbridge/connection.py

The HTTP reachability probe

Before opening the WebSocket, start_connection() runs _test_connection() (connection.py:202): an HTTP GET against the same target with a DRONE_REACHABILITY_TIMEOUT-second connect/read timeout. Rosbridge answers non-WebSocket requests with HTTP 400, which the probe treats as “alive and reachable.” Any other status re-raises.
The probe must send the same x-drone-ip / x-drone-port headers as the WebSocket when in jumphost mode — and it does (connection.py:218). If a refactor changes one path’s headers but not the other, the probe and the actual socket can target different drones, producing false “reachable” results or phantom failures.

SITL IP translation

The drone_ip passed into Connection is not always drone.ip. DroneControlService.resolve_endpoint rewrites it for SITL drones (src/service/drone_control_service.py:662), because their ip column usually holds a container name: resolve_endpoint also returns force_direct, which bypasses the jumphost for the Fargate, EC2 and local cases — the gateway already shares a network with those vehicles. So a physical drone is reached at its WireGuard IP, while remote SITL containers are reached on the on-prem office server (<office-docker-host>) — but from the jumphost/nginx perspective both are just an x-drone-ip:x-drone-port pair. That interchangeability is what lets SITL and physical drones share the exact same rosbridge/topic contract. See SITL Drone Lifecycle.

Configuration

Only the VPN/jumphost-relevant vars are shown here; the full catalogue lives in Gateway Environment Variables.
In production, jumphost-private.skyhub-prod.internal resolves to <jumphost-private-ip> inside the AWS VPC. The public side of the same box (the single t4g.nano WireGuard EC2 instance, public <prod-ingress-ip>) is also the WireGuard server and TLS ingress — making it the platform’s architectural keystone and primary single point of failure. Network layout is documented in Network & VPN Topology and VPC, WireGuard Jumphost & nginx Routing.

Setting up a jumphost deployment

1

Point rosbridge through the jumphost

Set JUMPHOST_IP to the private jumphost DNS/IP and JUMPHOST_PORT to the port nginx listens on for rosbridge (9090). Leaving JUMPHOST_IP empty makes every Connection dial the drone IP directly — correct only when the Gateway shares the drone’s L3 network.
2

Configure nginx to route by header

The jumphost nginx must read x-drone-ip and x-drone-port from the upgrade request and proxy_pass the WebSocket to http://$http_x_drone_ip:$http_x_drone_port. These are the exact header names emitted by Connection (lower-case).
3

Wire the drone-callback path back

Ensure drone callbacks arrive with a 10.71.* source, or the jumphost sets X-Real-IP to the drone’s WireGuard address. Confirm clients cannot forge X-Real-IP / X-Forwarded-For upstream of nginx.
4

Match the CIDR literal

If you use a drone subnet other than 10.71.0.0/16, update both DRONE_NETWORK_CIDR and the hard-coded "10.71." prefix in src/middleware/drone_vpn.py.

Debugging 400 IP not authenticated

The request did not arrive with a 10.71.* source. Check that the drone is actually on the WireGuard plane, and that the jumphost preserves the drone IP in remote_addr / X-Real-IP / X-Forwarded-For. If nginx terminates the connection, its X-Real-IP must be the drone’s 10.71.* address, not the jumphost’s own IP. The middleware logs which source it picked (Picked X-Real-IP: ...) or No valid VPN IP detected.
SITL uses X-Drone-IP: SKYHUB_SITL_<n>, which is accepted only when ENABLE_SITL=true. Verify the header value starts exactly with SKYHUB_SITL_ and matches the ip stored on the drones row (that is what get_drone_by_ip looks up).
Non-SITL X-Drone-IP prefixes (127., 172.17-20., 192.168., 10.223.) are trusted only when DEPLOYMENT_ENVIRONMENT=local. On a server deployment these are refused by design — do not “fix” this by widening the whitelist in production.
check_vpn_ip passed (an IP was resolved) but get_drone_by_ip(request.vpn_ip) returned no row — the resolved IP/container name does not match any drones.ip. This surfaces as a downstream 404/500, not a 400.

Rosbridge Connection & Reconnect

The Connection / SmartSocket internals, keepalive and bounded reconnect.

HTTP API Overview & Auth Models

Where VPN-IP trust sits among JWT and token/signature auth.

User VPN & Network Isolation

The WireGuard planes and per-user iptables that make source-IP trust safe.

VPC, WireGuard Jumphost & nginx

How the single jumphost is provisioned and routes traffic in AWS.