merge: Web Push 비활성 운영 설정을 main에 반영한다
This commit is contained in:
@@ -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
@@ -4,5 +4,6 @@
|
|||||||
data/*
|
data/*
|
||||||
!data/image/
|
!data/image/
|
||||||
!data/image/.gitkeep
|
!data/image/.gitkeep
|
||||||
secrets/
|
secrets/*
|
||||||
|
!secrets/*.example
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user