- scripts/deploy-voip.sh: full Hetzner cx23 + Docker + CF DNS deploy --create: provision VPS, install Docker --dns: update CF A + AAAA records --deploy: upload source, docker compose up --test: 6 HTTP checks + TLS + IPv6 --all: end-to-end in one command - Dockerfiles: use rust:latest (time crate needs 1.88+) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
14 KiB
Bash
Executable File
388 lines
14 KiB
Bash
Executable File
#!/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 <command>"
|
|
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
|