MVP #1

Merged
JMR-dev merged 1 commits from feat-mvp into main 2026-04-02 01:10:40 +00:00
8 changed files with 85 additions and 59 deletions
+15 -27
View File
@@ -8,14 +8,10 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
TF_VAR_gcp_project: ${{ vars.GCP_PROJECT || 'stoat-burrow' }}
TF_VAR_gcp_region: ${{ vars.GCP_REGION || 'us-west1' }}
TF_VAR_gcp_zone: ${{ vars.GCP_ZONE || 'us-west1-b' }}
TF_VAR_gcp_project: ${{ secrets.GCP_PROJECT }}
TF_VAR_gcp_region: ${{ vars.GCP_REGION }}
TF_VAR_gcp_zone: ${{ vars.GCP_ZONE }}
steps:
- name: Checkout repository
@@ -49,16 +45,12 @@ jobs:
shell: bash
run: |
cat > backend.hcl <<EOF
bucket = "stoat-tofu-state"
key = "webrtc-relay/terraform.tfstate"
region = "us-east-2"
endpoints = {
s3 = "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com"
s3 = ${{ secrets.R2_ENDPOINT_URL }}
}
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true
access_key = "${{ secrets.R2_ACCESS_KEY_ID }}"
secret_key = "${{ secrets.R2_SECRET_ACCESS_KEY }}"
EOF
- name: Tofu init and apply
@@ -68,18 +60,18 @@ jobs:
run: |
tofu init -backend-config=backend.hcl
tofu apply -auto-approve -input=false
echo "STATIC_IP=$(tofu output -raw relay_ip)" >> "${GITHUB_ENV}"
echo "${{ secrets.STATIC_IP }}=$(tofu output -raw relay_ip)" >> "${GITHUB_ENV}"
- name: Wait for SSH
shell: bash
run: |
for attempt in {1..30}; do
if nc -z -w 5 "${STATIC_IP}" 22; then
if nc -z -w 5 "${${{ secrets.STATIC_IP }}}" 22; then
exit 0
fi
sleep 10
done
echo "SSH did not become reachable on ${STATIC_IP}" >&2
echo "SSH did not become reachable on ${${{ secrets.STATIC_IP }}}" >&2
exit 1
- name: Run Ansible Playbook
@@ -90,13 +82,13 @@ jobs:
key: ${{ secrets.SSH_PRIVATE_KEY }}
inventory: |
[turn_nodes]
${{ env.STATIC_IP }} ansible_user=ubuntu
${{ secrets.STATIC_IP }} ansible_user=ubuntu
options: --ssh-common-args='-o StrictHostKeyChecking=no'
- name: SCP compose and config files
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.STATIC_IP }}
host: ${{ secrets.STATIC_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "compose/*"
@@ -106,22 +98,18 @@ jobs:
- name: Deploy Podman compose stack
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.STATIC_IP }}
host: ${{ secrets.STATIC_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -eu
cd /opt/stoat-turn
cat > .env <<EOF
STATIC_IP=${{ env.STATIC_IP }}
STATIC_IP = ${{ secrets.STATIC_IP }}
GITHUB_REPOSITORY_OWNER=${{ github.repository_owner }}
TURN_REALM=${{ vars.TURN_REALM || 'turn.example.com' }}
TURN_REALM=${{ secrets.TURN_REALM }}
TURN_SHARED_SECRET=${{ secrets.TURN_SHARED_SECRET }}
CADDY_DOMAIN=${{ vars.CADDY_DOMAIN || 'relay.example.com' }}
CADDY_EMAIL=${{ vars.CADDY_EMAIL || 'ops@example.com' }}
STOAT_UPSTREAM=${{ vars.STOAT_UPSTREAM || 'http://host.containers.internal:8080' }}
TURN_TLS_CERT_FILE=${{ vars.TURN_TLS_CERT_FILE }}
TURN_TLS_KEY_FILE=${{ vars.TURN_TLS_KEY_FILE }}
CADDY_EMAIL=${{ secrets.CADDY_EMAIL }}
EOF
printf '%s\n' "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
+9 -7
View File
@@ -1,6 +1,6 @@
# WebRTC Outpost
Configuration repository for a Coturn relay that supports a Stoat deployment on GCP. The stack provisions a `relay-main` Ubuntu 24.04 instance, hardens the host with `nftables`, `fail2ban`, and `unattended-upgrades`, then deploys Coturn plus a Coraza-enabled Caddy reverse proxy with Podman Compose.
Configuration repository for a Coturn relay that supports a Stoat deployment on GCP. The stack provisions a `relay-main` Ubuntu 24.04 instance, hardens the host with `nftables`, `fail2ban`, and `unattended-upgrades`, then deploys Coturn plus a Coraza-enabled Caddy service with Podman Compose. Caddy handles ACME certificate issuance, HTTPS health checks, and deny-by-default web responses on the TURN hostname. Coturn handles STUN and TURN traffic directly on `3478` and `5349`, reusing the certificate that Caddy stores in the shared data volume.
## Repository Layout
@@ -25,20 +25,22 @@ Configuration repository for a Coturn relay that supports a Stoat deployment on
- `GCP_REGION`
- `GCP_ZONE`
- `TURN_REALM`
- `CADDY_DOMAIN`
- `CADDY_EMAIL`
- `STOAT_UPSTREAM`
- `TURN_TLS_CERT_FILE`
- `TURN_TLS_KEY_FILE`
## Notes
- The OpenTofu S3 backend is configured at deploy time so the Cloudflare R2 endpoint does not need to be committed to the repository.
- The Google provider reads service account credentials from the standard `GOOGLE_APPLICATION_CREDENTIALS` shell environment variable. Set it to the JSON key file path for local `tofu` runs.
- The custom Coturn image renders runtime settings from environment variables before starting `turnserver`.
- `TURN_TLS_CERT_FILE` and `TURN_TLS_KEY_FILE` are optional. If they are omitted, Coturn starts on `3478` only and skips the `5349` TLS listener.
- The custom Coturn image waits for the Caddy-managed certificate for `TURN_REALM` to appear in the shared `caddy_data` volume before starting the TLS listener on `5349`.
- Caddy on the relay host only answers `/health` and returns `403` for other HTTPS requests. TURN and STUN traffic does not pass through Caddy; Coturn receives it directly through host networking.
- The Ansible playbook lowers `net.ipv4.ip_unprivileged_port_start` to `80` so a rootless Podman-managed Caddy container can bind to `80` and `443`.
## DNS Setup
- Create a DNS `A` record so the hostname used by `TURN_REALM` resolves to the OpenTofu-provisioned `relay_ip`.
- If you use Cloudflare, keep that record set to DNS-only. The orange-cloud proxy does not support TURN or STUN over UDP.
- Optional SRV records can advertise the default ports: `_stun._udp` on `3478`, `_turn._udp` on `3478`, and `_turns._tcp` on `5349` for the same hostname.
## Local OpenTofu Usage
Export the Google credentials path and required OpenTofu variables before running `tofu` locally:
+1 -6
View File
@@ -1,10 +1,5 @@
STATIC_IP=203.0.113.10
STATIC_IP=0.0.0.0
GITHUB_REPOSITORY_OWNER=example-owner
TURN_REALM=turn.example.com
TURN_SHARED_SECRET=replace-me
CADDY_DOMAIN=relay.example.com
CADDY_EMAIL=ops@example.com
STOAT_UPSTREAM=http://host.containers.internal:8080
TURN_TLS_CERT_FILE=
TURN_TLS_KEY_FILE=
TURN_TLS_LISTENING_PORT=5349
+6 -5
View File
@@ -4,7 +4,7 @@
admin off
}
{$CADDY_DOMAIN:localhost} {
{$TURN_REALM:localhost} {
log {
output stdout
format console
@@ -18,9 +18,6 @@
Referrer-Policy no-referrer
}
@health path /healthz
respond @health 200 "ok"
coraza_waf {
load_owasp_crs
directives `
@@ -30,5 +27,9 @@
`
}
reverse_proxy {$STOAT_UPSTREAM:http://host.containers.internal:8080}
route {
@health path /health /healthz
respond @health "Stoat Relay is Online" 200
respond 403
}
}
+4 -6
View File
@@ -7,9 +7,8 @@ services:
- "80:80"
- "443:443"
environment:
CADDY_DOMAIN: ${CADDY_DOMAIN}
CADDY_EMAIL: ${CADDY_EMAIL}
STOAT_UPSTREAM: ${STOAT_UPSTREAM}
TURN_REALM: ${TURN_REALM}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
@@ -19,17 +18,16 @@ services:
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/stoat-coturn:4.9.0-trixie
container_name: coturn-relay
restart: always
depends_on:
- caddy
network_mode: host
environment:
EXTERNAL_IP: ${STATIC_IP}
TURN_REALM: ${TURN_REALM}
TURN_SHARED_SECRET: ${TURN_SHARED_SECRET}
TURN_TLS_CERT_FILE: ${TURN_TLS_CERT_FILE:-}
TURN_TLS_KEY_FILE: ${TURN_TLS_KEY_FILE:-}
TURN_TLS_LISTENING_PORT: ${TURN_TLS_LISTENING_PORT:-5349}
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
- caddy_data:/caddy-data:ro
- caddy_data:/caddy-certs:ro
volumes:
caddy_data:
+3
View File
@@ -3,6 +3,7 @@ use-auth-secret
lt-cred-mech
listening-port=3478
tls-listening-port=5349
min-port=49152
max-port=65535
@@ -10,3 +11,5 @@ no-cli
stale-nonce=600
no-loopback-peers
no-multicast-peers
no-stdout-log
log-file=/var/log/turnserver.log
+1
View File
@@ -3,6 +3,7 @@ FROM coturn:4.9.0-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
+46 -8
View File
@@ -3,26 +3,64 @@ set -eu
conf_source="/etc/coturn/turnserver.conf"
conf_rendered="/tmp/turnserver.conf"
caddy_cert_root="/caddy-certs/caddy/certificates"
: "${TURN_REALM:?TURN_REALM is required}"
: "${TURN_SHARED_SECRET:?TURN_SHARED_SECRET is required}"
discover_tls_files() {
cert_file=""
key_file=""
if [ -d "${caddy_cert_root}" ]; then
cert_file=$(find "${caddy_cert_root}" -path "*/${TURN_REALM}/${TURN_REALM}.crt" -print -quit)
key_file=$(find "${caddy_cert_root}" -path "*/${TURN_REALM}/${TURN_REALM}.key" -print -quit)
fi
if [ -n "${cert_file}" ] && [ -n "${key_file}" ]; then
printf '%s\n%s\n' "${cert_file}" "${key_file}"
fi
}
wait_for_tls_files() {
elapsed=0
interval=5
timeout=300
while [ "${elapsed}" -le "${timeout}" ]; do
tls_files=$(discover_tls_files)
if [ -n "${tls_files}" ]; then
printf '%s\n' "${tls_files}"
return 0
fi
if [ "${elapsed}" -eq "${timeout}" ]; then
break
fi
echo "Waiting for Caddy certificate files for ${TURN_REALM} in ${caddy_cert_root}..." >&2
sleep "${interval}"
elapsed=$((elapsed + interval))
done
echo "Timed out waiting for Caddy certificate files for ${TURN_REALM} in ${caddy_cert_root}." >&2
return 1
}
tls_files=$(wait_for_tls_files)
tls_cert_file=$(printf '%s\n' "${tls_files}" | sed -n '1p')
tls_key_file=$(printf '%s\n' "${tls_files}" | sed -n '2p')
cp "${conf_source}" "${conf_rendered}"
{
echo
echo "realm=${TURN_REALM}"
echo "static-auth-secret=${TURN_SHARED_SECRET}"
echo "cert=${tls_cert_file}"
echo "pkey=${tls_key_file}"
} >> "${conf_rendered}"
if [ -n "${TURN_TLS_CERT_FILE:-}" ] && [ -n "${TURN_TLS_KEY_FILE:-}" ]; then
{
echo "tls-listening-port=${TURN_TLS_LISTENING_PORT:-5349}"
echo "cert=${TURN_TLS_CERT_FILE}"
echo "pkey=${TURN_TLS_KEY_FILE}"
} >> "${conf_rendered}"
fi
set -- turnserver -n -c "${conf_rendered}"
if [ -n "${EXTERNAL_IP:-}" ]; then