generated from ttipo/project-base
feat(docker): mail setup — 사내 목록 계정 · 메일을 받지 않는 시스템 계정의 RCPT 거절
사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다. 그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 — 백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다. 관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter — 위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고, setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다. 일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+580
-8
@@ -6,7 +6,7 @@
|
||||
* node --env-file=.env.development mail/scripts/setup.mjs
|
||||
* node --env-file=.env.development mail/scripts/setup.mjs --print-dns # 권장 DNS 레코드도 출력
|
||||
* MAIL_RECOVERY_ADMIN=… node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
* # 운영의 최초 bootstrap·관리자 복구 — 복구 관리자는 일회성으로만 준다
|
||||
* # 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다
|
||||
*
|
||||
* 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md).
|
||||
* 비밀번호는 어떤 경우에도 출력하지 않는다.
|
||||
@@ -32,15 +32,22 @@
|
||||
* 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능)
|
||||
* 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게
|
||||
* 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다)
|
||||
* 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 —
|
||||
* 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다)
|
||||
* 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다
|
||||
* (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안")
|
||||
* 7. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영)
|
||||
* 7. 사내 목록 계정 목록(Principal) 읽기 전용 역할 + 비밀번호 없는 계정(<MAIL_DIRECTORY_LOCAL_PART>@도메인) — 백엔드가 게이트웨이
|
||||
* 대리 접근으로 직원 이름 · 주소만 읽는다 (계약 docs/mail/api-contract.md 3절 "사내 목록").
|
||||
* 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때(개발 · 운영 --recovery)만 만들고 맞춘다.
|
||||
* 없으면 관리자로 되는 것(설명 · 저장 한도 · 비밀번호 빼기)만 맞추고 [주의] — 실패로 끝내지 않는다
|
||||
* 8. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영)
|
||||
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
|
||||
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
|
||||
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
|
||||
* 8. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
|
||||
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 9 보다 먼저 한다)
|
||||
* 9. [개발] 테스트 직원 계정
|
||||
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
|
||||
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
|
||||
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
|
||||
* 10. [개발] 테스트 직원 계정
|
||||
*
|
||||
* 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행)
|
||||
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
|
||||
@@ -99,6 +106,8 @@ const HTTP_ENDPOINT_POLICY = {
|
||||
};
|
||||
/** 기본 직원 역할 — 계정의 roles {"@type":"User"} 가 가리키는 기본 제공 역할 */
|
||||
const USER_ROLE_DESCRIPTION = 'User';
|
||||
/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */
|
||||
const ADMIN_ROLE_DESCRIPTION = 'System Administrator';
|
||||
/** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */
|
||||
const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/;
|
||||
/**
|
||||
@@ -173,6 +182,60 @@ const GATEWAY_PERMISSIONS = { authenticate: true, impersonate: true };
|
||||
const GATEWAY_DESCRIPTION = `메일 게이트웨이 (백엔드 대리 접근 전용) ${MANAGED_MARK}`;
|
||||
const ALLOWED_IP_REASON = `${MANAGED_MARK} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`;
|
||||
|
||||
/**
|
||||
* 사내 목록 계정 아이디 기본값 — 루트 env MAIL_DIRECTORY_LOCAL_PART. 백엔드도 이 값 하나를 읽으므로 기본값이 같아야 한다
|
||||
* (형식은 위 LOCAL_PART_PATTERN — MAIL_ADMIN_LOCAL_PART 와 같은 규칙).
|
||||
*/
|
||||
const DEFAULT_DIRECTORY_LOCAL_PART = 'mail-directory';
|
||||
/**
|
||||
* 사내 목록 계정 — 백엔드가 엔진의 계정 목록(JMAP Principal)에서 직원 이름 · 주소만 읽는 전용 계정
|
||||
* (계약 docs/mail/api-contract.md 3절 "사내 목록"). 역할은 로그인 + 계정 목록 읽기뿐이고 비밀번호가 없다 —
|
||||
* 백엔드가 게이트웨이 대리 접근(<사내 목록 주소>%<게이트웨이 주소>)으로만 쓴다. authenticate 가 없으면 대리 접근이 403 이다.
|
||||
* ⚠️ 엔진 관리자 역할(System Administrator)에는 jmapPrincipal* 이 없고, 엔진은 역할 · 계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를
|
||||
* 바꾸는 쪽이 가졌는지 본다 — 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다
|
||||
* ("You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet"). 그래서 복구 관리자로만 만들고 맞춘다.
|
||||
* 관리자로 되는 것: 계정의 설명 · 저장 한도 고치기, 비밀번호 빼기, 계정 지우기 (2026-10-29 Stalwart 0.16.27 에서 확인 — ensureDirectoryAccount).
|
||||
*/
|
||||
const DIRECTORY_ROLE_DESCRIPTION = `사내 목록 — 계정 목록 읽기 전용 ${MANAGED_MARK}`;
|
||||
const DIRECTORY_PERMISSIONS = { authenticate: true, jmapPrincipalGet: true, jmapPrincipalQuery: true };
|
||||
const DIRECTORY_DESCRIPTION = `사내 목록 (백엔드 계정 목록 읽기 전용) ${MANAGED_MARK}`;
|
||||
/**
|
||||
* 사내 목록 계정의 저장 한도(x:Account 의 quotas — maxDiskQuota, 바이트). 주소가 있으니 메일이 쌓이지 않게 작게 둔다.
|
||||
* 역할에 emailReceive 가 없어 엔진은 이 주소로 온 메일을 배달하지 않고 보낸 사람에게 반송한다(security.unauthorized "emailReceive",
|
||||
* 2026-10-29 확인) — 한도는 그 위의 두 번째 막이다. 0 은 받기는 하지만 뜻(제한 없음인지)을 확인하지 않아 쓰지 않는다.
|
||||
*/
|
||||
const DIRECTORY_QUOTAS = { maxDiskQuota: 1024 * 1024 };
|
||||
/** 계정의 권한 설정 기본값 — 역할에서만 물려받는다 (계정에 직접 권한을 더하지 않았다) */
|
||||
const INHERIT_PERMISSIONS = { '@type': 'Inherit' };
|
||||
/** JMAP 계정 목록(Principal) — 메일 쪽 세션의 이 능력 계정으로 Principal/query · get 을 부른다 */
|
||||
const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals';
|
||||
|
||||
/**
|
||||
* 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 받는 쪽 RCPT 단계에서 없는 주소처럼 거절한다.
|
||||
* 세 계정의 역할에는 emailReceive 가 없어(관리자의 System Administrator 역할도) 엔진은 RCPT 를 250 으로 받아 놓고, 배달할 때
|
||||
* security.unauthorized "emailReceive" 로 거절해 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 메일이면 모르는 사람에게 반송이 나가
|
||||
* 서버 평판이 떨어진다(backscatter). 없는 주소는 처음부터 RCPT 에서 550 이다 (2026-10-29 Stalwart 0.16.27 에서 확인).
|
||||
* 방법: x:MtaStageRcpt 의 script 식이 받는 주소가 이 계정들일 때만 아래 시스템 Sieve 스크립트를 고르고, 스크립트는 reject 한다
|
||||
* (reject 의 글이 그대로 SMTP 응답이 된다). 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다.
|
||||
* 엔진은 식의 rcpt 를 소문자로 넘기지만 +태그(부주소)는 떼지 않는다 — 그래서 '<아이디>+…@<도메인>' 꼴도 함께 본다.
|
||||
* 반영은 ReloadSettings 뒤다 (main 의 reloadSettings — 그 전에는 옛 규칙으로 받는다).
|
||||
*/
|
||||
/** 엔진이 없는 주소에 주는 응답과 같은 글 — 바깥에서 이 계정들이 있는지 알아볼 수 없게 */
|
||||
const SYSTEM_RCPT_REPLY = '550 5.1.2 Mailbox does not exist.';
|
||||
const SYSTEM_RCPT_SCRIPT = {
|
||||
name: 'arasystem-system-recipients',
|
||||
description: `메일을 받지 않는 시스템 계정 — RCPT 에서 거절 ${MANAGED_MARK}`,
|
||||
contents: ['require ["reject"];', `reject "${SYSTEM_RCPT_REPLY}";`].join('\r\n'),
|
||||
};
|
||||
/** x:MtaStageRcpt 의 script 기본값 — RCPT 단계 스크립트 없음 */
|
||||
const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' };
|
||||
/** 식에 그대로 넣는 주소의 꼴 — 따옴표 · 공백 · 식 문법이 들어갈 수 없게 (아이디는 loadConfig 가 이미 LOCAL_PART_PATTERN 으로 봤다) */
|
||||
const PLAIN_ADDRESS_PATTERN = /^[a-z0-9._-]+@[a-z0-9.-]+$/;
|
||||
/** RCPT 거절 확인에 쓰는 EHLO 이름 — 예약 도메인 (엔진은 FQDN 이 아닌 EHLO 를 거절한다) */
|
||||
const RCPT_CHECK_EHLO = 'setup-check.example.net';
|
||||
/** RCPT 거절 확인에서 한 세션에 보내는 RCPT 수 상한 — 엔진은 한 세션의 RCPT 실패를 5번까지만 받는다 (x:MtaStageRcpt 의 maxFailures) */
|
||||
const RCPT_CHECK_MAX_PROBES = 4;
|
||||
|
||||
/** 개발 전용 — 사내 도메인 밖 메일을 받는 Mailpit (docker-compose.dev.yml 의 mailpit 서비스) */
|
||||
const DEV_RELAY = {
|
||||
name: 'mailpit',
|
||||
@@ -350,6 +413,21 @@ function loadConfig(argv) {
|
||||
errors.push(`MAIL_ADMIN_LOCAL_PART(${adminLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
|
||||
}
|
||||
|
||||
// 사내 목록 계정 아이디 — 백엔드가 같은 값으로 그 계정을 대리 접근해 계정 목록을 읽는다 (ensureDirectoryAccount)
|
||||
const directoryLocalRaw = readEnv('MAIL_DIRECTORY_LOCAL_PART') ?? DEFAULT_DIRECTORY_LOCAL_PART;
|
||||
const directoryLocalPart = directoryLocalRaw.toLowerCase();
|
||||
if (!LOCAL_PART_PATTERN.test(directoryLocalPart)) {
|
||||
errors.push(
|
||||
`MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${directoryLocalRaw}").`,
|
||||
);
|
||||
} else if (directoryLocalPart === adminLocalPart) {
|
||||
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.');
|
||||
} else if (directoryLocalPart === gatewayLocal) {
|
||||
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.');
|
||||
} else if (!isProduction && DEV_USERS.some((user) => user.name === directoryLocalPart)) {
|
||||
errors.push(`MAIL_DIRECTORY_LOCAL_PART(${directoryLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
|
||||
}
|
||||
|
||||
// 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다
|
||||
const blockedAccounts = (readEnv('MAIL_BLOCKED_ACCOUNTS') ?? '')
|
||||
.split(',')
|
||||
@@ -477,6 +555,7 @@ function loadConfig(argv) {
|
||||
gatewayAddress,
|
||||
gatewayLocal,
|
||||
gatewayPassword,
|
||||
directoryLocalPart,
|
||||
appNetworkSubnet,
|
||||
networkName: `${projectName}_app-network`,
|
||||
trustedNetworks,
|
||||
@@ -1357,7 +1436,8 @@ function preflight(cfg) {
|
||||
/**
|
||||
* 직원(User) 역할에서 Principal 조회 권한을 뺀다.
|
||||
* 기본값으로는 직원 계정이 JMAP Principal/get 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다.
|
||||
* 웹메일은 백엔드를 거치고 백엔드는 Principal 메서드를 쓰지 않는다. 대리 접근 세션도 직원 역할의 권한을 쓴다.
|
||||
* 웹메일은 백엔드를 거치고, 대리 접근 세션도 직원 역할의 권한을 쓴다. 백엔드가 계정 목록을 읽는 것은 사내 목록 계정
|
||||
* (따로 만든 역할 — ensureDirectoryAccount)으로만이고, 이 함수는 기본 직원 역할(설명 "User")만 건드린다.
|
||||
* 역할 권한은 캐시되므로 ReloadSettings 가 아니라 InvalidateCaches 로 반영한다 (Stalwart 0.16 에서 확인).
|
||||
*/
|
||||
async function ensureUserRoleWithoutPrincipals(client) {
|
||||
@@ -1380,6 +1460,308 @@ async function ensureUserRoleWithoutPrincipals(client) {
|
||||
log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만)
|
||||
// ============================================================
|
||||
|
||||
/** 복구 관리자의 관리 API 클라이언트 — 값이 없거나, 로그인되지 않거나, 관리 권한이 없으면 null */
|
||||
async function openRecoveryClient(cfg) {
|
||||
if (!cfg.recovery) return null;
|
||||
const client = await openClient(cfg, cfg.recovery.user, cfg.recovery.password);
|
||||
if (!client?.accountId) return null;
|
||||
return (await hasManagementAccess(client)) ? client : null;
|
||||
}
|
||||
|
||||
/** 값이 true 인 키 (정렬) */
|
||||
function trueKeys(value) {
|
||||
return Object.entries(value ?? {})
|
||||
.filter(([, flag]) => flag === true)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** 사내 목록 역할이 setup 기준(DIRECTORY_PERMISSIONS 만 · 빼는 권한 없음 · 물려받는 역할 없음)과 다른 점 — 같으면 빈 배열 */
|
||||
function directoryRoleDrift(role) {
|
||||
const expected = Object.keys(DIRECTORY_PERMISSIONS);
|
||||
const enabled = trueKeys(role.enabledPermissions);
|
||||
const drift = [];
|
||||
const missing = expected.filter((key) => !enabled.includes(key));
|
||||
const extra = enabled.filter((key) => !expected.includes(key));
|
||||
const disabled = trueKeys(role.disabledPermissions);
|
||||
const inherited = trueKeys(role.roleIds);
|
||||
if (missing.length > 0) drift.push(`모자란 권한 ${missing.join(', ')}`);
|
||||
if (extra.length > 0) drift.push(`더 붙은 권한 ${extra.join(', ')}`);
|
||||
if (disabled.length > 0) drift.push(`빼는 권한 ${disabled.join(', ')}`);
|
||||
if (inherited.length > 0) drift.push(`물려받는 역할 ${inherited.join(', ')}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
/** 계정이 그 역할 하나만 가졌는가 (roleId 가 없으면 false) */
|
||||
function holdsOnlyRole(account, roleId) {
|
||||
return Boolean(roleId) && account.roles?.['@type'] === 'Custom' && sameTrueSet(account.roles.roleIds, [roleId]);
|
||||
}
|
||||
|
||||
/** 계정 하나를 출력할 이름 — 주소, 없으면 아이디 */
|
||||
function accountLabel(account) {
|
||||
return account.emailAddress ?? account.name ?? account.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사내 목록 계정을 맞춘다 — <MAIL_DIRECTORY_LOCAL_PART>@<도메인>, 역할 DIRECTORY_PERMISSIONS 하나 · 비밀번호 없음 · 저장 한도 DIRECTORY_QUOTAS
|
||||
* (계약 docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md "사내 목록 계정").
|
||||
* - 복구 관리자를 쓸 수 있으면(개발 · 운영 --recovery) 역할 · 계정을 만들거나 맞춘다.
|
||||
* - 없으면(운영의 일반 실행) 관리자로 되는 것(계정의 설명 · 저장 한도 · 비밀번호 빼기 · 옛 계정 지우기)만 맞추고, 역할 · 계정이 없거나
|
||||
* 권한이 다르면 [주의] 만 낸다 — 실패로 끝내지 않는다 (그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다 — 계약 3절).
|
||||
* 관리자로는 역할의 권한을 고칠 수 없다(빼기도 — DIRECTORY_ROLE_DESCRIPTION 머리말).
|
||||
* - 그 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 건드리지 않고 실패로 보고한다 (백엔드는 그 주소를 사내 목록 · 웹메일 로그인에서 뺀다).
|
||||
* - MAIL_DIRECTORY_LOCAL_PART 를 바꿨으면 옛 계정(setup 표시 + 이 역할 하나)은 새 계정이 준비된 뒤에 지운다.
|
||||
* 이 계정은 메일을 보내지도 받지도 못한다(역할에 emailSend · emailReceive 가 없다) — 그래서 개발의 Mailpit 경로 리로드 전에 만들어도 된다.
|
||||
* 준비되면 백엔드가 쓰는 방식 그대로 읽어 본다 (checkDirectoryAccess).
|
||||
* 돌려주는 값: { address, state } — state 는 ready(준비됨) · unreadable(맞췄지만 읽지 못함) · pending(복구 관리자가 있어야 맞춘다) · conflict(다른 계정의 주소)
|
||||
*/
|
||||
async function ensureDirectoryAccount(admin, cfg, domain) {
|
||||
const address = `${cfg.directoryLocalPart}@${domain.name}`;
|
||||
const label = `사내 목록 계정 ${address}`;
|
||||
const roleLabel = `역할 — 사내 목록 계정 목록 읽기 (${Object.keys(DIRECTORY_PERMISSIONS).join(', ')})`;
|
||||
const shape = `비밀번호 없음 · 저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트 · 역할은 위 하나`;
|
||||
const recovery = await openRecoveryClient(cfg);
|
||||
const via = recovery ? ' (복구 관리자 사용)' : '';
|
||||
// 계정의 설명 · 저장 한도 · 비밀번호 빼기 · 지우기는 관리자로도 된다
|
||||
const writer = recovery ?? admin;
|
||||
/** 복구 관리자가 있어야 맞출 수 있는 것 */
|
||||
const pending = [];
|
||||
let changed = false;
|
||||
|
||||
// 1) 역할 — 설명으로 찾는다
|
||||
const roles = await listAll(admin, 'Role', ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds']);
|
||||
const role = roles.find((item) => item.description === DIRECTORY_ROLE_DESCRIPTION);
|
||||
let roleId = role?.id ?? null;
|
||||
if (!role) {
|
||||
if (recovery) {
|
||||
const result = await setObjects(recovery, 'Role', {
|
||||
create: { role: { description: DIRECTORY_ROLE_DESCRIPTION, enabledPermissions: DIRECTORY_PERMISSIONS } },
|
||||
});
|
||||
roleId = result.created.role.id;
|
||||
changed = true;
|
||||
log(`[생성] ${roleLabel}${via}`);
|
||||
} else {
|
||||
pending.push('역할 없음');
|
||||
}
|
||||
} else {
|
||||
const drift = directoryRoleDrift(role);
|
||||
if (drift.length === 0) {
|
||||
log(`[유지] ${roleLabel}`);
|
||||
} else if (recovery) {
|
||||
await setObjects(recovery, 'Role', {
|
||||
update: { [role.id]: { enabledPermissions: DIRECTORY_PERMISSIONS, disabledPermissions: {}, roleIds: {} } },
|
||||
});
|
||||
changed = true;
|
||||
log(`[변경] ${roleLabel} 로 되돌림 — 전: ${drift.join(' · ')}${via}`);
|
||||
} else {
|
||||
pending.push(`역할의 권한이 다름(${drift.join(' · ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 계정 — 아이디 + 도메인으로 찾는다 (비밀번호 값은 읽지도 출력하지도 않는다 — 있는지만 본다)
|
||||
const accounts = await listAll(admin, 'Account', [
|
||||
'name',
|
||||
'domainId',
|
||||
'emailAddress',
|
||||
'description',
|
||||
'roles',
|
||||
'credentials',
|
||||
'quotas',
|
||||
'permissions',
|
||||
'memberGroupIds',
|
||||
]);
|
||||
const existing = accounts.find((account) => account.name === cfg.directoryLocalPart && account.domainId === domain.id);
|
||||
if (existing && !existing.description?.includes(MANAGED_MARK) && !holdsOnlyRole(existing, roleId)) {
|
||||
problems.push(
|
||||
`${address}(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 사내 목록 계정으로 쓰지 않았습니다 — 그 계정은 건드리지 않았습니다. ` +
|
||||
'MAIL_DIRECTORY_LOCAL_PART 를 아무도 쓰지 않는 아이디로 바꾸고 backend 를 다시 띄운 뒤 이 스크립트를 다시 실행하세요 ' +
|
||||
'(백엔드는 이 주소를 사내 목록 · 웹메일 로그인에서 뺀다 — mail/README.md "사내 목록 계정").',
|
||||
);
|
||||
return { address, state: 'conflict' };
|
||||
}
|
||||
const directoryRoles = roleId ? { '@type': 'Custom', roleIds: { [roleId]: true } } : null;
|
||||
if (!existing) {
|
||||
if (recovery && roleId) {
|
||||
await setObjects(recovery, 'Account', {
|
||||
create: {
|
||||
account: {
|
||||
'@type': 'User',
|
||||
name: cfg.directoryLocalPart,
|
||||
domainId: domain.id,
|
||||
description: DIRECTORY_DESCRIPTION,
|
||||
roles: directoryRoles,
|
||||
quotas: DIRECTORY_QUOTAS,
|
||||
},
|
||||
},
|
||||
});
|
||||
changed = true;
|
||||
log(`[생성] ${label} — ${shape}${via}`);
|
||||
} else {
|
||||
pending.push('계정 없음');
|
||||
}
|
||||
} else {
|
||||
const patch = {};
|
||||
const changes = [];
|
||||
if (existing.description !== DIRECTORY_DESCRIPTION) {
|
||||
patch.description = DIRECTORY_DESCRIPTION;
|
||||
changes.push('표시 이름');
|
||||
}
|
||||
if (stableJson(existing.quotas ?? {}) !== stableJson(DIRECTORY_QUOTAS)) {
|
||||
patch.quotas = DIRECTORY_QUOTAS;
|
||||
changes.push(`저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트`);
|
||||
}
|
||||
if (Object.keys(existing.credentials ?? {}).length > 0) {
|
||||
// 비밀번호가 있으면 그것으로 직접 로그인해 계정 목록(관리자 · 게이트웨이 주소 포함)을 읽을 수 있다
|
||||
patch.credentials = {};
|
||||
changes.push('비밀번호 뺌');
|
||||
}
|
||||
let rolePending = false;
|
||||
if (roleId && !holdsOnlyRole(existing, roleId)) {
|
||||
if (recovery) {
|
||||
patch.roles = directoryRoles;
|
||||
changes.push('역할');
|
||||
} else {
|
||||
pending.push('계정의 역할이 다름');
|
||||
rolePending = true;
|
||||
}
|
||||
}
|
||||
if (changes.length > 0) {
|
||||
await setObjects(writer, 'Account', { update: { [existing.id]: patch } });
|
||||
changed = true;
|
||||
log(`[변경] ${label} — ${changes.join(', ')}${via}`);
|
||||
} else if (roleId && !rolePending) {
|
||||
log(`[유지] ${label} — ${shape}`);
|
||||
}
|
||||
if (
|
||||
stableJson(existing.permissions ?? INHERIT_PERMISSIONS) !== stableJson(INHERIT_PERMISSIONS) ||
|
||||
Object.keys(existing.memberGroupIds ?? {}).length > 0
|
||||
) {
|
||||
warn(
|
||||
`${label} 에 역할 밖의 권한 설정(permissions)이나 그룹(memberGroupIds)이 있습니다 — setup 은 이것을 맞추지 않는다. ` +
|
||||
'관리 화면 › 계정에서 지우세요 (mail/README.md "사내 목록 계정").',
|
||||
);
|
||||
}
|
||||
}
|
||||
const ready = Boolean(roleId) && pending.length === 0;
|
||||
|
||||
// 3) 이 역할을 가진 다른 계정 — setup 이 만든 옛 계정(아이디를 바꿨다)은 새 계정이 준비된 뒤 지우고, 그 밖은 알린다
|
||||
if (roleId) {
|
||||
const others = accounts.filter(
|
||||
(account) => account !== existing && account.roles?.['@type'] === 'Custom' && account.roles.roleIds?.[roleId] === true,
|
||||
);
|
||||
const stale = others.filter((account) => account.description?.includes(MANAGED_MARK) && holdsOnlyRole(account, roleId));
|
||||
const foreign = others.filter((account) => !stale.includes(account));
|
||||
if (stale.length > 0 && ready) {
|
||||
await setObjects(writer, 'Account', { destroy: stale.map((account) => account.id) });
|
||||
changed = true;
|
||||
for (const account of stale) log(`[삭제] 옛 사내 목록 계정 ${accountLabel(account)} — MAIL_DIRECTORY_LOCAL_PART 에서 빠짐${via}`);
|
||||
} else if (stale.length > 0) {
|
||||
log(`[유지] 옛 사내 목록 계정 ${stale.map(accountLabel).join(', ')} — 새 계정 ${address} 가 준비된 뒤에 지운다`);
|
||||
}
|
||||
if (foreign.length > 0) {
|
||||
warn(
|
||||
`사내 목록 역할을 다른 계정도 가지고 있습니다: ${foreign.map(accountLabel).join(', ')} — 그 계정은 계정 목록 전체(관리자 · 게이트웨이 주소 포함)를 읽을 수 있다. ` +
|
||||
'의도한 것이 아니면 관리 화면 › 계정에서 그 계정의 역할을 바꾸세요 (mail/README.md "사내 목록 계정").',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 역할 권한 · 계정은 캐시된다 — 바로 반영되게 비운다
|
||||
if (changed) await setObjects(writer, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
|
||||
if (ready) {
|
||||
const readable = await checkDirectoryAccess(cfg, address, { strict: Boolean(recovery) });
|
||||
return { address, state: readable === false ? 'unreadable' : 'ready' };
|
||||
}
|
||||
|
||||
// 복구 관리자가 있어야 맞출 수 있는 것이 남았다
|
||||
const absent = pending.includes('역할 없음') || pending.includes('계정 없음');
|
||||
const how = cfg.isProduction
|
||||
? '--recovery 로 한 번 실행하세요'
|
||||
: `${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) 다시 실행하세요`;
|
||||
const message = absent
|
||||
? `사내 목록 계정이 없습니다 (${pending.join(' · ')}) — ${how} (mail/README.md "사내 목록 계정"). 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다.`
|
||||
: `사내 목록 계정이 setup 기준과 다릅니다 (${pending.join(' · ')}) — 관리자로는 맞출 수 없다(엔진이 거절한다). ${how} (mail/README.md "사내 목록 계정").`;
|
||||
if (cfg.recovery && !recovery) {
|
||||
// 복구 관리자를 줬는데 쓰지 못했다 — 운영은 --recovery 실행이므로 실패로 본다
|
||||
const reason =
|
||||
`복구 관리자(${RECOVERY_ENV_KEY})로 관리 API 에 로그인하지 못해 사내 목록 계정을 맞추지 못했습니다 — ` +
|
||||
`mail 컨테이너가 지금 값으로 만들어졌는지(up -d mail) 확인하세요. `;
|
||||
if (cfg.isProduction) problems.push(reason + message);
|
||||
else warn(reason + message);
|
||||
} else {
|
||||
warn(message);
|
||||
}
|
||||
// 계정은 있는데 기준과 다르면 — 지금 백엔드가 읽을 수 있는지만 알려 준다 (더 붙은 권한만 있으면 읽힌다)
|
||||
if (!absent && existing) await checkDirectoryAccess(cfg, address, { strict: false });
|
||||
return { address, state: 'pending' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 사내 목록 계정으로 계정 목록을 읽을 수 있는지 — 백엔드가 쓰는 방식 그대로
|
||||
* (게이트웨이 대리 접근 <사내 목록 주소>%<게이트웨이 주소> → 세션의 principals 계정 → Principal/query · get).
|
||||
* 역할을 바꾼 직후에는 캐시가 퍼질 때까지 몇 번 다시 본다. 이 스크립트의 접속 주소가 게이트웨이 허용 IP 밖이면
|
||||
* (MAIL_TRUSTED_NETWORKS 를 좁힌 운영) 게이트웨이 자신도 로그인되지 않으므로 건너뛴다.
|
||||
* 읽지 못하면 strict(복구 관리자로 맞춘 실행)는 실패로, 아니면 [주의] 로 남긴다. 돌려주는 값: true · false · null(건너뜀)
|
||||
*/
|
||||
async function checkDirectoryAccess(cfg, address, { strict }) {
|
||||
const login = `${address}%${cfg.gatewayAddress}`;
|
||||
const authorization = basicAuth(login, cfg.gatewayPassword);
|
||||
let outcome = '';
|
||||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||||
const sessionRes = await request(cfg, '/jmap/session', { headers: { authorization } });
|
||||
if (!sessionRes.ok) {
|
||||
await sessionRes.body?.cancel();
|
||||
if (sessionRes.status === 401 && (await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) === 401) {
|
||||
log(`[건너뜀] 이 스크립트의 접속 주소가 게이트웨이 허용 IP(신뢰 대역) 밖이라 사내 목록 계정의 대리 접근(${login})을 확인하지 못했습니다.`);
|
||||
return null;
|
||||
}
|
||||
outcome = `세션 HTTP ${sessionRes.status}${sessionRes.status === 403 ? ' — 역할에 authenticate 가 없거나 빼는 권한에 들어 있다' : ''}`;
|
||||
} else {
|
||||
const session = await sessionRes.json();
|
||||
const accountId = session.primaryAccounts?.[PRINCIPALS_CAPABILITY];
|
||||
if (!accountId) {
|
||||
outcome = `세션에 ${PRINCIPALS_CAPABILITY} 계정이 없음`;
|
||||
} else {
|
||||
// 백엔드처럼 찾고(query) 읽는다(get) — 둘 중 하나만 막혀도 사내 목록을 채우지 못한다
|
||||
const res = await request(cfg, '/jmap/', {
|
||||
method: 'POST',
|
||||
headers: { authorization, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', PRINCIPALS_CAPABILITY],
|
||||
methodCalls: [
|
||||
['Principal/query', { accountId, limit: 1 }, 'q'],
|
||||
['Principal/get', { accountId, '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, properties: ['email'] }, 'g'],
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const responses = (await res.json()).methodResponses;
|
||||
const failed = responses.find(([name]) => name === 'error');
|
||||
if (!failed) {
|
||||
log(`[확인] 사내 목록 — 대리 접근 ${login} 로 계정 목록을 읽을 수 있습니다 (Principal/query · get).`);
|
||||
return true;
|
||||
}
|
||||
outcome = `Principal/${failed[2] === 'q' ? 'query' : 'get'} → ${failed[1].type}`;
|
||||
} else {
|
||||
await res.body?.cancel();
|
||||
outcome = `JMAP 요청 HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS / 2);
|
||||
}
|
||||
const message =
|
||||
`사내 목록 계정으로 계정 목록을 읽지 못했습니다 — ${login}: ${outcome}. ` +
|
||||
'역할의 권한(authenticate · jmapPrincipalGet · jmapPrincipalQuery)과 게이트웨이 허용 IP 를 확인하세요 (mail/README.md "사내 목록 계정").';
|
||||
if (strict) problems.push(message);
|
||||
else warn(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 공개 443(https 리스너)의 HTTP 경로 정책 — 바꿨으면 true (ReloadSettings 뒤에 반영된다) */
|
||||
async function ensureHttpEndpointPolicy(client) {
|
||||
const current = (await call(client, 'x:Http/get', { ids: ['singleton'], properties: ['allowedEndpoints'] })).list[0];
|
||||
@@ -1392,6 +1774,97 @@ async function ensureHttpEndpointPolicy(client) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 받지 않는 시스템 계정의 주소 — 게이트웨이 · 사내 목록(역할을 setup 이 정한다 — emailReceive 없음)과,
|
||||
* 관리자 역할(ADMIN_ROLE_DESCRIPTION)에 emailReceive 가 없을 때의 관리자 역할 계정 모두(MAIL_ADMIN_LOCAL_PART 관리자 + 더 만든 관리자).
|
||||
* 관리자 역할을 찾지 못하거나 그 역할이 메일을 받게 바뀌었으면 관리자 주소는 넣지 않는다 — 받는 계정을 막지 않게.
|
||||
* 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면(conflict — 실제 메일함일 수 있다) 그 주소도 넣지 않는다.
|
||||
*/
|
||||
async function nonReceivingSystemAddresses(client, cfg, directory) {
|
||||
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
|
||||
const adminRole = roles.find((role) => role.description === ADMIN_ROLE_DESCRIPTION);
|
||||
const adminsReceive = !adminRole || adminRole.enabledPermissions?.emailReceive === true;
|
||||
const admins = adminsReceive
|
||||
? []
|
||||
: (await listAll(client, 'Account', ['emailAddress', 'roles']))
|
||||
.filter(isAdminAccount)
|
||||
.map((account) => account.emailAddress?.toLowerCase())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a === cfg.adminAddress ? -1 : b === cfg.adminAddress ? 1 : a.localeCompare(b)));
|
||||
if (adminsReceive) {
|
||||
warn(
|
||||
`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 관리자 주소는 RCPT 거절에 넣지 않았습니다 — ` +
|
||||
'관리자 계정이 메일을 받지 못한다면 그 주소로 온 메일은 받아 놓고 반송한다 (mail/README.md "보안").',
|
||||
);
|
||||
}
|
||||
return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])];
|
||||
}
|
||||
|
||||
/** 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다 */
|
||||
function systemRcptScriptExpression(addresses) {
|
||||
const condition = addresses
|
||||
.map((address) => {
|
||||
const at = address.lastIndexOf('@');
|
||||
return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`;
|
||||
})
|
||||
.join(' || ');
|
||||
return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' };
|
||||
}
|
||||
|
||||
/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */
|
||||
function isOwnRcptScriptExpression(value) {
|
||||
const branches = Object.values(value?.match ?? {});
|
||||
return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true.
|
||||
* 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고,
|
||||
* 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다.
|
||||
*/
|
||||
async function ensureSystemRecipientsRejected(client, addresses) {
|
||||
const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address));
|
||||
if (unsafe.length > 0) {
|
||||
problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`);
|
||||
return false;
|
||||
}
|
||||
const scriptLabel = `시스템 Sieve 스크립트 ${SYSTEM_RCPT_SCRIPT.name} — reject "${SYSTEM_RCPT_REPLY}"`;
|
||||
const scripts = await listAll(client, 'SieveSystemScript', ['name', 'description', 'isActive', 'contents']);
|
||||
const script = scripts.find((item) => item.name === SYSTEM_RCPT_SCRIPT.name);
|
||||
const desired = { description: SYSTEM_RCPT_SCRIPT.description, isActive: true, contents: SYSTEM_RCPT_SCRIPT.contents };
|
||||
if (!script) {
|
||||
await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } });
|
||||
log(`[생성] ${scriptLabel}`);
|
||||
} else {
|
||||
const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value));
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } });
|
||||
log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`);
|
||||
} else {
|
||||
log(`[유지] ${scriptLabel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`;
|
||||
const expression = systemRcptScriptExpression(addresses);
|
||||
const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script;
|
||||
if (stableJson(current) === stableJson(expression)) {
|
||||
log(`[유지] ${label}`);
|
||||
return true;
|
||||
}
|
||||
if (stableJson(current) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(current)) {
|
||||
warn(
|
||||
`RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 (${JSON.stringify(current)}) — ` +
|
||||
`메일을 받지 않는 시스템 계정(${addresses.join(', ')})은 지금처럼 받아 놓고 보낸 사람에게 반송한다. ` +
|
||||
'그 스크립트를 고르는 식에 이 계정들일 때 거절하는 갈래를 합치세요 (mail/README.md "보안").',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } });
|
||||
log(`[변경] ${label}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다.
|
||||
* 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다).
|
||||
@@ -1506,6 +1979,85 @@ async function checkHttpEndpointPolicy(cfg) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null.
|
||||
* 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
|
||||
*/
|
||||
function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) {
|
||||
const result = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
'exec',
|
||||
'-i',
|
||||
cfg.container,
|
||||
'curl',
|
||||
'-sS',
|
||||
'-v',
|
||||
'--max-time',
|
||||
'20',
|
||||
'--url',
|
||||
`smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`,
|
||||
'--mail-from',
|
||||
sender,
|
||||
'--mail-rcpt-allowfails',
|
||||
...recipients.flatMap((recipient) => ['--mail-rcpt', recipient]),
|
||||
'-T',
|
||||
'-',
|
||||
],
|
||||
{ input: '', encoding: 'utf8' },
|
||||
);
|
||||
if (result.error) return null;
|
||||
const replies = new Map();
|
||||
let asked = null;
|
||||
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
|
||||
const sent = /^> RCPT TO:<([^>]*)>/.exec(line);
|
||||
if (sent) {
|
||||
asked = sent[1].toLowerCase();
|
||||
continue;
|
||||
}
|
||||
const reply = /^< (\d{3}[ -].*)$/.exec(line);
|
||||
if (reply && asked) {
|
||||
replies.set(asked, reply[1]);
|
||||
asked = null;
|
||||
}
|
||||
}
|
||||
return replies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로
|
||||
* 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라,
|
||||
* 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다.
|
||||
* 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다).
|
||||
*/
|
||||
async function checkSystemRecipientsRejected(cfg, addresses) {
|
||||
const networks = cfg.docker ? containerNetworks(cfg.container) : null;
|
||||
const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null;
|
||||
if (!mailIp) {
|
||||
const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.';
|
||||
if (cfg.isProduction) problems.push(message);
|
||||
else log(`[건너뜀] ${message}`);
|
||||
return;
|
||||
}
|
||||
const last = addresses[addresses.length - 1];
|
||||
const probes = [...addresses, last.replace('@', '+setup-check@')].slice(0, RCPT_CHECK_MAX_PROBES);
|
||||
let replies = null;
|
||||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||||
replies = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes);
|
||||
if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break;
|
||||
await sleep(POLL_INTERVAL_MS / 2);
|
||||
}
|
||||
const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550'));
|
||||
if (notRejected.length === 0) {
|
||||
log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`);
|
||||
return;
|
||||
}
|
||||
problems.push(
|
||||
`시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` +
|
||||
'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서).
|
||||
* 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면
|
||||
@@ -1692,7 +2244,7 @@ function printHelp() {
|
||||
log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]');
|
||||
log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md');
|
||||
log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).');
|
||||
log(` --recovery 운영의 최초 bootstrap·관리자 복구 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`);
|
||||
log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`);
|
||||
}
|
||||
|
||||
/** 후속 조치가 필요하다 (종료 코드 3) — 안내를 출력하고 끝낸다 */
|
||||
@@ -1744,6 +2296,11 @@ async function main() {
|
||||
// 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API
|
||||
await ensureUserRoleWithoutPrincipals(admin);
|
||||
await ensureHttpEndpointPolicy(admin);
|
||||
// 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의])
|
||||
const directory = await ensureDirectoryAccount(admin, cfg, domain);
|
||||
// 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영)
|
||||
const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory);
|
||||
const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients);
|
||||
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다),
|
||||
// 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영)
|
||||
await ensureRecipientThrottle(admin);
|
||||
@@ -1752,6 +2309,7 @@ async function main() {
|
||||
if (!cfg.isProduction) await ensureDevRelay(admin);
|
||||
await reloadSettings(admin);
|
||||
await checkHttpEndpointPolicy(cfg);
|
||||
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
|
||||
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
|
||||
|
||||
const devUsers = [];
|
||||
@@ -1788,7 +2346,21 @@ async function main() {
|
||||
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`);
|
||||
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
|
||||
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
|
||||
log(' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가');
|
||||
log(
|
||||
' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가' +
|
||||
(systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''),
|
||||
);
|
||||
const directoryFallback = '읽지 못하는 동안 백엔드는 로그인한 직원 기록으로 대신한다';
|
||||
if (directory.state === 'ready') {
|
||||
log(` 사내 목록 ${directory.address} — 계정 목록(Principal) 읽기 전용 · 비밀번호 없음 · 대리 접근: ${directory.address}%${cfg.gatewayAddress}`);
|
||||
} else if (directory.state === 'unreadable') {
|
||||
log(` 사내 목록 ${directory.address} — 맞췄지만 계정 목록을 읽지 못함(위 [실패] · [주의]) — ${directoryFallback}`);
|
||||
} else if (directory.state === 'conflict') {
|
||||
log(` 사내 목록 ${directory.address} — 다른 계정의 주소라 쓰지 않음(위 [실패]) · MAIL_DIRECTORY_LOCAL_PART 를 바꾼다 — ${directoryFallback}`);
|
||||
} else {
|
||||
const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`;
|
||||
log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`);
|
||||
}
|
||||
if (gatewayReachesAdmin) {
|
||||
log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user