fix(runtime): harden smoke and credential boundaries

This commit is contained in:
2026-08-04 16:05:06 +00:00
parent 09d849de07
commit c49989631f
16 changed files with 517 additions and 8 deletions
+23
View File
@@ -5,14 +5,37 @@ ACME_EMAIL=admin@example.com
HTTP_PORT=80 HTTP_PORT=80
HTTPS_PORT=443 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. # Core2026 source. A public HTTPS URL needs no extra credential.
CORE_REPOSITORY_URL=https://github.com/your-org/core2026.git CORE_REPOSITORY_URL=https://github.com/your-org/core2026.git
CORE_BOOTSTRAP_REF=main 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). # Development bind override (used only with compose.dev.yaml).
# CORE_DEV_PATH=../core2026 # CORE_DEV_PATH=../core2026
# DEV_UID=1000 # DEV_UID=1000
# DEV_GID=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. # Persistent services. Use long random values; do not commit .env.
POSTGRES_DB=sammo POSTGRES_DB=sammo
+40 -2
View File
@@ -12,6 +12,7 @@ Docker Engine과 Compose plugin이 설치된 Linux 호스트에서 다음을 실
```sh ```sh
cp .env.example .env cp .env.example .env
# .env의 domain, Core2026 URL, Kakao key와 모든 비밀값을 교체합니다. # .env의 domain, Core2026 URL, Kakao key와 모든 비밀값을 교체합니다.
./scripts/check.sh
docker compose up -d --build --wait docker compose up -d --build --wait
docker compose ps 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 때문에 수 분이 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를 한 번 생성합니다. `INITIAL_ADMIN_*`은 사용자 table이 비어 있을 때만 superuser를 한 번 생성합니다.
그 뒤 `/gateway/admin/server-operations`에서 `che`, `kwe`, `pwe`, `twe`, `nya`, 그 뒤 `/gateway/admin/server-operations`에서 `che`, `kwe`, `pwe`, `twe`, `nya`,
`pya`, `hwe`마다 source branch/commit을 독립 선택합니다. `DB 유지 배포`는 현 `pya`, `hwe`마다 source branch/commit을 독립 선택합니다. `DB 유지 배포`는 현
@@ -37,8 +48,17 @@ Core2026 clone, frozen-lockfile install, build와 Gateway migration 때문에
release 상태를 삭제하는 파괴적 명령이므로 backup 없이 실행하지 않습니다. release 상태를 삭제하는 파괴적 명령이므로 backup 없이 실행하지 않습니다.
- 앱 rollback은 Prisma migration을 되돌리지 않습니다. 이전 앱과 새 schema의 - 앱 rollback은 Prisma migration을 되돌리지 않습니다. 이전 앱과 새 schema의
호환성을 배포 전에 확인합니다. 호환성을 배포 전에 확인합니다.
- Core2026 저장소가 비공개라면 이 공개 저장소에 credential을 추가하지 말고, - Core2026 저장소가 비공개라면 credential을 Git에 추가하지 않습니다. HTTPS는
호스트의 read-only deploy key나 credential helper를 별도 override로 mount합니다. `.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에 override 없이 production mode를 사용해야 하며, bind checkout의 Git metadata에
container worktree 경로를 등록하지 않도록 주의합니다. 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`를 채운 뒤 실제 값을 출력하지 않는 검사를 실행합니다. `.env`를 채운 뒤 실제 값을 출력하지 않는 검사를 실행합니다.
@@ -75,5 +112,6 @@ container worktree 경로를 등록하지 않도록 주의합니다.
``` ```
검사는 production/development Compose model과 Caddyfile 구문을 확인합니다. 검사는 production/development Compose model과 Caddyfile 구문을 확인합니다.
다른 env 파일은 `ENV_FILE=/path/to/file ./scripts/check.sh`로 검사합니다.
`docker compose config` 전체 출력에는 펼쳐진 비밀값이 포함될 수 있으므로 CI `docker compose config` 전체 출력에는 펼쳐진 비밀값이 포함될 수 있으므로 CI
artifact나 이슈에 그대로 첨부하지 않습니다. artifact나 이슈에 그대로 첨부하지 않습니다.
+11 -1
View File
@@ -1,10 +1,14 @@
services: services:
postgres:
restart: "no"
redis:
restart: "no"
runtime: runtime:
user: ${DEV_UID:-1000}:${DEV_GID:-1000} user: ${DEV_UID:-1000}:${DEV_GID:-1000}
environment: environment:
CORE_SOURCE_MODE: bind CORE_SOURCE_MODE: bind
CORE_BIND_ROOT: /workspace/core2026 CORE_BIND_ROOT: /workspace/core2026
RUNTIME_MODE: ${RUNTIME_MODE:-development} RUNTIME_MODE: development
VITE_PREVIEW_ALLOWED_HOSTS: '*' VITE_PREVIEW_ALLOWED_HOSTS: '*'
volumes: volumes:
- ${CORE_DEV_PATH:-../core2026}:/workspace/core2026 - ${CORE_DEV_PATH:-../core2026}:/workspace/core2026
@@ -17,6 +21,12 @@ services:
timeout: 5s timeout: 5s
retries: 30 retries: 30
start_period: 5m 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: caddy:
environment: environment:
SITE_ADDRESS: :80 SITE_ADDRESS: :80
restart: "no"
+13
View File
@@ -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"
+12
View File
@@ -39,9 +39,16 @@ services:
context: . context: .
dockerfile: runtime/Dockerfile dockerfile: runtime/Dockerfile
environment: 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_SOURCE_MODE: clone
CORE_REPOSITORY_URL: ${CORE_REPOSITORY_URL:?set CORE_REPOSITORY_URL in .env} CORE_REPOSITORY_URL: ${CORE_REPOSITORY_URL:?set CORE_REPOSITORY_URL in .env}
CORE_BOOTSTRAP_REF: ${CORE_BOOTSTRAP_REF:-main} 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_HOST: postgres
POSTGRES_PORT: '5432' POSTGRES_PORT: '5432'
POSTGRES_DB: ${POSTGRES_DB:-sammo} POSTGRES_DB: ${POSTGRES_DB:-sammo}
@@ -101,6 +108,11 @@ services:
timeout: 5s timeout: 5s
retries: 30 retries: 30
start_period: 5m 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 restart: unless-stopped
caddy: caddy:
+2 -1
View File
@@ -8,7 +8,8 @@ RUN apt-get update \
COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint
COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh 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 WORKDIR /srv/core/repository
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"]
+8 -1
View File
@@ -1,7 +1,14 @@
const path = require('node:path'); const path = require('node:path');
const root = process.env.GATEWAY_WORKSPACE_ROOT || '/srv/core/repository'; 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 = { module.exports = {
apps: [ apps: [
+28
View File
@@ -17,6 +17,34 @@ for key in POSTGRES_PASSWORD REDIS_PASSWORD GAME_TOKEN_SECRET KAKAO_REST_KEY KAK
require_env "$key" require_env "$key"
done 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 case "${CORE_SOURCE_MODE:-clone}" in
clone) clone)
require_env CORE_REPOSITORY_URL require_env CORE_REPOSITORY_URL
+10
View File
@@ -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
+58
View File
@@ -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.`);
}
}
+112
View File
@@ -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.');
}
}
+26 -3
View File
@@ -4,9 +4,32 @@ set -eu
stack_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) stack_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$stack_dir" cd "$stack_dir"
docker compose --env-file .env config --quiet env_file=${ENV_FILE:-.env}
docker compose --env-file .env -f compose.yaml -f compose.dev.yaml config --quiet if [ ! -f "$env_file" ]; then
docker run --rm -v "$stack_dir/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ 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 \ -e SITE_ADDRESS=:80 -e ACME_EMAIL=validate@example.invalid caddy:2.10.2-alpine \
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
echo 'Compose and Caddy configuration are valid.' echo 'Compose and Caddy configuration are valid.'
+20
View File
@@ -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);
}
});
+40
View File
@@ -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);
});
+49
View File
@@ -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')));
});
+65
View File
@@ -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')));
});