diff --git a/install.sh b/install.sh index bde1782..6e8935f 100755 --- a/install.sh +++ b/install.sh @@ -1,396 +1,120 @@ #!/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} -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 +# Public customer bootstrap. Auth is an external service; no Auth image or +# private source is downloaded by this script. +REGISTRY="${OVERCOOKED_REGISTRY:-git.leee.xin/overcooked}" +VERSION="${OVERCOOKED_VERSION:-latest}" +ROOT="${OVERCOOKED_ROOT:-/opt/overcooked}" +SERVER_IP="${OVERCOOKED_SERVER_IP:-}" +API_PORT="${OVERCOOKED_API_PORT:-8100}" +WEB_PORT="${OVERCOOKED_WEB_PORT:-8111}" +ACTIVATION_CODE="${OVERCOOKED_ACTIVATION_CODE:-}" +# Central OverCooked Auth gateway. Override for private deployments. +AUTH_URL="${OVERCOOKED_AUTH_URL:-http://148.135.97.10:18091}" +AUTH_PUBLIC_KEYS="${OVERCOOKED_AUTH_PUBLIC_KEYS:-}" 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 - command -v ss >/dev/null 2>&1 || die "未找到 ss,无法安全检测端口 $port;请安装 iproute2 后重试" - if 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 +command -v git >/dev/null 2>&1 || die "未找到 git" +[ -n "$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" ] || SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}') +[ -n "$SERVER_IP" ] || die "无法识别服务器 IP,请设置 OVERCOOKED_SERVER_IP" +case "$SERVER_IP" in *[!0-9.]*|*[!0-9]*) die "仅支持 IPv4 地址:$SERVER_IP";; esac [ -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" ] || printf '请输入授权码: '; [ -n "$ACTIVATION_CODE" ] || read -r ACTIVATION_CODE [ -n "$ACTIVATION_CODE" ] || die "授权码不能为空" -check_port 18080; check_port 18083; check_port 8096 - -install -d -m 700 "$ROOT" "$ROOT/config" "$ROOT/compose" "$ROOT/migrations" "$ROOT/node-images" "$ROOT/control-plane" -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" -chmod 644 "$ROOT/s-migrate.sh" -fetch_asset config/e-server.production.yaml "$ROOT/config/e-server.production.yaml" -chmod 644 "$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 644 {} \; -chmod 755 "$ROOT/migrations" "$ROOT/node-images" - -# E requires an HTTPS control-plane origin in production. Generate a local -# CA and service/client certificates for the restricted proxy sidecar. The -# proxy is generated locally and runs from the already-approved S WebUI image; -# no additional image or source checkout is needed. -CONTROL_PLANE_DIR="$ROOT/control-plane" -cat > "$CONTROL_PLANE_DIR/control-proxy.mjs" <<'NODE_PROXY' -import fs from "node:fs"; -import http from "node:http"; -import https from "node:https"; - -const listenPort = 8443; -const options = { - key: fs.readFileSync("/run/overcooked-control-plane/server.key"), - cert: fs.readFileSync("/run/overcooked-control-plane/server.crt"), - ca: fs.readFileSync("/run/overcooked-control-plane/ca.crt"), - requestCert: true, - rejectUnauthorized: true, - minVersion: "TLSv1.2", -}; - -function reject(response, status, message) { - response.writeHead(status, {"content-type": "text/plain; charset=utf-8"}); - response.end(message); -} - -const server = https.createServer(options, (request, response) => { - let target; - let decodedPath; - try { - target = new URL(request.url ?? "/", "https://control-proxy"); - decodedPath = decodeURIComponent(target.pathname); - } catch { - reject(response, 400, "invalid request target"); - return; - } - if (target.origin !== "https://control-proxy" || target.username || target.password || - decodedPath.includes("..") || decodedPath.includes("\u0000") || - decodedPath !== "/health/live" && !decodedPath.startsWith("/internal/v1/")) { - reject(response, 404, "not found"); - return; - } - if (decodedPath === "/health/live") { - response.writeHead(200, {"content-type": "application/json"}); - response.end('{"status":"live"}'); - return; - } - - const headers = {...request.headers}; - delete headers.host; - delete headers.connection; - delete headers["x-forwarded-for"]; - delete headers["x-forwarded-host"]; - delete headers["x-forwarded-proto"]; - headers.host = "s-control:8080"; - const upstream = http.request({hostname: "s-control", port: 8080, method: request.method, path: `${target.pathname}${target.search}`, headers, timeout: 10000}, (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.pipe(response); - }); - upstream.on("timeout", () => upstream.destroy(new Error("upstream timeout"))); - upstream.on("error", () => { - if (!response.headersSent) reject(response, 502, "control plane unavailable"); - else response.destroy(); - }); - request.pipe(upstream); -}); - -server.listen(listenPort, "0.0.0.0"); -NODE_PROXY -chmod 644 "$CONTROL_PLANE_DIR/control-proxy.mjs" -chown 1000:1000 "$CONTROL_PLANE_DIR/control-proxy.mjs" -openssl genrsa -out "$CONTROL_PLANE_DIR/ca.key" 3072 -openssl req -x509 -new -nodes -key "$CONTROL_PLANE_DIR/ca.key" -sha256 -days 3650 -subj "/CN=OverCooked customer control plane CA" -out "$CONTROL_PLANE_DIR/ca.crt" -openssl genrsa -out "$CONTROL_PLANE_DIR/server.key" 2048 -openssl req -new -key "$CONTROL_PLANE_DIR/server.key" -subj "/CN=control-proxy" -out "$CONTROL_PLANE_DIR/server.csr" -printf '%s\n' 'subjectAltName=DNS:control-proxy,DNS:product-e' 'extendedKeyUsage=serverAuth' > "$CONTROL_PLANE_DIR/server.ext" -openssl x509 -req -in "$CONTROL_PLANE_DIR/server.csr" -CA "$CONTROL_PLANE_DIR/ca.crt" -CAkey "$CONTROL_PLANE_DIR/ca.key" -CAcreateserial -out "$CONTROL_PLANE_DIR/server.crt" -days 825 -sha256 -extfile "$CONTROL_PLANE_DIR/server.ext" -openssl genrsa -out "$CONTROL_PLANE_DIR/client.key" 2048 -openssl req -new -key "$CONTROL_PLANE_DIR/client.key" -subj "/CN=overcooked-e" -out "$CONTROL_PLANE_DIR/client.csr" -printf '%s\n' 'extendedKeyUsage=clientAuth' > "$CONTROL_PLANE_DIR/client.ext" -openssl x509 -req -in "$CONTROL_PLANE_DIR/client.csr" -CA "$CONTROL_PLANE_DIR/ca.crt" -CAkey "$CONTROL_PLANE_DIR/ca.key" -CAcreateserial -out "$CONTROL_PLANE_DIR/client.crt" -days 825 -sha256 -extfile "$CONTROL_PLANE_DIR/client.ext" -rm -f "$CONTROL_PLANE_DIR/ca.key" "$CONTROL_PLANE_DIR/server.csr" "$CONTROL_PLANE_DIR/server.ext" "$CONTROL_PLANE_DIR/client.csr" "$CONTROL_PLANE_DIR/client.ext" "$CONTROL_PLANE_DIR/ca.srl" -chmod 644 "$CONTROL_PLANE_DIR/ca.crt" "$CONTROL_PLANE_DIR/server.crt" "$CONTROL_PLANE_DIR/client.crt" -chmod 640 "$CONTROL_PLANE_DIR/server.key" "$CONTROL_PLANE_DIR/client.key" -chown 1000:1000 "$CONTROL_PLANE_DIR/server.key" -# The proxy healthcheck (uid 1000) and E (uid 10001) both need the client key; -# a shared group keeps it unreadable to other container users. -chown 1000:10001 "$CONTROL_PLANE_DIR/client.key" -chmod 755 "$CONTROL_PLANE_DIR" +install -d -m 700 "$ROOT" +for repo in OverCooked-S Overcooked-E; do + if [ ! -d "$ROOT/$repo/.git" ]; then + git clone --depth 1 "https://git.leee.xin/OverCooked/$repo.git" "$ROOT/$repo" + else + git -C "$ROOT/$repo" fetch --depth 1 origin && git -C "$ROOT/$repo" reset --hard FETCH_HEAD + fi +done +install -d -m 700 "$ROOT/config" +command -v openssl >/dev/null 2>&1 || die "未找到 openssl" 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/production.env" < "$ROOT/config/e-server.production.yaml" <"$ROOT/config/e-server.production.yaml" <"$ROOT/config/e-public.override.yml" <<'EOF' +services: + e-server: + ports: + - "0.0.0.0:8096:8096" EOF -chmod 644 "$ROOT/config/e-server.production.yaml" - docker network inspect overcooked-control >/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 - -MEDIA_NODE_IMAGE="$REGISTRY/overcooked-media-node:$VERSION" -for image in "$REGISTRY/overcooked-e:$VERSION" "$REGISTRY/overcooked-s-control:$VERSION" "$REGISTRY/overcooked-s-webui:$VERSION" "$MEDIA_NODE_IMAGE"; do - docker pull "$image" - docker image inspect "$image" >/dev/null 2>&1 || die "镜像校验失败:$image" -done -image_tar=$(mktemp "$ROOT/node-images/media-node.tar.XXXXXX") -image_archive=$(mktemp "$ROOT/node-images/media-node.tar.gz.XXXXXX") -docker save "$MEDIA_NODE_IMAGE" -o "$image_tar" || die "无法导出 Media Node 镜像归档" -gzip -c "$image_tar" > "$image_archive" || die "无法压缩 Media Node 镜像归档" -rm -f "$image_tar" -mv "$image_archive" "$ROOT/node-images/media-node.tar.gz" -chmod 644 "$ROOT/node-images/media-node.tar.gz" - -wait_for_compose_health() { - compose_file=$1; project=$2; service=$3; attempt=1 - while [ "$attempt" -le 60 ]; do - container=$(docker compose --env-file "$ENV_FILE" -f "$compose_file" -p "$project" ps -q "$service" 2>/dev/null || true) - status='' - if [ -n "$container" ]; then - status=$(docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null || true) - fi - [ "$status" = healthy ] && return 0 - [ "$status" = unhealthy ] && die "$service 容器健康检查失败" - sleep 2 - attempt=$((attempt + 1)) - done - die "$service 容器健康检查超时" -} - -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 "$S_COMPOSE" -p "$S_PROJECT" up -d s-control control-proxy webui -wait_for_compose_health "$S_COMPOSE" "$S_PROJECT" control-proxy -docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d e-server - -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/" +docker compose -p overcooked-e --env-file "$ROOT/production.env" -f "$ROOT/Overcooked-E/deploy/compose/production.yml" run --rm e-server migrate +docker compose -p overcooked-e --env-file "$ROOT/production.env" -f "$ROOT/Overcooked-E/deploy/compose/production.yml" -f "$ROOT/Overcooked-E/deploy/compose/control-plane-network.yml" -f "$ROOT/config/e-public.override.yml" up -d --no-build +docker compose -p overcooked-s --env-file "$ROOT/production.env" -f "$ROOT/OverCooked-S/deploy/s/docker-compose.production.yml" up -d --no-build +echo "配置已写入 $ROOT/production.env" +echo "WebUI: http://$SERVER_IP:$WEB_PORT/" +echo "API: http://$SERVER_IP:$API_PORT/" 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 镜像)" +echo "首次启动后打开 http://$SERVER_IP:18083/setup 输入授权码和页面显示的 Setup Token。" +echo "镜像来源:$REGISTRY(不包含 Auth 镜像)"