#!/bin/sh
# Linux/systemd instance manager. All paths are fixed; no configuration is sourced.
set -eu
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
ROOT=/opt/playeraudio
STATE=/var/lib/playeraudio
ETC=/etc/playeraudio
die() { printf '%s\n' "$*" >&2; exit 1; }
usage() {
 cat <<'EOF'
playeraudio create NAME DOMAIN PORT COMMUNITY_NAME --tls CERT.pem KEY.pem
playeraudio create NAME DOMAIN PORT COMMUNITY_NAME --proxy
playeraudio list
playeraudio start|stop|restart|status|logs NAME
playeraudio backup NAME
playeraudio update NAME HTTPS_MANIFEST_URL
playeraudio certificates NAME CERT.pem KEY.pem
Each instance has its own Linux user, data directory and listening port.
Updates are explicit. Stop/start and updates affect only the selected instance.
EOF
}
case ${1:-help} in help|--help|-h) usage; exit 0;; esac
[ "$(uname -s)" = Linux ] || die 'Linux with systemd is required'
[ "$(id -u)" = 0 ] || die 'Run this manager with sudo'
command=$1; shift
mkdir -p "$STATE" "$ETC/instances" "$ETC/sites" "$ROOT/releases"
exec 9>"$STATE/manager.lock"
flock -x 9
valid_name() { case $1 in ''|*[!a-z0-9-]*|-*|*-|?????????????????????????*) die 'Use a name of 1–24 lowercase letters, digits or internal hyphens';; esac; }
instance() { [ "$#" -ge 1 ] || die 'An instance name is required'; name=$1; valid_name "$name"; dir="$STATE/instances/$name"; unit="playeraudio@$name.service"; [ -f "$ETC/instances/$name.env" ] || die 'Unknown instance'; }
proxy_reload() { caddy validate --config "$ETC/Caddyfile" --adapter caddyfile >/dev/null; systemctl reload-or-restart playeraudio-proxy.service; }
backup() {
 mkdir -p "$STATE/backups/$name"
 archive="$STATE/backups/$name/$(date -u +%Y%m%dT%H%M%SZ)-$$.tar"
 # Data and its storage key must be backed up together. This archive is private,
 # not encrypted; export it only through an encrypted backup destination.
 tar -C "$dir" -cf "$archive" data release.json
 chmod 600 "$archive"
 printf 'Private backup: %s\n' "$archive"
}
case $command in
 list) [ "$#" = 0 ] || die 'list takes no arguments'; for file in "$ETC"/instances/*.env; do [ -f "$file" ] || continue; name=${file##*/}; name=${name%.env}; printf '%s: ' "$name"; systemctl is-active "playeraudio@$name.service" || :; done;;
 create)
 [ "$#" = 5 ] || [ "$#" = 7 ] || die 'create requires NAME DOMAIN PORT COMMUNITY_NAME followed by --tls CERT KEY or --proxy'
 name=$1; domain=$2; port=$3; community=$4; valid_name "$name"
 mode=$5
 case $mode in --proxy) [ "$#" = 5 ] && command -v caddy >/dev/null && [ -f /etc/systemd/system/playeraudio-proxy.service ] || die 'Proxy mode requires Caddy and its installed service';; --tls) [ "$#" = 7 ] && [ -f "$6" ] && [ -f "$7" ] || die 'Direct TLS requires certificate and key files';; *) die 'Choose --tls CERT KEY or --proxy';; esac
 printf '%s' "$domain" | grep -Eq '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$' || die 'Use a lowercase public DNS domain'
 case $port in ''|*[!0-9]*) die 'Invalid port';; esac
 [ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || die 'Port must be 1024–65535'
 [ ! -e "$ETC/instances/$name.env" ] && [ ! -e "$STATE/instances/$name" ] || die 'Instance already exists'
 for file in "$ETC"/instances/*.env; do [ -f "$file" ] || continue; grep -qx "PLAYERAUDIO_SERVER_PORT=$port" "$file" && die 'Port already assigned'; done
 if [ "$mode" = --proxy ]; then for file in "$ETC"/sites/*.caddy; do [ -f "$file" ] || continue; grep -Fqx "$domain {" "$file" && die 'Proxy domain already assigned'; done; fi
 ss -H -ltn | awk '{print $4}' | grep -Eq ":$port$" && die 'Port is already listening'
 [ -x "$ROOT/default/playeraudio-server" ] || die 'Install a verified release first'
 if [ "$mode" = --tls ]; then
   "$ROOT/default/playeraudio-server" check-tls --cert "$6" --key "$7"
   openssl x509 -in "$6" -checkhost "$domain" -noout >/dev/null || die 'Certificate does not cover the instance hostname'
 fi
 dir="$STATE/instances/$name"; mkdir -p "$dir/data"
 user="pa-$name"; id "$user" >/dev/null 2>&1 && die 'Instance operating-system user already exists'
 useradd --system --home-dir "$dir" --shell /usr/sbin/nologin "$user"
 "$ROOT/default/playeraudio-server" setup --config "$dir/data/server.json" --name "$community"
 cp "$ROOT/default/release.json" "$dir/release.json"
 ln -s "$(readlink -f "$ROOT/default")" "$dir/current"
 if [ "$mode" = --tls ]; then
   mkdir -p "$dir/data/tls"
   install -m 600 "$6" "$dir/data/tls/cert.pem"
   install -m 600 "$7" "$dir/data/tls/key.pem"
 fi
 chown -R "$user:$user" "$dir/data"
 chmod 711 "$STATE" "$STATE/instances" "$dir"
 host=127.0.0.1; [ "$mode" = --proxy ] || host=0.0.0.0
 printf 'PLAYERAUDIO_SERVER_CONFIG=%s/data/server.json\nPLAYERAUDIO_SERVER_HOST=%s\nPLAYERAUDIO_SERVER_PORT=%s\nPLAYERAUDIO_PUBLIC_HOST=%s\n' "$dir" "$host" "$port" "$domain" >"$ETC/instances/$name.env"
 if [ "$mode" = --tls ]; then
   printf 'PLAYERAUDIO_TLS_CERT=%s/data/tls/cert.pem\nPLAYERAUDIO_TLS_KEY=%s/data/tls/key.pem\n' "$dir" "$dir" >>"$ETC/instances/$name.env"
 else
 cat >"$ETC/sites/$name.caddy" <<EOF
$domain {
  @community path /health /socket /branding/* /expressions/* /platform/ownership /membership/leave /access /access/password /files /files/*
  handle @community {
    reverse_proxy 127.0.0.1:$port
  }
  handle {
    respond "Not found" 404
  }
  header X-Content-Type-Options nosniff
}
EOF
 chmod 644 "$ETC/sites/$name.caddy"
 fi
 systemctl enable --now "playeraudio@$name.service"
 endpoint="https://$domain:$port"; if [ "$mode" = --proxy ]; then proxy_reload; endpoint="https://$domain"; fi
 printf 'Instance %s started at %s. Owner code: %s/data/setup-code.txt\nAdd this HTTPS endpoint in the client, then claim ownership with the private setup code.\n' "$name" "$endpoint" "$dir"
 ;;
 certificates)
 [ "$#" = 3 ] || die 'certificates requires NAME CERT KEY'; instance "$1"
 [ -d "$dir/data/tls" ] || die 'Instance does not use direct TLS'
 # Server reload validates the pair and retains its active certificate on failure.
 install -m 600 -o "pa-$name" -g "pa-$name" "$2" "$dir/data/tls/cert.next"
 install -m 600 -o "pa-$name" -g "pa-$name" "$3" "$dir/data/tls/key.next"
 "$dir/current/playeraudio-server" check-tls --cert "$dir/data/tls/cert.next" --key "$dir/data/tls/key.next"
 domain=$(sed -n 's/^PLAYERAUDIO_PUBLIC_HOST=//p' "$ETC/instances/$name.env")
 openssl x509 -in "$dir/data/tls/cert.next" -checkhost "$domain" -noout >/dev/null || die 'Certificate does not cover the instance hostname'
 openssl x509 -in "$dir/data/tls/cert.next" -pubkey -noout >"$dir/data/tls/cert.public.pem"
 openssl pkey -pubin -in "$dir/data/tls/cert.public.pem" -outform DER -out "$dir/data/tls/cert.public.der"
 openssl pkey -in "$dir/data/tls/key.next" -passin pass: -pubout -outform DER -out "$dir/data/tls/key.public.der"
 cmp -s "$dir/data/tls/cert.public.der" "$dir/data/tls/key.public.der" || die 'Certificate and private key do not match'
 rm -f "$dir/data/tls/cert.public.pem" "$dir/data/tls/cert.public.der" "$dir/data/tls/key.public.der"
 mv -f "$dir/data/tls/cert.next" "$dir/data/tls/cert.pem"
 mv -f "$dir/data/tls/key.next" "$dir/data/tls/key.pem"
 systemctl kill --kill-who=main --signal=HUP "$unit"
 ;;
 start|stop|restart|status|logs)
 [ "$#" = 1 ] || die 'Expected one instance name'; instance "$1"
 case $command in logs) exec journalctl -u "$unit" -n 100 --no-pager;; *) exec systemctl "$command" "$unit";; esac;;
 backup)
 [ "$#" = 1 ] || die 'Expected one instance name'; instance "$1"
 active=0; systemctl is-active --quiet "$unit" && active=1
 restore() { [ "$active" = 0 ] || systemctl start "$unit"; }; trap restore EXIT
 systemctl stop "$unit"; backup
 ;;
 update)
 [ "$#" = 2 ] || die 'update requires NAME HTTPS_MANIFEST_URL'; instance "$1"; url=$2
 target=$(jq -er '.target' "$dir/release.json"); sequence=$(jq -er '.sequence' "$dir/release.json")
 stage="$ROOT/releases/stage-$(date -u +%Y%m%dT%H%M%SZ)-$$"
 "$dir/current/playeraudio-server" stage-update --manifest-url "$url" --key "$ETC/release-key.hex" --target "$target" --minimum-sequence "$sequence" --destination "$stage"
 chmod 755 "$stage"
 old=$(readlink -f "$dir/current"); active=0; systemctl is-active --quiet "$unit" && active=1
 systemctl stop "$unit"
 # Back up while stopped, then authenticate the state with the candidate before
 # allowing it to serve writes. Do not automatically roll back data files.
 recover() { systemctl stop "$unit"; ln -s "$old" "$dir/recovery-$$"; mv -Tf "$dir/recovery-$$" "$dir/current"; install -m 600 "$old/release.json" "$dir/release-recovery.json"; mv -f "$dir/release-recovery.json" "$dir/release.json"; [ "$active" = 0 ] || systemctl start "$unit"; }
 trap recover EXIT
 backup
 if [ -f "$dir/data/community.enc" ]; then "$stage/playeraudio-server" check-store --config "$dir/data/server.json"; fi
 ln -s "$stage" "$dir/next-$$"; mv -Tf "$dir/next-$$" "$dir/current"
 if [ "$active" = 1 ]; then
   systemctl start "$unit"
   port=$(sed -n 's/^PLAYERAUDIO_SERVER_PORT=//p' "$ETC/instances/$name.env")
   expected=$(jq -er '.id' "$dir/data/server.json"); expected_version=$(jq -er '.version' "$stage/release.json"); tries=0
   health() {
     if grep -q '^PLAYERAUDIO_TLS_CERT=' "$ETC/instances/$name.env"; then
       domain=$(sed -n 's/^PLAYERAUDIO_PUBLIC_HOST=//p' "$ETC/instances/$name.env")
       curl --fail --silent --max-time 2 --resolve "$domain:$port:127.0.0.1" "https://$domain:$port/health"
     else curl --fail --silent --max-time 2 "http://127.0.0.1:$port/health"; fi
   }
   until health | jq -e --arg id "$expected" --arg version "$expected_version" '.status == "ok" and .id == $id and .protocol == 1 and .version == $version' >/dev/null; do
     tries=$((tries+1)); [ "$tries" -lt 15 ] || { systemctl stop "$unit"; die 'Candidate failed health checks; restoring previous executable'; }; sleep 1
   done
 fi
 install -m 600 "$stage/release.json" "$dir/release-next.json"
 mv -f "$dir/release-next.json" "$dir/release.json"
 trap - EXIT
 printf 'Updated %s; previous executable retained at %s\n' "$name" "$old"
 ;;
 *) usage; exit 1;;
esac
