#!/bin/sh
set -eu
umask 077
[ "$#" = 2 ] || { echo 'Usage: sh install.sh HTTPS_MANIFEST_URL TRUSTED_RELEASE_KEY.pem' >&2; exit 1; }
tmp=$(mktemp -d)
trap 'rm -f "$tmp/playeraudio" "$tmp/playeraudio@.service" "$tmp/install.sh"; rmdir "$tmp"' EXIT
cat >"$tmp/playeraudio" <<'PLAYERAUDIO_EMBEDDED_FILE'
#!/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

PLAYERAUDIO_EMBEDDED_FILE
cat >"$tmp/playeraudio@.service" <<'PLAYERAUDIO_EMBEDDED_FILE'
[Unit]
Description=PlayerAudio community %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pa-%i
Group=pa-%i
EnvironmentFile=/etc/playeraudio/instances/%i.env
ExecStart=/var/lib/playeraudio/instances/%i/current/playeraudio-server serve
WorkingDirectory=/var/lib/playeraudio/instances/%i/data
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/playeraudio/instances/%i/data
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

PLAYERAUDIO_EMBEDDED_FILE
cat >"$tmp/install.sh" <<'PLAYERAUDIO_EMBEDDED_FILE'
#!/bin/sh
# Bootstrap from a signed native release. The public PEM key must be obtained
# through a trusted channel, independently of the download being verified.
set -eu
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
umask 077
die() { printf '%s\n' "$*" >&2; exit 1; }
if [ "${1:-}" = --help ] || [ "$#" -ne 3 ]; then
 printf '%s\n' 'Usage: sh install.sh HTTPS_MANIFEST_URL TRUSTED_PUBLIC_KEY.pem NATIVE_DEPLOY_DIRECTORY' \
 'Installs the verified native binary and the reviewed manager/service files.' \
 'Requires Linux/systemd, curl, jq, OpenSSL, flock, tar and iproute2. Caddy is optional.'
 [ "${1:-}" = --help ]; exit
fi
[ "$(uname -s)" = Linux ] && [ "$(id -u)" = 0 ] || die 'Run as root on Linux'
for tool in curl jq openssl flock systemctl useradd ss tar; do command -v "$tool" >/dev/null || die "Missing prerequisite: $tool"; done
case $1 in https://*) ;; *) die 'Manifest must use HTTPS';; esac
[ -f "$2" ] || die 'Missing trusted release public key'
source=$(cd "$3" && pwd)
for file in playeraudio playeraudio@.service; do [ -f "$source/$file" ] || die "Missing reviewed installer file: $file"; done
[ ! -e /opt/playeraudio/default ] || die 'Already installed; use playeraudio update for individual instances'
tmp=$(mktemp -d); trap 'rm -f "$tmp/manifest.json" "$tmp/payload.json" "$tmp/signature" "$tmp/public.der" "$tmp/playeraudio-server"; rmdir "$tmp"' EXIT
curl --fail --silent --show-error --proto '=https' --max-time 30 --max-filesize 32768 "$1" -o "$tmp/manifest.json"
jq -er '.payload' "$tmp/manifest.json" | openssl base64 -d -A >"$tmp/payload.json"
jq -er '.signature' "$tmp/manifest.json" | openssl base64 -d -A >"$tmp/signature"
openssl pkeyutl -verify -pubin -inkey "$2" -rawin -in "$tmp/payload.json" -sigfile "$tmp/signature" >/dev/null || die 'Invalid release signature'
case $(uname -m) in x86_64) target=x86_64-unknown-linux-gnu;; aarch64) target=aarch64-unknown-linux-gnu;; *) die 'Unsupported architecture';; esac
jq -e --arg target "$target" --argjson now "$(date +%s)" '.schema == 1 and .store_format == 1 and .target == $target and .expires_at > $now and (.sequence | type == "number") and .sequence > 0 and (.size > 0 and .size <= 209715200) and (.sha256 | test("^[a-f0-9]{64}$"))' "$tmp/payload.json" >/dev/null || die 'Invalid release metadata'
url=$(jq -er '.url' "$tmp/payload.json"); case $url in https://*) ;; *) die 'Binary must use HTTPS';; esac
curl --fail --silent --show-error --proto '=https' --max-time 120 --max-filesize 209715200 "$url" -o "$tmp/playeraudio-server"
[ "$(wc -c <"$tmp/playeraudio-server" | tr -d ' ')" = "$(jq -r '.size' "$tmp/payload.json")" ] || die 'Binary size mismatch'
actual=$(openssl dgst -sha256 "$tmp/playeraudio-server" | awk '{print $NF}')
[ "$actual" = "$(jq -r '.sha256' "$tmp/payload.json")" ] || die 'Binary checksum mismatch'
chmod 700 "$tmp/playeraudio-server"
[ "$("$tmp/playeraudio-server" --version)" = "playeraudio-server $(jq -r '.version' "$tmp/payload.json")" ] || die 'Verified binary cannot run on this host or reports a different version'
mkdir -p /opt/playeraudio/releases/bootstrap /etc/playeraudio/instances /etc/playeraudio/sites /var/lib/playeraudio/instances
chmod 755 /opt/playeraudio /opt/playeraudio/releases /opt/playeraudio/releases/bootstrap /etc/playeraudio /etc/playeraudio/sites
install -m 755 "$tmp/playeraudio-server" /opt/playeraudio/releases/bootstrap/playeraudio-server
install -m 644 "$tmp/payload.json" /opt/playeraudio/releases/bootstrap/release.json
install -m 644 "$tmp/manifest.json" /opt/playeraudio/releases/bootstrap/manifest.json
openssl pkey -pubin -in "$2" -outform DER -out "$tmp/public.der"
# Ed25519 SubjectPublicKeyInfo has this exact 12-byte prefix and 32-byte key.
[ "$(wc -c <"$tmp/public.der" | tr -d ' ')" = 44 ] || die 'Release key must be Ed25519'
[ "$(od -An -tx1 -N12 "$tmp/public.der" | tr -d ' \n')" = 302a300506032b6570032100 ] || die 'Release key must be Ed25519'
tail -c 32 "$tmp/public.der" | od -An -tx1 | tr -d ' \n' >/etc/playeraudio/release-key.hex
chmod 600 /etc/playeraudio/release-key.hex
install -m 755 "$source/playeraudio" /usr/local/bin/playeraudio
install -m 644 "$source/playeraudio@.service" /etc/systemd/system/playeraudio@.service
ln -s /opt/playeraudio/releases/bootstrap /opt/playeraudio/default
if command -v caddy >/dev/null; then
id pa-proxy >/dev/null 2>&1 || useradd --system --home-dir /var/lib/playeraudio/proxy --shell /usr/sbin/nologin pa-proxy
mkdir -p /var/lib/playeraudio/proxy; chown pa-proxy:pa-proxy /var/lib/playeraudio/proxy
cat >/etc/playeraudio/Caddyfile <<'EOF'
{
  admin unix//run/playeraudio-proxy/admin.sock
}
import /etc/playeraudio/sites/*.caddy
EOF
chmod 644 /etc/playeraudio/Caddyfile
caddy_bin=$(command -v caddy)
cat >/etc/systemd/system/playeraudio-proxy.service <<EOF
[Unit]
Description=PlayerAudio shared HTTPS proxy
After=network-online.target
Wants=network-online.target
[Service]
User=pa-proxy
Group=pa-proxy
Environment=HOME=/var/lib/playeraudio/proxy
ExecStart=$caddy_bin run --config /etc/playeraudio/Caddyfile --adapter caddyfile
ExecReload=$caddy_bin reload --config /etc/playeraudio/Caddyfile --adapter caddyfile --address unix//run/playeraudio-proxy/admin.sock
RuntimeDirectory=playeraudio-proxy
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/playeraudio/proxy /run/playeraudio-proxy
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable playeraudio-proxy.service
fi
systemctl daemon-reload
printf '%s\n' 'Installed. Point the instance domain at this host and allow its TCP port.' \
 'Create: sudo playeraudio create community community.example.com 8788 "My community" --tls fullchain.pem privkey.pem' \
 'Alternatively use --proxy when Caddy is installed; proxy mode requires TCP 80/443.'

PLAYERAUDIO_EMBEDDED_FILE
sh "$tmp/install.sh" "$1" "$2" "$tmp"
