Files
2026-04-10 15:08:59 -04:00

368 lines
10 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: ./tools/service.sh <command> <name>"
echo ""
echo "Commands:"
echo " dns <name> Create A record for <name>.hantim.net"
echo " files <name> Create compose.yml and deploy workflow"
echo " nginx <name> <port> Create nginx conf for <name>.hantim.net"
echo " cert <name> Issue SSL certificate for <name>.hantim.net"
echo " all <name> <port> Run dns + files + nginx + cert"
echo ""
echo "Examples:"
echo " ./tools/service.sh files garage"
echo " ./tools/service.sh dns garage # garage.hantim.net -> server IP"
echo " ./tools/service.sh nginx garage 3903 # create nginx conf"
echo " ./tools/service.sh cert garage # issue SSL cert"
echo " ./tools/service.sh all garage 3903"
echo ""
echo "Requires: bws, jq, dig (for dns/cert)"
exit 1
}
COMMAND="${1:-}"
if [ -z "$COMMAND" ]; then
usage
fi
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# --- Dependency checks ---
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"
}
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
}
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
}
validate_name() {
local name="$1"
if [ -z "$name" ]; then
echo "Error: service name is required."
echo ""
usage
fi
if ! [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then
echo "Error: name must be alphanumeric (hyphens, dots, underscores allowed)."
exit 1
fi
}
# --- Commands ---
cmd_dns() {
local name="$1"
local zone="hantim.net"
local fqdn="$name.$zone"
require_dig
require_bws
SERVER_IP=$(dig +short "$zone" | head -1)
if [ -z "$SERVER_IP" ]; then
echo "Error: Could not resolve $zone to get server IP."
exit 1
fi
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 existing A records for $name in $zone..."
RECORDS=$(curl -s "$VULTR_API/domains/$zone/records" -H "$VULTR_AUTH")
echo "$RECORDS" | jq -r --arg sub "$name" \
'.records[] | select(.type == "A" and .name == $sub) | .id' | while read -r id; do
echo " Deleting existing A record for $fqdn (id: $id)"
curl -s -X DELETE "$VULTR_API/domains/$zone/records/$id" -H "$VULTR_AUTH"
done
echo " Creating A record: $fqdn -> $SERVER_IP"
RESP=$(mktemp)
HTTP_CODE=$(curl -s -o "$RESP" -w "%{http_code}" \
-X POST "$VULTR_API/domains/$zone/records" \
-H "$VULTR_AUTH" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$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 (HTTP $HTTP_CODE)"
cat "$RESP"
rm -f "$RESP"
exit 1
fi
rm -f "$RESP"
echo "==> Verifying DNS propagation for $fqdn..."
for i in $(seq 1 12); do
RESOLVED=$(dig +short "$fqdn" @ns1.vultr.com 2>/dev/null | head -1)
if [ "$RESOLVED" = "$SERVER_IP" ]; then
echo " DNS is live: $fqdn -> $SERVER_IP"
return
fi
if [ "$i" = "12" ]; then
echo " Warning: $fqdn did not resolve to $SERVER_IP after 60 seconds. It may take longer to propagate."
return
fi
sleep 5
done
}
cmd_files() {
local name="$1"
local all_exist=true
for f in "docker/$name/compose.yml" ".gitea/workflows/deploy-$name.yml"; 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/$name/compose.yml" ]; then
echo "==> Creating docker/$name/compose.yml..."
mkdir -p "$REPO_ROOT/docker/$name"
touch "$REPO_ROOT/docker/$name/.gitignore"
cat > "$REPO_ROOT/docker/$name/compose.yml" <<EOF
services:
$name:
image: TODO
restart: unless-stopped
container_name: $name
networks:
- shared
networks:
shared:
external: true
EOF
else
echo " docker/$name/compose.yml already exists, skipping."
fi
if [ ! -e "$REPO_ROOT/.gitea/workflows/deploy-$name.yml" ]; then
echo "==> Creating .gitea/workflows/deploy-$name.yml..."
mkdir -p "$REPO_ROOT/.gitea/workflows"
cat > "$REPO_ROOT/.gitea/workflows/deploy-$name.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/$name/g" "$REPO_ROOT/.gitea/workflows/deploy-$name.yml"
else
echo " .gitea/workflows/deploy-$name.yml already exists, skipping."
fi
echo ""
echo "Done. Edit docker/$name/compose.yml, then commit and push."
echo ""
echo "If this service needs secrets:"
echo " 1. Add secrets to Bitwarden Secrets Manager"
echo " 2. Map them in docker/$name/.env.keys (format: ENV_VAR=bws-secret-name)"
echo " 3. Add env_file: .env to compose.yml"
echo " configure.sh will generate the .env file on the server automatically."
}
cmd_nginx() {
local name="$1"
local port="$2"
local fqdn="$name.hantim.net"
if ! [[ "$port" =~ ^[0-9]+$ ]]; then
echo "Error: port must be a number."
exit 1
fi
if [ -e "$REPO_ROOT/docker/nginx/conf.d/$fqdn.conf" ]; then
echo " docker/nginx/conf.d/$fqdn.conf already exists, skipping."
return
fi
echo "==> Creating docker/nginx/conf.d/$fqdn.conf..."
cat > "$REPO_ROOT/docker/nginx/conf.d/$fqdn.conf" <<NGINX
server {
listen 80;
listen [::]:80;
server_name $fqdn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$fqdn\$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name $fqdn;
ssl_certificate /etc/letsencrypt/live/$fqdn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$fqdn/privkey.pem;
include /etc/nginx/conf.d/security-headers.inc;
location / {
proxy_pass http://$name:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_hide_header X-Frame-Options;
}
}
NGINX
}
cmd_cert() {
local name="$1"
local fqdn="$name.hantim.net"
resolve_server_ip
setup_deploy_key
echo "==> Issuing SSL certificate for $fqdn via deploy user..."
ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$fqdn"
echo " Certificate issued."
}
cmd_all() {
local name="$1"
local port="$2"
cmd_dns "$name"
cmd_files "$name"
cmd_nginx "$name" "$port"
cmd_cert "$name"
}
# --- Dispatch ---
case "$COMMAND" in
dns)
validate_name "${2:-}"
cmd_dns "${2:-}"
;;
files)
validate_name "${2:-}"
cmd_files "${2:-}"
;;
nginx)
validate_name "${2:-}"
if [ -z "${3:-}" ]; then
echo "Error: port is required for nginx command."
echo " Usage: ./tools/service.sh nginx <name> <port>"
exit 1
fi
cmd_nginx "${2:-}" "${3:-}"
;;
cert)
validate_name "${2:-}"
cmd_cert "${2:-}"
;;
all)
validate_name "${2:-}"
if [ -z "${3:-}" ]; then
echo "Error: port is required for all command."
echo " Usage: ./tools/service.sh all <name> <port>"
exit 1
fi
cmd_all "${2:-}" "${3:-}"
;;
*)
echo "Unknown command: $COMMAND"
echo ""
usage
;;
esac