HydraNeckWebRTC is a centralized WebRTC relay service. It runs moonlight-web-stream processes on worker machines and proxies WebRTC streams to browsers. A controller routes session requests to the least-loaded worker.
Two hydracluster roles map to systemd services:
hydraneckwebrtc-controller (singleton): Routes session requests, tracks worker health. Service: hydraneckwebrtc-controller.service, runs hydraneckwebrtc controller.hydraneckwebrtc (scalable): Manages local sessions, runs coturn for TURN relay. Service: hydraneckwebrtc.service, runs hydraneckwebrtc worker.Both roles use the same binary (/usr/local/bin/hydraneckwebrtc). Hydranode handles downloading the binary and creating the systemd service for both roles.
Worker nodes are fully automated through hydracluster recipes:
hydraneckwebrtc role (and optionally hydraneckwebrtc-controller for the singleton)wg-quick@<iface> for any config in /etc/wireguard/)/root/.hydraneckwebrtc-turn-credential)/etc/turnserver.conf with the node's IP and credential/root/.hydraneckwebrtc/config.yaml with controller URL, token, and ice_serversAdd to ~/.hydracluster/config.yaml:
hydraneckwebrtc:
controller_url: "https://hydraneckwebrtc.experiencenet.com"
controller_token: "controller-admin-token"
admin_token: "worker-admin-token"
/opt/moonlight-web-stream/web-serverwg-quick@wg0) — required to reach body nodes on WG IPscurl -o /usr/local/bin/hydraneckwebrtc \
https://releases.experiencenet.com/hydraneckwebrtc/production/latest/hydraneckwebrtc-linux-amd64
chmod +x /usr/local/bin/hydraneckwebrtc
# Worker
cp scripts/hydraneckwebrtc.service /etc/systemd/system/
# Controller (if running on this machine)
cp scripts/hydraneckwebrtc-controller.service /etc/systemd/system/
systemctl daemon-reload
Config file: ~/.hydraneckwebrtc/config.yaml
mode: controller
server:
domain: hydraneckwebrtc.experiencenet.com
admin_token: <secure-token>
workers:
heartbeat_timeout: 60s
mode: worker
server:
listen: ":47990"
admin_token: <secure-token>
controller:
url: https://hydraneckwebrtc.experiencenet.com
token: <controller-admin-token>
sessions:
max: 15
port_range_start: 8080
return_url: https://hydraheadwebstream.experiencenet.com # optional, default
sunshine:
username: sunshine
password: sunshine
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["turn:<server-public-ip>:3478"]
username: "hydraturn"
credential: "<turn-password>"
nps:
url: https://hydranps.experiencenet.com # optional, session records sent at session end
token: <nps-admin-token>
When both run on the same machine, the controller proxies /session/ paths to the worker.
Stream URLs use the controller's domain, so browsers connect to the controller which forwards to the worker internally.
Worker config for colocated setup (listens on a local port, no domain needed):
server:
listen: ":8090"
admin_token: <same-as-controller>
controller:
url: https://hydraneckwebrtc.experiencenet.com
token: <same-as-controller>
sessions:
max: 15
port_range_start: 8080
sunshine:
username: sunshine
password: sunshine
Use server.listen instead of server.domain for plain HTTP:
server:
listen: ":8080"
admin_token: dev-token
# Controller
systemctl enable --now hydraneckwebrtc-controller
# Worker
systemctl enable --now hydraneckwebrtc
# Controller
curl -sf https://hydraneckwebrtc.experiencenet.com/api/v1/health | jq .
# Worker (shows WireGuard status, active sessions, capacity)
curl -sf http://localhost:47990/api/v1/health | jq .
Worker health response includes:
status: "ok" or "degraded" (degraded when WireGuard is down)extra.process_id: worker process PID (identifies which instance is responding)extra.uptime: how long the worker has been runningextra.wireguard: true/false — whether a WireGuard interface is detectedextra.active_sessions / extra.max_sessions: current loadextra.sessions: list of active sessions, each with:
id, body_ip, status: identity and stateprocess_alive: whether the moonlight-web-stream process is still runningdata_directory_ok: whether the session's data directory exists on diskactive_connections: number of open WebSocket tunnelsage: how long since the session was createdQuick readiness check (single command):
curl -sf https://hydraneckwebrtc.experiencenet.com/api/v1/health | jq '{
status: .status,
wireguard: .extra.wireguard,
sessions: "\(.extra.active_sessions)/\(.extra.max_sessions)",
process_id: .extra.process_id,
uptime: .extra.uptime
}'
curl -H "Authorization: Bearer <token>" \
https://hydraneckwebrtc.experiencenet.com/api/v1/workers
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"body_ip":"10.0.1.50","sunshine_user":"sunshine","sunshine_pass":"sunshine"}' \
https://hydraneckwebrtc.experiencenet.com/api/v1/sessions
Optional fields: experience (name), exe_path (Windows path on the body), district. When exe_path is set, the worker registers the app in Sunshine and launches it via hydrabody before starting the stream (both non-fatal). When experience is set, it is stored for experience rating.
Returns session_id, stream_url, and worker_id.
When a browser connects to /session/{id}/..., the worker reverse proxy injects a script into HTML responses. This script detects WebRTC disconnects and shows a "Stream ended" overlay with a "Start new stream" link back to sessions.return_url (defaults to https://hydraheadwebstream.experiencenet.com).
The injected script also intercepts the moonlight-web-stream exit button, calling POST /session/{id}/quit (unauthenticated) to clean up the session server-side before redirecting.
The overlay includes a "Report a problem" link that opens https://issues.experiencenet.com/report in a new tab with session context prefilled as query parameters: project, category, title, session_id, experience, district, body, head, body_ip, disconnect_count, duration_ms, end_reason, and ice_candidate_type.
The overlay includes a star rating widget. When the user rates, POST /session/{id}/rating is sent to the worker, which forwards it to the experience library.
The injected script adds a mic toggle button (bottom-right of the stream page). When enabled:
getUserMedia({audio: true})POST /session/{id}/mic/offer (unauthenticated, browser-facing, proxied through controller)body_ip:47995 over WireGuardMic is automatically stopped when the stream ends.
# All sessions across workers (via controller)
curl -H "Authorization: Bearer <token>" \
https://hydraneckwebrtc.experiencenet.com/api/v1/sessions
curl -X DELETE -H "Authorization: Bearer <token>" \
https://hydraneckwebrtc.experiencenet.com/api/v1/sessions/<session-id>
On deletion (or cleanup), if the session had an experience launched via exe_path, the worker calls POST /api/v1/stop on the body to kill the experience processes.
hydraneckwebrtc update
# Or auto-update runs every 6 hours
hydraneckwebrtc version
hydraneckwebrtc check-update
hydraneckwebrtc role in hydracluster, trigger provisionheartbeat_timeoutAll WebRTC media is relayed through TURN (iceTransportPolicy: 'relay' is forced on the browser side). Direct host/srflx candidates are disabled because NAT binding timeouts cause stream drops. coturn is automatically installed and configured by the hydraneckwebrtc recipe on each worker node.
apt install coturn
Config (/etc/turnserver.conf):
listening-port=3478
external-ip=<server-public-ip>
realm=hydraneckwebrtc.experiencenet.com
server-name=hydraneckwebrtc.experiencenet.com
lt-cred-mech
user=hydraturn:<password>
min-port=49152
max-port=65535
no-multicast-peers
no-cli
log-file=/var/log/turnserver.log
simple-log
allowed-peer-ip=10.10.0.0-10.10.255.255
Ports required:
# Check coturn is running
systemctl status coturn
# Check listening
ss -ulnp | grep 3478
# In browser console during a stream, look for "typ relay" ICE candidates
controller.url and controller.token in worker config match controller's server.admin_tokenjournalctl -u hydraneckwebrtc -fheartbeat_timeout (default 60s)sessions.max/opt/moonlight-web-stream/web-servercurl -s .../api/v1/health | jq .extra.wireguard — if false, WireGuard is downwg show) and Sunshine on the bodySymptoms: all sessions to WireGuard-routed bodies fail with "body unreachable". Health endpoint shows "wireguard": false and "status": "degraded".
# Check WireGuard status
wg show
ip link show type wireguard
# Start WireGuard (config must exist in /etc/wireguard/)
systemctl enable --now wg-quick@wg0
# Verify body reachable
ping -c 2 -W 3 10.10.100.6
curl -sk -u sunshine:sunshine https://10.10.100.6:47990/api/currentClient
The neckwebrtc recipe now auto-enables WireGuard on provision, but if the server was set up before this change or WG was manually stopped, re-enable it manually. The worker logs a WARNING: no WireGuard interface detected on startup if no WG interface is found.
kill -0 (signal 0) on UnixALERT logweb-server.log in its session data dirfind /tmp/hydraneckwebrtc-sessions -name web-server.log -exec cat {} \;/tmp/hydraneckwebrtc-sessions/; they are cleaned on startupprocess_alive and data_directory_ok without SSH"cannot bind — is another hydraneckwebrtc already running?"systemctl list-units | grep hydraneck/tmp/hydraneckwebrtc-sessions/ are cleaned up (only after the port pre-flight confirms no other instance is running)pkill -f web-server# systemd journal
journalctl -u hydraneckwebrtc -f # worker
journalctl -u hydraneckwebrtc-controller -f # controller
# Key log prefixes
# [controller] - controller routing decisions
# [worker] - worker session management
# [session <id>] - per-session lifecycle
# [pairing] - Sunshine pairing flow
# [heartbeat] - worker-to-controller heartbeat
# [cleanup] - expired session cleanup
# [proxy] - reverse proxy errors
# [mic] - browser mic WebRTC connections
# [rating] - experience ratings
git tag v<X.Y.Z> && git push origin v<X.Y.Z>