merge: Docker runtime safety hardening
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+10
@@ -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
|
||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user