feat(docker): 12단계 계정 보안 — 엔진 웹훅 · /api/internal 닫기 · 요청 대상 400

메일 프로그램(IMAP · SMTP) 로그인 기록을 엔진 웹훅으로 받는다.
- 루트 env MAIL_WEBHOOK_SECRET(setup · backend 공용, 운영 필수)과 backend 의 APP_NETWORK_SUBNET
- setup 이 엔진 웹훅을 맞추고(표시 머리글 — 소금 + 키 지문) 서명한 빈 묶음으로 받는 곳을 확인한다
  키를 바꾼 실행 · 게이트웨이 OTP · 테스트 직원의 402 를 따로 다룬다
- nginx 두 server 가 /api/internal/ 을 닫고, 요청 대상의 날 \(경로) · # · 0x80 이상은 location 전에 400
  Express 가 이런 대상을 다시 읽어 닫은 경로(/api/internal · /api/health)가 백엔드에서 열렸다(2026-10-31 리뷰)
- CI "닫는 경로 시험"(두 server × 세 경로의 변형) · 지시어만 세는 정적 검사
- mail/README: 웹훅 · 12단계 전부터 운영 중인 서버를 올리는 차례 · 휴대폰을 잃은 직원(관리 API 명령)
엔진 DNS 가 Custom(운영)이어도 웹훅은 backend 를 푼다 — 메일 컨테이너가 도커 DNS 를 쓴다(개발에서 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:45:23 +09:00
parent c31442e639
commit d1ef0da488
11 changed files with 739 additions and 19 deletions
+391 -13
View File
@@ -51,6 +51,9 @@
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
* Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록")
* 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영).
* 리로드 뒤 메일 컨테이너 안에서 서명한 빈 묶음을 보내 받는 곳까지 닿는지 · 키가 맞는지 본다. 키가 비면 운영은 중단, 개발은 [주의] 뒤 웹훅을 끈다
* (mail/README.md "엔진 웹훅")
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
@@ -67,7 +70,7 @@
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createHmac, randomBytes, scryptSync } from 'node:crypto';
import { resolveTxt } from 'node:dns/promises';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { request as httpsRequest } from 'node:https';
@@ -448,6 +451,47 @@ const SPAMHAUS_DNSBL = [
},
];
/*
* 엔진 웹훅 — 메일 프로그램(IMAP · SMTP) 로그인 기록 (12단계 — 계약 docs/mail/api-contract.md 5절 "엔진 웹훅 받기" · "엔진 설정", mail/README.md "엔진 웹훅")
* 공개판 엔진에는 계정별 로그인 기록이 없어(x:Trace 는 엔터프라이즈 전용) 엔진이 인증 이벤트를 백엔드로 보내고 백엔드가 남긴다. 백엔드는 접속 주소(소켓 —
* 앱 네트워크 대역)와 본문 원문의 HMAC-SHA256(X-Signature — 키 MAIL_WEBHOOK_SECRET)을 보고 받는다.
* 엔진 쪽은 x:WebHook 하나다. 0.16.27 의 칸은 url · signatureKey(None · Value · EnvironmentVariable · File) · httpAuth · httpHeaders · throttle · timeout ·
* discardAfter · lossy · level · events · eventsPolicy · enable · allowInvalidCerts 이고 **이름 · 설명 칸이 없다**(소스 crates/registry/src/schema/structs.rs 의 WebHook) —
* setup 표시는 httpHeaders 의 머리글 하나(WEBHOOK_MARKER_HEADER)로 둔다. 읽으면 서명 키는 "****" 로만 온다(소스 schema/prelude.rs 의 secret_value) —
* 키가 바뀌었는지 알려고 그 머리글에 키의 지문(scrypt)을 함께 적는다. 칸에 객체를 통째로 보내면 events · httpHeaders 는 통째로 바뀐다(소스 types/map.rs · jmap/patch.rs).
* 엔진 기본값은 eventsPolicy exclude + 빈 목록(= 모든 이벤트) · level info · timeout 30초다 — 셋 다 바꾼다. level 은 이 판의 웹훅이 쓰지 않지만
* (소스 crates/common/src/config/telemetry.rs — apply_events 만) 새 판 문서는 쓴다(info 면 Debug 인 auth.failed · Trace 인 auth.mfa-required 가 빠진다).
* 바뀐 값은 ReloadSettings 뒤에 반영된다(2026-10-31 개발 엔진 실험 — 계약 5절의 근거).
* 웹훅의 HTTP 는 엔진의 x:DnsResolver 가 아니라 메일 컨테이너의 시스템 DNS(도커 DNS 127.0.0.11)로 이름을 푼다 — reqwest 의 기본 리졸버(hickory 기능을 켜지 않았다,
* crates/utils/Cargo.toml · src/http.rs). 그래서 운영처럼 엔진 DNS 가 Custom(리졸버에 바로 — 도커 이름을 모른다)이어도 backend 가 풀린다.
*/
const WEBHOOK_SECRET_ENV = 'MAIL_WEBHOOK_SECRET';
/** 서명 키 — 영문 · 숫자 32자 이상. Node --env-file 은 # 부터 자르고 compose 는 그대로 읽어 setup 과 backend 의 값이 어긋나지 않게 (계약 5절 "환경 변수") */
const WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
/** 받는 곳 — compose 의 서비스 이름 backend. mail 과 backend 가 함께 붙는 네트워크는 app-network 하나라 엔진의 접속 주소가 그 대역이다(백엔드가 소켓 주소로 확인한다) */
const WEBHOOK_HOST = 'backend';
const WEBHOOK_PATH = '/api/internal/mail-engine-events';
/** 보내는 이벤트 — 엔진 기본(exclude + 빈 목록)은 모든 이벤트다 */
const WEBHOOK_EVENTS = ['auth.success', 'auth.failed', 'auth.mfa-required'];
/** 그 밖의 칸 — 묶음 1초 · 기다림 5초(백엔드는 곧바로 200) · 못 보낸 것은 5분까지 다시 보내고 버린다 · 대기열이 넘쳐도 버리지 않는다 · 수준 trace(위 머리말) */
const WEBHOOK_SETTINGS = { throttle: 1000, timeout: 5000, discardAfter: 300000, lossy: false, level: 'trace', eventsPolicy: 'include', allowInvalidCerts: false };
const WEBHOOK_HTTP_AUTH = { '@type': 'Unauthenticated' };
/** setup 표시 머리글 — 값은 `[setup.mjs] key=<소금>.<키 지문>`. 엔진이 보내는 요청마다 실린다 — 받는 쪽(백엔드)은 쓰지 않는다 */
const WEBHOOK_MARKER_HEADER = 'X-Arasystem-Setup';
/**
* 키 지문 — scrypt(키, 소금)의 12바이트 hex. 소금은 setup 이 웹훅을 쓸 때마다 새로 뽑는 12바이트(배포마다 다르다)이고 지문과 함께 머리글에 싣는다.
* 지문은 엔진 관리 화면 · 관리 API 에 보이고 백엔드로도 간다(백엔드는 키를 이미 안다). 키는 무작위(openssl rand -hex 24)여야 지문으로 되짚을 수 없다 —
* 사람이 지은 키를 쓰지 않는다. 짧은 키라도 한 번 견주는 데 수십 ms 가 들게 평범한 해시가 아니라 scrypt 이고, 소금이 배포마다 달라 미리 만든 표로 맞춰 볼 수 없다
*/
const WEBHOOK_KEY_SALT_BYTES = 12;
const WEBHOOK_KEY_FINGERPRINT_BYTES = 12;
/** 표시 머리글의 모양 — 소금 · 지문 모두 24자 hex. 옛 모양(`key=<지문>` — 소금 없음)은 맞지 않아 키가 바뀐 것으로 보고 한 번 맞춘다 */
const WEBHOOK_MARKER_PATTERN = /^\[setup\.mjs\] key=([0-9a-f]{24})\.([0-9a-f]{24})$/;
/** setup 이 받는 곳까지 닿는지 볼 때 보내는 묶음 — 엔진의 본문과 같은 꼴에 이벤트만 없다. 서명은 setup 이 같은 키로 만든다(받는 쪽은 200 으로 답한다 — 남길 것이 없다) */
const WEBHOOK_PROBE_BODY = '{"events":[]}';
/** 엔진이 맞는 비밀번호에 2단계 인증 코드를 더 요구할 때의 응답 — 402 "MFA code required"(틀린 비밀번호는 401 — 2026-10-31 개발 엔진 0.16.27, 계약 5절) */
const HTTP_MFA_REQUIRED = 402;
/** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */
const PLACEHOLDER_PATTERN = /change[-_]?me/i;
@@ -511,8 +555,8 @@ function readEnv(key) {
return value ? value : undefined;
}
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 */
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|MAIL_\w+|MAILPIT_\w+)$/;
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 (BACKEND_PORT — 엔진 웹훅의 주소) */
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|BACKEND_PORT|MAIL_\w+|MAILPIT_\w+)$/;
/** Node --env-file 과 같은 규칙으로 읽는다 (따옴표 없는 값은 # 부터 주석 — 공백이 없어도) */
function parseEnvText(text) {
@@ -802,12 +846,47 @@ function loadConfig(argv) {
}
}
// 엔진 웹훅 서명 키 (12단계) — setup 과 backend 가 같은 값을 쓴다. 운영 필수, 개발은 비면 웹훅을 끈다(ensureEngineWebhook). 값은 어디에도 출력하지 않는다
const webhookSecretRaw = readEnv(WEBHOOK_SECRET_ENV);
let webhookSecret = null;
if (webhookSecretRaw) {
if (WEBHOOK_SECRET_PATTERN.test(webhookSecretRaw)) {
webhookSecret = webhookSecretRaw;
// 엔진 응답 · 오류 글에 실려 와도 출력에는 이름만 (redact)
hideInOutput(webhookSecret, `<${WEBHOOK_SECRET_ENV}>`);
} else {
errors.push(
`${WEBHOOK_SECRET_ENV} 는 영문 · 숫자로만 32자 이상이어야 합니다 (지금 ${webhookSecretRaw.length}자 — 값은 출력하지 않는다). ` +
'예: openssl rand -hex 24 — 바꾸면 backend 도 다시 띄운다.',
);
}
} else if (isProduction) {
errors.push(
`${WEBHOOK_SECRET_ENV} 가 설정되지 않았습니다 — 운영 필수(엔진 웹훅의 서명 키 · backend 와 같은 값 — 메일 프로그램 로그인 기록). ` +
'예: openssl rand -hex 24 (mail/README.md "엔진 웹훅").',
);
}
// 웹훅이 부를 백엔드 포트 — compose 의 backend PORT(=BACKEND_PORT)와 같다. 웹훅을 둘 때만 본다
const backendPortRaw = readEnv('BACKEND_PORT');
let backendPort = null;
if (webhookSecret) {
if (backendPortRaw && PORT_PATTERN.test(backendPortRaw) && Number(backendPortRaw) <= MAX_PORT) {
backendPort = Number(backendPortRaw);
} else {
errors.push(
`BACKEND_PORT 는 1~${MAX_PORT} 의 10진수(앞자리 0 없이)여야 합니다 — 엔진 웹훅의 주소 http://${WEBHOOK_HOST}:<BACKEND_PORT>${WEBHOOK_PATH} 에 쓴다 ` +
`(현재: ${backendPortRaw ?? '없음'}).`,
);
}
}
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
const secrets = {
MAIL_ADMIN_PASSWORD: adminPassword,
MAIL_GATEWAY_PASSWORD: gatewayPassword,
MAIL_RECOVERY_ADMIN: recovery?.password,
MAIL_DEV_USER_PASSWORD: devUserPassword,
[WEBHOOK_SECRET_ENV]: webhookSecretRaw,
};
for (const [key, value] of Object.entries(secrets)) {
if (!value || !PLACEHOLDER_PATTERN.test(value)) continue;
@@ -937,6 +1016,9 @@ function loadConfig(argv) {
egressNetwork: `${projectName}_${RESOLVER_EGRESS_NETWORK}`,
},
spamhausKey,
// 엔진 웹훅 (12단계) — 키가 없으면(개발) 둘 다 null 이고 웹훅을 끈다 (ensureEngineWebhook)
webhookSecret,
webhookUrl: webhookSecret ? `http://${WEBHOOK_HOST}:${backendPort}${WEBHOOK_PATH}` : null,
attachmentTotalMb,
uploadQuotaFloor,
devUserPassword,
@@ -1394,6 +1476,13 @@ async function openClient(cfg, user, password) {
await res.body?.cancel();
return null;
}
if (res.status === HTTP_MFA_REQUIRED) {
// 비밀번호는 맞고 그 계정에 2단계 인증(OTP)이 켜져 있다 — 비밀번호만으로는 관리 API 에 들어갈 수 없다 (12단계)
await res.body?.cancel();
throw new SetupError(`${user} 에 2단계 인증(OTP)이 켜져 있어 비밀번호만으로 로그인할 수 없습니다 (HTTP ${HTTP_MFA_REQUIRED}).`, {
hint: 'setup 이 쓰는 계정(관리자 · 복구 관리자 · 게이트웨이)에는 2단계 인증을 켜지 않는다 — 관리 화면에서 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
});
}
if (!res.ok) throw new SetupError(`세션 조회 실패 (HTTP ${res.status})`);
const session = await res.json();
return { cfg, auth, session, accountId: session.primaryAccounts?.[STALWART_CAPABILITY] };
@@ -1863,11 +1952,18 @@ function passwordKey(account) {
/**
* 로그인 확인 — 2xx 면 맞고 401 이면 틀리다. 그 밖(429 시도 과다·5xx 등)은 판단할 수 없으므로 중단한다
* (틀린 것으로 보고 비밀번호를 덮어쓰거나, 맞는 것으로 보고 넘어가면 안 된다).
* 402 — 엔진은 맞는 비밀번호에만 "2단계 인증 코드가 더 필요하다"(402)를 주고 틀리면 401 이다(12단계). mfaMeansMatch(웹메일에서 2단계 인증을 켤 수 있는
* 테스트 직원)면 맞는 것으로 보고, 그 밖의 계정(게이트웨이)이면 중단한다 — 게이트웨이에 2단계 인증이 켜지면 백엔드의 대리 접근이 모두 막힌다
*/
async function passwordMatches(cfg, address, password) {
async function passwordMatches(cfg, address, password, { mfaMeansMatch = false } = {}) {
const status = await loginStatus(cfg, address, password);
if (status >= 200 && status < 300) return true;
if ((status >= 200 && status < 300) || (status === HTTP_MFA_REQUIRED && mfaMeansMatch)) return true;
if (status === 401) return false;
if (status === HTTP_MFA_REQUIRED) {
throw new SetupError(`${address} 에 2단계 인증(OTP)이 켜져 있습니다 (HTTP ${HTTP_MFA_REQUIRED}) — 아무것도 바꾸지 않고 중단합니다.`, {
hint: '이 계정에는 2단계 인증을 켜지 않는다 — 관리 화면 · 관리 API 로 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
});
}
throw new SetupError(`${address} 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP ${status}). 아무것도 바꾸지 않았습니다.`, {
hint:
status === 429
@@ -1876,9 +1972,25 @@ async function passwordMatches(cfg, address, password) {
});
}
/**
* 게이트웨이 계정에 2단계 인증(OTP)이 켜져 있으면 아무것도 바꾸기 전에 멈춘다 (12단계). 대리 접근(<직원>%<게이트웨이>)은 게이트웨이 자신의
* 비밀번호 · OTP 를 확인하므로 백엔드의 대리 접근이 모두 402 로 막혀 웹메일 전체가 선다. 게이트웨이가 아직 없거나(처음 설치) 비밀번호가
* env 와 다르면 401 이라 지나간다(ensureAccount 가 맞춘다). 이 확인은 호스트(신뢰 대역)에서 하므로 자동 차단에 세지 않는다
*/
async function checkGatewayWithoutTwoFactor(cfg) {
if ((await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) !== HTTP_MFA_REQUIRED) return;
throw new SetupError(
`게이트웨이 계정(${cfg.gatewayAddress})에 2단계 인증(OTP)이 켜져 있습니다 — 백엔드의 대리 접근이 모두 막혀(HTTP ${HTTP_MFA_REQUIRED}) 웹메일 전체가 선다. 아무것도 바꾸지 않고 중단합니다.`,
{
hint:
'게이트웨이의 OTP 를 지운 뒤 다시 실행하세요 — mail/README.md "휴대폰을 잃은 직원" 의 절차를 게이트웨이 주소로 한다. 게이트웨이에는 2단계 인증을 켜지 않는다.',
},
);
}
/**
* 계정을 env 값에 맞춘다. 저장된 비밀번호는 읽을 수 없으므로 실제 로그인으로 확인하고, 다르면 env 값으로 바꾼다.
* spec: { name, description, roles, password, allowedIps?, label }
* spec: { name, description, roles, password, allowedIps?, label, mfaMeansMatch? } — mfaMeansMatch 는 테스트 직원만 (passwordMatches)
*/
async function ensureAccount(client, cfg, domain, spec) {
const address = `${spec.name}@${domain.name}`;
@@ -1919,7 +2031,7 @@ async function ensureAccount(client, cfg, domain, spec) {
patch[`credentials/${key}/allowedIps`] = toTrueSet(spec.allowedIps);
changes.push('허용 IP');
}
if (!(await passwordMatches(cfg, address, spec.password))) {
if (!(await passwordMatches(cfg, address, spec.password, { mfaMeansMatch: spec.mfaMeansMatch === true }))) {
patch[`credentials/${key}/secret`] = spec.password;
changes.push('비밀번호');
}
@@ -3156,9 +3268,16 @@ async function checkGatewayAdminReach(admin, cfg) {
return reachable;
}
/** 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지 */
/**
* 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지.
* 웹메일에서 2단계 인증을 켠 테스트 직원은 비밀번호만으로 로그인하지 못한다(402 — 12단계). 그때는 게이트웨이 대리 접근으로 본다 —
* 대리 접근 세션의 권한은 그 직원의 역할 그대로다(본인 Basic 과 같은 231개 — 2026-10-31 개발 엔진)
*/
async function checkPrincipalHidden(cfg, address) {
const client = await openClient(cfg, address, cfg.devUserPassword);
const viaGateway = (await loginStatus(cfg, address, cfg.devUserPassword)) === HTTP_MFA_REQUIRED;
const client = viaGateway
? await openClient(cfg, `${address}%${cfg.gatewayAddress}`, cfg.gatewayPassword)
: await openClient(cfg, address, cfg.devUserPassword);
const accountId = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
if (!client || !accountId) {
problems.push(`${address} 로 로그인하지 못해 계정 목록 비공개를 확인하지 못했습니다.`);
@@ -3177,7 +3296,8 @@ async function checkPrincipalHidden(cfg, address) {
});
[[name, result]] = (await res.json()).methodResponses;
if (name === 'error' && result.type === 'forbidden') {
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden).`);
const how = viaGateway ? ' — 2단계 인증이 켜져 있어 게이트웨이 대리 접근으로 봄' : '';
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden${how}).`);
return;
}
await sleep(POLL_INTERVAL_MS / 2);
@@ -3284,6 +3404,236 @@ async function printDnsZone(client, domain) {
log('-----');
}
// ============================================================
// 엔진 웹훅 — 메일 프로그램 로그인 기록 (12단계 — WEBHOOK_SECRET_ENV 머리말 · mail/README.md "엔진 웹훅")
// ============================================================
function webhookKeyFingerprint(secret, salt) {
return scryptSync(secret, salt, WEBHOOK_KEY_FINGERPRINT_BYTES).toString('hex');
}
/** 새 표시 머리글 값 — 쓸 때마다(만들기 · 고치기) 새 소금으로 키 지문을 적는다(엔진은 서명 키를 **** 로만 돌려준다 — WEBHOOK_KEY_SALT_BYTES) */
function newWebhookMarker(secret) {
const salt = randomBytes(WEBHOOK_KEY_SALT_BYTES).toString('hex');
return `${MANAGED_MARK} key=${salt}.${webhookKeyFingerprint(secret, salt)}`;
}
/** 엔진의 표시 머리글이 지금 키의 지문인가 — 머리글의 소금으로 다시 계산해 견준다. 옛 모양 · 다른 키면 false */
function webhookMarkerMatches(value, secret) {
const match = WEBHOOK_MARKER_PATTERN.exec(String(value ?? ''));
return Boolean(match) && webhookKeyFingerprint(secret, match[1]) === match[2];
}
/** setup 이 둔 웹훅인가 — 표시 머리글(키 지문과 상관없이 [setup.mjs] 로 시작) */
function isOwnWebhook(hook) {
return String(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER] ?? '').startsWith(MANAGED_MARK);
}
/** 웹훅 주소의 경로 — 주소가 아니면 null */
function webhookUrlPath(url) {
try {
return new URL(url).pathname;
} catch {
return null;
}
}
/** setup 이 두는 웹훅 — 만들기 · 고치기 모두 이 값 전체를 보낸다 (events · httpHeaders 는 통째로 바뀐다 · 표시 머리글은 새 소금) */
function desiredWebhook(cfg) {
return {
enable: true,
url: cfg.webhookUrl,
signatureKey: { '@type': 'Value', secret: cfg.webhookSecret },
httpAuth: WEBHOOK_HTTP_AUTH,
httpHeaders: { [WEBHOOK_MARKER_HEADER]: newWebhookMarker(cfg.webhookSecret) },
events: toTrueSet(WEBHOOK_EVENTS),
...WEBHOOK_SETTINGS,
};
}
/**
* 엔진의 웹훅이 setup 의 값과 다른 칸 — 같으면 빈 배열. 서명 키는 형식(Value)과 표시 머리글의 키 지문으로 본다 —
* ⚠️ 관리 화면에서 서명 키만 바꾸면(머리글은 그대로) 알아채지 못한다. 엔진은 키를 **** 로만 돌려주기 때문이다
*/
function webhookDrift(hook, cfg) {
const drift = [];
if (hook.enable !== true) drift.push('꺼짐');
if (hook.url !== cfg.webhookUrl) drift.push('주소');
const keyChanged =
hook.signatureKey?.['@type'] !== 'Value' || !webhookMarkerMatches(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER], cfg.webhookSecret);
if (keyChanged) drift.push('서명 키');
if (!keyChanged && stableJson(Object.keys(hook.httpHeaders ?? {})) !== stableJson([WEBHOOK_MARKER_HEADER])) drift.push('HTTP 머리글');
if (stableJson(hook.httpAuth ?? null) !== stableJson(WEBHOOK_HTTP_AUTH)) drift.push('HTTP 인증');
if (!sameTrueSet(hook.events, WEBHOOK_EVENTS)) drift.push('이벤트');
for (const [key, value] of Object.entries(WEBHOOK_SETTINGS)) {
if (hook[key] !== value) drift.push(key);
}
return drift;
}
/**
* 엔진 웹훅을 맞춘다 — setup 표시로 찾아 없으면 만들고, 다르면 고치고, 같으면 둔다. 저장만 한다(main 의 리로드 뒤에 반영 — checkEngineWebhook 이 본다).
* 키가 없으면(개발 — 운영은 loadConfig 가 이미 멈췄다) 만들지 않고 있던 것은 끈다. 표시 없이 같은 받는 곳으로 가는 웹훅은 사람이 만든 것으로 보고 [주의] 만 낸다.
* 돌려주는 값: { state: 'ready' | 'disabled' | 'absent', id, keyChanged } — keyChanged 는 이번 실행에서 엔진에 서명 키를 넣었는가(만들기 · 키가 바뀜)
*/
async function ensureEngineWebhook(client, cfg) {
const hooks = await listAll(client, 'WebHook', [
'enable',
'url',
'signatureKey',
'httpAuth',
'httpHeaders',
'events',
'eventsPolicy',
'level',
'throttle',
'timeout',
'discardAfter',
'lossy',
'allowInvalidCerts',
]);
const own = hooks.filter(isOwnWebhook);
const lookalikes = hooks.filter((hook) => !isOwnWebhook(hook) && webhookUrlPath(hook.url) === WEBHOOK_PATH);
if (lookalikes.length > 0) {
warn(
`백엔드의 웹훅 받는 곳(${WEBHOOK_PATH})으로 가는 웹훅이 setup 표시 없이 ${lookalikes.length}개 있습니다(id ${lookalikes.map((hook) => hook.id).join(', ')}) — ` +
'사람이 만든 것으로 보고 건드리지 않습니다. 같은 이벤트가 두 번 가거나 다른 키로 서명돼 백엔드가 거절한다 — 관리 화면에서 지우세요 (mail/README.md "엔진 웹훅").',
);
}
if (!cfg.webhookSecret) {
warn(
`${WEBHOOK_SECRET_ENV} 가 비어 있어 엔진 웹훅을 두지 않습니다 — 메일 프로그램(IMAP · SMTP) 로그인 기록과 앱 비밀번호의 마지막 사용이 남지 않는다. ` +
'무작위 값(openssl rand -hex 24)을 넣고 setup 을 다시 실행한 뒤 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅").',
);
const enabled = own.filter((hook) => hook.enable !== false);
if (enabled.length > 0) {
await setObjects(client, 'WebHook', { update: Object.fromEntries(enabled.map((hook) => [hook.id, { enable: false }])) });
log(`[변경] 엔진 웹훅 — 끔 (${WEBHOOK_SECRET_ENV} 비어 있음 · ${enabled.length}개)`);
}
return { state: own.length > 0 ? 'disabled' : 'absent', id: null, keyChanged: false };
}
const target = `${cfg.webhookUrl} (${WEBHOOK_EVENTS.join(' · ')} — 서명 키 ${WEBHOOK_SECRET_ENV})`;
if (own.length === 0) {
const result = await setObjects(client, 'WebHook', { create: { webhook: desiredWebhook(cfg) } });
log(`[생성] 엔진 웹훅 → ${target}`);
return { state: 'ready', id: result.created.webhook.id, keyChanged: true };
}
const [hook, ...extra] = own;
if (extra.length > 0) {
// 같은 이벤트가 여러 번 간다 — 하나만 둔다
await setObjects(client, 'WebHook', { destroy: extra.map((item) => item.id) });
log(`[삭제] 엔진 웹훅 — setup 표시가 같은 것 ${extra.length}개 (하나만 둔다)`);
}
const drift = webhookDrift(hook, cfg);
if (drift.length === 0) {
log(`[유지] 엔진 웹훅 → ${target}`);
} else {
await setObjects(client, 'WebHook', { update: { [hook.id]: desiredWebhook(cfg) } });
log(`[변경] 엔진 웹훅 — ${drift.join(', ')} → ${target}`);
}
return { state: 'ready', id: hook.id, keyChanged: drift.includes('서명 키') };
}
/**
* 메일 컨테이너 안(엔진과 같은 자리 · 같은 시스템 DNS)에서 받는 곳으로 서명한 빈 묶음(WEBHOOK_PROBE_BODY)을 보낸다 — { code, error }.
* 서명은 표준 입력으로 넘긴다(curl -H @-) — 키로 만든 값이 docker exec 의 명령줄(도커 이벤트 · ps)에 남지 않게. docker 를 쓸 수 없으면 null
*/
function postWebhookProbeInsideMail(cfg) {
if (!cfg.docker) return null;
const signature = createHmac('sha256', cfg.webhookSecret).update(WEBHOOK_PROBE_BODY).digest('base64');
const result = spawnSync(
'docker',
[
'exec',
'-i',
cfg.container,
'curl',
'-sS',
'-o',
'/dev/null',
'-w',
'%{http_code}',
'--max-time',
'5',
'-H',
'@-',
'-H',
'content-type: application/json',
'--data-binary',
WEBHOOK_PROBE_BODY,
cfg.webhookUrl,
],
{ input: `X-Signature: ${signature}\n`, encoding: 'utf8' },
);
if (result.error) return { code: null, error: result.error.message };
const code = Number(result.stdout?.trim());
return {
code: Number.isInteger(code) && code > 0 ? code : null,
error: result.status === 0 ? null : (result.stderr ?? '').trim().split(/\r?\n/).pop()?.slice(0, 200) || `종료 코드 ${result.status}`,
};
}
/**
* 리로드 뒤 — 엔진에 남은 웹훅이 setup 의 값 그대로인지 다시 읽고, 받는 곳까지 닿는지 · 백엔드의 키가 맞는지 메일 컨테이너에서 서명한 빈 묶음으로 본다.
* 2xx 면 [확인] — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다는 뜻이다. 엔진에 든 키는 읽을 수 없어 표시 머리글의 지문으로만 안다(webhookDrift).
* 401(키 · 접속 주소가 맞지 않음) · 404(받는 곳이 없는 백엔드)는 운영은 실패, 개발은 [주의](backend 를 다시 띄우기 전일 수 있다).
* 다만 이번 실행에서 엔진에 키를 넣었으면(webhook.keyChanged — 키를 바꿈 · 처음 만듦) backend 는 아직 옛 키일 수 있어 401 도 [주의] + 다음 명령이다.
* 닿지 않으면(백엔드가 아직 뜨는 중 등) 몇 번 다시 해 보고 [주의] — 다시 실행하면 된다
*/
async function checkEngineWebhook(client, cfg, webhook) {
if (webhook.state !== 'ready') return;
const [hook] = (await call(client, 'x:WebHook/get', { ids: [webhook.id] })).list ?? [];
const drift = hook ? webhookDrift(hook, cfg) : ['없어짐'];
if (drift.length > 0) {
problems.push(`엔진 웹훅이 리로드 뒤 setup 의 값과 다릅니다(${drift.join(', ')}) — 그사이 관리 화면 등에서 바꿨는지 보고 다시 실행하세요.`);
return;
}
log(`[확인] 엔진 웹훅 켜짐 — ${cfg.webhookUrl} · ${WEBHOOK_EVENTS.join(' · ')} · 수준 trace (리로드 뒤 다시 읽음)`);
let probe = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
probe = postWebhookProbeInsideMail(cfg);
if (!probe || probe.code !== null) break;
await sleep(POLL_INTERVAL_MS);
}
const compose = cfg.isProduction
? 'sudo docker compose --env-file .env.production'
: 'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development';
// 401 — 새 키로 다시 만든다(compose 의 environment). 404 — 새 코드가 떠야 한다: 운영은 이미지를 다시 만들고(frontend 의 nginx 스니펫도 함께), 개발은 소스가 붙어 있어 다시 띄우기만
const restartBackend = `${compose} up -d backend`;
const reloadBackendCode = cfg.isProduction ? `${compose} up -d --build backend frontend` : `${compose} restart backend`;
const fail = (message) => (cfg.isProduction ? problems.push(message) : warn(message));
if (!probe) {
log('[건너뜀] docker 를 쓸 수 없어 엔진 웹훅이 받는 곳까지 닿는지 확인하지 못했습니다.');
} else if (probe.code !== null && probe.code >= 200 && probe.code < 300) {
log(`[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP ${probe.code} — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다)`);
} else if (probe.code === 401 && webhook.keyChanged) {
warn(
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — 이번 실행에서 엔진에 ${WEBHOOK_SECRET_ENV} 를 넣었고 backend 는 아직 옛 키(또는 키 없음)로 떠 있다. ` +
`backend 를 다시 띄운 뒤(${restartBackend}) setup 을 한 번 더 실행해 [확인] 을 본다 — 그 사이 메일 프로그램 로그인 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅").`,
);
} else if (probe.code === 401) {
fail(
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — backend 의 ${WEBHOOK_SECRET_ENV} 가 setup 과 다르거나(키를 바꾼 뒤 backend 를 다시 띄우지 않음) ` +
`backend 가 엔진의 접속 주소를 앱 네트워크(APP_NETWORK_SUBNET)로 보지 못한다. backend 를 다시 띄운 뒤(${restartBackend}) setup 을 다시 실행하세요.`,
);
} else if (probe.code === 404) {
fail(
`엔진 웹훅의 받는 곳(${WEBHOOK_PATH})이 백엔드에 없습니다(HTTP 404) — 12단계 전의 backend 가 떠 있다. ` +
`새 코드로 backend 를 띄운 뒤(${reloadBackendCode}) setup 을 다시 실행하세요.`,
);
} else if (probe.code !== null) {
warn(`엔진 웹훅의 받는 곳이 서명한 빈 묶음에 HTTP ${probe.code} 로 답했습니다 — 200 이어야 한다 (백엔드 로그를 본다: docker logs ${cfg.backendContainer}).`);
} else {
warn(
`메일 컨테이너에서 엔진 웹훅의 받는 곳(${cfg.webhookUrl})에 닿지 못했습니다(${probe.error}) — backend 가 뜨는 중이면 잠시 뒤 setup 을 다시 실행한다. ` +
`그동안의 이벤트는 엔진이 5분까지 다시 보낸다 (docker logs ${cfg.backendContainer}).`,
);
}
}
// ============================================================
// 시범 공존 — 기존 서버(mailcow)와 함께 (PILOT_HOST_ENV_KEY 머리말 · mail/README.md "파일럿 공존")
// ============================================================
@@ -4469,12 +4819,28 @@ function authenticatedRcptFromBackend(cfg, user, recipient) {
/**
* 개발 — 인증한 세션(465, 테스트 직원)은 이 엔진에 없는 사내 주소를 받는가 (RCPT 250 뒤 RSET — 보내지 않는다).
* 웹메일(JMAP 제출)도 같은 RCPT 를 지나 기존 서버로 간다. 운영은 직원 비밀번호가 없어 하지 않는다 (mail/README.md "파일럿 공존" 의 확인 명령으로 본다).
* candidates — 테스트 직원 주소들. 2단계 인증을 켠 직원은 비밀번호만으로 SMTP AUTH 를 하지 못한다("334 5.7.8 … multi-factor" — 12단계) — 비밀번호
* 로그인이 402 가 아닌 첫 직원을 고르고, 모두 켜져 있으면 [건너뜀] 이다(allowRelaying 문제로 잘못 알리지 않게)
*/
async function checkPilotAuthenticatedRelay(cfg, user) {
async function checkPilotAuthenticatedRelay(cfg, candidates) {
if (!containerNetworks(cfg.backendContainer)) {
warn(`${cfg.backendContainer} 가 없어 인증한 세션의 확인(465 RCPT)을 건너뜁니다 — 백엔드를 띄운 뒤 다시 실행하면 확인한다.`);
return;
}
let user = null;
for (const address of candidates) {
if ((await loginStatus(cfg, address, cfg.devUserPassword)) !== HTTP_MFA_REQUIRED) {
user = address;
break;
}
}
if (!user) {
log(
`[건너뜀] 시범 공존 — 인증한 세션의 확인(${DEV_SUBMISSION_PORT} RCPT): 테스트 직원(${candidates.join(', ')})이 모두 2단계 인증을 켜 비밀번호만으로 SMTP AUTH 를 할 수 없다 — ` +
'한 사람의 2단계 인증을 끄면 다음 실행이 확인한다.',
);
return;
}
const probe = pilotProbeAddress(cfg);
let reply = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
@@ -4539,6 +4905,8 @@ async function main() {
}
await waitForHttp(cfg, STARTUP_TIMEOUT_MS);
// 게이트웨이에 2단계 인증이 켜져 있으면 웹메일 전체가 선다 — 아무것도 바꾸기 전에 멈춘다 (12단계)
await checkGatewayWithoutTwoFactor(cfg);
// 앱 네트워크 · 메일 전용 리졸버 확인(아무것도 바꾸기 전 — 어긋나면 멈춘다)과 Spamhaus 키 시험
const dnsCheck = preflight(cfg);
const admin = await ensureAdmin(cfg);
@@ -4594,6 +4962,8 @@ async function main() {
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
const engineDnsChanged = await ensureEngineDns(admin, cfg);
const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus);
// 엔진 웹훅 — 인증 이벤트를 백엔드로 (메일 프로그램 로그인 기록 — 12단계, 아래 리로드로 반영 · 키가 없으면(개발) 끈다)
const webhook = await ensureEngineWebhook(admin, cfg);
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
if (!cfg.isProduction) await ensureDevRelay(admin, keepPilotRoute);
await reloadSettings(admin);
@@ -4612,6 +4982,7 @@ async function main() {
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
if (pilot) await checkPilotSenderGuard(cfg);
checkEngineDnssec(cfg, engineDnsChanged);
await checkEngineWebhook(admin, cfg, webhook);
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
const devUsers = [];
@@ -4627,13 +4998,15 @@ async function main() {
description: user.description,
roles: { '@type': 'User' },
password: cfg.devUserPassword,
// 웹메일에서 2단계 인증을 켠 테스트 직원(402)은 비밀번호가 맞는 것으로 본다 — 덮어쓰지 않는다 (12단계)
mfaMeansMatch: true,
});
devUsers.push(`${address} (${user.description})`);
}
await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`);
await checkPrincipalHidden(cfg, `${DEV_USERS[0].name}@${domain.name}`);
// 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에)
if (pilot) await checkPilotAuthenticatedRelay(cfg, `${DEV_USERS[0].name}@${domain.name}`);
// 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에 · 2단계 인증이 꺼진 직원으로)
if (pilot) await checkPilotAuthenticatedRelay(cfg, DEV_USERS.map((user) => `${user.name}@${domain.name}`));
}
if (cfg.isProduction || cfg.printDns) await printDnsZone(admin, domain);
@@ -4669,6 +5042,11 @@ async function main() {
const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`;
log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`);
}
if (webhook.state === 'ready') {
log(` 웹훅 ${cfg.webhookUrl} — ${WEBHOOK_EVENTS.join(' · ')} · 서명 키 ${WEBHOOK_SECRET_ENV} (메일 프로그램 로그인 기록)`);
} else {
log(` 웹훅 꺼짐 (${WEBHOOK_SECRET_ENV} 비어 있음 — 메일 프로그램 로그인 기록이 남지 않는다)`);
}
if (gatewayReachesAdmin) {
log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")');
}