#!/usr/bin/env bash set -euo pipefail # LobbyForge installer. # # Usage (clone + run — the script needs the repo's compose files, # templates and scripts next to it, so curl | bash does NOT work): # git clone https://github.com/Juanka-e/LobbyForge.git # cd LobbyForge && bash install.sh # # Optional env: # LOBBYFORGE_TURN_EXTERNAL_IP public IP for coturn behind 1:1 NAT # (unset = auto-detect) # # This script: # 1. Checks prerequisites (Docker, Docker Compose, curl, openssl). # 2. Prompts for domain name and generates secrets. # 3. Provisions a Let's Encrypt certificate via certbot. # 4. Starts the full production stack via docker-compose.prod.yml. # 5. Runs the Doctor health check. set -e umask 077 # Secrets written by this script should only be readable by the owner. BOLD='\033[1m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' echo -e "${BOLD}╔══════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ LobbyForge Installer ║${NC}" echo -e "${BOLD}║ Self-hosted voice community platform ║${NC}" echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}" echo "" # ── 1. Prerequisites ────────────────────────────────────────────── echo -e "${BOLD}Checking prerequisites...${NC}" check_cmd() { if ! command -v "$1" &>/dev/null; then echo -e "${RED}✗ $1 is not installed. Please install it first.${NC}" echo " $2" exit 1 fi } check_cmd docker "Install Docker: https://docs.docker.com/engine/install/" check_cmd docker "Install Docker Compose (included with Docker Desktop or 'docker-compose-plugin')" check_cmd curl "Install curl (usually preinstalled)" check_cmd openssl "Install openssl (usually preinstalled)" # Verify docker compose subcommand if ! docker compose version &>/dev/null; then echo -e "${RED}✗ 'docker compose' plugin is not available. Install docker-compose-plugin.${NC}" exit 1 fi echo -e "${GREEN}✓ All prerequisites found.${NC}" echo "" # ── 2. Configuration ────────────────────────────────────────────── echo -e "${BOLD}Configuration${NC}" echo "" # Find the repo root (where this script lives or CWD). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_FILE="$SCRIPT_DIR/infra/docker/docker-compose.prod.yml" ENV_FILE="$SCRIPT_DIR/.env.prod" if [ ! -f "$COMPOSE_FILE" ]; then echo -e "${RED}✗ docker-compose.prod.yml not found at $COMPOSE_FILE${NC}" echo " Make sure you're running this from the repo root." exit 1 fi # Domain (validated — used in sed and nginx config) read -rp "$(echo -e ${BOLD}'Enter your domain (e.g. lobby.example.com): '${NC})" DOMAIN if [ -z "$DOMAIN" ]; then echo -e "${RED}✗ Domain is required for HTTPS/WebRTC.${NC}" exit 1 fi # LF-010/OPS-005: full DNS label validation — each label 1-63 chars, # alphanumeric first/last, letters/digits/hyphens only, total FQDN <= 253. if ! [[ "$DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]] || [ ${#DOMAIN} -gt 253 ]; then echo -e "${RED}✗ Invalid domain "$DOMAIN" — each label must be 1-63 chars, alphanumeric start/end, hyphens inside only (max 253 total).${NC}" exit 1 fi # Instance name read -rp "$(echo -e ${BOLD}'Community name [LobbyForge Community]: '${NC})" INSTANCE_NAME INSTANCE_NAME="${INSTANCE_NAME:-LobbyForge Community}" # Deployment mode read -rp "$(echo -e ${BOLD}'Official hub instance? (enables discovery/marketplace) [y/N]: '${NC})" IS_OFFICIAL if [[ "$IS_OFFICIAL" =~ ^[Yy]$ ]]; then DEPLOYMENT_MODE="official" else DEPLOYMENT_MODE="self_host" fi # Generate secrets. On a RE-RUN, values already present in .env.prod are # reused — rotating every credential each run would invalidate existing # sessions, admin tokens and TURN credentials for no benefit. reuse_env() { local key="$1" generated="$2" if [ -f "$ENV_FILE" ]; then local prev prev=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true) if [ -n "$prev" ]; then echo "$prev" return fi fi echo "$generated" } echo "" echo -e "${BOLD}Generating secure secrets...${NC}" SESSION_SECRET=$(reuse_env LOBBYFORGE_SESSION_SECRET "$(openssl rand -hex 32)") ADMIN_TOKEN=$(reuse_env LOBBYFORGE_ADMIN_TOKEN "$(openssl rand -hex 32)") SETUP_TOKEN=$(reuse_env LOBBYFORGE_SETUP_TOKEN "$(openssl rand -hex 32)") PG_PASSWORD=$(reuse_env POSTGRES_PASSWORD "$(openssl rand -hex 16)") REDIS_PASSWORD=$(reuse_env REDIS_PASSWORD "$(openssl rand -hex 16)") LK_API_KEY=$(reuse_env LIVEKIT_API_KEY "devkey_$(openssl rand -hex 8)") LK_API_SECRET=$(reuse_env LIVEKIT_API_SECRET "$(openssl rand -hex 32)") # LF-019: shared coturn <-> LiveKit TURN credential (hex — render-configs # validates the shape before writing it into both configs). TURN_SECRET=$(reuse_env LOBBYFORGE_TURN_SECRET "$(openssl rand -hex 32)") # LF-SEC-010: shared tokens for the isolated plugin-worker runtime # (RPC auth + the storage-capability proxy). Unused unless dynamic # plugins are enabled, but generated unconditionally so the compose # interpolation always resolves. PLUGIN_WORKER_TOKEN=$(reuse_env LOBBYFORGE_PLUGIN_WORKER_TOKEN "$(openssl rand -hex 32)") PLUGIN_STORAGE_TOKEN=$(reuse_env LOBBYFORGE_PLUGIN_STORAGE_TOKEN "$(openssl rand -hex 32)") # V5-003: coturn public address behind 1:1 NAT. Reused from an existing # .env.prod; export LOBBYFORGE_TURN_EXTERNAL_IP on the first run to set # it. Empty = coturn auto-detects (correct for public-interface VPSes). TURN_EXTERNAL_IP="${LOBBYFORGE_TURN_EXTERNAL_IP:-$(reuse_env LOBBYFORGE_TURN_EXTERNAL_IP '')}" echo -e "${GREEN}✓ Secrets generated.${NC}" echo "" # ── 2b. Preflight: NEVER mutate a live installation (V4-003) ────── # The old order wrote .env.prod + configs for the NEW domain and THEN # failed at certbot (port 80 held by the running stack) — leaving the # host one container-restart away from loading a certificate that does # not exist. All writes below go to a STAGING directory and are # activated atomically only after the certificate succeeds. STACK_RUNNING=false if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^lobbyforge-nginx$'; then STACK_RUNNING=true fi EXISTING_DOMAIN="" if [ -f "$ENV_FILE" ]; then EXISTING_DOMAIN=$(grep -E '^NEXT_PUBLIC_BASE_URL=' "$ENV_FILE" | tail -1 | sed 's|^NEXT_PUBLIC_BASE_URL=https://||' | tr -d ' ' || true) fi if [ "$STACK_RUNNING" = true ]; then if [ -n "$EXISTING_DOMAIN" ] && [ "$EXISTING_DOMAIN" = "$DOMAIN" ]; then echo -e "${GREEN}✓ Stack already running on $DOMAIN.${NC}" echo "" echo -e "${BOLD}What do you want to do?${NC}" echo " 1) How to update (shows the signed-release updater commands)" echo " 2) Renew TLS certificates only" echo " 3) Nothing — exit" echo "" read -rp "$(echo -e ${BOLD}'Choice [1/2/3]: '${NC})" STACK_ACTION STACK_ACTION="${STACK_ACTION:-3}" case "$STACK_ACTION" in 1) # 22nd-audit: the installer no longer performs updates. The old # `up -d --build` path bypassed EVERY update safety mechanism # (signed manifest, verified backup, digest pinning, migration # step, rollback state) and could silently flip a digest-pinned # deployment back to a mutable local build. echo -e "${YELLOW}Updates are handled by the signed-release updater now:${NC}" echo " node scripts/lfctl.mjs update check" echo " node scripts/lfctl.mjs update plan" echo " node scripts/lfctl.mjs update apply --yes" echo "" echo "This installer is for fresh installs and config repair only." echo "No changes were made." exit 0 ;; 2) docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec certbot certbot renew echo "(nginx auto-reloads within ~60s of a certificate change; no restart needed.)" exit 0 ;; *) echo "No changes made." exit 0 ;; esac fi echo -e "${RED}✗ The stack is RUNNING (lobbyforge-nginx) but this run targets '$DOMAIN'${NC}" echo " while the existing installation targets '${EXISTING_DOMAIN:-}'." echo " Switching domains rewrites env/configs and needs a new certificate —" echo " stop the stack first:" echo " docker compose -f $COMPOSE_FILE --env-file $ENV_FILE down" echo " Nothing has been modified." exit 1 fi # ── 3. Stage .env.prod + rendered configs (activated in step 6) ──── STAGE_DIR="$(mktemp -d "$SCRIPT_DIR/.install-staging.XXXXXX")" trap 'rm -rf "$STAGE_DIR"' EXIT echo -e "${BOLD}Staging .env.prod + configs (activated after the certificate succeeds)...${NC}" # 21st/22nd-audit: persist the installed version — a tagged clone installs # that tag. An untagged checkout falls back to the root package.json # version (still valid semver for the updater — "source" would crash it). # sed on a missing package.json exits 2 — guard the pipeline (set -e). GIT_VERSION="$(git -C "$SCRIPT_DIR" describe --tags --exact-match 2>/dev/null || true)" if [ -z "$GIT_VERSION" ]; then GIT_VERSION="$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$SCRIPT_DIR/package.json" 2>/dev/null | head -1 || true)" if [ -n "$GIT_VERSION" ]; then echo -e "${YELLOW}⚠ Not a tagged checkout — recording package.json version $GIT_VERSION (source build).${NC}" fi fi if [ -z "$GIT_VERSION" ]; then GIT_VERSION="0.0.0-unknown" echo -e "${YELLOW}⚠ No tag and no package.json version found — recording $GIT_VERSION.${NC}" fi # 22nd-audit: a re-run on an EXISTING install must not reset the update # model — if lfctl already moved this server to a signed digest, keep it. DEPLOY_VERSION="$(reuse_env LOBBYFORGE_VERSION "$GIT_VERSION")" DEPLOY_IMAGE="$(reuse_env LOBBYFORGE_IMAGE 'lobbyforge-web:latest')" cat > "$STAGE_DIR/.env.prod" << EOF # LobbyForge production environment — generated by install.sh # Domain: $DOMAIN # PostgreSQL POSTGRES_DB=lobbyforge POSTGRES_USER=lobbyforge POSTGRES_PASSWORD=$PG_PASSWORD DATABASE_URL=postgres://lobbyforge:$PG_PASSWORD@postgres:5432/lobbyforge # Redis REDIS_PASSWORD=$REDIS_PASSWORD # LiveKit LIVEKIT_API_KEY=$LK_API_KEY LIVEKIT_API_SECRET=$LK_API_SECRET LIVEKIT_URL=http://livekit:7880 NEXT_PUBLIC_LIVEKIT_URL=wss://$DOMAIN/livekit NEXT_PUBLIC_WS_URL=wss://$DOMAIN/ws # Security LOBBYFORGE_SESSION_SECRET=$SESSION_SECRET LOBBYFORGE_ADMIN_TOKEN=$ADMIN_TOKEN LOBBYFORGE_SETUP_TOKEN=$SETUP_TOKEN LOBBYFORGE_TURN_SECRET=$TURN_SECRET LOBBYFORGE_PLUGIN_WORKER_TOKEN=$PLUGIN_WORKER_TOKEN LOBBYFORGE_PLUGIN_STORAGE_TOKEN=$PLUGIN_STORAGE_TOKEN LOBBYFORGE_TURN_EXTERNAL_IP=$TURN_EXTERNAL_IP # Product LOBBYFORGE_DEPLOYMENT_MODE=$DEPLOYMENT_MODE LOBBYFORGE_INSTANCE_NAME=$INSTANCE_NAME LOBBYFORGE_TRUSTED_PROXY=x-forwarded-for NEXT_PUBLIC_BASE_URL=https://$DOMAIN # Deployment state (21st-audit): lfctl reads the deployed version from # here / deployment-state.json — never a hardcoded constant. Fresh # installs run the locally-built image; `lfctl update apply` switches # LOBBYFORGE_IMAGE to the digest pinned in the signed release manifest. # Re-runs PRESERVE whatever lfctl last deployed (see DEPLOY_* above). LOBBYFORGE_VERSION=$DEPLOY_VERSION LOBBYFORGE_IMAGE=$DEPLOY_IMAGE # Node NODE_ENV=production EOF echo -e "${GREEN}✓ .env.prod staged. Save these credentials:${NC}" echo " Admin token: $ADMIN_TOKEN" echo " Setup token: $SETUP_TOKEN (remove after first-run setup)" echo "" # ── 4. Render nginx + LiveKit configs (LF-010-R) ────────────────── # The tracked *.template files are the immutable source of truth; the # generated configs are re-rendered from scratch on EVERY run. This is # what makes a re-run with a different domain correct: the old in-place # `sed -i` destroyed the placeholder on first run and left nginx/LiveKit # stuck on the previous domain. echo -e "${BOLD}Rendering nginx + LiveKit + TURN configs (LF-010-R)...${NC}" # security-review AUTHZ-006 follow-up: LiveKit's webhook (track_published # -> the web app removes a participant whose track kind does not match # its source) must name one of LiveKit's keys — this install's key, which # a re-run reuses from .env.prod. if TURN_EXTERNAL_IP="$TURN_EXTERNAL_IP" LIVEKIT_WEBHOOK_API_KEY="$LK_API_KEY" bash "$SCRIPT_DIR/scripts/render-configs.sh" "$DOMAIN" "$TURN_SECRET" "$SCRIPT_DIR/infra" "$STAGE_DIR/infra"; then echo -e "${GREEN}✓ Nginx, LiveKit and coturn TURN configs staged.${NC}" else echo -e "${RED}✗ Failed to render configs from templates.${NC}" exit 1 fi echo "" # ── 5. Provision SSL certificate ────────────────────────────────── echo -e "${BOLD}Provisioning SSL certificate via Let's Encrypt...${NC}" echo -e "${YELLOW}This requires port 80 to be open and DNS to point here.${NC}" read -rp "$(echo -e ${BOLD}'Proceed with certbot now? [Y/n]: '${NC})" DO_CERTBOT DO_CERTBOT="${DO_CERTBOT:-Y}" # LF-010: Initialize explicitly to avoid unbound variable with set -u. CERT_FAILED=false if [[ "$DO_CERTBOT" =~ ^[Yy]$ ]]; then # Preflight (step 2b) already refused to continue with a running # stack, so port 80 is expected free for the standalone challenge. if docker run --rm \ -p 80:80 \ -v "$SCRIPT_DIR/infra/certbot/conf:/etc/letsencrypt" \ certbot/certbot:v5.8.0@sha256:f70ad0adbb7e117f0fe42a63c553f28ea451edabc0148757b6efcd9735acaa20 certonly --standalone \ -d "$DOMAIN" \ --non-interactive \ --agree-tos \ --register-unsafely-without-email \ --no-eff-email; then if [ -d "$SCRIPT_DIR/infra/certbot/conf/live/$DOMAIN" ]; then echo -e "${GREEN}✓ Certificate provisioned for $DOMAIN${NC}" else echo -e "${RED}✗ Certificate directory not found — certbot may have failed silently.${NC}" CERT_FAILED=true fi else echo -e "${RED}✗ Certbot FAILED.${NC}" echo " Common causes: DNS not pointing here yet, port 80 blocked, rate limit." CERT_FAILED=true fi else echo -e "${YELLOW}Skipping Let's Encrypt.${NC}" # Bring-your-own certificate — e.g. a 15-year Cloudflare Origin CA # .pem pair (see docs/DEPLOY_CLOUDFLARE.md). Files are installed at the # exact path nginx expects (infra/certbot/conf/live/, which is # git-ignored); the certbot sidecar simply finds nothing to renew and # its "No renewals were attempted" logs are harmless. read -rp "$(echo -e ${BOLD}'Provide your own certificate instead? (Cloudflare Origin CA etc.) [y/N]: '${NC})" OWN_CERT if [[ "$OWN_CERT" =~ ^[Yy]$ ]]; then read -rp "$(echo -e ${BOLD}'Certificate (fullchain) PEM path: '${NC})" CERT_PEM_PATH read -rp "$(echo -e ${BOLD}'Private key PEM path: '${NC})" KEY_PEM_PATH # Expand a leading ~ so users can paste shell-style paths. CERT_PEM_PATH="${CERT_PEM_PATH/#\~/$HOME}" KEY_PEM_PATH="${KEY_PEM_PATH/#\~/$HOME}" LIVE_DIR="$SCRIPT_DIR/infra/certbot/conf/live/$DOMAIN" if [ ! -f "$CERT_PEM_PATH" ] || [ ! -f "$KEY_PEM_PATH" ]; then echo -e "${RED}✗ Certificate or key file not found.${NC}" CERT_FAILED=true elif ! openssl x509 -noout -in "$CERT_PEM_PATH" 2>/dev/null; then echo -e "${RED}✗ $CERT_PEM_PATH is not a valid PEM X.509 certificate.${NC}" CERT_FAILED=true elif ! openssl pkey -in "$KEY_PEM_PATH" -noout 2>/dev/null; then echo -e "${RED}✗ $KEY_PEM_PATH is not a valid PEM private key.${NC}" CERT_FAILED=true elif [ "$(openssl x509 -noout -pubkey -in "$CERT_PEM_PATH" 2>/dev/null | openssl sha256)" != \ "$(openssl pkey -in "$KEY_PEM_PATH" -pubout 2>/dev/null | openssl sha256)" ]; then echo -e "${RED}✗ Certificate does not match the private key (public keys differ).${NC}" CERT_FAILED=true else mkdir -p "$LIVE_DIR" cp "$CERT_PEM_PATH" "$LIVE_DIR/fullchain.pem" cp "$KEY_PEM_PATH" "$LIVE_DIR/privkey.pem" chmod 600 "$LIVE_DIR/privkey.pem" echo -n "Installed certificate valid until: " openssl x509 -noout -enddate -in "$LIVE_DIR/fullchain.pem" | cut -d= -f2 || true echo -e "${GREEN}✓ Certificate installed at infra/certbot/conf/live/$DOMAIN (nginx picks it up as-is).${NC}" fi else CERT_FAILED=true fi fi # P0-D: Fail-closed — WebRTC requires HTTPS (secure context). Without a # certificate the production Nginx config cannot start (it loads cert files # unconditionally), so aborting is the only safe action. if [ "$CERT_FAILED" = true ]; then echo "" echo -e "${RED}✗ TLS certificate is not available. Aborting — WebRTC voice requires HTTPS.${NC}" echo " Existing .env.prod and rendered configs were NOT modified (all writes were staged)." echo " Fix the issue above and re-run: bash install.sh" exit 1 fi echo "" # ── 6. Activate the staged installation (V4-003) ───────────────── # The certificate exists, so the new domain is fully provisioned: move # every staged file into place (same-filesystem renames) and only then # start the stack. Any earlier failure left the live files untouched. echo -e "${BOLD}Activating configuration for $DOMAIN...${NC}" mv "$STAGE_DIR/.env.prod" "$ENV_FILE" mv "$STAGE_DIR/infra/nginx/conf.d/app.conf" "$SCRIPT_DIR/infra/nginx/conf.d/app.conf" mv "$STAGE_DIR/infra/livekit/livekit.yaml" "$SCRIPT_DIR/infra/livekit/livekit.yaml" mv "$STAGE_DIR/infra/turn/turnserver.conf" "$SCRIPT_DIR/infra/turn/turnserver.conf" echo -e "${GREEN}✓ Activated .env.prod, nginx, LiveKit and TURN configs.${NC}" echo "" # ── 7. Start the stack ──────────────────────────────────────────── echo -e "${BOLD}Starting LobbyForge...${NC}" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build echo "" echo -e "${GREEN}${BOLD}✓ LobbyForge is starting!${NC}" echo "" echo -e "${BOLD}Next steps:${NC}" echo " 1. Visit https://$DOMAIN to run the first-run setup wizard." echo " 2. Use the Setup Token when prompted: $SETUP_TOKEN" echo " 3. After setup, remove the Setup Token from .env.prod." echo " 4. Visit Admin → Doctor & Health to verify everything is healthy." echo "" echo -e "${BOLD}Important credentials (save these!):${NC}" echo " Admin Token: $ADMIN_TOKEN" echo " Setup Token: $SETUP_TOKEN (remove after setup!)" echo "" echo -e "${BOLD}Firewall ports required for voice (LF-019):${NC}" echo " 80/tcp + 443/tcp web (nginx + HTTPS)" echo " 3478/tcp + 3478/udp TURN (coturn)" echo " 5349/tcp + 5349/udp TURN/TLS (UDP-blocked networks)" echo " 49160-49200/udp TURN relay range" echo " 50000-60000/udp LiveKit RTC media" echo "" echo " Example (ufw): ufw allow 3478/tcp; ufw allow 3478/udp; ufw allow 5349/tcp; ufw allow 5349/udp; ufw allow 49160:49200/udp; ufw allow 50000:60000/udp" echo "" echo -e "${BOLD}To stop:${NC} docker compose -f $COMPOSE_FILE --env-file $ENV_FILE down" echo -e "${BOLD}To update (signed releases only — never 'git pull' on a production checkout):${NC}" echo " node scripts/lfctl.mjs update check" echo " node scripts/lfctl.mjs update plan" echo " node scripts/lfctl.mjs update apply --yes"