#!/bin/bash set -euo pipefail GITEA_URL="https://git.timothykim.net" GITEA_ORG="hantim" TEMPLATE_REPO="static-site-template" SPINNER='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' spin_wait() { local secs=$1 msg=$2 start=$3 i=0 while [ $i -lt $((secs * 4)) ]; do local elapsed=$(( start + i / 4 )) local sc=${SPINNER:$(( (i) % ${#SPINNER} )):1} printf "\r %s %s (%ds)" "$sc" "$msg" "$elapsed" sleep 0.25 i=$((i + 1)) done } usage() { echo "Usage: ./tools/app.sh " echo "" echo "Commands:" echo " dns Set up Vultr DNS zone and A records" echo " repo Create Gitea repo from template" echo " files Create local compose, workflow, nginx conf" echo " cert Issue SSL certificate via deploy SSH" echo " garage Create Garage media bucket and grant access" echo " build Trigger initial build workflow" echo " verify Check if site is live" echo " monitor Create UptimeRobot HTTPS monitor" echo " all Run all steps (dns, repo, files, cert, garage, commit, build, verify, monitor)" echo "" echo "Requires: bws, jq, dig" exit 1 } COMMAND="${1:-}" APP="${2:-}" if [ -z "$COMMAND" ] || [ -z "$APP" ]; then usage fi if ! [[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then echo "Error: app name must be alphanumeric (hyphens, dots, underscores allowed)." exit 1 fi CONTAINER_NAME="${APP//./_}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # --- Dependency checks (only what each command needs) --- require_dig() { if ! command -v dig &>/dev/null; then echo "Error: dig is not installed." echo " On macOS: brew install bind" echo " On Linux: dnf install bind-utils" exit 1 fi } require_bws() { if ! command -v bws &>/dev/null; then echo "Error: Bitwarden Secrets Manager CLI (bws) is not installed." echo " https://github.com/bitwarden/sdk-sm/releases" exit 1 fi if ! command -v jq &>/dev/null; then echo "Error: jq is not installed." exit 1 fi BWS_TOKEN_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hantim/bws-token" if [ -z "${BWS_ACCESS_TOKEN:-}" ]; then if [ -f "$BWS_TOKEN_FILE" ]; then BWS_ACCESS_TOKEN=$(cat "$BWS_TOKEN_FILE") else echo "==> Bitwarden Secrets Manager access token required." echo " Generate one for your machine account at:" echo " https://vault.bitwarden.com/#/sm/machine-accounts" read -rp " Paste access token: " BWS_ACCESS_TOKEN if [ -z "$BWS_ACCESS_TOKEN" ]; then echo "ERROR: Access token cannot be empty." exit 1 fi mkdir -p "$(dirname "$BWS_TOKEN_FILE")" echo "$BWS_ACCESS_TOKEN" > "$BWS_TOKEN_FILE" chmod 600 "$BWS_TOKEN_FILE" fi fi export BWS_ACCESS_TOKEN } bws_get() { local name="$1" local value value=$(bws secret list | jq -r --arg name "$name" '.[] | select(.key == $name) | .value') if [ -z "$value" ]; then echo "ERROR: Secret '$name' not found in Bitwarden Secrets Manager." >&2 exit 1 fi echo "$value" } resolve_server_ip() { require_dig SERVER_IP=$(dig +short hantim.net | head -1) if [ -z "$SERVER_IP" ]; then echo "Error: Could not resolve hantim.net to get server IP." exit 1 fi } setup_deploy_key() { require_bws echo "==> Fetching deploy key..." DEPLOY_KEY=$(bws_get "hantim-deploy-ssh-private-key") DEPLOY_KEY_FILE=$(mktemp) echo "$DEPLOY_KEY" > "$DEPLOY_KEY_FILE" chmod 600 "$DEPLOY_KEY_FILE" trap "rm -f $DEPLOY_KEY_FILE" EXIT } # --- Commands --- cmd_dns() { resolve_server_ip require_bws echo "==> Fetching Vultr API key..." VULTR_API_KEY=$(bws_get "hantim-vultr-api-key") VULTR_API="https://api.vultr.com/v2" VULTR_AUTH="Authorization: Bearer $VULTR_API_KEY" echo "==> Checking DNS zone for $APP on Vultr..." ZONE_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ "$VULTR_API/domains/$APP" \ -H "$VULTR_AUTH") if [ "$ZONE_CHECK" != "200" ]; then echo " Creating DNS zone for $APP..." HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$VULTR_API/domains" \ -H "$VULTR_AUTH" \ -H "Content-Type: application/json" \ -d "{\"domain\": \"$APP\"}") if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then echo "Error: Failed to create DNS zone (HTTP $HTTP_CODE)" exit 1 fi echo " Created DNS zone for $APP" fi echo "==> Setting A records for $APP..." RECORDS=$(curl -s "$VULTR_API/domains/$APP/records" -H "$VULTR_AUTH") echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "") | .id' | while read -r id; do echo " Deleting existing A record for $APP (id: $id)" curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" done echo "$RECORDS" | jq -r '.records[] | select(.type == "A" and .name == "www") | .id' | while read -r id; do echo " Deleting existing A record for www.$APP (id: $id)" curl -s -X DELETE "$VULTR_API/domains/$APP/records/$id" -H "$VULTR_AUTH" done echo " Creating A record: $APP -> $SERVER_IP" RESP=$(mktemp) HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ -X POST "$VULTR_API/domains/$APP/records" \ -H "$VULTR_AUTH" \ -H "Content-Type: application/json" \ -d "{\"name\": \"\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then echo "Error: Failed to create A record for $APP (HTTP $HTTP_CODE)" cat "$RESP" rm -f "$RESP" exit 1 fi rm -f "$RESP" echo " Creating A record: www.$APP -> $SERVER_IP" RESP=$(mktemp) HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \ -X POST "$VULTR_API/domains/$APP/records" \ -H "$VULTR_AUTH" \ -H "Content-Type: application/json" \ -d "{\"name\": \"www\", \"type\": \"A\", \"data\": \"$SERVER_IP\", \"ttl\": 3600}") if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then echo "Error: Failed to create A record for www.$APP (HTTP $HTTP_CODE)" cat "$RESP" rm -f "$RESP" exit 1 fi rm -f "$RESP" echo "==> Waiting for DNS to propagate..." ELAPSED=0 for i in $(seq 1 30); do RESOLVED=$(dig +short "$APP" @ns1.vultr.com 2>/dev/null) if [ "$RESOLVED" = "$SERVER_IP" ]; then printf "\r\033[K DNS is live.\n" return fi if [ "$i" = "30" ]; then printf "\r\033[K" echo "Error: DNS for $APP did not resolve to $SERVER_IP after 5 minutes." echo " If this is a new domain, make sure nameservers are set to Vultr on directnic.com." exit 1 fi spin_wait 10 "Waiting for DNS..." "$ELAPSED" ELAPSED=$((ELAPSED + 10)) done } cmd_repo() { require_bws echo "==> Fetching Gitea token..." GITEA_TOKEN=$(bws_get "hantim-new-app-script") REPO_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \ "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP" \ -H "Authorization: token $GITEA_TOKEN") if [ "$REPO_CHECK" = "200" ]; then echo " Gitea repo $GITEA_ORG/$APP already exists, skipping." return fi echo "==> Creating Gitea repo $GITEA_ORG/$APP from template $TEMPLATE_REPO..." HTTP_CODE=$(curl -s -o /tmp/new-app-response.json -w "%{http_code}" \ -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$TEMPLATE_REPO/generate" \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"owner\": \"$GITEA_ORG\", \"name\": \"$APP\", \"git_content\": true, \"labels\": false, \"webhooks\": false }") if [ "$HTTP_CODE" != "201" ]; then echo "Error: Failed to create repo (HTTP $HTTP_CODE)" cat /tmp/new-app-response.json rm -f /tmp/new-app-response.json exit 1 fi rm -f /tmp/new-app-response.json echo " Created: $GITEA_URL/$GITEA_ORG/$APP" } cmd_files() { local all_exist=true for f in "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf"; do if [ ! -e "$REPO_ROOT/$f" ]; then all_exist=false break fi done if [ "$all_exist" = true ]; then echo " All files already exist, skipping." return fi if [ ! -e "$REPO_ROOT/docker/$APP/compose.yml" ]; then echo "==> Creating docker/$APP/compose.yml..." mkdir -p "$REPO_ROOT/docker/$APP" cat > "$REPO_ROOT/docker/$APP/compose.yml" < Creating .gitea/workflows/deploy-$APP.yml..." mkdir -p "$REPO_ROOT/.gitea/workflows" cat > "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" <<'OUTER' name: Deploy APP_PLACEHOLDER on: push: branches: [main] paths: - 'docker/APP_PLACEHOLDER/**' jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy via SSH run: | mkdir -p ~/.ssh echo "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER OUTER sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" else echo " .gitea/workflows/deploy-$APP.yml already exists, skipping." fi if [ ! -e "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" ]; then echo "==> Creating docker/nginx/conf.d/$APP.conf..." cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" < Issuing SSL certificate for $APP via deploy user..." ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$APP" echo " Certificate issued." } cmd_garage() { require_bws echo "==> Fetching Garage secrets..." GARAGE_ADMIN_TOKEN=$(bws_get "hantim-garage-admin-token") GARAGE_MEDIA_KEY_ID=$(bws_get "hantim-garage-media-key-id") GARAGE_API="https://garage.hantim.net" GARAGE_AUTH="Authorization: Bearer $GARAGE_ADMIN_TOKEN" echo "==> Creating Garage media bucket for $APP..." BUCKET_RESP=$(curl -s -X POST "$GARAGE_API/v2/CreateBucket" \ -H "$GARAGE_AUTH" \ -H "Content-Type: application/json" \ -d "{\"globalAlias\": \"$APP\"}") BUCKET_ID=$(echo "$BUCKET_RESP" | jq -r '.id') if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then BUCKET_ID=$(curl -s -X POST "$GARAGE_API/v2/GetBucketInfo" \ -H "$GARAGE_AUTH" \ -H "Content-Type: application/json" \ -d "{\"globalAlias\": \"$APP\"}" | jq -r '.id') if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then echo "Error: Failed to create or find bucket for $APP" echo "$BUCKET_RESP" exit 1 fi echo " Bucket already exists, continuing..." fi echo " Allowing media-key access..." curl -sf -X POST "$GARAGE_API/v2/AllowBucketKey" \ -H "$GARAGE_AUTH" \ -H "Content-Type: application/json" \ -d "{\"bucketId\": \"$BUCKET_ID\", \"accessKeyId\": \"$GARAGE_MEDIA_KEY_ID\", \"permissions\": {\"read\": true, \"write\": true, \"owner\": false}}" > /dev/null echo " Enabling website access..." curl -sf -X POST "$GARAGE_API/v2/UpdateBucket?id=$BUCKET_ID" \ -H "$GARAGE_AUTH" \ -H "Content-Type: application/json" \ -d '{"websiteAccess": {"enabled": true, "indexDocument": "index.html"}}' > /dev/null echo " Bucket '$APP' ready." echo "" echo " Upload media:" echo " aws --endpoint-url https://s3.hantim.net s3 cp s3://$APP/" echo " aws --endpoint-url https://s3.hantim.net s3 sync static/media/ s3://$APP/" echo " Served at: https://www.$APP/media/" } cmd_build() { require_bws echo "==> Fetching Gitea token..." GITEA_TOKEN=$(bws_get "hantim-new-app-script") echo "==> Triggering initial build for $APP..." ELAPSED=0 TRIGGER_CODE="" for i in $(seq 1 60); do TRIGGER_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$APP/actions/workflows/build.yml/dispatches" \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref": "main"}') [ "$TRIGGER_CODE" != "404" ] && printf "\r\033[K" && break spin_wait 2 "Waiting for repo to be ready..." "$ELAPSED" ELAPSED=$((ELAPSED + 2)) done if [ "$TRIGGER_CODE" = "204" ]; then echo " Build triggered." else echo " Warning: Could not trigger build (HTTP $TRIGGER_CODE). Check manually:" echo " $GITEA_URL/$GITEA_ORG/$APP/actions" fi } cmd_verify() { echo "==> Checking if https://www.$APP is live..." ELAPSED=0 for i in $(seq 1 18); do if curl -sf "https://www.$APP" > /dev/null 2>&1; then printf "\r\033[K Site is live at https://www.$APP\n" return fi spin_wait 10 "Waiting for site..." "$ELAPSED" ELAPSED=$((ELAPSED + 10)) done printf "\r\033[K" echo "WARNING: https://www.$APP is not responding after 3 minutes." echo " Check that:" echo " - DNS for $APP and www.$APP points to the server" echo " - The build completed: $GITEA_URL/$GITEA_ORG/$APP/actions" echo " - The container is running on the server: docker ps" } cmd_monitor() { require_bws echo "==> Fetching UptimeRobot API key..." UPTIMEROBOT_API_KEY=$(bws_get "hantim-uptimerobot-api-key") UPTIMEROBOT_API="https://api.uptimerobot.com/v3" UPTIMEROBOT_AUTH="Authorization: Bearer $UPTIMEROBOT_API_KEY" MONITOR_URL="https://www.$APP" echo "==> Checking for existing UptimeRobot monitor for $MONITOR_URL..." EXISTING=$(curl -s -G "$UPTIMEROBOT_API/monitors" \ -H "$UPTIMEROBOT_AUTH" \ --data-urlencode "search=$APP") MATCH=$(echo "$EXISTING" | jq -r --arg url "$MONITOR_URL" \ '.monitors[]? | select(.url == $url) | .id') if [ -n "$MATCH" ]; then echo " Monitor already exists (id: $MATCH), skipping." return fi echo "==> Fetching alert contacts..." ALERT_CONTACTS=$(curl -s "$UPTIMEROBOT_API/user/alert-contacts" \ -H "$UPTIMEROBOT_AUTH" | jq '[.[] | {alertContactId: .id, threshold: 5, recurrence: 30}]') echo "==> Creating UptimeRobot HTTPS monitor for $MONITOR_URL..." RESP=$(curl -s -X POST "$UPTIMEROBOT_API/monitors" \ -H "$UPTIMEROBOT_AUTH" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg name "$APP" --arg url "$MONITOR_URL" --argjson contacts "$ALERT_CONTACTS" \ '{friendlyName: $name, url: $url, type: 1, interval: 300, timeout: 30, assignedAlertContacts: $contacts, tagNames: ["hantim"]}')") MONITOR_ID=$(echo "$RESP" | jq -r '.id // empty') if [ -n "$MONITOR_ID" ]; then echo " Monitor created (id: $MONITOR_ID)." else ERROR_MSG=$(echo "$RESP" | jq -r 'if .message then (.message | if type == "array" then join("; ") else . end) else .error // "unknown error" end') echo " Warning: Failed to create monitor: $ERROR_MSG" fi } cmd_all() { cmd_dns cmd_repo cmd_cert cmd_files echo "==> Committing and pushing hantim-server..." cd "$REPO_ROOT" git add "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf" if git diff --cached --quiet; then echo " No changes to commit, skipping." else git commit -m "add $APP" git push fi cmd_garage cmd_build cmd_verify cmd_monitor } # --- Dispatch --- case "$COMMAND" in dns) cmd_dns ;; repo) cmd_repo ;; files) cmd_files ;; cert) cmd_cert ;; garage) cmd_garage ;; build) cmd_build ;; verify) cmd_verify ;; monitor) cmd_monitor ;; all) cmd_all ;; *) echo "Unknown command: $COMMAND" echo "" usage ;; esac