my-sea voice Phase C: WebRTC mesh signaling app + TURN endpoint + voice-btn wiring + coturn infra — TDD
Phase C (final) of the my-sea invite → spectator → voice blueprint. Self-
hosted WebRTC mesh voice, built room-general but wired for my-sea only; epic
6-seat rooms reuse the same consumer later (key on Room.id). Media never
touches the server — only signaling is relayed. Built from the blueprint's
distilled spec (disco-voice-mesh.pdf unreadable in-env: no poppler/pypdf).
- C1: new apps/voice/ — RoomVoiceConsumer (AsyncJsonWebsocketConsumer):
signaling-only relay (room group voice.<room_id> + per-peer peer.<uuid>;
hello→present handshake, offer/answer/ice routed by target/source, left on
disconnect). room_id is a STRING kwarg (mysea-<owner_id> now). _can_join
gates: mysea → owner OR present invitee (token deposited, not left); epic
UUID → seated gamer (later). routing.py ws/voice/<str:room_id>/; asgi.py
aggregates epic + voice urlpatterns under AuthMiddlewareStack.
voice-mesh.js: VoiceRoom client (getUserMedia AEC/NS/AGC, mesh
RTCPeerConnection, newcomer-offers handshake, tuneOpus SDP munge =
inbandfec+dtx+40kbps cap, mute via getAudioTracks().enabled), lazy-loaded.
- C2: apps/api VoiceTURNCredentialsAPI at /api/voice/turn-credentials/ —
coturn use-auth-secret REST scheme: username=<expiry>:<user_id>,
credential=base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) + stun/turn
iceServers + ttl. Authenticated-only. 4 ITs (HMAC shape, auth gate).
- C3: settings COTURN_SHARED_SECRET / COTURN_TURN_HOST / COTURN_REALM /
COTURN_TTL env block.
- C4: #id_voice_btn wiring — _burger.html renders .active + data-room-id when
voice_active; burger-btn.js bindVoiceBtn (active click → lazy-load
voice-mesh.js → join / toggle-mute; inactive → existing 2-pulse flash).
my_sea (owner) + my_sea_visit (spectator) views compute voice_active
(open 24h window) + voice_room_id=mysea-<owner_id>; spectator page now
includes the burger. 4 voice-context ITs.
- C5: infra/coturn.conf.j2 (use-auth-secret, the external-ip footgun, relay
port range, TLS 5349, peer-IP lockdown) + infra/coturn-playbook.yaml
(dedicated droplet, PySwiss-style split: install coturn, template conf, ufw
3478/5349/49152-65535, systemd enable) + [coturn] inventory placeholder.
*** Manual ops step: provision the droplet + fill inventory before voice
works on staging/prod; CI/local need none of it. ***
- C6: 8 channels ITs (@tag channels) — connect/auth/_can_join gate (owner,
present invitee, stranger, not-present, anon) + hello/present handshake +
offer routing + left-on-disconnect. Scope-injected; TransactionTestCase.
- JS: VoiceMeshSpec.js (tuneOpus) + voice-mesh.js registered in SpecRunner.
1440 IT/UT green; voice channels IT + full Jasmine + voice-btn FT green.
Voice infra is code-complete — provision the coturn droplet to go live.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:57:09 -04:00
|
|
|
# coturn (TURN/STUN) config for the EarthmanRPG WebRTC mesh voice feature —
|
|
|
|
|
# Phase C of the my-sea invite/voice sprint. Rendered by coturn-playbook.yaml
|
|
|
|
|
# onto a DEDICATED droplet (PySwiss-style split), NOT the app droplet.
|
|
|
|
|
#
|
|
|
|
|
# The app's /api/voice/turn-credentials/ endpoint signs ephemeral credentials
|
|
|
|
|
# with HMAC-SHA1(<expiry>:<user_id>, secret); `use-auth-secret` +
|
|
|
|
|
# `static-auth-secret` here must use the SAME secret (COTURN_SHARED_SECRET in
|
|
|
|
|
# the app env).
|
|
|
|
|
|
|
|
|
|
listening-port=3478
|
|
|
|
|
tls-listening-port=5349
|
|
|
|
|
|
|
|
|
|
fingerprint
|
|
|
|
|
lt-cred-mech
|
|
|
|
|
use-auth-secret
|
|
|
|
|
static-auth-secret={{ coturn_secret }}
|
|
|
|
|
realm={{ coturn_realm }}
|
|
|
|
|
|
|
|
|
|
# ── THE #1 FOOTGUN ──────────────────────────────────────────────────────────
|
|
|
|
|
# Without external-ip, coturn hands out its PRIVATE address as the relay
|
|
|
|
|
# candidate and every relayed connection silently fails. On a DigitalOcean
|
|
|
|
|
# droplet with a single public IP set it to that IP; if the droplet also has a
|
|
|
|
|
# private/anchor IP, use PUBLIC/PRIVATE so coturn maps between them.
|
|
|
|
|
external-ip={{ coturn_public_ip }}{% if coturn_private_ip is defined and coturn_private_ip %}/{{ coturn_private_ip }}{% endif %}
|
2026-05-27 14:10:28 -04:00
|
|
|
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
|
|
|
|
|
# Dual-stack: advertise IPv6 relay candidates too. coturn auto-binds all
|
|
|
|
|
# interfaces (incl. v6) since no listening-ip is pinned; this maps the public
|
|
|
|
|
# v6 explicitly. Set coturn_public_ip6 in inventory to enable — leave it unset
|
|
|
|
|
# for a pure-IPv4 server (the v6 peer-lockdown below is gated on the same var).
|
|
|
|
|
external-ip={{ coturn_public_ip6 }}
|
|
|
|
|
{% endif %}
|
my-sea voice Phase C: WebRTC mesh signaling app + TURN endpoint + voice-btn wiring + coturn infra — TDD
Phase C (final) of the my-sea invite → spectator → voice blueprint. Self-
hosted WebRTC mesh voice, built room-general but wired for my-sea only; epic
6-seat rooms reuse the same consumer later (key on Room.id). Media never
touches the server — only signaling is relayed. Built from the blueprint's
distilled spec (disco-voice-mesh.pdf unreadable in-env: no poppler/pypdf).
- C1: new apps/voice/ — RoomVoiceConsumer (AsyncJsonWebsocketConsumer):
signaling-only relay (room group voice.<room_id> + per-peer peer.<uuid>;
hello→present handshake, offer/answer/ice routed by target/source, left on
disconnect). room_id is a STRING kwarg (mysea-<owner_id> now). _can_join
gates: mysea → owner OR present invitee (token deposited, not left); epic
UUID → seated gamer (later). routing.py ws/voice/<str:room_id>/; asgi.py
aggregates epic + voice urlpatterns under AuthMiddlewareStack.
voice-mesh.js: VoiceRoom client (getUserMedia AEC/NS/AGC, mesh
RTCPeerConnection, newcomer-offers handshake, tuneOpus SDP munge =
inbandfec+dtx+40kbps cap, mute via getAudioTracks().enabled), lazy-loaded.
- C2: apps/api VoiceTURNCredentialsAPI at /api/voice/turn-credentials/ —
coturn use-auth-secret REST scheme: username=<expiry>:<user_id>,
credential=base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) + stun/turn
iceServers + ttl. Authenticated-only. 4 ITs (HMAC shape, auth gate).
- C3: settings COTURN_SHARED_SECRET / COTURN_TURN_HOST / COTURN_REALM /
COTURN_TTL env block.
- C4: #id_voice_btn wiring — _burger.html renders .active + data-room-id when
voice_active; burger-btn.js bindVoiceBtn (active click → lazy-load
voice-mesh.js → join / toggle-mute; inactive → existing 2-pulse flash).
my_sea (owner) + my_sea_visit (spectator) views compute voice_active
(open 24h window) + voice_room_id=mysea-<owner_id>; spectator page now
includes the burger. 4 voice-context ITs.
- C5: infra/coturn.conf.j2 (use-auth-secret, the external-ip footgun, relay
port range, TLS 5349, peer-IP lockdown) + infra/coturn-playbook.yaml
(dedicated droplet, PySwiss-style split: install coturn, template conf, ufw
3478/5349/49152-65535, systemd enable) + [coturn] inventory placeholder.
*** Manual ops step: provision the droplet + fill inventory before voice
works on staging/prod; CI/local need none of it. ***
- C6: 8 channels ITs (@tag channels) — connect/auth/_can_join gate (owner,
present invitee, stranger, not-present, anon) + hello/present handshake +
offer routing + left-on-disconnect. Scope-injected; TransactionTestCase.
- JS: VoiceMeshSpec.js (tuneOpus) + voice-mesh.js registered in SpecRunner.
1440 IT/UT green; voice channels IT + full Jasmine + voice-btn FT green.
Voice infra is code-complete — provision the coturn droplet to go live.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:57:09 -04:00
|
|
|
|
|
|
|
|
# Relay port range — open this exact UDP range in the firewall (playbook does).
|
|
|
|
|
min-port=49152
|
|
|
|
|
max-port=65535
|
|
|
|
|
|
|
|
|
|
# ── TLS (turns: on 5349) — prod hardening ──────────────────────────────────
|
|
|
|
|
{% if coturn_tls_cert is defined and coturn_tls_cert %}
|
|
|
|
|
cert={{ coturn_tls_cert }}
|
|
|
|
|
pkey={{ coturn_tls_key }}
|
|
|
|
|
{% endif %}
|
|
|
|
|
no-tlsv1
|
|
|
|
|
no-tlsv1_1
|
|
|
|
|
|
|
|
|
|
# ── Lockdown: relay only, no SSRF via the TURN server ───────────────────────
|
|
|
|
|
no-multicast-peers
|
|
|
|
|
no-cli
|
|
|
|
|
no-software-attribute
|
|
|
|
|
# Block relaying to private ranges so the box can't be used to probe internals.
|
|
|
|
|
denied-peer-ip=10.0.0.0-10.255.255.255
|
|
|
|
|
denied-peer-ip=172.16.0.0-172.31.255.255
|
|
|
|
|
denied-peer-ip=192.168.0.0-192.168.255.255
|
|
|
|
|
denied-peer-ip=127.0.0.0-127.255.255.255
|
2026-05-27 14:10:28 -04:00
|
|
|
{% if coturn_public_ip6 is defined and coturn_public_ip6 %}
|
|
|
|
|
# IPv6 lockdown parity (only emitted when serving v6): loopback, link-local
|
|
|
|
|
# (fe80::/10), and unique-local (fc00::/7). coturn takes start-end ranges, not
|
|
|
|
|
# CIDR. Keeps a dual-stack relay from being pointed at internal v6 addresses.
|
|
|
|
|
denied-peer-ip=::1
|
|
|
|
|
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
|
|
|
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
|
|
|
|
{% endif %}
|
my-sea voice Phase C: WebRTC mesh signaling app + TURN endpoint + voice-btn wiring + coturn infra — TDD
Phase C (final) of the my-sea invite → spectator → voice blueprint. Self-
hosted WebRTC mesh voice, built room-general but wired for my-sea only; epic
6-seat rooms reuse the same consumer later (key on Room.id). Media never
touches the server — only signaling is relayed. Built from the blueprint's
distilled spec (disco-voice-mesh.pdf unreadable in-env: no poppler/pypdf).
- C1: new apps/voice/ — RoomVoiceConsumer (AsyncJsonWebsocketConsumer):
signaling-only relay (room group voice.<room_id> + per-peer peer.<uuid>;
hello→present handshake, offer/answer/ice routed by target/source, left on
disconnect). room_id is a STRING kwarg (mysea-<owner_id> now). _can_join
gates: mysea → owner OR present invitee (token deposited, not left); epic
UUID → seated gamer (later). routing.py ws/voice/<str:room_id>/; asgi.py
aggregates epic + voice urlpatterns under AuthMiddlewareStack.
voice-mesh.js: VoiceRoom client (getUserMedia AEC/NS/AGC, mesh
RTCPeerConnection, newcomer-offers handshake, tuneOpus SDP munge =
inbandfec+dtx+40kbps cap, mute via getAudioTracks().enabled), lazy-loaded.
- C2: apps/api VoiceTURNCredentialsAPI at /api/voice/turn-credentials/ —
coturn use-auth-secret REST scheme: username=<expiry>:<user_id>,
credential=base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) + stun/turn
iceServers + ttl. Authenticated-only. 4 ITs (HMAC shape, auth gate).
- C3: settings COTURN_SHARED_SECRET / COTURN_TURN_HOST / COTURN_REALM /
COTURN_TTL env block.
- C4: #id_voice_btn wiring — _burger.html renders .active + data-room-id when
voice_active; burger-btn.js bindVoiceBtn (active click → lazy-load
voice-mesh.js → join / toggle-mute; inactive → existing 2-pulse flash).
my_sea (owner) + my_sea_visit (spectator) views compute voice_active
(open 24h window) + voice_room_id=mysea-<owner_id>; spectator page now
includes the burger. 4 voice-context ITs.
- C5: infra/coturn.conf.j2 (use-auth-secret, the external-ip footgun, relay
port range, TLS 5349, peer-IP lockdown) + infra/coturn-playbook.yaml
(dedicated droplet, PySwiss-style split: install coturn, template conf, ufw
3478/5349/49152-65535, systemd enable) + [coturn] inventory placeholder.
*** Manual ops step: provision the droplet + fill inventory before voice
works on staging/prod; CI/local need none of it. ***
- C6: 8 channels ITs (@tag channels) — connect/auth/_can_join gate (owner,
present invitee, stranger, not-present, anon) + hello/present handshake +
offer routing + left-on-disconnect. Scope-injected; TransactionTestCase.
- JS: VoiceMeshSpec.js (tuneOpus) + voice-mesh.js registered in SpecRunner.
1440 IT/UT green; voice channels IT + full Jasmine + voice-btn FT green.
Voice infra is code-complete — provision the coturn droplet to go live.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:57:09 -04:00
|
|
|
|
|
|
|
|
log-file=/var/log/turnserver/turn.log
|
|
|
|
|
simple-log
|