diff --git a/warzone/deploy/docker/Dockerfile.server b/warzone/deploy/docker/Dockerfile.server index 3959c32..5bdfd5c 100644 --- a/warzone/deploy/docker/Dockerfile.server +++ b/warzone/deploy/docker/Dockerfile.server @@ -1,6 +1,6 @@ # featherChat server — multi-stage build # Build context: featherChat repo root (../../..) -FROM rust:1.85-bookworm AS builder +FROM rust:latest AS builder WORKDIR /build diff --git a/warzone/deploy/docker/Dockerfile.wzp b/warzone/deploy/docker/Dockerfile.wzp index b2be8a3..ba4492d 100644 --- a/warzone/deploy/docker/Dockerfile.wzp +++ b/warzone/deploy/docker/Dockerfile.wzp @@ -1,6 +1,6 @@ # WZP relay + web bridge — multi-stage build # Build context: featherChat repo root (../../..) -FROM rust:1.85-bookworm AS builder +FROM rust:latest AS builder WORKDIR /build diff --git a/warzone/scripts/deploy-voip.sh b/warzone/scripts/deploy-voip.sh new file mode 100755 index 0000000..0c2f4c2 --- /dev/null +++ b/warzone/scripts/deploy-voip.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy featherChat + WZP stack to voip.manko.yoga on a Hetzner VPS. +# Prerequisites: hcloud CLI authenticated, SSH key "wz", CF API token. +# +# Usage: +# ./scripts/deploy-voip.sh --create Create VPS, install Docker, deploy stack +# ./scripts/deploy-voip.sh --deploy Upload source + docker compose up (on existing VPS) +# ./scripts/deploy-voip.sh --dns Update Cloudflare DNS records +# ./scripts/deploy-voip.sh --test Run smoke tests against voip.manko.yoga +# ./scripts/deploy-voip.sh --ssh SSH into the VPS +# ./scripts/deploy-voip.sh --destroy Delete VPS + DNS records +# ./scripts/deploy-voip.sh --logs Tail docker compose logs +# ./scripts/deploy-voip.sh --all Create + DNS + deploy + test + +VM_NAME="fc-voip" +SSH_KEY_NAME="wz" +SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" +SERVER_TYPE="cx23" +IMAGE="debian-12" +LOCATION="fsn1" +REMOTE_USER="root" +DOMAIN="voip.manko.yoga" +CF_ZONE="manko.yoga" + +PROJECT_ROOT="/Users/manwe/CascadeProjects/featherChat" +DEPLOY_DIR="warzone/deploy/docker" + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -q" + +# --------------------------------------------------------------------------- +# CF Token — read from deploy/docker/cf_api_token.txt or env +# --------------------------------------------------------------------------- + +get_cf_token() { + if [ -f "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" ]; then + cat "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" | tr -d '\n' + elif [ -n "${CF_API_TOKEN:-}" ]; then + echo "$CF_API_TOKEN" + else + echo "ERROR: No CF token. Create $DEPLOY_DIR/cf_api_token.txt or set CF_API_TOKEN" >&2 + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +get_vm_ip() { + hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ' +} + +get_vm_ipv6() { + hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||' +} + +ssh_cmd() { + local ip + ip=$(get_vm_ip) + [ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; } + ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@" +} + +scp_to() { + local ip + ip=$(get_vm_ip) + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# --create: Create VPS + install Docker +# --------------------------------------------------------------------------- + +do_create() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -n "$existing" ]; then + echo "VM already exists: $VM_NAME" + echo " IPv4: $(get_vm_ip)" + echo " IPv6: $(get_vm_ipv6)" + return + fi + + echo "[1/4] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..." + hcloud server create \ + --name "$VM_NAME" \ + --type "$SERVER_TYPE" \ + --image "$IMAGE" \ + --ssh-key "$SSH_KEY_NAME" \ + --location "$LOCATION" \ + --quiet + + local ipv4 ipv6 + ipv4=$(get_vm_ip) + ipv6=$(get_vm_ipv6) + echo " IPv4: $ipv4" + echo " IPv6: $ipv6" + + # Wait for SSH + echo "[2/4] Waiting for SSH..." + for i in $(seq 1 30); do + if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null; then + break + fi + sleep 2 + done + + # Install Docker + echo "[3/4] Installing Docker..." + ssh_cmd "apt-get update -qq > /dev/null 2>&1 && \ + apt-get install -y -qq ca-certificates curl gnupg > /dev/null 2>&1 && \ + install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \ + chmod a+r /etc/apt/keyrings/docker.gpg && \ + echo 'deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable' > /etc/apt/sources.list.d/docker.list && \ + apt-get update -qq > /dev/null 2>&1 && \ + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1" + + # Enable IPv6 in Docker + echo "[4/4] Configuring Docker IPv6..." + ssh_cmd 'mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && systemctl restart docker' + + echo "" + echo "=== VPS Ready ===" + echo "IPv4: $ipv4" + echo "IPv6: $ipv6" + echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4" +} + +# --------------------------------------------------------------------------- +# --dns: Update Cloudflare DNS records +# --------------------------------------------------------------------------- + +do_dns() { + local ipv4 ipv6 cf_token zone_id + ipv4=$(get_vm_ip) + ipv6=$(get_vm_ipv6) + cf_token=$(get_cf_token) + + [ -z "$ipv4" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; } + + echo "Updating DNS: $DOMAIN" + echo " A → $ipv4" + echo " AAAA → ${ipv6}1" + + # Get zone ID + zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \ + -H "Authorization: Bearer $cf_token" \ + -H "Content-Type: application/json" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") + + echo " Zone: $zone_id" + + # Upsert A record + local a_id + a_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=A" \ + -H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "") + + if [ -n "$a_id" ]; then + curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$a_id" \ + -H "Authorization: Bearer $cf_token" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null + echo " A record updated" + else + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ + -H "Authorization: Bearer $cf_token" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null + echo " A record created" + fi + + # Upsert AAAA record (append ::1 to the /64 prefix) + local aaaa_id aaaa_addr="${ipv6}1" + aaaa_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=AAAA" \ + -H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "") + + if [ -n "$aaaa_id" ]; then + curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$aaaa_id" \ + -H "Authorization: Bearer $cf_token" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null + echo " AAAA record updated" + else + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ + -H "Authorization: Bearer $cf_token" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null + echo " AAAA record created" + fi + + echo " Done. Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short" +} + +# --------------------------------------------------------------------------- +# --deploy: Upload source + docker compose up +# --------------------------------------------------------------------------- + +do_deploy() { + local ip cf_token + ip=$(get_vm_ip) + cf_token=$(get_cf_token) + [ -z "$ip" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; } + + echo "[1/4] Creating source tarball..." + tar czf /tmp/fc-voip.tar.gz \ + --exclude='target' \ + --exclude='.git' \ + --exclude='.claude' \ + --exclude='.DS_Store' \ + --exclude='notes' \ + -C "$PROJECT_ROOT" \ + warzone/Cargo.toml warzone/Cargo.lock warzone/crates \ + warzone/deploy/docker \ + warzone/wasm-pkg \ + warzone-phone/Cargo.toml warzone-phone/Cargo.lock warzone-phone/crates \ + .dockerignore \ + 2>/dev/null || true + + local size + size=$(du -h /tmp/fc-voip.tar.gz | cut -f1) + echo " Tarball: $size" + + echo "[2/4] Uploading to $ip..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-voip.tar.gz "$REMOTE_USER@$ip:/root/fc-voip.tar.gz" + ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-voip.tar.gz -C /root/featherChat" + rm -f /tmp/fc-voip.tar.gz + + echo "[3/4] Setting up CF token + docker compose..." + ssh_cmd "cd /root/featherChat/warzone/deploy/docker && echo '$cf_token' > cf_api_token.txt && chmod 600 cf_api_token.txt" + + echo "[4/4] Building + starting stack (this takes a while on first run)..." + ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose up -d --build 2>&1" | tail -20 + + echo "" + echo "=== Deployed ===" + echo "URL: https://$DOMAIN" + echo "Logs: $0 --logs" + echo "Test: $0 --test" +} + +# --------------------------------------------------------------------------- +# --test: Smoke test +# --------------------------------------------------------------------------- + +do_test() { + echo "=== Smoke Test: $DOMAIN ===" + echo "" + + local pass=0 fail=0 + + check() { + local name="$1" url="$2" expect="$3" + local status + status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000") + if [ "$status" = "$expect" ]; then + echo " OK $name ($status)" + pass=$((pass + 1)) + else + echo " FAIL $name (got $status, expected $expect)" + fail=$((fail + 1)) + fi + } + + check "Web UI" "https://$DOMAIN/" "200" + check "API health" "https://$DOMAIN/v1/health" "200" + check "WASM module" "https://$DOMAIN/wasm/warzone_wasm.js" "200" + check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200" + check "Audio bridge" "https://$DOMAIN/audio/" "200" + check "Bot list" "https://$DOMAIN/v1/bot/list" "200" + + # TLS check + echo -n " " + local issuer + issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "unknown") + echo "TLS: $issuer" + + # IPv4 + echo -n " " + local v4 + v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?") + echo "IPv4: $v4" + + # IPv6 + echo -n " " + local v6 + v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?") + echo "IPv6: $v6" + + # IPv6 connectivity + echo -n " " + local v6_status + v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000") + if [ "$v6_status" = "200" ]; then + echo "IPv6 reachable: OK ($v6_status)" + pass=$((pass + 1)) + else + echo "IPv6 reachable: no ($v6_status)" + fi + + echo "" + echo "Results: $pass passed, $fail failed" +} + +# --------------------------------------------------------------------------- +# --ssh / --logs / --destroy +# --------------------------------------------------------------------------- + +do_ssh() { + local ip + ip=$(get_vm_ip) + [ -z "$ip" ] && { echo "No VM." >&2; exit 1; } + exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" +} + +do_logs() { + ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose logs -f --tail=50" +} + +do_destroy() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -z "$existing" ]; then + echo "No VM '$VM_NAME' to destroy." + return + fi + + echo "Destroying VM: $VM_NAME" + hcloud server delete "$VM_NAME" + echo "VM deleted." + + # Optionally clean DNS + echo "" + read -p "Also remove DNS records for $DOMAIN? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + local cf_token zone_id + cf_token=$(get_cf_token) + zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \ + -H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") + + for type in A AAAA; do + local rec_id + rec_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \ + -H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "") + if [ -n "$rec_id" ]; then + curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \ + -H "Authorization: Bearer $cf_token" > /dev/null + echo " Deleted $type record" + fi + done + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +case "${1:-}" in + --create) do_create ;; + --dns) do_dns ;; + --deploy) do_deploy ;; + --test) do_test ;; + --ssh) do_ssh ;; + --logs) do_logs ;; + --destroy) do_destroy ;; + --all) do_create; do_dns; do_deploy; do_test ;; + *) + echo "Deploy featherChat stack to voip.manko.yoga" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " --create Create Hetzner cx23 VPS + install Docker" + echo " --dns Update Cloudflare DNS (A + AAAA)" + echo " --deploy Upload source + docker compose up" + echo " --test Smoke test (6 HTTP checks + TLS + IPv6)" + echo " --ssh SSH into the VPS" + echo " --logs Tail docker compose logs" + echo " --destroy Delete VPS + optionally DNS" + echo " --all create + dns + deploy + test" + echo "" + echo "First run: $0 --all" + echo "Redeploy: $0 --deploy && $0 --test" + ;; +esac