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>
This commit is contained in:
Disco DeDisco
2026-05-27 13:57:09 -04:00
parent d0c39b51b6
commit 41217d5438
26 changed files with 957 additions and 2 deletions

View File

@@ -0,0 +1,81 @@
# Provision the dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice —
# Phase C of the my-sea invite/voice sprint. Mirrors the PySwiss split: its own
# DigitalOcean droplet, NOT the app box. CI needs none of this (signaling tests
# use the in-memory channel layer; the TURN endpoint is unit-tested w. a fake
# secret) — this runs only when you actually stand voice up on staging/prod.
#
# Prereqs (manual, one-time):
# 1. Create a DO droplet + a reserved/static public IP; point
# turn.earthmanrpg.me at it.
# 2. Add it to inventory.ini under [coturn] with host_vars:
# coturn_secret, coturn_realm, coturn_public_ip[, coturn_private_ip,
# coturn_tls_cert, coturn_tls_key]
# 3. Put the SAME coturn_secret into the APP droplet's env as
# COTURN_SHARED_SECRET (+ COTURN_TURN_HOST=turn.earthmanrpg.me,
# COTURN_REALM) so the /api/voice/turn-credentials/ HMAC matches.
#
# Run: ansible-playbook -i inventory.ini coturn-playbook.yaml
#
# nginx already proxy-upgrades WebSocket on the APP droplet (nginx.conf.j2), so
# ws/voice/ rides the existing proxy — no nginx change here.
- hosts: coturn
become: true
tasks:
- name: Install coturn
ansible.builtin.apt:
name: coturn
state: latest
update_cache: true
- name: Enable the coturn daemon
ansible.builtin.lineinfile:
path: /etc/default/coturn
regexp: '^#?TURNSERVER_ENABLED='
line: 'TURNSERVER_ENABLED=1'
- name: Ensure turn log dir exists
ansible.builtin.file:
path: /var/log/turnserver
state: directory
owner: turnserver
group: turnserver
mode: '0755'
- name: Deploy turnserver.conf
ansible.builtin.template:
src: coturn.conf.j2
dest: /etc/turnserver.conf
mode: '0640'
notify: Restart coturn
- name: Open STUN/TURN signaling ports (3478 udp+tcp)
community.general.ufw:
rule: allow
port: '3478'
proto: "{{ item }}"
loop: [udp, tcp]
- name: Open TURN-over-TLS port (5349 tcp)
community.general.ufw:
rule: allow
port: '5349'
proto: tcp
- name: Open the relay UDP port range (49152-65535)
community.general.ufw:
rule: allow
port: '49152:65535'
proto: udp
- name: Enable + start coturn
ansible.builtin.systemd:
name: coturn
enabled: true
state: started
handlers:
- name: Restart coturn
ansible.builtin.systemd:
name: coturn
state: restarted

49
infra/coturn.conf.j2 Normal file
View File

@@ -0,0 +1,49 @@
# 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 %}
# 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
log-file=/var/log/turnserver/turn.log
simple-log

View File

@@ -8,3 +8,10 @@ dashboard.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.s
[cicd]
gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
# Dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice — provisioned by
# coturn-playbook.yaml. UNCOMMENT + fill once the droplet + static IP exist
# (see the playbook header). coturn_secret must equal the app's
# COTURN_SHARED_SECRET. coturn_private_ip / coturn_tls_* are optional.
# [coturn]
# turn.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd coturn_secret=CHANGEME coturn_realm=earthmanrpg.me coturn_public_ip=CHANGEME