skyhub_janus packages the meetecho janus-gateway WebRTC server as the SkyHub video plane’s Selective Forwarding Unit (SFU). A drone (physical SkyCore or SITL) publishes exactly one H264 stream into a Janus VideoRoom; Janus fans that single stream out to every Dashboard viewer subscribed to the room. It carries no MAVLink, no telemetry, and no control — video flows entirely out-of-band from the Gateway rosbridge control path. Three parties touch Janus, and they do not all use the same port:

Gateway

Creates and destroys one room per drone over the HTTP API (:8088 /janus).

WHIP server

Registers the drone’s H264 ingest as a room publisher over the WebSocket API (:8188).

Dashboard

Joins the room as a WebRTC subscriber over the WebSocket API (wss :8188).

Role in the video pipeline

The Gateway only ever manages the room; the actual media never passes through the Gateway. See Gateway → Janus video rooms for the server-side room lifecycle and WHIP for the ingest half.

Built from meetecho source

There is no vendored Janus binary — the image compiles it. Dockerfile is a two-stage Ubuntu Focal build that first builds Janus’ native dependencies from pinned sources, then Janus itself:
Dockerfile
# usrsctp pinned to a commit, libnice 0.1.18, libsrtp v2.3.0
RUN git clone https://github.com/meetecho/janus-gateway.git
RUN cd janus-gateway && sh autogen.sh && \
    ./configure --prefix=/usr/local \
      --enable-post-processing --enable-websockets \
      --enable-rabbitmq-event-handler --enable-gelf-event-handler \
      --disable-all-loggers && \
    make -j$(nproc) && make install && make configs
Key facts a future editor must preserve:
  • Compiled-in features are fixed at build time: WebSockets transport, post-processing, and the RabbitMQ + GELF event handlers. All loggers are disabled (--disable-all-loggers). Adding a transport or event handler that isn’t in this ./configure line requires a rebuild, not just a config flip.
  • janus-gateway is cloned unpinned (git clone … .git with no branch/tag), so each image build tracks upstream master HEAD. Pin a tag here if you need reproducible builds.
  • Two Dockerfiles exist: Dockerfile (pulls base images from Docker Hub) and Dockerfile.ecr (identical build, but base images from public.ecr.aws/ubuntu/ubuntu:focal for CI). .github/workflows/build.yml builds on pushes to development and pushes the image to ECR as skyhub-janus-on-prem-dev (and the test UI as skyhub-janus-ui-on-prem-dev).
  • The container runs Janus as an unprivileged app user (uid 999) via entrypoint.sh.

Boot: dockerize renders the templates

entrypoint.sh uses dockerize to copy the config templates into place, then launches Janus:
entrypoint.sh
dockerize -template /templates:/usr/local/etc/janus su app -c /usr/local/bin/janus
The templates/*.jcfg files are static — they contain no {{ }} placeholders. dockerize copies them verbatim over /usr/local/etc/janus/. That has two consequences that trip people up:
  1. The JANUS_* environment variables in the Gateway’s docker-compose.yml (JANUS_STUN_SERVERS, JANUS_STUN_PORT, JANUS_FULL_TRICKLE, JANUS_ICE_LITE, JANUS_RTP_PORT_RANGE, JANUS_DEBUG_LEVEL, JANUS_WEBSOCKETS_ENABLED, …) are not consumed by any template and have no effect on Janus. To change ICE, STUN, room, or media settings you must edit the .jcfg templates directly and rebuild the image.
  2. Dockerfile runs a sed that sets stun_server = "stun.l.google.com" / stun_port = 19302 on the base image’s janus.jcfg, but that file is overwritten at boot by templates/janus.jcfg, whose nat block sets no stun_server at all. See STUN / ICE reality below.
entrypoint.sh also has optional RabbitMQ gating: if RABBITMQ_ENABLED=true or RABBITMQ_EVENTHANDLER_ENABLED=true, dockerize waits (-wait tcp://host:port -timeout 60s) for the broker before starting Janus.

Ports & transports

The effective transport config comes from templates/janus.transport.websockets.jcfg and templates/janus.transport.http.jcfg. Only 8088, 8089, 8188 and the 10000–10099/udp media range (the bolded rows) are published by the Gateway’s docker-compose.yml; the WSS (8989) and admin ports stay container-internal.
PortTransportPurposePublished
8088HTTP /janusJanus REST API — Gateway room CRUD via JANUS_URLyes
8089HTTPS /janusTLS REST (disabled in template, port mapped)yes
8188WebSocket APIWHIP publisher + Dashboard subscriber signallingyes
8989WSS APITLS WebSocket API (wss = false, disabled)no
10000–10099/udpRTP/RTCPWebRTC media (rtp_port_range in janus.jcfg)yes
7088Admin HTTP /adminAdmin/monitor API (admin_secret = "janusoverlord")no
7188Admin WebSocketAdmin/monitor over WSno
Ports 8088 and 8188 speak the same Janus API over different transports. The Gateway’s Python janus_client talks HTTP on :8088; WHIP and the browser use WebSockets on :8188. There is no functional difference in the API surface, only the transport.

Configuration templates

All runtime config lives in templates/*.jcfg. The ones that matter for video:

janus.jcfg — core, NAT & media

SettingValueNotes
events.broadcasttrueEvent handlers globally armed (individual handlers still off)
media.rtp_port_range"10000-10099"Must match the published UDP range
media.min_nack_queue200ms
nat.nat_1_1_mapping"localhost"Placeholder — must be the public/reachable IP in real deployments
nat.keep_private_host"true"Also advertise the private host candidate
nat.full_tricklefalseHalf-trickle (Janus sends its candidates in the SDP)
nat.ice_lite / nat.ice_tcpfalseFull ICE, UDP only
nat.ice_ignore_list"vmnet"Skip VMware interfaces
admin_secret"janusoverlord"Admin API secret (default — rotate for prod)

STUN / ICE reality

This is the config most relevant to “video connects but shows no frames,” so be precise:
  • Server-side, Janus has no stun_server configured (the template nat block omits it). It advertises host candidates only, rewritten by nat_1_1_mapping = "localhost". In production behind the WireGuard jumphost / nginx, nat_1_1_mapping = "localhost" means Janus hands remote browsers a media candidate pointing at localhost — signalling completes, but media never arrives. If you deploy Janus reachable from real browsers, set nat_1_1_mapping to the machine’s public IP (and open UDP 10000–10099).
  • The Google STUN that actually works is on the browser side: the Dashboard sets janusIceServers: ['stun:stun.l.google.com:19302'] in its Angular environment (environment*.ts), and connects to janusGatewayUrl (e.g. wss://prod.skyhub.ai:8188). That STUN gathers the viewer’s srflx candidates, not Janus’.
When changing STUN/TURN, edit templates/janus.jcfg (nat block: nat_1_1_mapping, or uncomment turn_server/turn_rest_api) and the Dashboard’s janusIceServers — the two ends are configured independently, and setting only one will not fix a broken media path. Rebuild the Janus image after editing the template.

The VideoRoom model

Every drone gets its own room whose id equals drone.id (integer ids — string_ids = false). The room is created by the Gateway’s VideoService, not by a static config file, using the janus_client library over the HTTP API:
src/service/video_service.py
session = JanusSession(base_url=self.base_url)        # base_url = JANUS_URL
plugin_handle = JanusVideoRoomPlugin()
await plugin_handle.attach(session)
await plugin_handle.create_room(drone.id, config)     # room id == drone.id
The room config built in VideoService.__get_room_config (src/service/video_service.py:68):
FieldValueMeaning
descriptiondrone.nameHuman label
pinvideo_room_passwordJoin PIN handed to WHIP + Dashboard
videocodec"h264"Must match the drone’s H264 pipeline
h264_profile"42e01f"Baseline profile (VIDEO_PROFILE)
audiocodec"opus" (opus_fec)Audio, unused by video-only feeds
max_publishers6Concurrent senders
bitrate60000006 Mbps target
fir_freq3Keyframe (FIR) request cadence, seconds
permanenttruePersist across restarts
is_privatefalse / record falseListed, not recorded
The Gateway then pushes {room_number, room_password, room_mgmt_token} to the drone over the rosbridge topic /video_room_details, and toggles streaming with /video_room_state (via the GET /video_room/<id>/start|stop|restart routes in src/routes/video_room_routes.py). Room lifecycle detail lives in Gateway video rooms.
templates/janus.plugin.videoroom.jcfg also defines a static room-1234 “Demo Room” (h264, bitrate = 6000). It is a leftover demo target, unrelated to drone rooms — real drones always get an API-created per-drone room. Do not assume drone video uses room 1234.

Event handlers & observability

Janus can stream live core/plugin events (sessions, JSEP, WebRTC state, media stats, slowlink, etc.) to an external sink. The RabbitMQ and GELF handlers are compiled in but disabled by default — every handler template ships with enabled = false:
Edit templates/janus.eventhandler.rabbitmqevh.jcfg: set enabled = true, point host/port at your broker, and keep events = "all" (or narrow it). It publishes to exchange wembrane with route key from-janus-events. Then set RABBITMQ_EVENTHANDLER_ENABLED=true (+ RABBITMQ_EVENTHANDLER_HOST/PORT) so entrypoint.sh waits for the broker before boot. Rebuild the image.
Edit templates/janus.eventhandler.gelfevh.jcfg: set enabled = true, backend = "<graylog-host>", port, protocol = "tcp". GELF ships JSON events to Graylog-compatible collectors. Rebuild.
The admin API is enabled on HTTP :7088 (/admin) and WS :7188 with admin_secret = "janusoverlord", but neither port is published by docker-compose.yml. To inspect live sessions/handles, publish 7088 and query /admin with the secret, or docker exec into the container.
Global event broadcasting is already on (events.broadcast = true in janus.jcfg, events = true on the VideoRoom plugin), so enabling a handler is only a matter of flipping its enabled flag and pointing it at a sink.

Debugging “connects but shows no frames”

1

Codec / profile mismatch

The room forces h264, baseline 42e01f. If the drone’s GStreamer pipeline encodes a profile the room or the viewer can’t decode, the subscriber attaches but renders nothing. Confirm the on-drone pipeline is H264 baseline — see drone video streaming.
2

ICE / nat_1_1_mapping = localhost

The template advertises localhost as Janus’ host candidate. For any browser not on the Janus host, signalling succeeds but media has no route. Set nat_1_1_mapping to the public IP and rebuild. This is the single most common cause of black video in a remote deployment.
3

Blocked media ports

WebRTC media uses UDP 10000–10099. If that range isn’t open end-to-end (host firewall, security group, or the jumphost), ICE connectivity checks fail and no RTP arrives.
4

Wrong room pin / token

The Dashboard must join with the same video_room_password (pin) the Gateway generated. A stale/mismatched pin rejects the subscribe. If the room was destroyed, use GET /video_room/<id>/restart (which recreates the room, VideoService.recreate).
5

Publisher never joined

“No frames” often means the publisher half failed. Check that WHIP registered a publisher on :8188 — if the drone never posted its SDP offer, the room has no source. Continue in WHIP.

Test UI

janus-test-ui/ is a small Alpine + darkhttpd image that serves the stock meetecho Janus demo pages (including videoroom.html) for manually poking a room. It is a debugging convenience only — the SkyHub Dashboard uses its own JanusService, not these demos.

Gotchas

  • Env vars are inert. The JANUS_* values in docker-compose.yml are not wired into the static templates — configure Janus by editing templates/*.jcfg and rebuilding, not through the environment.
  • Two config layers, template wins. The Dockerfile sed of Google STUN into janus.jcfg is overwritten at boot by templates/janus.jcfg; treat the templates as the single source of truth.
  • Unpinned upstream. janus-gateway builds from master HEAD; a rebuild can silently pick up breaking upstream changes.
  • Default secrets. admin_secret ("janusoverlord") and api_secret/token auth are the meetecho sample defaults — token auth is commented out, so rooms are gated only by the per-room pin. Harden before exposing Janus publicly.
  • Room id == drone id. Deleting/recreating a drone reuses the same room id; VideoService.delete tolerates “No such room” (426) errors so a missing room never blocks drone deletion.
  • Media ports fixed at 10000–10099. Changing the range means editing both janus.jcfg and the docker-compose.yml UDP mapping.

WHIP Ingest Server

The ingest front door that turns a drone’s H264 into a Janus publisher.

Gateway Video Rooms

Server-side room lifecycle, /video_room_details, start/stop.

Dashboard Video

How the Angular JanusService subscribes and renders the feed.

On-Drone Video Streaming

The GStreamer → WHIP pipeline that produces the H264 source.