skyhub_user_vpn) is the platform’s network-security plane. It is a small
Python/Flask daemon that runs WireGuard + iptables on a single host and continuously reconciles the
live WireGuard peer set against the shared PostgreSQL database. Its job is threefold:
- Give every Dashboard operator and every physical drone its own WireGuard tunnel with a stable private IP.
- Enforce per-user isolation — an operator’s traffic can only reach the drones they own.
- Publish generated client configs to S3 so the Gateway can hand them out on demand.
10.71.x address into something the Gateway can actually route a
rosbridge connection to. SITL drones do not use this service — they are Docker containers reached over
DOCKER_HOST_IP (see SITL Simulator).
The service runs on the single WireGuard EC2 instance (
skyhub-prod-skyhub-wireguard-server,
t4g.nano, private <jumphost-private-ip>, public <prod-ingress-ip>) — the same box that is the platform’s public
TLS ingress and jumphost. It cannot run on Fargate because it needs network_mode: host and
NET_ADMIN/NET_RAW to manage kernel WireGuard interfaces (skyhub_user_vpn/docker-compose.yml). The
prod image is skyhub-prod-vpn-image. See VPC, WireGuard Jumphost & nginx Routing.Three WireGuard planes on one host
The service models three independent WireGuard networks (“planes”), each with its own interface, UDP listen port, and/16 range. Defaults come from libs/config/settings.py and are set explicitly in
docker-compose.yml.
| Plane | Interface | UDP port | Range | Who connects | Manager class |
|---|---|---|---|---|---|
| core | wg0 | 51822 | 10.69.0.0/16 | On-prem hosts: Docker host (<office-docker-host>), SITL host (<office-host>), SigNoz | CoreVPN |
| user | users0 | 51823 | 10.70.0.0/16 | Dashboard operator laptops | UserVPN |
| drone | drones0 | 51824 | 10.71.0.0/16 | Physical drones (rosbridge on 10.71.x:9090+) | DroneVPN |
EntityVpnManager (libs/service/wireguard.py) that overrides only its
data_dir, config_dir, interface, port, range, and the SQL that lists its entities
(libs/service/core_vpn.py, customer_vpn.py, drone_vpn.py).
The drone plane is created with use_real_interface=True and MTU = 1350 (cellular-friendly); the user
plane uses the default MTU = 1500. Every generated client config routes all four ranges through the
tunnel — AllowedIPs = 10.69.0.0/16, 10.70.0.0/16, 10.71.0.0/16, 172.31.0.0/16 (bridged_ranges in
settings.py:44) — which is why a drone on drones0 can reach the Gateway and DB in the AWS VPC
(172.31.0.0/16), and a user on users0 can reach drones on 10.71.x. Peers also receive
DNS = 172.31.0.2 so they can resolve *.skyhub-prod.internal names.
The reconciliation loop and S3 config distribution
Each plane manager runs a backgroundtick() loop (wireguard.py:383) every DB_REFRESH_SECONDS
(default 10s). It never mutates the database — it treats the DB as the source of truth and makes the
WireGuard peer set match it.
Read desired state from the DB
UserVPN selects every user with a non-null ip; DroneVPN selects every non-SITL drone, mapping
drone.ip (a 10.71.x address stored on the row). SITL rows are excluded (WHERE d."type" != 'sitl').Diff against current peers
It computes
create_this = their_ips - our_ips and delete_this = our_ips - their_ips. On the very first
run it seeds vpn_entities from {data_dir}/mappings.json in S3 so a restart doesn’t churn every peer.Generate keys + client config per new entity
For each new IP,
_generate_vpn_config_for_entity mints a fresh keypair and pre-shared key and produces
two files: a server-side [Peer] block and a client-side access.conf (its own [Interface] + the
server [Peer] with Endpoint = EXTERNAL_IP:port and PersistentKeepalive = 25).monitor() thread polls wg show {iface} dump once per second to track per-peer rx/tx bytes,
latest handshake, and an is_active flag; this feeds the /drones_status endpoint.
S3 bucket layout (VPN_BUCKET)
The bucket is skyhub-{env}-user-vpn (prod: skyhub-prod-user-vpn). Per plane, data_dir/config_dir
are user_data/user, drone_data/drone, core_data/core.
| S3 key | Written by | Purpose |
|---|---|---|
{data_dir}/server_private_key, server_public_key | plane manager | Server WG keypair, persisted so keys survive restarts |
{data_dir}/{iface}.conf | plane manager | Rendered server interface config |
{data_dir}/mappings.json | plane manager | ip→entity snapshot used to seed the first run |
{config_dir}/{entity_id}/access.conf | plane manager | Client config the peer imports (the Gateway presigns this) |
{config_dir}/{entity_id}/peer.conf | plane manager | The [Peer] block merged into the server config |
user/{user_id}/access.conf or
drone/{drone_id}/access.conf from this same bucket (skyhub_gateway_service/src/service/vpn_service.py:21-43,
30s expiry) and returns the URL to the Dashboard or drone. The layout above is exactly what those keys
resolve to.
Per-user iptables isolation
TheIptablesManager (libs/service/iptables.py) is the security core. On startup _prepare() installs
MASQUERADE (NAT) rules so traffic can flow user↔drone and core↔drone across interfaces. Then, on every
change cycle, it enforces a default-deny posture on the user plane by appending, as the last rule:
ACCEPT rules inserted before that
REJECT. Those rules are derived from the database: for each (user_ip → drone_ip) pair the manager adds
an ACCEPT on the drone’s rosbridge port plus a fixed set of service ports.
| Port | Protocol | Use |
|---|---|---|
drone.port (e.g. 9090+n) | tcp | rosbridge (Gateway control + telemetry) |
8554 | udp, tcp | RTSP video |
14900 | udp | MAVLink (mavp2p udps:0.0.0.0:14900) |
22 | tcp | SSH |
FORWARD ACCEPT rule is precise — source {user_ip}/32, destination {drone_ip}/32, in-interface
users0, matched protocol/port (libs/models/rule.py:41). The loop diffs rules_in_db against
rules_in_runtime, applies the delta, deduplicates any stray duplicates, and re-asserts the trailing
REJECT. Net effect: a user’s WireGuard traffic reaches only the drones they own, and only on those
ports.
The :5050 status & firewall API
main.py starts a Flask app (dev server, or gunicorn/gevent in Dockerfile.ecr) bound to
0.0.0.0:5050 (HTTP_PORT, default 5050). Routes live in libs/routes/vpn_routes.py; most responses
are cached ~1s (the /version liveness route is cached 15 min). This is a read-only introspection surface — it never changes tunnels.
| Method | Path | Returns |
|---|---|---|
| GET | /status/user/<ip> | {ip, status} — 200 if the user IP is a known peer, else 404 |
| GET | /status/drone/<ip> | {ip, status} — same for the drone plane |
| GET | /firewall | Full per-user allow state (IptablesManager.current_state) |
| GET | /firewall/user/<ip> | One user’s current allow state |
| GET | /drones_status | Live wg stats (rx/tx, handshake, is_active) keyed by drone id — send {"ips": [...]} as a JSON body |
| GET | /version | {"version": "canned flesh"} liveness ping |
http://{VPN_SERVICE_IP}:{VPN_SERVICE_PORT} (vpn_service.py:45-77). For
example, the fleet page’s connectivity indicators come from POST-style /drones_status:
How 10.71.x drone addressing reaches the Gateway
A drone is only reachable once its tunnel is up. The Gateway gates activation on the VPN status API and
then routes rosbridge to the drone’s 10.71.x address through the jumphost.
Two Gateway-side pieces close the loop:
- Activation wait (
src/routes/drone_routes.py:1585): the activation handler loopswhile not get_vpn_service().status_drone_vpn(drone.ip)(≤15s) so it only hands back the drone’s VPN config + ECR credentials once the WireGuard handshake is confirmed. - VPN-source-IP trust (
src/middleware/drone_vpn.py): once on the plane, all drone→Gateway callbacks (/drone/pull, executions, asset upload) authenticate purely by source IP —check_vpn_ipaccepts the request only ifremote_addr/X-Real-IP/X-Forwarded-Forstarts with10.71., then resolves the drone viaget_drone_by_ip. This is one of the platform’s three coexisting auth models (JWT, VPN-IP trust, token/signature). See VPN IP Authentication & Jumphost Routing and Authentication & Security Model.
<vpc-host-ip>) is not itself on the drone plane, so it reaches
10.71.x:9090 through the jumphost using x-drone-ip/x-drone-port headers (JUMPHOST_IP /
JUMPHOST_PORT default 9090). That routing and the pooled rosbridge connection are documented in
DroneControlService & Rosbridge Dispatch and
Network & VPN Topology.
Configuration reference
Environment variables (libs/config/settings.py; values below are the defaults / docker-compose.yml
settings).
| Variable | Default | Purpose |
|---|---|---|
CORE_VPN_RANGE / CORE_VPN_PORT / CORE_IFACE | 10.69.0.0/16 / 51822 / wg0 | Core plane (currently unmanaged) |
USER_VPN_RANGE / USER_VPN_PORT / USER_IFACE | 10.70.0.0/16 / 51823 / users0 | Operator plane |
DRONE_VPN_RANGE / DRONE_VPN_PORT / DRONE_IFACE | 10.71.0.0/16 / 51824 / drones0 | Drone plane |
EXTERNAL_IP | <dev-vpn-endpoint> (dev) | Public WireGuard endpoint written into client access.conf |
AWS_RANGE | 172.31.0.0/16 | Added to peer AllowedIPs so tunnels reach the VPC |
DNS_SERVER | 172.31.0.2 | DNS pushed to peers (resolves *.skyhub-prod.internal) |
KEEPALIVE_DURATION | 25 | PersistentKeepalive seconds |
DB_REFRESH_SECONDS | 10 | Reconcile loop interval |
FORCE_UPDATE | False | Regenerate every peer config each cycle |
SITL_HOST | <office-host> | Substituted for SITL drone IPs in the reference queries |
REAL_INTERFACE / SECOND_INTERFACE | ens5 / ens6 | Physical NICs used for NAT/masquerade |
HTTP_PORT | 5050 | Status API bind port |
VPN_BUCKET | skyhub-dev-user-vpn | S3 bucket for keys + peer configs |
DB_HOST / DB_NAME / DB_USERNAME / DB_PASSWORD | database.skyhub-*.internal / skyhub / <redacted> | Shared PostgreSQL (read-only use) |
VPN_SERVICE_IP / VPN_SERVICE_PORT (5050),
VPN_BUCKET (shared), DRONE_NETWORK_CIDR (10.71.0.0/16), JUMPHOST_IP / JUMPHOST_PORT.
Troubleshooting a drone that is unreachable from the Gateway. Work outward:
- Is the tunnel up?
GET /status/drone/<ip>on:5050—404means the peer isn’t in the WireGuard config, i.e. the row’sipis null or the reconcile loop hasn’t run. - Is there recent traffic?
GET /drones_statuswith the drone’s IP — checklatest_handshake_ts/is_active. - Is the firewall allowing it?
GET /firewall/user/<user_ip>— confirm anACCEPTexists foruser_ip → drone_ipon the rosbridge port. Remember the live rules come from direct ownership (drone.user_id), notuser_drone_access(see the warning above). - Only then look at the jumphost/rosbridge hop (DroneControlService & Rosbridge Dispatch).
Related pages
Ecosystem Overview
Where the VPN sits among Janus, WHIP, SITL, and the WS Proxy.
VPN IP Auth & Jumphost
The Gateway’s
check_vpn_ip middleware and jumphost rosbridge routing.Network & VPN Topology
The full three-plane picture across the platform.
Billing, Calendar, VPN & Video API
The Gateway HTTP endpoints that presign VPN configs and report status.

