feat(turn): self-hosted coturn support + time-limited creds + failure UX
- /api/ice: when TURN_SECRET is set, mint short-lived HMAC credentials (coturn use-auth-secret) so no permanent password is exposed and the relay can't be abused. Static TURN_USERNAME/CREDENTIAL still supported. - share.html: connection watchdog + clear "couldn't connect on this network" message instead of a blank screen when no path can be established. - deploy/coturn: ready-to-run turnserver.conf + docker-compose + README for hosting our own TURN on a VM we own (flat cost, no per-GB billing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tento commit je obsažen v:
@@ -0,0 +1,44 @@
|
|||||||
|
# Self-hosted TURN (coturn) for BizGaze Connect
|
||||||
|
|
||||||
|
Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct
|
||||||
|
WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host
|
||||||
|
our own coturn on a VM we already own — flat cost, no per-GB billing.
|
||||||
|
|
||||||
|
## 1. VM prerequisites
|
||||||
|
- A VM with a **public IP** (your data-center VM is fine).
|
||||||
|
- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP.
|
||||||
|
- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com`
|
||||||
|
|
||||||
|
## 2. Open firewall ports (on the VM and any edge firewall)
|
||||||
|
- `3478/udp` and `3478/tcp` (STUN/TURN)
|
||||||
|
- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls
|
||||||
|
- `49152-65535/udp` (relay range)
|
||||||
|
|
||||||
|
## 3. Configure
|
||||||
|
Edit `turnserver.conf`:
|
||||||
|
- `external-ip=` your VM's public IP
|
||||||
|
- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`)
|
||||||
|
- `realm=` your domain
|
||||||
|
- `cert=` / `pkey=` paths to your Let's Encrypt cert
|
||||||
|
|
||||||
|
## 4. Run
|
||||||
|
```
|
||||||
|
docker compose up -d # uses docker-compose.yml here
|
||||||
|
# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Point the app at it (production env)
|
||||||
|
```
|
||||||
|
TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp
|
||||||
|
TURN_SECRET=<the same static-auth-secret from turnserver.conf>
|
||||||
|
TURN_TTL=86400
|
||||||
|
```
|
||||||
|
The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no
|
||||||
|
permanent password is exposed, and outsiders can't reuse your relay. Restart the app.
|
||||||
|
|
||||||
|
## 6. Verify
|
||||||
|
- `GET https://<app>/api/ice` should return a `turn:`/`turns:` entry with a username + credential.
|
||||||
|
- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
|
||||||
|
Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from
|
||||||
|
`/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks
|
||||||
|
are covered.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Run coturn on your VM: docker compose up -d
|
||||||
|
# host networking is required so the UDP relay port range works without per-port mapping.
|
||||||
|
services:
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: coturn
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns:
|
||||||
|
command: ["-c", "/etc/coturn/turnserver.conf"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# coturn config for BizGaze Connect self-hosted TURN.
|
||||||
|
# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or
|
||||||
|
# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder.
|
||||||
|
|
||||||
|
# --- listening ---
|
||||||
|
listening-port=3478
|
||||||
|
tls-listening-port=5349
|
||||||
|
# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal
|
||||||
|
# through strict corporate firewalls (uncomment + ensure nothing else uses 443):
|
||||||
|
# alt-tls-listening-port=443
|
||||||
|
|
||||||
|
# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE.
|
||||||
|
external-ip=CHANGE_ME_PUBLIC_IP
|
||||||
|
|
||||||
|
# Relay port range (open these UDP ports in the firewall too).
|
||||||
|
min-port=49152
|
||||||
|
max-port=65535
|
||||||
|
|
||||||
|
# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) ---
|
||||||
|
use-auth-secret
|
||||||
|
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
|
realm=connect.yourdomain.com
|
||||||
|
|
||||||
|
# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com ---
|
||||||
|
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
|
||||||
|
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
|
||||||
|
|
||||||
|
# --- hardening ---
|
||||||
|
fingerprint
|
||||||
|
no-cli
|
||||||
|
no-multicast-peers
|
||||||
|
no-tcp-relay
|
||||||
|
# Block relaying to private/internal ranges (prevents your relay being used to reach
|
||||||
|
# your own LAN / cloud metadata — important SSRF protection):
|
||||||
|
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||||
|
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||||
|
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||||
|
denied-peer-ip=169.254.0.0-169.254.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=::1
|
||||||
|
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||||
|
# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps
|
||||||
|
# bps-capacity=0
|
||||||
|
# total-quota=100
|
||||||
@@ -168,11 +168,15 @@ async function startStreaming(){
|
|||||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||||
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
|
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
|
||||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||||
pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
|
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
|
||||||
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
||||||
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
||||||
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||||
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
||||||
|
// Watchdog: if we can't establish the peer connection in time, show a clear reason
|
||||||
|
// instead of a blank screen (covers networks with no usable path even via TURN).
|
||||||
|
clearTimeout(window.__connWatch);
|
||||||
|
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
|
||||||
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
||||||
}
|
}
|
||||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
|||||||
+15
-7
@@ -122,16 +122,24 @@ route('GET', '/api/setup-state', async (req, res) => {
|
|||||||
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
|
// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
|
||||||
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
|
// configured. Two credential modes:
|
||||||
|
// - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint
|
||||||
|
// time-limited credentials per request (no permanent password is ever handed out, so
|
||||||
|
// outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h).
|
||||||
|
// - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential.
|
||||||
route('GET', '/api/ice', async (req, res) => {
|
route('GET', '/api/ice', async (req, res) => {
|
||||||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||||
if (process.env.TURN_URLS) {
|
if (process.env.TURN_URLS) {
|
||||||
iceServers.push({
|
const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean);
|
||||||
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
|
let username = process.env.TURN_USERNAME || '';
|
||||||
username: process.env.TURN_USERNAME || '',
|
let credential = process.env.TURN_CREDENTIAL || '';
|
||||||
credential: process.env.TURN_CREDENTIAL || '',
|
if (process.env.TURN_SECRET) {
|
||||||
});
|
const ttl = parseInt(process.env.TURN_TTL || '86400', 10);
|
||||||
|
username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "<expiry>"
|
||||||
|
credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64');
|
||||||
|
}
|
||||||
|
iceServers.push({ urls, username, credential });
|
||||||
}
|
}
|
||||||
json(res, 200, { iceServers });
|
json(res, 200, { iceServers });
|
||||||
});
|
});
|
||||||
|
|||||||
Odkázat v novém úkolu
Zablokovat Uživatele