AWS VPC
172.31.0.0/16 — the Fargate cluster and its Cloud Map services in eu-central-1. Private, single-AZ.Infra / on-prem
10.69.0.0/16 — the WireGuard “core” plane: the jumphost, the on-prem office server (nexus0 at <office-docker-host>) and SigNoz.Users & drones
10.70.0.0/16 (dashboard users) and 10.71.0.0/16 (drones) — per-entity WireGuard tunnels handed out by the User VPN service.All four networks (VPC + three WireGuard planes) terminate on one t4g.nano EC2 instance — the WireGuard/jumphost/nginx host at public EIP
<prod-ingress-ip> (prod.skyhub.ai). It is simultaneously the VPN server, the sole public TLS ingress, the Docker-API proxy, and the rosbridge bridge. See the single-point-of-failure warning at the bottom of this page.The full picture
The AWS-side private IPs above are the real Cloud Map A-records captured from prod; they change on task replacement, so always resolve by DNS name, never by hard-coded IP.WireGuard address plan
The per-user/per-drone tunnels are managed by the User VPN service (a container running on the jumphost). It runs three independent WireGuard planes on one host, each a separate interface, UDP port and/16. Ground truth is skyhub_user_vpn/docker-compose.yml and skyhub_user_vpn/libs/config/settings.py:
| Plane | CIDR | Interface | UDP port | Who lives here |
|---|---|---|---|---|
| Core / infra | 10.69.0.0/16 | wg0 | 51822 | Jumphost, nexus0 office server (<office-docker-host>), SigNoz |
| Users | 10.70.0.0/16 | users0 | 51823 | Dashboard operators (one address per user) |
| Drones | 10.71.0.0/16 | drones0 | 51824 | Physical drones (one address per drone) |
modules/wireguard/wireguard.tf) listens on 51820/udp and owns 10.69.0.1/16; a static peer list defines the team members and “spare” keys on the 10.69 plane. The drones0/users0 planes on 51823/51824 are the ones that grow as you provision fleet.
The office Docker host
nexus0 is addressed as <office-docker-host>, but in the Terraform peer list that address is literally labelled spare-key-1 — a real host reuses a “spare” slot. Keep this in mind when auditing peers (modules/wireguard/wireguard.tf:478).Per-user isolation
Membership in the10.71 plane does not grant a drone universal reachability. The User VPN service reads the shared Postgres user_drone_access table and writes iptables rules so a user’s 10.70.x address can only reach the 10.71.x drones they actually own (skyhub_user_vpn/sql_4_iptables.sql). Peer configs (access.conf) are generated per entity and distributed via the S3 bucket skyhub-prod-user-vpn. The Gateway never provisions tunnels itself — it only presigns those configs and queries tunnel status. See User VPN & Network Isolation for the full model.
The jumphost: single public ingress
Every externally reachable endpoint is an nginxserver {} block on the jumphost that TLS-terminates and reverse-proxies into the VPC or over WireGuard. The routing table below is skyhub_terraform/nginx/default (also generated at boot from post_install_1.tftpl):
| Listen | TLS | Proxies to | Purpose |
|---|---|---|---|
80 / 443 | yes | skyhub-prod-ui-bucket S3 website | Dashboard SPA |
5000 /socket.io/ | yes | gateway.skyhub-prod.internal:5000 | Socket.IO telemetry (WebSocket upgrade) |
5000 / | yes | gateway.skyhub-prod.internal:5000 | REST API (auth, drones, missions, billing…) |
7070 | yes | ws_proxy.skyhub-prod.internal:7070 | Gamepad / manual-control WebSocket |
8188 | yes | janus.skyhub-prod.internal:8188 | Janus WebRTC signalling |
2375 | no | <office-docker-host>:2375 | Dev-only Docker Engine API proxy (server_name dev.skyhub.ai) — not the prod SITL path (prod SITL uses direct SSH to nexus0) |
9090 | no | http://$http_x_drone_ip:$http_x_drone_port | Dynamic rosbridge proxy (header-routed) |
4317 | no | <office-docker-host>:4318 | OTLP traces/logs to on-prem SigNoz |
172.31.0.2 (resolver 172.31.0.2 valid=10s). UDP media for WebRTC is relayed separately; the WireGuard SG opens udp 10000-21000, within which Janus uses the RTP range 10000-10099 (rtp_port_range, skyhub_janus/templates/janus.jcfg:178). That template sets nat_1_1_mapping = "localhost" with keep_private_host = "true" (lines 218-219), so Janus does not rewrite ICE candidates to the public EIP.
Two paths to the same on-prem host
nexus0 (<office-docker-host>) is reached two different ways, and a refactor must keep them separate:
Docker API — container management
The Gateway manages SITL containers via
REMOTE_DOCKER_HOST=ssh://nexus0@<office-docker-host> — a direct SSH-over-WireGuard connection to the office server nexus0 (<office-docker-host>), not a tcp Docker proxy through nginx. src/service/sitl_drone_service.py keys off the ssh:// prefix and drives Docker via the system SSH client. Used only for create/start/stop/destroy. Covered in SITL Drone Lifecycle.rosbridge — telemetry & commands
Drone control/telemetry rides rosbridge WebSockets. In prod
JUMPHOST_IP=jumphost-private.skyhub-prod.internal and JUMPHOST_PORT=9090, so every connection targets the jumphost :9090 block and carries x-drone-ip/x-drone-port headers (the SITL host <office-docker-host>:9090+n, or a physical drone’s 10.71.x:9090). See Rosbridge Connection.Cloud Map internal DNS
Inside the VPC, every backend service is a Fargate task registered in the AWS Cloud Map private DNS namespaceskyhub-prod.internal. Services address each other by these stable names — renaming a service breaks nginx, the gateway env, and inter-service calls at once.
| Cloud Map name | Private IP | Service |
|---|---|---|
gateway.skyhub-prod.internal | <vpc-host-ip> | Flask API + Socket.IO (:5000) |
database.skyhub-prod.internal | 172.31.12.178 | Postgres 16.3 on EFS (:5432) — not RDS |
redis.skyhub-prod.internal | 172.31.0.207 | Redis alpine (:6379) — not ElastiCache |
janus.skyhub-prod.internal | 172.31.10.110 | Janus WebRTC SFU (:8088/:8188) |
whip.skyhub-prod.internal | 172.31.4.1 | WHIP ingest (:7080) |
ws_proxy.skyhub-prod.internal | 172.31.2.246 | Gamepad WS proxy (:7070) |
jumphost-public.skyhub-prod.internal | <jumphost-private-ip> | Jumphost public ENI (EIP <prod-ingress-ip>) |
jumphost-private.skyhub-prod.internal | <jumphost-private-ip> | Jumphost private ENI (Docker/VPN/OTLP target) |
172.31.0.0/16, a single AZ with one public subnet (<vpc-subnet>/20), one private subnet (172.31.0.0/20) and a single NAT gateway. Details in AWS Infrastructure Overview and VPC, WireGuard Jumphost & nginx Routing.
How the Gateway allocates IPs
When a user or drone is provisioned,IPService (src/service/ip_service.py) hands out the next free host address inside the relevant WireGuard /16. The CIDRs come straight from env (src/application/settings.py:75-77):
src/application/settings.py
get_user_ip() and get_drone_ip() (src/service/ip_service.py:22-63) each:
- Query all currently taken IPs from Postgres, ordered by
INETcast so allocation is deterministic. - Reserve the gateway (
.1) and broadcast (.255) addresses of the first subnet only (10.70.0.1/10.70.0.255,10.71.0.1/10.71.0.255). - Return the first
network.hosts()address that is neither taken nor reserved — so the first user lands on10.70.0.2and the first drone on10.71.0.2.
ip is the container name (e.g. SKYHUB_SITL_3), not a real address, so they consume no drone-plane IP. For ordering purposes only, the query casts SITL rows as if they were at SITL_HOST (<office-docker-host>) via a SQL CASE (src/service/ip_service.py:51).
IPService.is_ip_in_drone_cidr() / is_ip_in_user_cidr() also back membership checks used elsewhere in the Gateway (e.g. distinguishing a real VPN drone from a SITL row).
How the Gateway trusts a drone’s address
Physical drones authenticate to the Gateway purely by their WireGuard source IP — there is no per-drone token. Thecheck_vpn_ip decorator (src/middleware/drone_vpn.py) accepts a request if remote_addr, X-Real-IP or X-Forwarded-For starts with 10.71., then sets request.vpn_ip and lets the route resolve the drone via get_drone_by_ip():
src/middleware/drone_vpn.py
X-Drone-IP header, allowed only for SKYHUB_SITL_* container names (when ENABLE_SITL) or a whitelist of local prefixes (127., 172.17-20., 192.168., 10.223.) when IS_LOCAL_ENVIRONMENT. Anything else returns 400 Access denied: IP not authenticated!. This is the 10.71 drone plane’s whole security value: it is trust-by-network-membership, so it is only sound while the Gateway is unreachable except through the VPN/jumphost. The auth model and its spoofing caveat are covered in Authentication & Security Model and VPN IP Authentication & Jumphost Routing.
Why the whole design hinges on the jumphost
Gotchas to preserve
- Resolve by name, not IP. The
172.31.xaddresses in this page are current Cloud Map records; they move on task replacement. Only the jumphost EIP (<prod-ingress-ip>) and the on-prem<office-docker-host>are stable. - Two “office server” endpoints, one host. Docker API via direct SSH (
ssh://nexus0@<office-docker-host>) vs rosbridge via nginx:9090both land on<office-docker-host>for different jobs. Keep them split. :9090is an unvalidated header proxy — it must stay behind the VPN (see the SSRF warning above).- Env drift:
SITL_HOSTis derived fromDOCKER_HOST_IP; changing the office server address moves both the rosbridge target and the SITL IP-ordering anchor.USER_NETWORK_CIDR/DRONE_NETWORK_CIDRmust match the User VPN service’sUSER_VPN_RANGE/DRONE_VPN_RANGEor allocation and10.71.trust silently diverge.
Related pages
Platform Architecture Overview
Where each component sits and how the control plane fits together.
Real-time Transport Channels
Socket.IO, rosbridge, WebRTC and Redis pub/sub — the wires that ride this topology.
VPC, WireGuard Jumphost & nginx Routing
The Terraform-level view: subnets, NAT, security groups and the generated nginx config.
Gateway Environment Variables
Every
JUMPHOST_*, *_NETWORK_CIDR, DOCKER_HOST_IP and VPN knob with defaults.
