feat: Web Push 비활성 운영 설정을 준비한다
VAPID private key를 Compose secret으로 주입하고 Web Push를 기본 비활성으로 전달한다. 활성화 입력과 Compose 모델 검증, 운영 README를 함께 추가한다.
This commit is contained in:
@@ -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
@@ -4,5 +4,6 @@
|
||||
data/*
|
||||
!data/image/
|
||||
!data/image/.gitkeep
|
||||
secrets/
|
||||
secrets/*
|
||||
!secrets/*.example
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user