Share a feed outside the fleet shipped · a separate package

Documents the atlasd 3.0.0 release lineView as Markdown

One drone's picture and positions, watched by a partner agency in a browser for the length of a mission, then cut. This guide brings up the gateway at the site, the feed server — hosted or inside your own network — and the token issuer, and ends with the link you hand over. The mechanism is under The feed gateway.

What you need: a multicast group already carried on the fleet (Multicast video over the mesh), a host for the gateway with an internet leg, a host for the server with a hostname and a certificate, Docker with compose on the server host, and ffmpeg on the gateway host.

1 — Pick the mode

Mode 1 for a demo or a quick share: a node that already subscribes to the group runs the gateway app; skip to step 3. Mode 2 for anything shared with outsiders for a season: a dedicated host with its own read-only record and two links into the fleet. Both use the same gateway file.

2 — Mode 2: the sink record and two links

On the operator host, add the gateway host as a sink. A sink receives and never sends; it gets no classes, no relaying and no authorship whatever else the record says:

$ atlasd keygen --identity          # on the gateway host; copy the public halves over
$ atlasd fleet add-node --name gw-site1 --node-id 7f0c… \
    --transport-key … --signing-key … \
    --allowed-ip 10.77.0.40/32 --role gateway --sink
$ atlasd fleet check                # no sink warnings expected
$ atlasd fleet sign && atlasd fleet push -c /etc/atlas/config.toml

On the gateway host, two links into the fleet — two radios, or a radio and a cable — and the groups under subscribe. redundant_parents = 2 is optional: the gateway is then fed by its tree parent and by a second neighbour that already carries the stream for someone else (a ground station that subscribes, or a node on its path), so losing the parent link costs no gap. Atlas never pulls a node onto the tree for the gateway; with no such neighbour it has one parent and a dead parent is a re-root.

/etc/atlas/config.toml (the gateway host)
[interface]
name = "atlas0"
address = "10.77.0.40/24"
node_id = "7f0c…"
private_key = "…"
signing_key = "…"

[auth]
provider = "fleet"
fleet_list = "/etc/atlas/fleet-list.toml"

[[link]]
name = "radio"
bind_device = "wlan0"

[[link]]
name = "cable"
bind_device = "eth0"

[multicast]
enabled = true
groups = ["239.20.0.1", "239.20.0.2"]       # identical on every node
subscribe = ["239.20.0.1", "239.20.0.2"]    # video (MPEG-TS) and MAVLink
redundant_parents = 2                       # a redundant last hop (sinks only)

The host's internet leg is a third interface. Forward nothing between it and the fleet leg — sysctl -w net.ipv4.ip_forward=0 net.ipv6.conf.all.forwarding=0 and an nft forward chain with policy drop; nothing needs to be open inbound, the gateway only connects out (SRT to the server's UDP 8890, HTTPS and WSS to 443). The gateway app bridges at the application layer, outward only. Check the record: atlasd fleet check warns if the sink was given classes, relaying, authorship or a fleet dashboard by mistake. On the gateway, multicast.redundant_honoured = true in the snapshot says the second parent took, and multicast.alt_parent_accepted grows once the primary path fails; caps.sink_local_dropped counts what the host's own TUN offered and the daemon held back — so a ping from this host through the tunnel never leaves. On any peer, caps.sink_dropped in /api/stats counts data the sink nevertheless originated.

3 — The gateway file

/etc/feedgate/feedgate.toml
[gateway]
name = "gw-site1"
interface = "atlas0"               # the tunnel whose groups the gateway reads
health_listen = "127.0.0.1:9811"   # GET /health, loopback only

[server]
base_url = "https://feeds.nexusatlas.net"   # or https://feeds.example.intranet
ca_file = ""                                # the customer's CA, PEM, when self-hosted
control_poll_secs = 2                       # how often the gateway asks for a stop
health_report_secs = 5

[[feed]]
id = "uav1"
secret = "…"                       # the feed's gateway_secret on the server

[feed.video]
group = "239.20.0.1:5000"          # MPEG-TS over UDP as the source writes it
publish = "srt"                    # "whip" needs ffmpeg 8 or newer
srt_url = "srt://feeds.nexusatlas.net:8890"
srt_passphrase = "…"
srt_latency_ms = 200

[feed.telemetry]
mavlink = "239.20.0.2:14550"       # read only; the gateway never sends on this socket
rate_hz = 2
raw_mavlink = false                # the raw read-only channel, off by default
$ feedgate check -c /etc/feedgate/feedgate.toml
gateway gw-site1 on atlas0 → https://feeds.nexusatlas.net
  feed uav1: video 239.20.0.1:5000 → Srt uav1
  feed uav1: mavlink 239.20.0.2:14550 (raw off), 2 Hz
$ feedgate run -c /etc/feedgate/feedgate.toml [--interface atlas0]

To show viewers a map of the mesh nodes beside the picture — each node's name, last position and fix age, with the vehicle's two-minute track — set share_positions = true in the gateway file and grant the positions channel on the link. Both are off by default; a link without the channel, or a gateway without the setting, shares no positions.

The gateway refuses to start with the example file's placeholder secrets; secrets use A-Z a-z 0-9 . _ ~ - because they ride the SRT URL. The package ships a hardened systemd unit (feedgate.service, no capabilities — joining a group needs none) and a container image that runs with --network host so it sees the tunnel interface; mode 1 is this same file on a node that already subscribes, with --interface picking the tunnel.

4 — The server package

One compose file brings up the media server, feedgate-server (the auth hook, the session reaper, the local token issuer and its console, the player page, the telemetry fan-out and the gateways' control and health endpoints) and the TLS proxy in front of them:

docker-compose.yml (shipped in the package, abridged)
services:
  mediamtx:
    image: bluenviron/mediamtx:1.21.2
    volumes: ["./mediamtx.yml:/mediamtx.yml:ro", "recordings:/recordings"]
    environment: { MTX_WEBRTCADDITIONALHOSTS: "${FEEDS_PUBLIC_IP}" }
    ports: ["8189:8189/udp", "8189:8189/tcp", "8890:8890/udp"]   # WebRTC media; SRT in. The API (9997) is never published
  feedgate-server:
    build: { context: ../.., dockerfile: nexus-atlas-feedgate/Dockerfile, target: server }
    volumes: ["./feedgate-server.toml:/etc/feedgate/feedgate-server.toml:ro", "./secrets:/etc/feedgate/secrets:ro", "feedgate-data:/var/lib/feedgate"]
  caddy:
    image: caddy:2.11.7-alpine
    environment: { FEEDS_HOSTNAME: "${FEEDS_HOSTNAME}" }
    ports: ["443:443", "443:443/udp", "80:80"]
    volumes: ["./Caddyfile.${FEEDS_PLACEMENT:-public}:/etc/caddy/Caddyfile:ro", "./certs:/certs:ro", "caddy-data:/data"]
volumes: { recordings: {}, feedgate-data: {}, caddy-data: {} }

.env carries FEEDS_HOSTNAME, FEEDS_PLACEMENT (public or intranet) and FEEDS_PUBLIC_IP, the address viewers' browsers reach for WebRTC media. Caddy sends /<path>/whep to the media server's WebRTC listener, /<path>/*.m3u8|*.mp4 to its HLS listener, /list and /get to its playback API, and everything else — player, console, API, telemetry sockets — to feedgate-server. fetch-mediamtx.sh pins the media server release by digest for an install without containers.

feedgate-server.toml
[server]
listen = "0.0.0.0:8080"
public_base_url = "https://feeds.nexusatlas.net"
data_dir = "/var/lib/feedgate"            # audit, issued tokens, revocations, stopped feeds

[mediamtx]
api_url = "http://mediamtx:9997"          # internal network only, never published
whep_base_url = "https://feeds.nexusatlas.net"
hls_base_url = "https://feeds.nexusatlas.net"   # the proxy routes /<path>/*.m3u8 to the HLS listener
sync_paths = true                         # each feed's path (SRT passphrase, recording) set through the API at start and every 30 s

[auth]
revocation_poll_secs = 5                  # a revoked or expired viewer is cut within this
reap_unknown_sessions = true

[issuer]
enabled = true
id = "feeds.nexusatlas.net"
key_file = "/etc/feedgate/secrets/issuer.key"   # feedgate-server keygen --out issuer.key
max_ttl_secs = 86400
default_ttl_secs = 7200

[[issuer.user]]
name = "ops"
password_hash = "…"                       # feedgate-server hash-password

[[feed]]
id = "uav1"
title = "UAV 1 — EO camera"
gateway_secret = "…"                     # 16 characters or more, A-Z a-z 0-9 . _ ~ -
srt_passphrase = "…"                     # 10–79 characters, the same alphabet
recording = false                         # optional per feed, off unless turned on
record_retention = "7d"                   # fMP4 segments under the recordings volume, deleted after this
mavlink_raw = false
latency_note = "about 1 s behind real time"
$ cp .env.example .env && cp feedgate-server.example.toml feedgate-server.toml   # then edit both
$ docker compose build
$ mkdir -p secrets && docker compose run --rm --no-deps --user "$(id -u)" \
    -v "$PWD/secrets:/out" --entrypoint feedgate-server feedgate-server keygen --out /out/issuer.key
$ sudo chown 10001 secrets/issuer.key          # the container's feedgate user
$ echo '<password>' | feedgate-server hash-password   # argon2id, into password_hash
$ docker compose up -d

The server refuses to start while any user still has the example's placeholder password hash, or any feed its placeholder secrets. It creates each feed's media-server path through the API (SRT passphrase, recording, retention); no other path exists, so nothing can be published anywhere else. Every authorisation, kick, link issued, revocation, sign-in and gateway connection is one JSON line in data_dir/audit.jsonl.

5 — Issue a link, and cut it

Open https://feeds.nexusatlas.net/console, sign in as an issuer user, tick the feeds the link should open — one or several — name the viewer, set the validity and each feed's channels, Create link. A link opens exactly the feeds it names, and nothing else. It is shown once: https://feeds.nexusatlas.net/view#t=<token>, the page that lists the link's feeds (/watch/uav1#t=<token> opens one feed directly) — the token rides the fragment, which the browser never sends to the server. Offline, with the key: feedgate-server issue -c … --feed uav1 --viewer "Agency Desk" --ttl 7200. The page plays WHEP, falls back to LL-HLS, draws the viewer's name, the feed and the time across the picture and states the latency note; with several feeds the viewer composes them on one screen — side by side, picture-in-picture, a 2×2 grid, or the video beside the map. A link can also require a code: a six-digit PIN the server generates, or a password you type. The code is shown once beside the link; send it over a different channel than the link. The server keeps only its Argon2id hash; the player asks for the code and calls POST /api/v1/unlock, and the right one opens a session of unlocked_session_hours (12 by default), never past the link's own expiry. After five wrong tries the viewer waits with a growing back-off; after twenty failed codes the link locks until you reset it. A server trusts its own issuer and any other whose public key it lists — another feed server's local issuer, say, read from that server's GET /api/v1/issuer — and polls that issuer's signed revocation list every revocation_poll_secs:

# a further issuer this server trusts, beside its own
[[auth.issuer]]
id = "feeds.example.intranet"
public_key = "…"                          # from that server's GET /api/v1/issuer
revocations_url = "https://feeds.example.intranet/api/v1/revocations"

Revoke link (one token, and every feed it names), Revoke viewer (that viewer's links on that feed) or Revoke all links (the feed) from the console: the cut is immediate on this server and within revocation_poll_secs on any other server trusting the same issuer — the video session is kicked through the media server's API, the telemetry socket closed, and the page shows "This link has ended" within a few seconds because the player asks GET /api/v1/feeds/uav1/token whether its link is still accepted whenever the picture stalls. An expired link is cut within about a second of its expiry. To cut everyone at once, Stop the feed: every viewer is cut, the publisher is refused, and the gateway collects the stop on its next control poll and stops publishing; Start brings it back.

6 — Inside your own network

The same package, with three settings changed: the hostname (public_base_url, and the gateway's base_url), the certificate — a leaf from your own CA on the proxy, and that CA's PEM in the gateway's ca_file — and the issuer: the local one as above, bound to your sign-in through trusted_user_header and trusted_proxies when an SSO proxy fronts the console. Nothing in the package calls nexusatlas.net. The gateway host's internet leg then points at the intranet server; the fleet side is unchanged.

7 — What to check

  • On the gateway host, GET http://127.0.0.1:9811/health: per feed publishing, stopped_by_server, uplink_connected, datagrams_in and bytes_in, last_datagram_ms_ago, publisher_restarts, mavlink_frames and mavlink_crc_errors, and downlink_dropped — always 0 unless the server misbehaves; nothing it sends down is ever used. The same document reaches the server every health_report_secs.
  • On the gateway, multicast.redundant_honoured is true when redundant_parents = 2 took; caps.sink_local_dropped counts what the host's own TUN offered and the daemon held back. On a peer of the sink, caps.sink_dropped stays at zero; if it climbs, the daemon on the gateway host is originating data into the fleet — not what a stock build does — and the record is doing its job.
  • On the server, GET /api/v1/status (gateway_connected, readers per feed, the counters, the trusted issuers' revocation sequence) and data_dir/audit.jsonl: who watched which feed, when, from where.
  • In the browser, the latency note matches what you see; a shared feed is the fleet's latency plus the channel's, never a cockpit.

Boundaries

  • Multicast inside the fleet is read by every relay on its tree, with or without a gateway.
  • Mode 1 puts the public side on a full member; use it for a demo, not for a season.
  • A token is a bearer secret: HTTPS only, short expiries, no long-lived links in email threads; where a link requires a code, the code goes by another channel than the link.
  • Nothing travels toward the fleet, raw MAVLink included: a viewer's frame is dropped and counted (viewer_frames_dropped) at the server, the server never sends data down to a gateway, the gateway's MAVLink socket has no send path, and in mode 2 the sink flag drops anything the gateway might originate anyway.
  • A sink answers nothing through the tunnel — no ping, no dashboard; manage the host over its own leg.
  • The watermark is an overlay the player draws; a technically able viewer can strip it from their own screen.
Esc
↑ ↓ navigateEnter — openEsc — close