#!/bin/bash set -euo pipefail # Deploy featherChat + WZP to chat.manko.yoga on Hetzner cx23. # Clones from git, builds with Docker, sets up Caddy + TLS. # # Usage: # ./scripts/deploy-chat.sh --create Create VPS + install Docker # ./scripts/deploy-chat.sh --dns Update CF DNS (A + AAAA) # ./scripts/deploy-chat.sh --deploy Clone repos + docker compose up # ./scripts/deploy-chat.sh --redeploy Git pull + rebuild # ./scripts/deploy-chat.sh --test Smoke test # ./scripts/deploy-chat.sh --ssh SSH into VPS # ./scripts/deploy-chat.sh --logs Tail logs # ./scripts/deploy-chat.sh --destroy Delete VPS + DNS # ./scripts/deploy-chat.sh --all create + dns + deploy + test VM_NAME="fc-chat" SSH_KEY_NAME="wz" SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" SERVER_TYPE="cx23" IMAGE="debian-12" LOCATION="fsn1" REMOTE_USER="root" DOMAIN="chat.manko.yoga" CF_ZONE="manko.yoga" # Git repos (public, HTTP) GIT_FC="https://git.manko.yoga/manawenuz/featherChat.git" GIT_WZP="https://git.manko.yoga/manawenuz/wz-phone.git" GIT_BRANCH="feature/call-ring-group" DEPLOY_DIR="/root/featherChat" DOCKER_DIR="$DEPLOY_DIR/warzone/deploy/docker" # Local paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" CF_TOKEN_FILE="$PROJECT_DIR/deploy/docker/cf_api_token.txt" SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -q" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- get_cf_token() { if [ -f "$CF_TOKEN_FILE" ]; then cat "$CF_TOKEN_FILE" | tr -d '\n' elif [ -n "${CF_API_TOKEN:-}" ]; then echo "$CF_API_TOKEN" else echo "ERROR: No CF token. Create deploy/docker/cf_api_token.txt" >&2; exit 1 fi } get_vm_ip() { hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' } 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" "$@" } # --------------------------------------------------------------------------- # --create # --------------------------------------------------------------------------- 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 ($(get_vm_ip))" return fi echo "[1/3] 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" echo "[2/3] Waiting for SSH..." for i in $(seq 1 30); do ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null && break sleep 2 done echo "[3/3] Installing Docker..." ssh_cmd 'export DEBIAN_FRONTEND=noninteractive && \ apt-get update -qq > /dev/null && \ apt-get install -y -qq ca-certificates curl gnupg git > /dev/null && \ 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 && \ apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null && \ 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 # --------------------------------------------------------------------------- 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." >&2; exit 1; } echo "Updating DNS: $DOMAIN" echo " A → $ipv4" echo " AAAA → ${ipv6}1" zone_id=$(curl -4 -s "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_content in "A:$ipv4" "AAAA:${ipv6}1"; do local type="${type_content%%:*}" content="${type_content#*:}" local rec_id rec_id=$(curl -4 -s "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 -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \ -H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \ --data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null echo " $type updated" else curl -4 -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\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null echo " $type created" fi done echo " Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short" } # --------------------------------------------------------------------------- # --deploy # --------------------------------------------------------------------------- do_deploy() { local ip cf_token ip=$(get_vm_ip) cf_token=$(get_cf_token) [ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; } echo "[1/4] Cloning repos on VPS..." ssh_cmd "rm -rf $DEPLOY_DIR && \ git clone --depth 1 -b $GIT_BRANCH $GIT_FC $DEPLOY_DIR && \ git clone --depth 1 $GIT_WZP $DEPLOY_DIR/warzone-phone" echo "[2/4] Updating Caddyfile domain..." ssh_cmd "sed -i 's/voip.manko.yoga/$DOMAIN/g' $DOCKER_DIR/Caddyfile" echo "[3/4] Setting up CF token..." ssh_cmd "echo '$cf_token' > $DOCKER_DIR/cf_api_token.txt && chmod 600 $DOCKER_DIR/cf_api_token.txt" echo "[4/4] Building + starting stack (takes a few minutes on first run)..." ssh_cmd "cd $DOCKER_DIR && \ sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \ docker compose up -d --build 2>&1" | tail -30 echo "" echo "=== Deployed ===" echo "URL: https://$DOMAIN" echo "Logs: $0 --logs" echo "Test: $0 --test" } # --------------------------------------------------------------------------- # --redeploy # --------------------------------------------------------------------------- do_redeploy() { local ip; ip=$(get_vm_ip) [ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; } echo "[1/2] Pulling latest..." ssh_cmd "cd $DEPLOY_DIR && git pull && \ cd $DEPLOY_DIR/warzone-phone && git pull" echo "[2/2] Rebuilding..." ssh_cmd "cd $DOCKER_DIR && \ sed -i 's/voip.manko.yoga/$DOMAIN/g' Caddyfile && \ sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \ docker compose up -d --build 2>&1" | tail -20 echo "=== Redeployed ===" } # --------------------------------------------------------------------------- # --test # --------------------------------------------------------------------------- do_test() { echo "=== Smoke Test: $DOMAIN ===" local pass=0 fail=0 check() { local name="$1" url="$2" expect="$3" local status status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "$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 "Health" "https://$DOMAIN/v1/health" "200" check "WASM" "https://$DOMAIN/wasm/warzone_wasm.js" "200" check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200" check "Bot list" "https://$DOMAIN/v1/bot/list" "200" check "Whoami" "https://$DOMAIN/v1/whoami" "200" # TLS local issuer issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "?") echo " TLS: $issuer" # IPv4 local v4; v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?") echo " A: $v4" # IPv6 local v6; v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?") echo " AAAA: $v6" # IPv6 connectivity local v6_status v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000") [ "$v6_status" = "200" ] && echo " IPv6: reachable ($v6_status)" && pass=$((pass + 1)) || echo " IPv6: not reachable ($v6_status)" # Whoami content local whoami whoami=$(curl -4 -s "https://$DOMAIN/v1/whoami" 2>/dev/null) echo " Whoami: $whoami" echo "" echo "Results: $pass passed, $fail failed" } # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- 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 $DOCKER_DIR && 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'." return fi echo "Destroying: $VM_NAME" hcloud server delete "$VM_NAME" echo "VM deleted." read -p "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 -4 -s "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 -4 -s "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 "") [ -n "$rec_id" ] && curl -4 -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" done fi } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- case "${1:-}" in --create) do_create ;; --dns) do_dns ;; --deploy) do_deploy ;; --redeploy) do_redeploy ;; --test) do_test ;; --ssh) do_ssh ;; --logs) do_logs ;; --destroy) do_destroy ;; --all) do_create; do_dns; do_deploy; echo ""; echo "Waiting 30s for TLS cert..."; sleep 30; do_test ;; *) echo "Deploy featherChat to chat.manko.yoga (Hetzner cx23)" echo "" echo "Usage: $0 " echo "" echo " --create Create VPS + install Docker" echo " --dns Update Cloudflare A + AAAA records" echo " --deploy Clone repos + docker compose up" echo " --redeploy Git pull + rebuild" echo " --test Smoke test (6 checks + TLS + IPv6)" echo " --ssh SSH into VPS" echo " --logs Tail docker compose logs" echo " --destroy Delete VPS + DNS" echo " --all Full deploy (create + dns + deploy + test)" ;; esac