Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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:-<unknown>}'."
  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/<domain>, 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"