From c9a61e561469d7d4d86751a2c387279c1ca116b1 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 27 May 2026 14:10:28 -0400 Subject: [PATCH] coturn: optional dual-stack TURN via guarded coturn_public_ip6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set coturn_public_ip6 in inventory to advertise IPv6 relay candidates (2nd external-ip) AND emit matching v6 denied-peer-ip ranges (::1, fe80::/10, fc00::/7) for SSRF parity with the v4 lockdown. Unset → byte-identical pure-IPv4 config as before, so it's zero-risk opt-in. Droplet now has IPv6 on; this makes the conf dual-stack-ready. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- infra/coturn.conf.j2 | 15 +++++++++++++++ infra/inventory.ini | 3 +++ 2 files changed, 18 insertions(+) diff --git a/infra/coturn.conf.j2 b/infra/coturn.conf.j2 index bee44ab..c30bc28 100644 --- a/infra/coturn.conf.j2 +++ b/infra/coturn.conf.j2 @@ -22,6 +22,13 @@ realm={{ coturn_realm }} # 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 %} +{% 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 %} # Relay port range — open this exact UDP range in the firewall (playbook does). min-port=49152 @@ -44,6 +51,14 @@ 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 +{% 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 %} log-file=/var/log/turnserver/turn.log simple-log diff --git a/infra/inventory.ini b/infra/inventory.ini index 28d4e54..c71f27d 100644 --- a/infra/inventory.ini +++ b/infra/inventory.ini @@ -13,5 +13,8 @@ gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed # 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_public_ip6 (optional): set the droplet's public IPv6 to serve +# dual-stack TURN (adds a v6 external-ip + matching v6 peer-denial lockdown); +# leave unset for a pure-IPv4 relay. # [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