merge: Web Push 비활성 운영 설정을 main에 반영한다

This commit is contained in:
2026-08-23 13:25:15 +00:00
10 changed files with 129 additions and 2 deletions
+10
View File
@@ -86,6 +86,16 @@ GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT= GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT=
# Web Push is intentionally off until a VAPID key pair and contact subject are
# configured. The private key file is mounted as a Compose secret and must not
# be committed. The public key is safe to expose to browsers.
WEB_PUSH_ENABLED=false
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
WEB_PUSH_VAPID_PUBLIC_KEY=
WEB_PUSH_VAPID_PRIVATE_KEY_FILE=./secrets/web_push_vapid_private_key.example
WEB_PUSH_POLL_INTERVAL_MS=1000
WEB_PUSH_OUTBOX_POLL_MS=1000
# Shared image service. The two secret files must contain the values configured # Shared image service. The two secret files must contain the values configured
# for the core2026 caller on the image server and must not be committed. # for the core2026 caller on the image server and must not be committed.
IMAGE_SERVICE_URL=https://sam-image.hided.net IMAGE_SERVICE_URL=https://sam-image.hided.net
+2 -1
View File
@@ -4,5 +4,6 @@
data/* data/*
!data/image/ !data/image/
!data/image/.gitkeep !data/image/.gitkeep
secrets/ secrets/*
!secrets/*.example
*.log *.log
+14
View File
@@ -110,6 +110,20 @@ mount되며 원문은 환경 변수나 브라우저 bundle에 들어가지 않
URL query나 `VITE_*`가 아니라 `IMAGE_UPLOAD_CORE2026_SECRET_FILE`이 가리키는 URL query나 `VITE_*`가 아니라 `IMAGE_UPLOAD_CORE2026_SECRET_FILE`이 가리키는
mode 0600 secret 파일에만 둡니다. mode 0600 secret 파일에만 둡니다.
## Web Push 활성화 경계
Gateway의 Android, iPhone과 Windows 브라우저 알림 기반은 포함하지만 기본값은
`WEB_PUSH_ENABLED=false`입니다. 이 상태에서는 구독 버튼과 전송 worker가
비활성이고, 알림 이벤트를 나중에 소급 전송할 backlog도 만들지 않습니다.
활성화할 때만 VAPID key pair를 생성하여 공개키는
`WEB_PUSH_VAPID_PUBLIC_KEY`, private key는 Git에서 제외한
`WEB_PUSH_VAPID_PRIVATE_KEY_FILE`에 각각 넣고, 운영 연락처를
`WEB_PUSH_VAPID_SUBJECT``mailto:` 또는 HTTPS URL로 설정합니다. private key는
runtime의 `/run/secrets/web_push_vapid_private_key`에 Compose secret으로만
mount됩니다. 설정 후 `./scripts/check.sh`가 키 파일의 존재와 activation 필드를
검증한 다음 runtime을 재생성해야 하며, 실제 활성화와 배포는 별도 운영 작업입니다.
## 데이터와 복구 경계 ## 데이터와 복구 경계
- PostgreSQL, Redis, Core clone/worktree, PM2 상태와 Caddy 인증서는 named - PostgreSQL, Redis, Core clone/worktree, PM2 상태와 Caddy 인증서는 named
+12
View File
@@ -117,6 +117,12 @@ services:
GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED: ${GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED:-true} GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED: ${GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED:-true}
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS: ${GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS:-7} GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS: ${GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS:-7}
GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT: ${GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT:-} GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT: ${GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT:-}
WEB_PUSH_ENABLED: ${WEB_PUSH_ENABLED:-false}
WEB_PUSH_VAPID_SUBJECT: ${WEB_PUSH_VAPID_SUBJECT:-}
WEB_PUSH_VAPID_PUBLIC_KEY: ${WEB_PUSH_VAPID_PUBLIC_KEY:-}
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: /run/secrets/web_push_vapid_private_key
WEB_PUSH_POLL_INTERVAL_MS: ${WEB_PUSH_POLL_INTERVAL_MS:-1000}
WEB_PUSH_OUTBOX_POLL_MS: ${WEB_PUSH_OUTBOX_POLL_MS:-1000}
GATEWAY_API_HOST: 0.0.0.0 GATEWAY_API_HOST: 0.0.0.0
GATEWAY_API_PORT: '15001' GATEWAY_API_PORT: '15001'
GATEWAY_FRONTEND_PORT: '15000' GATEWAY_FRONTEND_PORT: '15000'
@@ -151,6 +157,8 @@ services:
- frontend-artifacts:/srv/frontend-artifacts - frontend-artifacts:/srv/frontend-artifacts
- ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:?set IMAGE_UPLOAD_CORE2026_SECRET_FILE in .env}:/run/secrets/image_upload_core2026_secret:ro - ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:?set IMAGE_UPLOAD_CORE2026_SECRET_FILE in .env}:/run/secrets/image_upload_core2026_secret:ro
- ${IMAGE_SYNC_CORE2026_SECRET_FILE:?set IMAGE_SYNC_CORE2026_SECRET_FILE in .env}:/run/secrets/image_sync_core2026_secret:ro - ${IMAGE_SYNC_CORE2026_SECRET_FILE:?set IMAGE_SYNC_CORE2026_SECRET_FILE in .env}:/run/secrets/image_sync_core2026_secret:ro
secrets:
- web_push_vapid_private_key
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -192,6 +200,10 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
secrets:
web_push_vapid_private_key:
file: ${WEB_PUSH_VAPID_PRIVATE_KEY_FILE:-./secrets/web_push_vapid_private_key.example}
volumes: volumes:
postgres-data: postgres-data:
redis-data: redis-data:
+14
View File
@@ -49,6 +49,20 @@ export const validateComposeModel = (model, mode) => {
if (!artifactMount || artifactMount.read_only === true) { if (!artifactMount || artifactMount.read_only === true) {
errors.push('/srv/frontend-artifacts must be a writable runtime volume'); errors.push('/srv/frontend-artifacts must be a writable runtime volume');
} }
if (!['true', 'false'].includes(runtime.environment?.WEB_PUSH_ENABLED)) {
errors.push('WEB_PUSH_ENABLED must be literal true or false');
}
if (runtime.environment?.WEB_PUSH_VAPID_PRIVATE_KEY_FILE !== '/run/secrets/web_push_vapid_private_key') {
errors.push('WEB_PUSH_VAPID_PRIVATE_KEY_FILE must use the mounted Compose secret');
}
const webPushSecret = (Array.isArray(runtime.secrets) ? runtime.secrets : []).find(
(candidate) => candidate?.target === '/run/secrets/web_push_vapid_private_key',
);
if (!webPushSecret) errors.push('the VAPID private key must be mounted as a runtime secret');
if (runtime.environment?.WEB_PUSH_ENABLED === 'true') {
if (!runtime.environment?.WEB_PUSH_VAPID_SUBJECT) errors.push('WEB_PUSH_VAPID_SUBJECT must be configured');
if (!runtime.environment?.WEB_PUSH_VAPID_PUBLIC_KEY) errors.push('WEB_PUSH_VAPID_PUBLIC_KEY must be configured');
}
const caddyArtifactMount = (Array.isArray(caddy?.volumes) ? caddy.volumes : []).find( const caddyArtifactMount = (Array.isArray(caddy?.volumes) ? caddy.volumes : []).find(
(candidate) => candidate?.target === '/srv/frontend-artifacts', (candidate) => candidate?.target === '/srv/frontend-artifacts',
); );
+19
View File
@@ -108,6 +108,25 @@ export const validateEnvironment = (env) => {
errors.push('CORE_SSH_KNOWN_HOSTS_BASE64 must contain valid base64-encoded known_hosts data'); errors.push('CORE_SSH_KNOWN_HOSTS_BASE64 must contain valid base64-encoded known_hosts data');
} }
const webPushEnabled = env.WEB_PUSH_ENABLED?.trim() === 'true';
if (env.WEB_PUSH_ENABLED && !['true', 'false'].includes(env.WEB_PUSH_ENABLED.trim())) {
errors.push('WEB_PUSH_ENABLED must be true or false');
}
if (webPushEnabled) {
const subject = env.WEB_PUSH_VAPID_SUBJECT?.trim() ?? '';
const publicKey = env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
const privateKeyFile = env.WEB_PUSH_VAPID_PRIVATE_KEY_FILE?.trim() ?? '';
if (!/^(?:mailto:|https:\/\/)/i.test(subject) || isPlaceholder(subject)) {
errors.push('WEB_PUSH_VAPID_SUBJECT must be a non-placeholder mailto: or HTTPS contact');
}
if (!publicKey || isPlaceholder(publicKey)) {
errors.push('WEB_PUSH_VAPID_PUBLIC_KEY is required when Web Push is enabled');
}
if (!privateKeyFile || isPlaceholder(privateKeyFile) || privateKeyFile.endsWith('.example')) {
errors.push('WEB_PUSH_VAPID_PRIVATE_KEY_FILE is required when Web Push is enabled');
}
}
return errors; return errors;
}; };
+10 -1
View File
@@ -10,7 +10,12 @@ if [ ! -f "$env_file" ]; then
exit 66 exit 66
fi fi
for key in IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE; do secret_file_keys='IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE'
if [ "$(sed -n 's/^WEB_PUSH_ENABLED=//p' "$env_file" | tail -n 1)" = true ]; then
secret_file_keys="$secret_file_keys WEB_PUSH_VAPID_PRIVATE_KEY_FILE"
fi
for key in $secret_file_keys; do
value=$(sed -n "s/^${key}=//p" "$env_file" | tail -n 1) value=$(sed -n "s/^${key}=//p" "$env_file" | tail -n 1)
if [ -z "$value" ]; then if [ -z "$value" ]; then
echo "$key must name a readable, non-empty secret file." >&2 echo "$key must name a readable, non-empty secret file." >&2
@@ -24,6 +29,10 @@ for key in IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE; do
echo "$key must name a readable, non-empty secret file." >&2 echo "$key must name a readable, non-empty secret file." >&2
exit 66 exit 66
fi fi
if [ "$key" = WEB_PUSH_VAPID_PRIVATE_KEY_FILE ] && grep -q '^replace-with-' "$secret_path"; then
echo "$key must not use the tracked example placeholder." >&2
exit 66
fi
done done
docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \
@@ -0,0 +1 @@
replace-with-vapid-private-key
+18
View File
@@ -20,6 +20,8 @@ const safeRuntime = {
IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret', IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret',
VITE_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net', VITE_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net',
VITE_GATEWAY_USER_ICON_BASE_URL: 'https://sam-image.hided.net/icons', VITE_GATEWAY_USER_ICON_BASE_URL: 'https://sam-image.hided.net/icons',
WEB_PUSH_ENABLED: 'false',
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: '/run/secrets/web_push_vapid_private_key',
FRONTEND_SERVE_MODE: 'static', FRONTEND_SERVE_MODE: 'static',
FRONTEND_SHARED_ASSET_PUBLIC_PATH: '/gateway/profile-assets', FRONTEND_SHARED_ASSET_PUBLIC_PATH: '/gateway/profile-assets',
RELEASE_BUILDER_URL: 'http://builder:15100', RELEASE_BUILDER_URL: 'http://builder:15100',
@@ -45,6 +47,12 @@ const safeRuntime = {
read_only: true, read_only: true,
}, },
], ],
secrets: [
{
source: 'web_push_vapid_private_key',
target: '/run/secrets/web_push_vapid_private_key',
},
],
restart: 'unless-stopped', restart: 'unless-stopped',
mem_limit: String(4 * 1024 * 1024 * 1024), mem_limit: String(4 * 1024 * 1024 * 1024),
memswap_limit: String(4 * 1024 * 1024 * 1024), memswap_limit: String(4 * 1024 * 1024 * 1024),
@@ -149,3 +157,13 @@ test('rejects missing or excessive build memory and parallelism limits', () => {
assert.ok(errors.some((error) => error.includes('turn daemon Node heap limit'))); assert.ok(errors.some((error) => error.includes('turn daemon Node heap limit')));
assert.ok(errors.some((error) => error.includes('Rayon thread count'))); assert.ok(errors.some((error) => error.includes('Rayon thread count')));
}); });
test('requires Web Push activation to keep the private key in a mounted secret', () => {
const services = safeServices();
services.runtime.environment.WEB_PUSH_ENABLED = 'true';
services.runtime.secrets = [];
const errors = validateComposeModel({ services }, 'production');
assert.ok(errors.some((error) => error.includes('VAPID private key')));
assert.ok(errors.some((error) => error.includes('WEB_PUSH_VAPID_SUBJECT')));
assert.ok(errors.some((error) => error.includes('WEB_PUSH_VAPID_PUBLIC_KEY')));
});
+29
View File
@@ -75,3 +75,32 @@ test('rejects credentials embedded in repository URLs and mixed auth modes', ()
assert.ok(errors.some((error) => error.includes('must not embed credentials'))); assert.ok(errors.some((error) => error.includes('must not embed credentials')));
assert.ok(errors.some((error) => error.includes('only one Core repository authentication mode'))); assert.ok(errors.some((error) => error.includes('only one Core repository authentication mode')));
}); });
test('keeps Web Push disabled by default and validates activation inputs', () => {
assert.deepEqual(validateEnvironment({ ...validEnv, WEB_PUSH_ENABLED: 'false' }), []);
const errors = validateEnvironment({ ...validEnv, WEB_PUSH_ENABLED: 'true' });
assert.ok(errors.some((error) => error.includes('WEB_PUSH_VAPID_SUBJECT')));
assert.ok(errors.some((error) => error.includes('WEB_PUSH_VAPID_PUBLIC_KEY')));
assert.ok(errors.some((error) => error.includes('WEB_PUSH_VAPID_PRIVATE_KEY_FILE')));
assert.ok(
validateEnvironment({
...validEnv,
WEB_PUSH_ENABLED: 'true',
WEB_PUSH_VAPID_SUBJECT: 'mailto:admin@test.invalid',
WEB_PUSH_VAPID_PUBLIC_KEY: 'test-public-key',
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: './secrets/web_push_vapid_private_key.example',
}).some((error) => error.includes('WEB_PUSH_VAPID_PRIVATE_KEY_FILE')),
);
assert.deepEqual(
validateEnvironment({
...validEnv,
WEB_PUSH_ENABLED: 'true',
WEB_PUSH_VAPID_SUBJECT: 'mailto:admin@test.invalid',
WEB_PUSH_VAPID_PUBLIC_KEY: 'test-public-key',
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: './secrets/web-push-private-key',
}),
[],
);
});