The WHIP server (skyhub_whip) is the ingest front door for live drone video. It is a SkyHub fork of Meetecho’s simple-whip-server — a small Node.js/Express service that implements the WHIP (WebRTC-HTTP Ingestion Protocol) REST API and bridges each ingested WebRTC stream to a Janus VideoRoom publisher. On-drone GStreamer pipelines whipsink their H264 stream into a WHIP endpoint over plain HTTP; WHIP negotiates the WebRTC PeerConnection with Janus on the publisher’s behalf and drops the media into the room. The Dashboard then subscribes to that same room over WebRTC. WHIP handles only the publish (ingest) side — the subscribe side and the SFU itself live in Janus (see Janus WebRTC SFU).
WHIP is a stateless-ish relay of signalling: it holds endpoint bookkeeping in memory (endpoints{} / resources{} in src/server.js) and proxies WebRTC negotiation to Janus. It never touches media RTP itself — that flows drone → Janus directly once ICE completes.

Where it sits in the video plane

The upstream orchestration (Janus room creation, pushing /video_room_details to the drone) is owned by the Gateway — see Janus Video Rooms & On-Drone Video Control. The on-drone camera/pipeline side is documented in Video Streaming (RTSP → WHIP/WebRTC), and the browser subscribe side in App State & Video (Janus/WebRTC).

Runtime & configuration

Configuration lives in src/config.js; only a few values are environment-driven. The listen port 7080 is hardcoded in config — it is not an env var.
Env varDefaultPurpose
JANUS_ADDRESSws://127.0.0.1:8188Janus WebSocket API backend WHIP drives (config.janus.address)
ROOM_TIMEOUT180 (seconds)unusedEndpointTimeout — how often the stale-endpoint cleaner runs. The 3-minute idle window it enforces is hardcoded (src/server.js:111) and does not change with this value; the 180s default just happens to equal 3 min
GATEWAY_SERVICE_IP10.69.0.3Gateway address WHIP calls back to auto-create a missing Janus room (src/server.js:39)
GATEWAY_SERVICE_PORT5000Gateway port for that callback
Non-configurable defaults worth knowing (all in src/config.js):
  • port: 7080 — the REST API bind port.
  • rest: '/whip' — base path prefix for every route below.
  • allowTrickle: true — trickle-ICE via PATCH is accepted (disable to force a 405).
  • strictETags: false — ETag mismatches are logged but not rejected; flip to true for spec-strict behavior.
  • iceServers: [] — empty, so WHIP returns no ICE-server Link headers by default. STUN/TURN is instead configured on the publisher side (the drone’s whipsink stun-server=…) and inside Janus.
Port trap: both Dockerfile and Dockerfile.ecr declare EXPOSE 3000, but the app actually binds :7080. The EXPOSE line is vestigial from the upstream template — always map/publish 7080, not 3000. Janus’ WebSocket API is a separate port (:8188) that WHIP consumes; it is not WHIP’s own listen port.

REST API (/whip)

Every path is prefixed with /whip. Endpoint and resource state is held in memory, so a WHIP restart drops all endpoints (the Gateway/drone re-create them via /create on the next stream).
Method & pathPurposeNotable responses
GET /whip/healthcheckLiveness probe200
GET /whip/endpointsList all configured endpoints (unauthenticated — testbed)200 JSON array
POST /whip/createRegister an ingest endpoint mapped to a Janus room200; 400 bad args / already exists; 403 no Bearer
OPTIONS /whip/endpoint/:idCORS preflight; emits ICE Link headers204
POST /whip/endpoint/:idPublish: post an SDP offer → becomes a VideoRoom publisher201 SDP answer; 404/403/406/503
GET /whip/endpoint/:idFetch an endpoint’s JSON state200 / 404
HEAD/PUT /whip/endpoint/:idDisallowed by spec405
DELETE /whip/endpoint/:idPermanently destroy the endpoint200 / 404
PATCH /whip/resource/:ridTrickle ICE candidates / ICE restart204 trickle, 200 restart
DELETE /whip/resource/:ridTear down the PeerConnection, keep the endpoint200 / 404
GET/HEAD/POST/PUT /whip/resource/:ridDisallowed by spec405
GET /whip/room_number/:room_numberList endpoints bound to a room (marked TODO: secure)200
DELETE /whip/room_number/:room_numberDestroy every endpoint bound to a room (marked TODO: secure)200
GET /whip/free/:endpoint_idIs an endpoint id free to reuse?200 free / 403 in use
The static web demo (web/) is served at the server root (http://<host>:7080/) for visually creating/listing/tearing down endpoints — it cannot publish or view media.
The upstream README says publish returns 200 OK. The SkyHub fork actually writes 201 with the SDP answer body (res.writeHeader(201, …) in src/server.js), alongside the Location, ETag, and Accept-Patch: application/trickle-ice-sdpfrag headers. Treat 201 as success for a publish.

Endpoint lifecycle

1

Create the endpoint

The publisher POSTs { "id": "<uuid>", "room": <number> } to /whip/create. The physical/SkyCore publisher sends an Authorization: Bearer <token> header, and WHIP rejects a missing/empty Bearer with 403 (src/server.js:243). The SITL node currently omits the Authorization header entirely (skyhub_sitl/video_stream_node.py:58), so a real SITL /create would hit that 403 — an existing SITL-side gap. id and room are both required. WHIP checks whether the Janus room already exists via isRoomExists (a VideoRoom exists request), and if not, calls back to the Gateway to create it (see below). On success the endpoint is stored enabled: false and /whip/endpoint/<id> becomes live.
curl -H 'Content-Type: application/json' \
     -H 'Authorization: Bearer <room_mgmt_token>' \
     -d '{"id": "abc123", "room": 1234}' \
     http://localhost:7080/whip/create
2

Publish an SDP offer

POST the WebRTC SDP offer to /whip/endpoint/<id> with Content-Type: application/sdp (body must contain v=0, else 406). If the endpoint carries a token, a matching Bearer is required (403). Janus must be connected or you get 503. WHIP attaches a VideoRoom handle, sends joinandconfigure as a publisher (display name = the endpoint label), and returns the SDP answer with a Location: /whip/resource/<rid> header.
3

Trickle ICE / restart

Send ICE candidates as Content-Type: application/trickle-ice-sdpfrag via PATCH /whip/resource/<rid> (204). If the ufrag/pwd changed, WHIP treats it as an ICE restart, rewrites the stored offer, and returns 200 with fresh server ICE credentials. Requires allowTrickle: true.
4

Tear down

DELETE /whip/resource/<rid> stops the PeerConnection but keeps the endpoint (a new session can be published to the same room). DELETE /whip/endpoint/<id> destroys the endpoint permanently. Both accept an override header to bypass the Bearer check (used for admin/forced cleanup).

Auth model

WHIP auth is intentionally light and per-endpoint:
  • /whip/create requires any non-empty Bearer token — it is passed straight through to the Gateway room-create callback, which validates it against the drone’s video-room token.
  • /whip/endpoint/:id, PATCH/DELETE /resource/:id, and DELETE /endpoint/:id only enforce a Bearer if that endpoint was created with a token. SITL’s /create payload omits token, so its endpoints are effectively open.
  • /whip/endpoints, /whip/room_number/*, and /whip/free/* are unauthenticated and flagged TODO:FIXME: Secure and improve in src/server.js. Do not expose WHIP directly to the internet — it lives behind the VPN/jumphost. See Authentication & Security Model.

The WHIP → Janus bridge

src/whip-janus.js is the Janus API stack. On boot it opens a janus-protocol WebSocket to JANUS_ADDRESS, creates a Janus session, and starts 15s keep-alives. It verifies the janus.plugin.videoroom plugin is present and detects multistream (Janus version ≥ 1000). Publishing is a two-step VideoRoom flow:
  1. attach a handle to janus.plugin.videoroom.
  2. Send joinandconfigure with ptype: "publisher", audio: true, video: true, display: <label>, and the SDP jsep; Janus returns the SDP answer WHIP relays back to the drone.
An optional plain-RTP recipient (with room secret/adminKey) triggers an rtp_forward so the same publisher can be mirrored to a raw RTP sink — unused by the default SkyHub flow but available. If Janus emits a hangup, WHIP fires the endpoint’s teardown callback and clears its state. If the Janus WebSocket drops, WHIP tears down all endpoints and reconnects every 2s.

Room auto-creation callback

WHIP will not create a Janus room on its own. If isRoomExists reports the room is missing during /create or /endpoint, WHIP POSTs to the Gateway:
POST http://<GATEWAY_SERVICE_IP>:<GATEWAY_SERVICE_PORT>/api/v1/video_room?room=<room>
Authorization: <the Bearer forwarded from the publisher>
Body: {"room_number": <room>}
The Gateway’s create_video_room handler (src/routes/video_room_routes.py) resolves the drone by room id + token and calls VideoService.create(...) to create the room in Janus, then returns 200 so WHIP proceeds to publish. This is why WHIP needs a route back to the Gateway (default 10.69.0.3:5000, the core WireGuard plane) — see the video API in Billing, Calendar, VPN, Video & Isaac Sim API. In normal operation the Gateway has already created the room when the drone was provisioned, so this callback is a self-healing fallback.

How drones and SITL publish

Both real drones and SITL speak the same whipsink contract; only the pipeline and hostname differ.
skyhub_sitl/video_stream_node.py subscribes to the /video_room_details rosbridge topic. On receipt it POSTs /whip/create with a generated id (uuid) and the room_number, saving the details to .janus_room_details/room.json. When MAVROS reports the vehicle armed, it spawns a live777-client container that runs a software GStreamer pipeline:
video_stream_node.py
udpsrc port=8554 buffer-size=10000000 ! tsdemux ! h264parse ! avdec_h264 !
  videoconvert ! x264enc bitrate=6000 ! rtph264pay config-interval=1 pt=96 ssrc=2 !
  queue ! application/x-rtp,media=video,encoding-name=H264,payload=96 !
  whipsink whip-endpoint=${WHIP_SERVER_URL}/whip/endpoint/<room_id>
WHIP_SERVER_URL defaults to http://172.17.0.1:7080 (the Docker bridge gateway). See SITL Simulator.
The publisher URL points at /whip/endpoint/<room_id>, but the endpoint must have been created first via /whip/create (the SITL node does this on /video_room_details; SkyCore does the equivalent). Publishing to an unknown id returns 404 Invalid endpoint ID, and publishing to an already-active endpoint returns 403 Endpoint ID already in use.

Stale-endpoint cleanup

WHIP runs a cleaner on an interval of unusedEndpointTimeout seconds (ROOM_TIMEOUT, default 180s). Any endpoint that is not enabled and whose lastEnabled timestamp is older than 3 minutes has its Janus publisher session removed and its sdpOffer/ice/resource/etag cleared. Endpoints that never published (lastEnabled == 0) are skipped. This prevents leaked VideoRoom publishers when a drone disarms or drops without a clean DELETE.

Troubleshooting: a camera won’t publish

The endpoint was never created. Confirm the drone/SITL sent POST /whip/create first (check GET /whip/endpoints or GET /whip/free/<id>). For SITL this is triggered by the /video_room_details rosbridge message — verify the Gateway pushed room details to the drone.
The publish request lacked Content-Type: application/sdp or the body did not contain v=0. This is a malformed offer from the GStreamer whipsink.
WHIP lost its WebSocket to Janus (JANUS_ADDRESS). It tears down all endpoints and reconnects every 2s. Check Janus is up on :8188 and reachable from WHIP; see Janus WebRTC SFU.
The room did not exist and the Gateway callback (/api/v1/video_room) failed or was unreachable. Verify GATEWAY_SERVICE_IP/GATEWAY_SERVICE_PORT and that the forwarded Bearer token maps to a real drone video room.
Signalling worked but media/ICE did not connect. WHIP is out of the picture at this point — the RTP path is drone → Janus. Check STUN/TURN reachability and Janus ICE config. This is a Janus-side issue: see Janus WebRTC SFU.

Adding a new video source

To wire a new camera or sender to WHIP, the sender must: (1) obtain a room_id/room_number and token from the Gateway’s video-room provisioning; (2) POST /whip/create with that id + room and a Bearer token; (3) build a GStreamer pipeline that RTP-payloads H264 (rtph264pay pt=96 ssrc=2) and terminates in whipsink whip-endpoint=<WHIP>/whip/endpoint/<room_id> (add auth-token= if the endpoint was created with a token, and a stun-server= for NAT traversal). Follow the existing camera classes in SkyCore as templates — see Video Streaming (RTSP → WHIP/WebRTC). WHIP itself needs no code change; it is source-agnostic as long as the offer is a standard WHIP H264 publish.

Deployment

WHIP is packaged as a small Node.js image (Dockerfile uses node:alpine; Dockerfile.ecr uses public.ecr.aws/docker/library/node:20-alpine), installs prod deps (npm install --production), and runs npm start — which sets DEBUG=whip:*,-whip:debug,janus:*,-janus:debug,-janus:vdebug for logging (the debug/vdebug namespaces are excluded; the verbose whip:*,janus:* set is the separate start-debug script, package.json:29). CI (.github/workflows/build.yml) runs SonarCloud on PRs and, on the development branch, builds and pushes the image to ECR with Slack notifications. In production it runs on the ECS cluster behind Cloud Map service discovery (whip.skyhub-{env}.internal) alongside Janus — see ECS Fargate Services. For the whole video plane end-to-end, start at the SkyHub Ecosystem Overview and Real-time Transport Channels.