From c49989631f16fc097b0e0bcf0bd07db1b4b7a8d9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 16:05:06 +0000 Subject: [PATCH] fix(runtime): harden smoke and credential boundaries --- .env.example | 23 ++++++ README.md | 42 +++++++++- compose.dev.yaml | 12 ++- compose.smoke.yaml | 13 +++ compose.yaml | 12 +++ runtime/Dockerfile | 3 +- runtime/ecosystem.config.cjs | 9 ++- runtime/entrypoint.sh | 28 +++++++ runtime/git-askpass.sh | 10 +++ runtime/validate-compose-model.mjs | 58 +++++++++++++ runtime/validate-env.mjs | 112 ++++++++++++++++++++++++++ scripts/check.sh | 29 ++++++- test/ecosystem-config.test.mjs | 20 +++++ test/runtime-secret-boundary.test.mjs | 40 +++++++++ test/validate-compose-model.test.mjs | 49 +++++++++++ test/validate-env.test.mjs | 65 +++++++++++++++ 16 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 compose.smoke.yaml create mode 100755 runtime/git-askpass.sh create mode 100644 runtime/validate-compose-model.mjs create mode 100644 runtime/validate-env.mjs create mode 100644 test/ecosystem-config.test.mjs create mode 100644 test/runtime-secret-boundary.test.mjs create mode 100644 test/validate-compose-model.test.mjs create mode 100644 test/validate-env.test.mjs diff --git a/.env.example b/.env.example index 054e8a5..f85a7c2 100644 --- a/.env.example +++ b/.env.example @@ -5,14 +5,37 @@ ACME_EMAIL=admin@example.com HTTP_PORT=80 HTTPS_PORT=443 +# Runtime hard limits. Keep swap equal to memory so the container cannot consume host swap. +RUNTIME_MEMORY_LIMIT=4g +RUNTIME_MEMORY_SWAP_LIMIT=4g +RUNTIME_CPU_LIMIT=4 +RUNTIME_PIDS_LIMIT=256 +# Optional smoke-only limits used with compose.smoke.yaml (maximum supported defaults shown). +# SMOKE_RUNTIME_MEMORY_LIMIT=4g +# SMOKE_RUNTIME_MEMORY_SWAP_LIMIT=4g +# SMOKE_RUNTIME_CPU_LIMIT=4 +# SMOKE_RUNTIME_PIDS_LIMIT=256 + # Core2026 source. A public HTTPS URL needs no extra credential. CORE_REPOSITORY_URL=https://github.com/your-org/core2026.git CORE_BOOTSTRAP_REF=main +# Optional private repository authentication. Keep only one mode enabled. +# HTTPS mode uses a read-only token through GIT_ASKPASS and never embeds it in the URL. +CORE_REPOSITORY_USERNAME= +CORE_REPOSITORY_TOKEN= +# SSH mode accepts base64-encoded deploy key and known_hosts contents. +CORE_SSH_PRIVATE_KEY_BASE64= +CORE_SSH_KNOWN_HOSTS_BASE64= + # Development bind override (used only with compose.dev.yaml). # CORE_DEV_PATH=../core2026 # DEV_UID=1000 # DEV_GID=1000 +# DEV_RUNTIME_MEMORY_LIMIT=4g +# DEV_RUNTIME_MEMORY_SWAP_LIMIT=4g +# DEV_RUNTIME_CPU_LIMIT=4 +# DEV_RUNTIME_PIDS_LIMIT=256 # Persistent services. Use long random values; do not commit .env. POSTGRES_DB=sammo diff --git a/README.md b/README.md index 0a0d345..966f439 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Docker Engine과 Compose plugin이 설치된 Linux 호스트에서 다음을 실 ```sh cp .env.example .env # .env의 domain, Core2026 URL, Kakao key와 모든 비밀값을 교체합니다. +./scripts/check.sh docker compose up -d --build --wait docker compose ps ``` @@ -22,6 +23,16 @@ DNS의 `DOMAIN` A/AAAA record가 호스트를 가리키고 80/443 TCP 및 443 UD Core2026 clone, frozen-lockfile install, build와 Gateway migration 때문에 수 분이 걸릴 수 있습니다. +runtime은 기본적으로 memory/swap 각각 4 GiB, CPU 4개, PID 256개 상한을 +가집니다. 호스트 용량에 맞춰 `RUNTIME_*_LIMIT`을 조정할 수 있지만 0 또는 +무제한으로 두지 않습니다. PM2 process/restart 수가 예상보다 증가하면 로그 수집보다 +runtime 중지가 우선입니다. + +`scripts/check.sh`는 example placeholder, 짧은 비밀값, 잘못된 domain/email, +repository credential 조합을 실제 값 출력 없이 거부합니다. 최소 길이는 DB·Redis +password 24자, game/bootstrap token 32자, 최초 관리자 password 16자입니다. +배포 전 무작위 값은 예를 들어 `openssl rand -base64 36`으로 생성합니다. + `INITIAL_ADMIN_*`은 사용자 table이 비어 있을 때만 superuser를 한 번 생성합니다. 그 뒤 `/gateway/admin/server-operations`에서 `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, `hwe`마다 source branch/commit을 독립 선택합니다. `DB 유지 배포`는 현 @@ -37,8 +48,17 @@ Core2026 clone, frozen-lockfile install, build와 Gateway migration 때문에 release 상태를 삭제하는 파괴적 명령이므로 backup 없이 실행하지 않습니다. - 앱 rollback은 Prisma migration을 되돌리지 않습니다. 이전 앱과 새 schema의 호환성을 배포 전에 확인합니다. -- Core2026 저장소가 비공개라면 이 공개 저장소에 credential을 추가하지 말고, - 호스트의 read-only deploy key나 credential helper를 별도 override로 mount합니다. +- Core2026 저장소가 비공개라면 credential을 Git에 추가하지 않습니다. HTTPS는 + `.env`의 `CORE_REPOSITORY_USERNAME`과 read-only + `CORE_REPOSITORY_TOKEN`을 함께 설정합니다. URL에 credential을 포함하지 않으며 + runtime은 0600 파일로 옮긴 뒤 원래 env를 제거하고 `GIT_ASKPASS`로만 + 전달합니다. +- SSH deploy key를 사용할 때는 `CORE_SSH_PRIVATE_KEY_BASE64`와 + `CORE_SSH_KNOWN_HOSTS_BASE64`를 함께 설정합니다. 각각 `base64 -w0`로 인코딩한 + read-only key와 검증한 known_hosts 내용입니다. HTTPS와 SSH mode를 동시에 + 설정할 수 없습니다. 원래 base64 env는 파일 생성 직후 제거됩니다. 생성된 + credential/key 파일은 runtime volume에서 0600으로 관리되고 해당 mode를 + 해제한 다음 기동하면 제거됩니다. 상태 확인과 로그: @@ -66,6 +86,23 @@ watch process를 별도 shell에서 실행합니다. 운영 release-controller override 없이 production mode를 사용해야 하며, bind checkout의 Git metadata에 container worktree 경로를 등록하지 않도록 주의합니다. +`compose.dev.yaml`은 `RUNTIME_MODE=development`와 모든 service의 +`restart: no`를 literal로 고정합니다. `.env`의 `RUNTIME_MODE`로 이를 덮어쓸 수 +없습니다. production PM2 검증에 개발 override를 섞지 않습니다. 개발 runtime에도 +기본 4 GiB/4 CPU/256 PID 상한이 있으며 `DEV_RUNTIME_*_LIMIT`으로 더 낮출 수 +있습니다. + +운영 entrypoint와 PM2를 검증할 때는 개발 override 대신 전용 smoke override를 +사용합니다. 이 override는 clone mode를 유지하면서 모든 service를 `restart: no`로 +고정하고 runtime 상한을 최대 4 GiB/4 CPU/256 PID로 제한합니다. + +```sh +docker compose -f compose.yaml -f compose.smoke.yaml up -d --build --wait +``` + +검증 shell에는 EXIT/INT/TERM trap과 전체 timeout을 두고, 정상·실패 어느 경우든 +`docker compose stop`으로 끝냅니다. `down --volumes`는 사용하지 않습니다. + ## 설정 검증 `.env`를 채운 뒤 실제 값을 출력하지 않는 검사를 실행합니다. @@ -75,5 +112,6 @@ container worktree 경로를 등록하지 않도록 주의합니다. ``` 검사는 production/development Compose model과 Caddyfile 구문을 확인합니다. +다른 env 파일은 `ENV_FILE=/path/to/file ./scripts/check.sh`로 검사합니다. `docker compose config` 전체 출력에는 펼쳐진 비밀값이 포함될 수 있으므로 CI artifact나 이슈에 그대로 첨부하지 않습니다. diff --git a/compose.dev.yaml b/compose.dev.yaml index aff9680..9186d4e 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,10 +1,14 @@ services: + postgres: + restart: "no" + redis: + restart: "no" runtime: user: ${DEV_UID:-1000}:${DEV_GID:-1000} environment: CORE_SOURCE_MODE: bind CORE_BIND_ROOT: /workspace/core2026 - RUNTIME_MODE: ${RUNTIME_MODE:-development} + RUNTIME_MODE: development VITE_PREVIEW_ALLOWED_HOSTS: '*' volumes: - ${CORE_DEV_PATH:-../core2026}:/workspace/core2026 @@ -17,6 +21,12 @@ services: timeout: 5s retries: 30 start_period: 5m + mem_limit: ${DEV_RUNTIME_MEMORY_LIMIT:-4g} + memswap_limit: ${DEV_RUNTIME_MEMORY_SWAP_LIMIT:-4g} + cpus: ${DEV_RUNTIME_CPU_LIMIT:-4} + pids_limit: ${DEV_RUNTIME_PIDS_LIMIT:-256} + restart: "no" caddy: environment: SITE_ADDRESS: :80 + restart: "no" diff --git a/compose.smoke.yaml b/compose.smoke.yaml new file mode 100644 index 0000000..bd9ffd9 --- /dev/null +++ b/compose.smoke.yaml @@ -0,0 +1,13 @@ +services: + postgres: + restart: "no" + redis: + restart: "no" + runtime: + mem_limit: ${SMOKE_RUNTIME_MEMORY_LIMIT:-4g} + memswap_limit: ${SMOKE_RUNTIME_MEMORY_SWAP_LIMIT:-4g} + cpus: ${SMOKE_RUNTIME_CPU_LIMIT:-4} + pids_limit: ${SMOKE_RUNTIME_PIDS_LIMIT:-256} + restart: "no" + caddy: + restart: "no" diff --git a/compose.yaml b/compose.yaml index dcc8242..0fedc53 100644 --- a/compose.yaml +++ b/compose.yaml @@ -39,9 +39,16 @@ services: context: . dockerfile: runtime/Dockerfile environment: + DOMAIN: ${DOMAIN:?set DOMAIN in .env} + PUBLIC_SCHEME: ${PUBLIC_SCHEME:-https} + ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env} CORE_SOURCE_MODE: clone CORE_REPOSITORY_URL: ${CORE_REPOSITORY_URL:?set CORE_REPOSITORY_URL in .env} CORE_BOOTSTRAP_REF: ${CORE_BOOTSTRAP_REF:-main} + CORE_REPOSITORY_USERNAME: ${CORE_REPOSITORY_USERNAME:-} + CORE_REPOSITORY_TOKEN: ${CORE_REPOSITORY_TOKEN:-} + CORE_SSH_PRIVATE_KEY_BASE64: ${CORE_SSH_PRIVATE_KEY_BASE64:-} + CORE_SSH_KNOWN_HOSTS_BASE64: ${CORE_SSH_KNOWN_HOSTS_BASE64:-} POSTGRES_HOST: postgres POSTGRES_PORT: '5432' POSTGRES_DB: ${POSTGRES_DB:-sammo} @@ -101,6 +108,11 @@ services: timeout: 5s retries: 30 start_period: 5m + mem_limit: ${RUNTIME_MEMORY_LIMIT:-4g} + memswap_limit: ${RUNTIME_MEMORY_SWAP_LIMIT:-4g} + cpus: ${RUNTIME_CPU_LIMIT:-4} + pids_limit: ${RUNTIME_PIDS_LIMIT:-256} + stop_grace_period: 30s restart: unless-stopped caddy: diff --git a/runtime/Dockerfile b/runtime/Dockerfile index d9e2d57..74b746e 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -8,7 +8,8 @@ RUN apt-get update \ COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh -COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs /opt/sammo/ +COPY --chmod=755 runtime/git-askpass.sh /opt/sammo/git-askpass.sh +COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs runtime/validate-env.mjs /opt/sammo/ WORKDIR /srv/core/repository ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"] diff --git a/runtime/ecosystem.config.cjs b/runtime/ecosystem.config.cjs index 298afcd..192c3eb 100644 --- a/runtime/ecosystem.config.cjs +++ b/runtime/ecosystem.config.cjs @@ -1,7 +1,14 @@ const path = require('node:path'); const root = process.env.GATEWAY_WORKSPACE_ROOT || '/srv/core/repository'; -const common = { env: { ...process.env }, autorestart: true, kill_timeout: 15000 }; +const common = { + env: { ...process.env }, + autorestart: true, + max_restarts: 5, + min_uptime: 10000, + restart_delay: 2000, + kill_timeout: 15000, +}; module.exports = { apps: [ diff --git a/runtime/entrypoint.sh b/runtime/entrypoint.sh index 0e75f91..588e52f 100755 --- a/runtime/entrypoint.sh +++ b/runtime/entrypoint.sh @@ -17,6 +17,34 @@ for key in POSTGRES_PASSWORD REDIS_PASSWORD GAME_TOKEN_SECRET KAKAO_REST_KEY KAK require_env "$key" done +node /opt/sammo/validate-env.mjs + +git_auth_dir=/srv/data/git-auth +mkdir -p "$git_auth_dir" +umask 077 + +if [ -n "${CORE_REPOSITORY_USERNAME:-}" ] || [ -n "${CORE_REPOSITORY_TOKEN:-}" ]; then + printf '%s' "$CORE_REPOSITORY_USERNAME" >"$git_auth_dir/https-username" + printf '%s' "$CORE_REPOSITORY_TOKEN" >"$git_auth_dir/https-token" + chmod 600 "$git_auth_dir/https-username" "$git_auth_dir/https-token" + export SAMMO_GIT_AUTH_DIR="$git_auth_dir" + export GIT_ASKPASS=/opt/sammo/git-askpass.sh + export GIT_TERMINAL_PROMPT=0 + unset CORE_REPOSITORY_USERNAME CORE_REPOSITORY_TOKEN +else + rm -f "$git_auth_dir/https-username" "$git_auth_dir/https-token" +fi + +if [ -n "${CORE_SSH_PRIVATE_KEY_BASE64:-}" ] || [ -n "${CORE_SSH_KNOWN_HOSTS_BASE64:-}" ]; then + printf '%s' "$CORE_SSH_PRIVATE_KEY_BASE64" | base64 -d >"$git_auth_dir/id_deploy" + printf '%s' "$CORE_SSH_KNOWN_HOSTS_BASE64" | base64 -d >"$git_auth_dir/known_hosts" + chmod 600 "$git_auth_dir/id_deploy" "$git_auth_dir/known_hosts" + export GIT_SSH_COMMAND="ssh -i $git_auth_dir/id_deploy -o IdentitiesOnly=yes -o UserKnownHostsFile=$git_auth_dir/known_hosts -o StrictHostKeyChecking=yes" + unset CORE_SSH_PRIVATE_KEY_BASE64 CORE_SSH_KNOWN_HOSTS_BASE64 +else + rm -f "$git_auth_dir/id_deploy" "$git_auth_dir/known_hosts" +fi + case "${CORE_SOURCE_MODE:-clone}" in clone) require_env CORE_REPOSITORY_URL diff --git a/runtime/git-askpass.sh b/runtime/git-askpass.sh new file mode 100755 index 0000000..79012c1 --- /dev/null +++ b/runtime/git-askpass.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +auth_dir=${SAMMO_GIT_AUTH_DIR:-/srv/data/git-auth} + +case "${1:-}" in + *Username*) cat "$auth_dir/https-username" ;; + *Password*) cat "$auth_dir/https-token" ;; + *) exit 1 ;; +esac diff --git a/runtime/validate-compose-model.mjs b/runtime/validate-compose-model.mjs new file mode 100644 index 0000000..e1826bb --- /dev/null +++ b/runtime/validate-compose-model.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs'; + +const MAX_SAFE_DEV_MEMORY = 4 * 1024 * 1024 * 1024; +const MAX_SAFE_DEV_CPUS = 4; +const MAX_SAFE_DEV_PIDS = 256; + +const positive = (value) => typeof value === 'number' ? value > 0 : Number(value) > 0; + +export const validateComposeModel = (model, mode) => { + const errors = []; + const runtime = model?.services?.runtime; + if (!runtime) return ['runtime service is missing']; + + if (!positive(runtime.mem_limit)) errors.push('runtime memory limit must be positive'); + if (!positive(runtime.memswap_limit)) errors.push('runtime memory+swap limit must be positive'); + if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) { + errors.push('runtime memory+swap limit must equal its memory limit'); + } + if (!positive(runtime.cpus)) errors.push('runtime CPU limit must be positive'); + if (!positive(runtime.pids_limit)) errors.push('runtime PID limit must be positive'); + + if (mode === 'development') { + if (runtime.environment?.RUNTIME_MODE !== 'development') { + errors.push('development runtime mode must be literal development'); + } + if (runtime.environment?.CORE_SOURCE_MODE !== 'bind') { + errors.push('development Core source mode must be bind'); + } + } else if (runtime.environment?.CORE_SOURCE_MODE !== 'clone') { + errors.push(`${mode} Core source mode must be clone`); + } else if (runtime.environment?.RUNTIME_MODE === 'development') { + errors.push(`${mode} runtime must not use development mode`); + } + + if (mode === 'development' || mode === 'smoke') { + for (const name of ['postgres', 'redis', 'runtime', 'caddy']) { + if (model?.services?.[name]?.restart !== 'no') errors.push(`${name} ${mode} restart policy must be no`); + } + if (Number(runtime.mem_limit) > MAX_SAFE_DEV_MEMORY) errors.push(`${mode} runtime memory limit exceeds 4 GiB`); + if (Number(runtime.cpus) > MAX_SAFE_DEV_CPUS) errors.push(`${mode} runtime CPU limit exceeds 4`); + if (Number(runtime.pids_limit) > MAX_SAFE_DEV_PIDS) errors.push(`${mode} runtime PID limit exceeds 256`); + } + + return errors; +}; + +if (process.argv[1] && process.argv[1].endsWith('validate-compose-model.mjs')) { + const mode = process.argv[2]; + const model = JSON.parse(fs.readFileSync(0, 'utf8')); + const errors = validateComposeModel(model, mode); + if (errors.length) { + console.error(`Compose ${mode} safety validation failed:`); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 65; + } else { + console.log(`Compose ${mode} safety validation passed.`); + } +} diff --git a/runtime/validate-env.mjs b/runtime/validate-env.mjs new file mode 100644 index 0000000..2257256 --- /dev/null +++ b/runtime/validate-env.mjs @@ -0,0 +1,112 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REQUIRED = [ + 'DOMAIN', + 'ACME_EMAIL', + 'CORE_REPOSITORY_URL', + 'POSTGRES_PASSWORD', + 'REDIS_PASSWORD', + 'GAME_TOKEN_SECRET', + 'GATEWAY_BOOTSTRAP_TOKEN', + 'INITIAL_ADMIN_USERNAME', + 'INITIAL_ADMIN_PASSWORD', + 'KAKAO_REST_KEY', +]; + +const MIN_LENGTH = new Map([ + ['POSTGRES_PASSWORD', 24], + ['REDIS_PASSWORD', 24], + ['GAME_TOKEN_SECRET', 32], + ['GATEWAY_BOOTSTRAP_TOKEN', 32], + ['INITIAL_ADMIN_PASSWORD', 16], +]); + +const isPlaceholder = (value) => + value.startsWith('replace-with-') || value.includes('example.com') || value.includes('your-org'); + +const decodeBase64 = (value) => { + try { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return null; + return Buffer.from(value, 'base64').toString('utf8'); + } catch { + return null; + } +}; + +export const validateEnvironment = (env) => { + const errors = []; + for (const key of REQUIRED) { + const value = env[key]?.trim() ?? ''; + if (!value) errors.push(`${key} is required`); + else if (isPlaceholder(value)) errors.push(`${key} still contains an example placeholder`); + } + + for (const [key, length] of MIN_LENGTH) { + const value = env[key] ?? ''; + if (value && value.length < length) errors.push(`${key} must contain at least ${length} characters`); + } + + const domain = env.DOMAIN?.trim() ?? ''; + if (domain && !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(domain)) { + errors.push('DOMAIN must be a hostname without a scheme, path, or port'); + } + + const email = env.ACME_EMAIL?.trim() ?? ''; + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.push('ACME_EMAIL must be a valid email address'); + + const repositoryUrl = env.CORE_REPOSITORY_URL?.trim() ?? ''; + if (repositoryUrl) { + if (/^https?:\/\//i.test(repositoryUrl)) { + try { + const parsed = new URL(repositoryUrl); + if (parsed.username || parsed.password) errors.push('CORE_REPOSITORY_URL must not embed credentials'); + } catch { + errors.push('CORE_REPOSITORY_URL is not a valid HTTP(S) URL'); + } + } else if (!/^(?:ssh:\/\/|git@)[^\s]+/i.test(repositoryUrl)) { + errors.push('CORE_REPOSITORY_URL must use HTTP(S) or SSH'); + } + } + + const httpsUser = env.CORE_REPOSITORY_USERNAME?.trim() ?? ''; + const httpsToken = env.CORE_REPOSITORY_TOKEN?.trim() ?? ''; + const sshKey = env.CORE_SSH_PRIVATE_KEY_BASE64?.trim() ?? ''; + const sshHosts = env.CORE_SSH_KNOWN_HOSTS_BASE64?.trim() ?? ''; + if (Boolean(httpsUser) !== Boolean(httpsToken)) { + errors.push('CORE_REPOSITORY_USERNAME and CORE_REPOSITORY_TOKEN must be set together'); + } + if (Boolean(sshKey) !== Boolean(sshHosts)) { + errors.push('CORE_SSH_PRIVATE_KEY_BASE64 and CORE_SSH_KNOWN_HOSTS_BASE64 must be set together'); + } + if ((httpsUser || httpsToken) && (sshKey || sshHosts)) errors.push('configure only one Core repository authentication mode'); + if ((httpsUser || httpsToken) && !/^https?:\/\//i.test(repositoryUrl)) { + errors.push('HTTPS repository credentials require an HTTP(S) CORE_REPOSITORY_URL'); + } + if ((sshKey || sshHosts) && !/^(?:ssh:\/\/|git@)/i.test(repositoryUrl)) { + errors.push('SSH repository credentials require an SSH CORE_REPOSITORY_URL'); + } + if (sshKey) { + const decoded = decodeBase64(sshKey); + if (!decoded?.includes('BEGIN OPENSSH PRIVATE KEY')) { + errors.push('CORE_SSH_PRIVATE_KEY_BASE64 must encode an OpenSSH private key'); + } + } + if (sshHosts && !decodeBase64(sshHosts)?.trim()) { + errors.push('CORE_SSH_KNOWN_HOSTS_BASE64 must contain valid base64-encoded known_hosts data'); + } + + return errors; +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + const errors = validateEnvironment(process.env); + if (errors.length) { + console.error('Environment validation failed:'); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 64; + } else { + console.log('Environment validation passed without printing secret values.'); + } +} diff --git a/scripts/check.sh b/scripts/check.sh index 662f25a..6a927fc 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,9 +4,32 @@ set -eu stack_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$stack_dir" -docker compose --env-file .env config --quiet -docker compose --env-file .env -f compose.yaml -f compose.dev.yaml config --quiet -docker run --rm -v "$stack_dir/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ +env_file=${ENV_FILE:-.env} +if [ ! -f "$env_file" ]; then + echo "$env_file does not exist; copy .env.example and replace every placeholder first." >&2 + exit 66 +fi + +docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ + -v "$stack_dir/runtime/validate-env.mjs:/opt/sammo/validate-env.mjs:ro" \ + node:24.18.0-bookworm-slim node /opt/sammo/validate-env.mjs +docker compose --env-file "$env_file" config --quiet +docker compose --env-file "$env_file" -f compose.yaml -f compose.dev.yaml config --quiet +docker compose --env-file "$env_file" -f compose.yaml -f compose.smoke.yaml config --quiet +docker compose --env-file "$env_file" config --format json | \ + docker run --rm -i --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 \ + -v "$stack_dir/runtime/validate-compose-model.mjs:/opt/sammo/validate-compose-model.mjs:ro" \ + node:24.18.0-bookworm-slim node /opt/sammo/validate-compose-model.mjs production +docker compose --env-file "$env_file" -f compose.yaml -f compose.dev.yaml config --format json | \ + docker run --rm -i --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 \ + -v "$stack_dir/runtime/validate-compose-model.mjs:/opt/sammo/validate-compose-model.mjs:ro" \ + node:24.18.0-bookworm-slim node /opt/sammo/validate-compose-model.mjs development +docker compose --env-file "$env_file" -f compose.yaml -f compose.smoke.yaml config --format json | \ + docker run --rm -i --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 \ + -v "$stack_dir/runtime/validate-compose-model.mjs:/opt/sammo/validate-compose-model.mjs:ro" \ + node:24.18.0-bookworm-slim node /opt/sammo/validate-compose-model.mjs smoke +docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 \ + -v "$stack_dir/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ -e SITE_ADDRESS=:80 -e ACME_EMAIL=validate@example.invalid caddy:2.10.2-alpine \ caddy validate --config /etc/caddy/Caddyfile echo 'Compose and Caddy configuration are valid.' diff --git a/test/ecosystem-config.test.mjs b/test/ecosystem-config.test.mjs new file mode 100644 index 0000000..c61b87a --- /dev/null +++ b/test/ecosystem-config.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ecosystem = require('../runtime/ecosystem.config.cjs'); + +test('defines exactly four unique, bounded Gateway processes', () => { + assert.deepEqual( + ecosystem.apps.map((app) => app.name), + ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator', 'sammo:release-controller'], + ); + assert.equal(new Set(ecosystem.apps.map((app) => app.name)).size, 4); + for (const app of ecosystem.apps) { + assert.equal(app.autorestart, true); + assert.equal(app.max_restarts, 5); + assert.equal(app.min_uptime, 10_000); + assert.equal(app.restart_delay, 2_000); + } +}); diff --git a/test/runtime-secret-boundary.test.mjs b/test/runtime-secret-boundary.test.mjs new file mode 100644 index 0000000..5d51bd8 --- /dev/null +++ b/test/runtime-secret-boundary.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +test('Git askpass reads HTTPS credentials from protected files instead of process env', async () => { + const authDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-git-auth-')); + try { + await fs.writeFile(path.join(authDir, 'https-username'), 'deploy-user', { mode: 0o600 }); + await fs.writeFile(path.join(authDir, 'https-token'), 'deploy-token', { mode: 0o600 }); + + const username = await execFileAsync('./runtime/git-askpass.sh', ['Username'], { + env: { SAMMO_GIT_AUTH_DIR: authDir }, + }); + const token = await execFileAsync('./runtime/git-askpass.sh', ['Password'], { + env: { SAMMO_GIT_AUTH_DIR: authDir }, + }); + + assert.equal(username.stdout, 'deploy-user'); + assert.equal(token.stdout, 'deploy-token'); + } finally { + await fs.rm(authDir, { recursive: true }); + } +}); + +test('runtime entrypoint removes raw repository secrets before launching PM2', async () => { + const entrypoint = await fs.readFile(new URL('../runtime/entrypoint.sh', import.meta.url), 'utf8'); + const pm2Start = entrypoint.indexOf('"$pm2_bin" start'); + const httpsUnset = entrypoint.indexOf('unset CORE_REPOSITORY_USERNAME CORE_REPOSITORY_TOKEN'); + const sshUnset = entrypoint.indexOf('unset CORE_SSH_PRIVATE_KEY_BASE64 CORE_SSH_KNOWN_HOSTS_BASE64'); + + assert.ok(pm2Start > 0); + assert.ok(httpsUnset > 0 && httpsUnset < pm2Start); + assert.ok(sshUnset > 0 && sshUnset < pm2Start); +}); diff --git a/test/validate-compose-model.test.mjs b/test/validate-compose-model.test.mjs new file mode 100644 index 0000000..fa55b38 --- /dev/null +++ b/test/validate-compose-model.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateComposeModel } from '../runtime/validate-compose-model.mjs'; + +const safeRuntime = { + environment: { CORE_SOURCE_MODE: 'clone' }, + restart: 'unless-stopped', + mem_limit: String(4 * 1024 * 1024 * 1024), + memswap_limit: String(4 * 1024 * 1024 * 1024), + cpus: 4, + pids_limit: 256, +}; + +test('accepts a bounded production runtime', () => { + assert.deepEqual(validateComposeModel({ services: { runtime: safeRuntime } }, 'production'), []); +}); + +test('accepts a literal, non-restarting development model', () => { + const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }])); + services.runtime = { + ...safeRuntime, + restart: 'no', + environment: { CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'development' }, + }; + assert.deepEqual(validateComposeModel({ services }, 'development'), []); +}); + +test('accepts a bounded, non-restarting production smoke model', () => { + const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }])); + services.runtime = { ...safeRuntime, restart: 'no' }; + assert.deepEqual(validateComposeModel({ services }, 'smoke'), []); +}); + +test('rejects an unbounded or production-mode development runtime', () => { + const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'unless-stopped' }])); + services.runtime = { + environment: { CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'production' }, + restart: 'unless-stopped', + mem_limit: 0, + memswap_limit: 0, + cpus: 0, + pids_limit: 0, + }; + const errors = validateComposeModel({ services }, 'development'); + assert.ok(errors.some((error) => error.includes('memory limit must be positive'))); + assert.ok(errors.some((error) => error.includes('literal development'))); + assert.ok(errors.some((error) => error.includes('restart policy must be no'))); +}); diff --git a/test/validate-env.test.mjs b/test/validate-env.test.mjs new file mode 100644 index 0000000..06e0f9c --- /dev/null +++ b/test/validate-env.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateEnvironment } from '../runtime/validate-env.mjs'; + +const validEnv = { + DOMAIN: 'game.test.invalid', + ACME_EMAIL: 'admin@test.invalid', + CORE_REPOSITORY_URL: 'https://git.test.invalid/team/core2026.git', + POSTGRES_PASSWORD: 'p'.repeat(24), + REDIS_PASSWORD: 'r'.repeat(24), + GAME_TOKEN_SECRET: 'g'.repeat(32), + GATEWAY_BOOTSTRAP_TOKEN: 'b'.repeat(32), + INITIAL_ADMIN_USERNAME: 'admin', + INITIAL_ADMIN_PASSWORD: 'a'.repeat(16), + KAKAO_REST_KEY: 'kakao-test-key', +}; + +test('accepts a complete public repository environment', () => { + assert.deepEqual(validateEnvironment(validEnv), []); +}); + +test('accepts paired HTTPS credentials without embedding them in the URL', () => { + assert.deepEqual( + validateEnvironment({ ...validEnv, CORE_REPOSITORY_USERNAME: 'deploy', CORE_REPOSITORY_TOKEN: 'token' }), + [], + ); +}); + +test('accepts a paired SSH deploy key and pinned host data', () => { + assert.deepEqual( + validateEnvironment({ + ...validEnv, + CORE_REPOSITORY_URL: 'ssh://git@git.test.invalid:2222/team/core2026.git', + CORE_SSH_PRIVATE_KEY_BASE64: Buffer.from('-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n').toString('base64'), + CORE_SSH_KNOWN_HOSTS_BASE64: Buffer.from('git.test.invalid ssh-ed25519 test\n').toString('base64'), + }), + [], + ); +}); + +test('rejects placeholders, weak secrets, and partial credentials', () => { + const errors = validateEnvironment({ + ...validEnv, + DOMAIN: 'game.example.com', + POSTGRES_PASSWORD: 'short', + CORE_REPOSITORY_USERNAME: 'deploy', + }); + assert.ok(errors.some((error) => error.includes('DOMAIN still contains'))); + assert.ok(errors.some((error) => error.includes('POSTGRES_PASSWORD must contain'))); + assert.ok(errors.some((error) => error.includes('must be set together'))); +}); + +test('rejects credentials embedded in repository URLs and mixed auth modes', () => { + const errors = validateEnvironment({ + ...validEnv, + CORE_REPOSITORY_URL: 'https://deploy:secret@git.test.invalid/team/core2026.git', + CORE_REPOSITORY_USERNAME: 'deploy', + CORE_REPOSITORY_TOKEN: 'token', + CORE_SSH_PRIVATE_KEY_BASE64: 'key', + CORE_SSH_KNOWN_HOSTS_BASE64: 'hosts', + }); + assert.ok(errors.some((error) => error.includes('must not embed credentials'))); + assert.ok(errors.some((error) => error.includes('only one Core repository authentication mode'))); +});