#!/bin/sh set -eu umask 077 # Standalone public customer bootstrap. It never checks out application source # and it never pulls an Auth image. REGISTRY=${OVERCOOKED_REGISTRY:-git.leee.xin/overcooked} VERSION=${OVERCOOKED_VERSION:-latest} ROOT=${OVERCOOKED_ROOT:-/opt/overcooked} SERVER_IP=${OVERCOOKED_SERVER_IP:-} ACTIVATION_CODE=${OVERCOOKED_ACTIVATION_CODE:-} AUTH_URL=${OVERCOOKED_AUTH_URL:-http://148.135.97.10:18091} AUTH_PUBLIC_KEYS=${OVERCOOKED_AUTH_PUBLIC_KEYS:-} INSTALLER_BASE_URL=${OVERCOOKED_INSTALLER_BASE_URL:-https://git.leee.xin/OverCooked/OverCooked-Installer/raw/branch/main} TURNSTILE_SITE_KEY=${OVERCOOKED_TURNSTILE_SITE_KEY:-} TURNSTILE_SECRET_KEY=${OVERCOOKED_TURNSTILE_SECRET_KEY:-} S_COMPOSE=$ROOT/compose/s.yml E_COMPOSE=$ROOT/compose/e.yml ENV_FILE=$ROOT/production.env S_PROJECT=overcooked-s E_PROJECT=overcooked-e FAILURE_REPORTED=0 S_POSTGRES_PASSWORD=not-generated S_VALKEY_PASSWORD=not-generated S_INTERNAL_SHARED_SECRET=not-generated S_ACCESS_TOKEN_SECRET=not-generated S_SETUP_TOKEN=not-generated E_POSTGRES_PASSWORD=not-generated E_PLAYBACK_TICKET_SECRET=not-generated E_PLAYBACK_LEASE_KEY=not-generated E_STORAGE_CREDENTIAL_KEY=not-generated E_PLUGIN_SECRET_KEY=not-generated die() { echo "overcooked-install: $*" >&2; exit 1; } is_ipv4() { old_ifs=$IFS; IFS=.; set -- $1; IFS=$old_ifs [ "$#" = 4 ] || return 1 for octet in "$@"; do case "$octet" in ''|*[!0-9]*) return 1 ;; esac [ "$octet" -le 255 ] 2>/dev/null || return 1 done } is_private_ipv4() { second=$(printf '%s\n' "$1" | cut -d. -f2) case "$1" in 0.*|10.*|127.*|169.254.*|192.168.*|100.64.*|100.65.*|100.66.*|100.67.*|100.68.*|100.69.*|100.70.*|100.71.*|100.72.*|100.73.*|100.74.*|100.75.*|100.76.*|100.77.*|100.78.*|100.79.*|100.8*.*|100.9*.*) return 0 ;; 172.*) [ "$second" -ge 16 ] 2>/dev/null && [ "$second" -le 31 ] 2>/dev/null; return ;; esac return 1 } check_port() { port=$1 if command -v ss >/dev/null 2>&1 && ss -H -ltn 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" || $4 ~ p "\\]$" { found=1 } END { exit !found }'; then die "端口 $port 已被占用,请停止占用进程或调整部署端口" fi } redact_logs() { sed -e "s|$S_POSTGRES_PASSWORD|[REDACTED]|g" \ -e "s|$S_VALKEY_PASSWORD|[REDACTED]|g" \ -e "s|$S_INTERNAL_SHARED_SECRET|[REDACTED]|g" \ -e "s|$S_ACCESS_TOKEN_SECRET|[REDACTED]|g" \ -e "s|$S_SETUP_TOKEN|[REDACTED]|g" \ -e "s|$E_POSTGRES_PASSWORD|[REDACTED]|g" \ -e "s|$E_PLAYBACK_TICKET_SECRET|[REDACTED]|g" \ -e "s|$E_PLAYBACK_LEASE_KEY|[REDACTED]|g" \ -e "s|$E_STORAGE_CREDENTIAL_KEY|[REDACTED]|g" \ -e "s|$E_PLUGIN_SECRET_KEY|[REDACTED]|g" } show_failure() { [ "$FAILURE_REPORTED" = 1 ] && return 0 FAILURE_REPORTED=1 [ -f "$ENV_FILE" ] || return 0 echo "安装失败,以下是脱敏后的容器状态和最近日志:" >&2 docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" ps >&2 || true docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" ps >&2 || true docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true } on_exit() { rc=$?; [ "$rc" -eq 0 ] || show_failure; exit "$rc"; } trap on_exit EXIT [ "$(id -u)" = 0 ] || die "请使用 sudo/root 执行" command -v docker >/dev/null 2>&1 || die "未找到 Docker Engine" docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2" command -v curl >/dev/null 2>&1 || die "未找到 curl" command -v openssl >/dev/null 2>&1 || die "未找到 openssl" command -v tar >/dev/null 2>&1 || die "未找到 tar" command -v mktemp >/dev/null 2>&1 || die "未找到 mktemp" for pair in "OVERCOOKED_REGISTRY:$REGISTRY" "OVERCOOKED_VERSION:$VERSION" "OVERCOOKED_ROOT:$ROOT" "OVERCOOKED_AUTH_URL:$AUTH_URL" "OVERCOOKED_INSTALLER_BASE_URL:$INSTALLER_BASE_URL"; do name=${pair%%:*}; value=${pair#*:}; [ -n "$value" ] || die "$name 不能为空" case "$value" in *' '*|*' '*|*'\r'*|*'\n'*) die "$name 包含非法空白字符" ;; esac done case "$REGISTRY" in *://*|*/|*//*|*=*) die "OVERCOOKED_REGISTRY 必须是镜像 Registry 路径" ;; esac case "$VERSION" in */*|*:*|*[!A-Za-z0-9._-]*) die "OVERCOOKED_VERSION 格式无效" ;; esac case "$ROOT" in /*) ;; *) die "OVERCOOKED_ROOT 必须是绝对路径" ;; esac case "$INSTALLER_BASE_URL" in *://*) ;; *) die "Installer 地址必须是 HTTP(S) URL" ;; esac if [ -z "$SERVER_IP" ]; then command -v ip >/dev/null 2>&1 || die "未找到 ip,请设置 OVERCOOKED_SERVER_IP" SERVER_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}') [ -n "$SERVER_IP" ] || die "无法识别服务器公网 IPv4,请设置 OVERCOOKED_SERVER_IP" EXPLICIT_SERVER_IP=0 else EXPLICIT_SERVER_IP=1 fi is_ipv4 "$SERVER_IP" || die "仅支持有效 IPv4 地址:$SERVER_IP" if [ "$EXPLICIT_SERVER_IP" = 0 ] && is_private_ipv4 "$SERVER_IP"; then die "检测到私有地址,请设置 OVERCOOKED_SERVER_IP=公网IPv4" fi [ -n "$AUTH_PUBLIC_KEYS" ] || die "请设置 OVERCOOKED_AUTH_PUBLIC_KEYS(Auth 公钥 JSON)" case "$AUTH_PUBLIC_KEYS" in \{*\}) ;; *) die "OVERCOOKED_AUTH_PUBLIC_KEYS 必须是 JSON 对象" ;; esac case "$AUTH_PUBLIC_KEYS" in *'\n'*|*'\r'*) die "OVERCOOKED_AUTH_PUBLIC_KEYS 不得包含换行" ;; esac if [ -z "$ACTIVATION_CODE" ]; then [ -t 0 ] || die "授权码不能为空,请设置 OVERCOOKED_ACTIVATION_CODE" printf '请输入授权码: ' >&2; IFS= read -r ACTIVATION_CODE fi [ -n "$ACTIVATION_CODE" ] || die "授权码不能为空" if [ -z "$TURNSTILE_SITE_KEY" ] || [ -z "$TURNSTILE_SECRET_KEY" ]; then die "当前 S 生产镜像需要 OVERCOOKED_TURNSTILE_SITE_KEY 和 OVERCOOKED_TURNSTILE_SECRET_KEY;请提供成对配置" fi check_port 18080; check_port 18083; check_port 8096 install -d -m 700 "$ROOT" "$ROOT/config" "$ROOT/compose" "$ROOT/migrations" "$ROOT/node-images" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) fetch_asset() { relative=$1; destination=$2; source="$SCRIPT_DIR/$relative" if [ -f "$source" ]; then install -m 600 "$source" "$destination"; return; fi temporary=$(mktemp "$destination.download.XXXXXX") curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 "$INSTALLER_BASE_URL/$relative" -o "$temporary" || die "下载公开 Installer 资产失败:$relative" [ -s "$temporary" ] || die "公开 Installer 资产为空:$relative" install -m 600 "$temporary" "$destination"; rm -f "$temporary" } fetch_asset compose/s.yml "$S_COMPOSE" fetch_asset compose/e.yml "$E_COMPOSE" fetch_asset s-migrate.sh "$ROOT/s-migrate.sh" fetch_asset config/e-server.production.yaml "$ROOT/config/e-server.production.yaml" fetch_asset s-migrations.tar.gz "$ROOT/s-migrations.tar.gz" tar -xzf "$ROOT/s-migrations.tar.gz" -C "$ROOT/migrations"; rm -f "$ROOT/s-migrations.tar.gz" find "$ROOT/migrations" -type f ! -name '*.up.sql' -delete find "$ROOT/migrations" -type f -name '*.up.sql' -exec chmod 600 {} \; secret() { openssl rand -hex 32; } S_POSTGRES_PASSWORD=$(secret); S_VALKEY_PASSWORD=$(secret) S_INTERNAL_SHARED_SECRET=$(secret); S_ACCESS_TOKEN_SECRET=$(secret); S_SETUP_TOKEN=$(secret) E_POSTGRES_PASSWORD=$(secret); E_PLAYBACK_TICKET_SECRET=$(secret); E_PLAYBACK_LEASE_KEY=$(secret) E_STORAGE_CREDENTIAL_KEY=$(secret); E_PLUGIN_SECRET_KEY=$(secret) INSTANCE_SUFFIX=$(hostname 2>/dev/null | tr -cd '[:alnum:]' | cut -c1-24); [ -n "$INSTANCE_SUFFIX" ] || INSTANCE_SUFFIX=customer cat > "$ENV_FILE" < "$ROOT/config/e-server.production.yaml" </dev/null 2>&1 || docker network create overcooked-control >/dev/null docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" config >/dev/null docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" config >/dev/null for image in "$REGISTRY/overcooked-e:$VERSION" "$REGISTRY/overcooked-s-control:$VERSION" "$REGISTRY/overcooked-s-webui:$VERSION" "$REGISTRY/overcooked-media-node:$VERSION"; do docker pull "$image" docker image inspect "$image" >/dev/null 2>&1 || die "镜像校验失败:$image" done docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d postgres valkey docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" run --rm --no-deps e-server migrate -config /etc/overcooked-e/config.yaml docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d postgres valkey docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" run --rm --no-deps s-migrate docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d e-server docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d s-control webui wait_for() { label=$1; url=$2; attempt=1 while [ "$attempt" -le 60 ]; do curl -fsS --max-time 5 "$url" >/dev/null 2>&1 && return 0 sleep 2; attempt=$((attempt + 1)) done die "$label 健康检查失败:$url" } wait_for "S live" http://127.0.0.1:18080/health/live wait_for "S ready" http://127.0.0.1:18080/health/ready wait_for "E live" http://127.0.0.1:8096/health/live wait_for "WebUI" http://127.0.0.1:18083/ wait_for "Setup" http://127.0.0.1:18083/setup echo "OverCooked 客户部署已通过容器健康检查。" echo "WebUI: http://$SERVER_IP:18083/" echo "API: http://$SERVER_IP:18080/" echo "E: http://$SERVER_IP:8096/" echo "首次初始化: http://$SERVER_IP:18083/setup" echo "Setup Token 已保存于 $ENV_FILE(不会在终端显示,请限制文件访问权限)。" echo "镜像来源:$REGISTRY(仅公开 E、S Control、S WebUI、Media Node;不包含 Auth 镜像)"