diff --git a/.env.example b/.env.example index 918a359..563c57c 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,16 @@ GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 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 # for the core2026 caller on the image server and must not be committed. IMAGE_SERVICE_URL=https://sam-image.hided.net diff --git a/.gitignore b/.gitignore index 31ec611..9dbcb43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ data/* !data/image/ !data/image/.gitkeep -secrets/ +secrets/* +!secrets/*.example *.log diff --git a/README.md b/README.md index f1162bd..cf3c961 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,20 @@ mount되며 원문은 환경 변수나 브라우저 bundle에 들어가지 않 URL query나 `VITE_*`가 아니라 `IMAGE_UPLOAD_CORE2026_SECRET_FILE`이 가리키는 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 diff --git a/compose.yaml b/compose.yaml index a726ee0..1b64faa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -117,6 +117,12 @@ services: GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED: ${GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED:-true} GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS: ${GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS:-7} 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_PORT: '15001' GATEWAY_FRONTEND_PORT: '15000' @@ -151,6 +157,8 @@ services: - 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_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: postgres: condition: service_healthy @@ -192,6 +200,10 @@ services: condition: service_healthy restart: unless-stopped +secrets: + web_push_vapid_private_key: + file: ${WEB_PUSH_VAPID_PRIVATE_KEY_FILE:-./secrets/web_push_vapid_private_key.example} + volumes: postgres-data: redis-data: diff --git a/runtime/validate-compose-model.mjs b/runtime/validate-compose-model.mjs index a78d4e9..16e78a9 100644 --- a/runtime/validate-compose-model.mjs +++ b/runtime/validate-compose-model.mjs @@ -49,6 +49,20 @@ export const validateComposeModel = (model, mode) => { if (!artifactMount || artifactMount.read_only === true) { 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( (candidate) => candidate?.target === '/srv/frontend-artifacts', ); diff --git a/runtime/validate-env.mjs b/runtime/validate-env.mjs index 0d3482f..3916b9f 100644 --- a/runtime/validate-env.mjs +++ b/runtime/validate-env.mjs @@ -108,6 +108,25 @@ export const validateEnvironment = (env) => { 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; }; diff --git a/scripts/check.sh b/scripts/check.sh index 34e11d0..db2e7e5 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,7 +10,12 @@ if [ ! -f "$env_file" ]; then exit 66 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) if [ -z "$value" ]; then 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 exit 66 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 docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ diff --git a/secrets/web_push_vapid_private_key.example b/secrets/web_push_vapid_private_key.example new file mode 100644 index 0000000..73baa6e --- /dev/null +++ b/secrets/web_push_vapid_private_key.example @@ -0,0 +1 @@ +replace-with-vapid-private-key diff --git a/test/validate-compose-model.test.mjs b/test/validate-compose-model.test.mjs index b17b197..926b432 100644 --- a/test/validate-compose-model.test.mjs +++ b/test/validate-compose-model.test.mjs @@ -20,6 +20,8 @@ const safeRuntime = { IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret', VITE_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net', 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_SHARED_ASSET_PUBLIC_PATH: '/gateway/profile-assets', RELEASE_BUILDER_URL: 'http://builder:15100', @@ -45,6 +47,12 @@ const safeRuntime = { read_only: true, }, ], + secrets: [ + { + source: 'web_push_vapid_private_key', + target: '/run/secrets/web_push_vapid_private_key', + }, + ], restart: 'unless-stopped', mem_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('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'))); +}); diff --git a/test/validate-env.test.mjs b/test/validate-env.test.mjs index 614b913..305204e 100644 --- a/test/validate-env.test.mjs +++ b/test/validate-env.test.mjs @@ -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('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', + }), + [], + ); +});