Add a second, LAN-only TURN relay to avoid hairpin NAT entirely
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 25s

The existing coturn instance advertises the router's public IP as its
relay address (external-ip=94.255.139.35) — correct for real external
users, but when both peers are on the same home network as the router,
relay traffic has to hairpin through the router's own WAN IP. That only
worked with hwnat (hardware NAT offload) disabled, which pegged the
router's CPU at 93% for well under half its rated throughput — not an
acceptable trade for the whole household's network.

Instead of touching hwnat, added a second coturn deployment
(coturn-lan, own secret, own relay port range 49210-49220) exposed via
its own MetalLB LoadBalancer IP (10.10.3.46) on the same LAN segment.
That address is answered directly via L2 ARP — no NAT, no hairpin,
hwnat never touches it — and it's deliberately not port-forwarded from
WAN, so it's LAN-only by construction.

iceConfig() now appends this as an extra iceServers entry whenever
TURN_LAN_HOST/TURN_LAN_SECRET are set. It's harmless for anyone not on
that network — the address is simply unreachable and ICE ignores it —
and WebRTC tries every configured server, using whichever candidates
actually connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 13:13:25 +02:00
1 parent 5836424baf
commit 78d97ee733
2 files changed
+37 -6

No files matched your search

+29 -6
View File
@@ -24,8 +24,22 @@ const STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist'))
// STUN_URLS comma-separated stun: urls
// TURN_URLS comma-separated turn:/turns: urls
// TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls
// TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay
// (own coturn instance + secret, reachable only from
// the local network via its own LoadBalancer IP with
// no NAT/hairpin involved). Harmless to hand out to
// every client: anyone not on that network simply
// can't reach the address and ICE ignores it.
// ICE_SERVERS_JSON complete {"iceServers":[...]} override
// ---------------------------------------------------------------------------
function hmacTurnCredential(secret, label, ttl) {
const expiry = Math.floor(Date.now() / 1000) + ttl;
const username = `${expiry}:${label}`;
const hmac = crypto.createHmac('sha1', secret);
hmac.update(username);
return { username, credential: hmac.digest('base64') };
}
function iceConfig() {
if (process.env.ICE_SERVERS_JSON) {
try {
@@ -47,13 +61,9 @@ function iceConfig() {
// If a TURN secret is provided (coturn use-auth-secret / static-auth-secret), generate ephemeral HMAC credentials
const turnSecret = process.env.TURN_SECRET || process.env.TURN_STATIC_AUTH_SECRET || '';
const ttl = Number(process.env.TURN_TTL || 86400);
if (turnSecret) {
const ttl = Number(process.env.TURN_TTL || 86400);
const expiry = Math.floor(Date.now() / 1000) + ttl;
username = `${expiry}:gibbz`;
const hmac = crypto.createHmac('sha1', turnSecret);
hmac.update(username);
credential = hmac.digest('base64');
({ username, credential } = hmacTurnCredential(turnSecret, 'gibbz', ttl));
}
iceServers.push({
@@ -61,6 +71,19 @@ function iceConfig() {
username,
credential,
});
if (process.env.TURN_LAN_HOST && process.env.TURN_LAN_SECRET) {
const { username: lanUsername, credential: lanCredential } =
hmacTurnCredential(process.env.TURN_LAN_SECRET, 'gibbz-lan', ttl);
iceServers.push({
urls: [
`turn:${process.env.TURN_LAN_HOST}:3478?transport=udp`,
`turn:${process.env.TURN_LAN_HOST}:3478?transport=tcp`,
],
username: lanUsername,
credential: lanCredential,
});
}
}
return { iceServers };
}