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
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
+40 -2
View File
@@ -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나 이슈에 그대로 첨부하지 않습니다.
+11 -1
View File
@@ -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"
+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: .
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:
+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/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"]
+8 -1
View File
@@ -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: [
+28
View File
@@ -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
+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)
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.'
+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')));
});