diff --git a/coturn/entrypoint.sh b/coturn/entrypoint.sh new file mode 100755 index 0000000..9bf6322 --- /dev/null +++ b/coturn/entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# coturn entrypoint: stage the TLS certificate for the unprivileged user. +# +# Let's Encrypt keeps privkey.pem as root:root 0600, but coturn drops to +# nobody, so it cannot read the key from the mounted store and silently +# starts without TLS listeners (no turns: on 5349). Rather than loosening +# permissions on the shared Let's Encrypt tree -- which would expose the key +# to every other container and user on the host -- copy it to a private +# directory owned by the runtime user. +# +# This mirrors what the Prosody entrypoint does. Because the copy happens at +# start, a certificate renewal needs a container restart to be picked up; +# that is handled by the certbot deploy hook. +set -e + +SRC="/etc/letsencrypt/live/guschin.info" +DST="/var/lib/coturn/certs" +RUN_AS_UID="${COTURN_UID:-65534}" +RUN_AS_GID="${COTURN_GID:-65533}" + +if [ -r "$SRC/fullchain.pem" ] && [ -r "$SRC/privkey.pem" ]; then + mkdir -p "$DST" + # -L to dereference the live/ symlinks into archive/. + cp -L "$SRC/fullchain.pem" "$DST/fullchain.pem" + cp -L "$SRC/privkey.pem" "$DST/privkey.pem" + chown "$RUN_AS_UID:$RUN_AS_GID" "$DST/fullchain.pem" "$DST/privkey.pem" + chmod 644 "$DST/fullchain.pem" + chmod 600 "$DST/privkey.pem" + chown "$RUN_AS_UID:$RUN_AS_GID" "$DST" + echo "coturn: staged TLS certificate for uid $RUN_AS_UID" +else + # Not fatal: coturn still serves STUN and plain TURN on 3478. Make the + # degraded state loud rather than letting it hide in the noise. + echo "coturn: WARNING - cannot read $SRC; turns:// on 5349 will be DISABLED" >&2 +fi + +exec "$@" diff --git a/coturn/turnserver.conf b/coturn/turnserver.conf index 83da6d8..7a116b2 100644 --- a/coturn/turnserver.conf +++ b/coturn/turnserver.conf @@ -34,8 +34,15 @@ realm=guschin.info # TLS for turns:. Uses the existing multi-SAN guschin.info certificate, which # is why the advertised TURN host is guschin.info and not turn.guschin.info # (the latter is not in the cert's SANs, so TLS validation would fail). -cert=/etc/letsencrypt/live/guschin.info/fullchain.pem -pkey=/etc/letsencrypt/live/guschin.info/privkey.pem +# These are the copies staged by entrypoint.sh, not the Let's Encrypt +# originals: the real privkey.pem is root-only and coturn runs as nobody. +cert=/var/lib/coturn/certs/fullchain.pem +pkey=/var/lib/coturn/certs/privkey.pem + +# Drop privileges after binding. The container starts as root only so the +# entrypoint can stage the certificate. +proc-user=nobody +proc-group=nogroup # Harden: this is a relay for our own users, not an open proxy. # Deny relaying to private ranges so TURN can't be used to reach internal diff --git a/docker-compose.yml b/docker-compose.yml index cafb576..2ebf0d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,13 +69,16 @@ services: network_mode: host volumes: - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro + - ./coturn/entrypoint.sh:/coturn-entrypoint.sh:ro # The live/ files are relative symlinks into ../../archive/, so both # trees must be mounted at their real paths for the links to resolve. - # coturn reads the certificate directly (unlike Prosody, whose - # entrypoint copies it), so a broken symlink silently disables TLS. - /etc/letsencrypt/live/guschin.info:/etc/letsencrypt/live/guschin.info:ro - /etc/letsencrypt/archive/guschin.info:/etc/letsencrypt/archive/guschin.info:ro - command: ["-c", "/etc/coturn/turnserver.conf"] + # Start as root so the entrypoint can stage the TLS key for the + # unprivileged runtime user; coturn itself drops to nobody via -u below. + user: "0:0" + entrypoint: ["/coturn-entrypoint.sh"] + command: ["turnserver", "-c", "/etc/coturn/turnserver.conf"] restart: unless-stopped mem_limit: 128M