This commit is contained in:
Jason Ross
2026-04-01 18:20:33 -05:00
commit 110021726b
19 changed files with 636 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
GOOGLE_APPLICATION_CREDENTIALS="secret-path-to-your-service-account-key.json"
TF_VAR_gcp_project="your-gcp-project"
TF_VAR_gcp_region="us-east1"
TF_VAR_gcp_zone="b"
+41
View File
@@ -0,0 +1,41 @@
name: 01 - Build and Push Images
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "docker/**"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Coturn image
uses: docker/build-push-action@v5
with:
context: ./docker/coturn
push: true
tags: ghcr.io/${{ github.repository_owner }}/stoat-coturn:4.9.0-trixie
- name: Build and push Caddy WAF image
uses: docker/build-push-action@v5
with:
context: ./docker/caddy
push: true
tags: ghcr.io/${{ github.repository_owner }}/stoat-caddy-waf:latest
+129
View File
@@ -0,0 +1,129 @@
name: 02 - Deploy Infrastructure and Stack
on:
workflow_dispatch:
jobs:
deploy:
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' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare SSH key for provisioning
shell: bash
run: |
install -m 700 -d "${HOME}/.ssh"
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > "${HOME}/.ssh/id_ed25519"
chmod 600 "${HOME}/.ssh/id_ed25519"
{
echo "TF_VAR_admin_ssh_public_key<<EOF"
ssh-keygen -y -f "${HOME}/.ssh/id_ed25519"
echo "EOF"
} >> "${GITHUB_ENV}"
- name: Prepare Google credentials file
shell: bash
run: |
credentials_file="${RUNNER_TEMP}/gcp-service-account.json"
printf '%s' "${{ secrets.GCP_SA_KEY }}" > "${credentials_file}"
chmod 600 "${credentials_file}"
echo "GOOGLE_APPLICATION_CREDENTIALS=${credentials_file}" >> "${GITHUB_ENV}"
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
- name: Write OpenTofu backend config
working-directory: ./tofu
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"
}
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true
EOF
- name: Tofu init and apply
id: tofu
working-directory: ./tofu
shell: bash
run: |
tofu init -backend-config=backend.hcl
tofu apply -auto-approve -input=false
echo "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
exit 0
fi
sleep 10
done
echo "SSH did not become reachable on ${STATIC_IP}" >&2
exit 1
- name: Run Ansible Playbook
uses: dawidd6/action-ansible-playbook@v2
with:
playbook: setup_host.yml
directory: ./ansible
key: ${{ secrets.SSH_PRIVATE_KEY }}
inventory: |
[turn_nodes]
${{ env.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 }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "compose/*"
target: "/opt/stoat-turn"
strip_components: 1
- name: Deploy Podman compose stack
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.STATIC_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -eu
cd /opt/stoat-turn
cat > .env <<EOF
STATIC_IP=${{ env.STATIC_IP }}
GITHUB_REPOSITORY_OWNER=${{ github.repository_owner }}
TURN_REALM=${{ vars.TURN_REALM || 'turn.example.com' }}
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 }}
EOF
printf '%s\n' "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin
podman compose pull
podman compose up -d
+8
View File
@@ -0,0 +1,8 @@
.codex
.env
.terraform/
.terraform.lock.hcl
*.retry
*.tfstate
*.tfstate.*
compose/.env
+57
View File
@@ -0,0 +1,57 @@
# 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.
## Repository Layout
- `tofu/`: OpenTofu infrastructure for the static IP, VM, and GCP firewall rule.
- `ansible/`: Host preparation and hardening for Ubuntu 24.04.
- `compose/`: Runtime configuration for Coturn and Caddy.
- `docker/`: Custom images for Coturn and Caddy.
- `.github/workflows/`: CI workflows for building images and deploying the stack.
## Required GitHub Secrets
- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`
- `GCP_SA_KEY`
- `SSH_PRIVATE_KEY`
- `TURN_SHARED_SECRET`
## Recommended GitHub Variables
- `GCP_PROJECT`
- `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 Ansible playbook lowers `net.ipv4.ip_unprivileged_port_start` to `80` so a rootless Podman-managed Caddy container can bind to `80` and `443`.
## Local OpenTofu Usage
Export the Google credentials path and required OpenTofu variables before running `tofu` locally:
```bash
export GOOGLE_APPLICATION_CREDENTIALS="secret-path"
export TF_VAR_gcp_project="your-gcp-project"
export TF_VAR_gcp_region="us-west1"
export TF_VAR_gcp_zone="us-west1-b"
cd tofu
tofu init
tofu apply
```
`network_name` defaults to `default`, `instance_name` defaults to `relay-main`, and `admin_ssh_public_key` is optional unless you want SSH access provisioned on the VM.
+101
View File
@@ -0,0 +1,101 @@
---
- name: Configure relay-main Host
hosts: all
become: true
vars:
deploy_user: "{{ ansible_user }}"
handlers:
- name: reload nftables
ansible.builtin.systemd:
name: nftables
state: reloaded
- name: restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
tasks:
- name: Install system dependencies
ansible.builtin.apt:
name:
- podman
- podman-docker
- docker-compose
- podman-compose
- nftables
- fail2ban
- unattended-upgrades
state: present
update_cache: true
cache_valid_time: 3600
- name: Enable apt timers used by unattended-upgrades
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
- name: Enable automatic security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
owner: root
group: root
mode: "0644"
- name: Ensure target directory exists and is owned by the deployment user
ansible.builtin.file:
path: /opt/stoat-turn
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
- name: Allow rootless Podman to bind ports 80 and above
ansible.builtin.copy:
dest: /etc/sysctl.d/60-rootless-podman-ports.conf
content: |
net.ipv4.ip_unprivileged_port_start = 80
owner: root
group: root
mode: "0644"
- name: Apply sysctl settings
ansible.builtin.command:
cmd: sysctl --system
changed_when: false
- name: Install nftables policy
ansible.builtin.template:
src: templates/nftables.conf.j2
dest: /etc/nftables.conf
owner: root
group: root
mode: "0644"
notify: reload nftables
- name: Install fail2ban jail configuration
ansible.builtin.template:
src: templates/jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: restart fail2ban
- name: Enable nftables and fail2ban
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- nftables
- fail2ban
+11
View File
@@ -0,0 +1,11 @@
[DEFAULT]
banaction = nftables-multiport
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
+31
View File
@@ -0,0 +1,31 @@
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
policy drop;
iif lo accept
ct state established,related accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
tcp dport 22 accept
tcp dport { 80, 443, 3478, 5349 } accept
udp dport { 3478, 5349 } accept
udp dport 49152-65535 accept
}
chain forward {
type filter hook forward priority 0;
policy drop;
}
chain output {
type filter hook output priority 0;
policy accept;
}
}
+10
View File
@@ -0,0 +1,10 @@
STATIC_IP=203.0.113.10
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
+34
View File
@@ -0,0 +1,34 @@
{
email {$CADDY_EMAIL:ops@example.com}
order coraza_waf first
admin off
}
{$CADDY_DOMAIN:localhost} {
log {
output stdout
format console
}
encode zstd gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
@health path /healthz
respond @health 200 "ok"
coraza_waf {
load_owasp_crs
directives `
SecRuleEngine On
SecRequestBodyAccess On
SecAuditEngine RelevantOnly
`
}
reverse_proxy {$STOAT_UPSTREAM:http://host.containers.internal:8080}
}
+36
View File
@@ -0,0 +1,36 @@
services:
caddy:
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/stoat-caddy-waf:latest
container_name: caddy-waf
restart: always
ports:
- "80:80"
- "443:443"
environment:
CADDY_DOMAIN: ${CADDY_DOMAIN}
CADDY_EMAIL: ${CADDY_EMAIL}
STOAT_UPSTREAM: ${STOAT_UPSTREAM}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
coturn:
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/stoat-coturn:4.9.0-trixie
container_name: coturn-relay
restart: always
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
volumes:
caddy_data:
caddy_config:
+12
View File
@@ -0,0 +1,12 @@
fingerprint
use-auth-secret
lt-cred-mech
listening-port=3478
min-port=49152
max-port=65535
no-cli
stale-nonce=600
no-loopback-peers
no-multicast-peers
+8
View File
@@ -0,0 +1,8 @@
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/corazawaf/coraza-caddy/v2
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+11
View File
@@ -0,0 +1,11 @@
FROM coturn:4.9.0-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
conf_source="/etc/coturn/turnserver.conf"
conf_rendered="/tmp/turnserver.conf"
: "${TURN_REALM:?TURN_REALM is required}"
: "${TURN_SHARED_SECRET:?TURN_SHARED_SECRET is required}"
cp "${conf_source}" "${conf_rendered}"
{
echo
echo "realm=${TURN_REALM}"
echo "static-auth-secret=${TURN_SHARED_SECRET}"
} >> "${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
set -- "$@" --external-ip "${EXTERNAL_IP}"
fi
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
terraform {
required_version = ">= 1.11.5"
backend "s3" {}
}
+60
View File
@@ -0,0 +1,60 @@
provider "google" {
project = var.gcp_project
region = var.gcp_region
}
resource "google_compute_address" "turn_static_ip" {
name = "stoat-turn-ip"
network_tier = "PREMIUM"
region = var.gcp_region
}
resource "google_compute_instance" "turn_outpost" {
name = var.instance_name
machine_type = var.machine_type
zone = var.gcp_zone
tags = ["webrtc-outpost", "caddy-web"]
boot_disk {
initialize_params {
image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64"
size = 20
}
}
metadata = var.admin_ssh_public_key == "" ? {} : {
ssh-keys = "ubuntu:${var.admin_ssh_public_key}"
}
network_interface {
network = var.network_name
access_config {
nat_ip = google_compute_address.turn_static_ip.address
network_tier = "PREMIUM"
}
}
}
resource "google_compute_firewall" "webrtc_rules" {
name = "allow-webrtc-and-web"
network = var.network_name
allow {
protocol = "tcp"
ports = ["80", "443", "3478", "5349"]
}
allow {
protocol = "udp"
ports = ["3478", "5349", "49152-65535"]
}
target_tags = ["webrtc-outpost", "caddy-web"]
}
output "relay_ip" {
description = "Public static IP for the relay."
value = google_compute_address.turn_static_ip.address
}
+38
View File
@@ -0,0 +1,38 @@
variable "gcp_project" {
description = "GCP project ID used for provider-scoped resources."
type = string
}
variable "gcp_region" {
description = "GCP region used for regional resources."
type = string
}
variable "gcp_zone" {
description = "GCP zone used for the relay instance."
type = string
}
variable "network_name" {
description = "VPC network name."
type = string
default = "default"
}
variable "instance_name" {
description = "Name of the relay VM."
type = string
default = "relay-main"
}
variable "machine_type" {
description = "GCP machine type for the relay."
type = string
default = "e2-micro"
}
variable "admin_ssh_public_key" {
description = "Optional SSH public key injected for the ubuntu user."
type = string
default = ""
}
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 7.26"
}
}
}