feat: Web Push 비활성 운영 설정을 준비한다

VAPID private key를 Compose secret으로 주입하고 Web Push를 기본 비활성으로 전달한다. 활성화 입력과 Compose 모델 검증, 운영 README를 함께 추가한다.
This commit is contained in:
2026-08-23 13:16:21 +00:00
parent 0c2b9632eb
commit 663e66b8f9
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_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
+2 -1
View File
@@ -4,5 +4,6 @@
data/*
!data/image/
!data/image/.gitkeep
secrets/
secrets/*
!secrets/*.example
*.log
+14
View File
@@ -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
+12
View File
@@ -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:
+14
View File
@@ -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',
);
+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');
}
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;
};
+10 -1
View File
@@ -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" \
@@ -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',
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')));
});
+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('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',
}),
[],
);
});