feat(docker): 13단계 운영 기반 — 백업 · 감시 · 관리자 단단히 · 휴지통 30일 · 메일 프로그램 자동 설정

전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다.
- 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다.
  운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지).
- 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측).
- restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트.
- 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다).
- 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:44:44 +09:00
parent f0e7890bf1
commit 73babeaa2c
21 changed files with 2249 additions and 34 deletions
+322 -2
View File
@@ -36,6 +36,8 @@
* (기본 보안 설정은 IP 당 인증 실패가 하루 100회면 그 IP 를 차단한다)
* 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능)
* 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게
* 관리자 역할에서 대리 접근(impersonate)을 빼고 관리자 비밀번호의 허용 IP 를 앱 네트워크로 묶는다(13단계 — 관리자 비밀번호 하나로
* 바깥 993 에서 <직원>%<관리자> 로 아무 메일함에나 들어가지 못하게 · ADMIN_DISABLED_PERMISSION 머리말). 리로드 뒤 관리자의 실제 권한으로 확인한다
* 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다)
* 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 —
* 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다)
@@ -49,6 +51,8 @@
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
* 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30
* (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말)
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
* Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록")
* 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영).
@@ -128,6 +132,47 @@ const HTTP_ENDPOINT_POLICY = {
const USER_ROLE_DESCRIPTION = 'User';
/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */
const ADMIN_ROLE_DESCRIPTION = 'System Administrator';
/**
* 관리자 계정 단단히 (13단계 — mail/README.md "관리자 계정 단단히"). 둘을 맞춘다:
* 1. 관리자 역할(ADMIN_ROLE_DESCRIPTION)의 빼는 권한(disabledPermissions)에 impersonate — 그대로면 관리자 비밀번호 하나로 바깥 993 IMAP · JMAP 에서
* <직원>%<관리자> 로 아무 직원 메일함에나 들어가고(엔진 로그에는 관리자 로그인만 남는다), 관리 API 로 accountId=<직원> 메일함도 바로 연다.
* 빼면 둘 다 막히고(대리 접근 403 · IMAP 끊김 · 남의 메일함 forbidden) 관리 API(계정 · 설정)는 그대로다.
* 엔진은 역할을 바꿀 때 "바꾼 뒤의 권한 전체"를 바꾸는 쪽이 가졌는지만 본다(0.16.27 jmap/src/registry/mapping/principal.rs 의 validate_role) —
* 빼는 것은 관리자로 되고, 되돌리기(빼는 권한 지우기 · impersonate 를 주는 역할 · 계정에 게이트웨이 역할)는 관리자로 안 된다(복구 관리자만).
* 켜는 권한(enabledPermissions)에서 지우지 않고 빼는 권한에 두는 까닭 — 엔진은 역할 · 계정 권한의 켜는 것끼리 · 빼는 것끼리 합친 뒤 뺀다
* (common/src/auth/permissions.rs 의 union · finalize). 계정에 Merge 로 impersonate 를 켜도 이 역할을 가진 계정은 여전히 못 한다(2026-10-31 임시 엔진).
* 게이트웨이(자기 역할의 impersonate — 백엔드)와 복구 관리자(역할을 쓰지 않는 슈퍼유저)의 대리 접근은 그대로다
* 2. 영구 관리자(<MAIL_ADMIN_LOCAL_PART>@도메인) 비밀번호 자격의 허용 IP = 앱 네트워크(APP_NETWORK_SUBNET) — 바깥(게시 포트 993 · 465 · 443 은 원래 주소 그대로 보인다)에서
* 관리자 비밀번호로 로그인하지 못하게. 이 스크립트(호스트 → 컨테이너 주소 · 개발 포트 매핑)와 SSH 터널(호스트의 sshd → 컨테이너 주소)은
* 앱 네트워크의 게이트웨이 주소(<서브넷>.1)로 보이고, backend 컨테이너의 node 로 하는 관리 API 는 그 컨테이너 주소다 — 둘 다 대역 안이다.
* 밖이면 엔진은 401 이 아니라 403(IMAP 은 끊김) — security.ip-unauthorized "IP address not allowed." 로 남는다 (2026-10-31 임시 엔진 · 개발 엔진).
* MAIL_TRUSTED_NETWORKS(게이트웨이 대역 — 더 좁힐 수 있다)가 아니라 앱 네트워크 전체인 까닭 — 좁히면 호스트(<서브넷>.1)가 빠져 이 스크립트가 막힐 수 있다.
* 운영자가 대역 안으로 더 좁힌 값(/32 등)은 그대로 둔다. 대역 밖 항목이 있으면(없음 · 옛 대역 · 0.0.0.0/0) 대역 하나로 바꾼다.
* 둘 다 관리자로 된다. 다만 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 건다(--recovery) — 걸자마자 막히면 되돌리는 길이
* 복구 관리자(허용 IP · 역할을 보지 않는다 — mail/README.md "운영 배포" 1 · 4 · 5)뿐이다. 걸지 못하면 운영은 종료 1, 개발은 [주의].
* APP_NETWORK_SUBNET 을 바꿔 관리자 로그인이 403 이면 복구 관리자로 지금 대역으로 고친다(ensureAdmin · resetAdminAllowedIps)
*/
const ADMIN_DISABLED_PERMISSION = 'impersonate';
/** 관리자 허용 IP 를 걸어도 되는 이 실행의 관리 API 주소 — 루프백(개발 포트 매핑) · compose 이름 mail(앱 네트워크 안의 컨테이너) · 앱 네트워크 안의 IPv4 */
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
/**
* 휴지통 · 스팸메일함 자동 비우기 (13단계 — mail/README.md "휴지통 · 스팸메일함 자동 비우기") — 엔진의 x:DataRetention 싱글톤.
* expungeTrashAfter — 휴지통 · 스팸메일함에 **들어온 때부터** 이만큼 지나면 다음 계정 정리에서 메일째 영구 삭제(받은 시각이 아니다 —
* 0.16.27 email/src/message/delete.rs 가 내부 칸 DeletedAt 으로 고른다. 받은편지함에도 함께 든 메일은 통째로 지워진다). 엔진 기본값이 30일이지만 기대지 않고 맞춘다.
* 정리 시각 셋 — 엔진 기본(00:00 · 02:00 · 04:00)은 메일 컨테이너의 현지 시각(chrono Local — utils/src/cron.rs)이고 컨테이너에 TZ 가 없어 UTC 다 → KST 09:00 · 11:00 · 13:00(일하는 시간).
* 새벽으로 옮긴다: 계정 정리(휴지통 · 스팸 30일) 03:00 → 백업 03:30(ops/backup — 엔진을 몇 초 멈춘다) → 데이터 정리 04:00 → 원문 정리 04:30 (KST).
* setup 은 컨테이너의 UTC 차이(date +%z)로 바꿔 넣는다(UTC 컨테이너면 18:00 · 19:00 · 19:30).
* 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 스케줄러가 다음 시각을 기동 때와 실행 직후에만 정한다
* (services/src/task_manager/scheduler.rs — 2026-10-31 임시 엔진: 저장 · 리로드 · 재시작 뒤 task-scheduled due 17:59:59Z · 18:59:59Z · 19:29:59Z)
*/
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const KST_OFFSET_MINUTES = 9 * 60;
const RETENTION_SCHEDULES_KST = [
{ field: 'expungeSchedule', label: '계정 정리(휴지통 · 스팸 30일)', hour: 3, minute: 0 },
{ field: 'dataCleanupSchedule', label: '데이터 정리', hour: 4, minute: 0 },
{ field: 'blobCleanupSchedule', label: '원문(blob) 정리', hour: 4, minute: 30 },
];
const MINUTES_PER_DAY = 24 * 60;
/** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */
const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/;
/**
@@ -1380,6 +1425,19 @@ function statusInsideMail(cfg, url) {
return result.status === 0 && Number.isInteger(code) ? code : null;
}
/**
* 메일 컨테이너의 현지 시각이 UTC 와 몇 분 다른가 (이미지의 date +%z — 엔진의 정리 시각이 이 시각으로 돈다 · RETENTION_SCHEDULES_KST).
* docker 를 쓸 수 없거나 읽지 못하면 null
*/
function mailUtcOffsetMinutes(cfg) {
if (!cfg.docker) return null;
const result = runDocker(['exec', cfg.container, 'date', '+%z']);
const match = /^([+-])(\d{2})(\d{2})$/.exec(result.stdout?.trim() ?? '');
if (result.status !== 0 || !match) return null;
const minutes = Number(match[2]) * 60 + Number(match[3]);
return match[1] === '-' ? -minutes : minutes;
}
/**
* 메일 컨테이너 안에서 이름을 IPv4 로 풀어 본다 (이미지의 getent — 엔진이 쓰는 것과 같은 리졸버 설정).
* IPv4 주소면 그대로. 돌려주는 값: 주소 배열(못 풀면 빈 배열), docker 로 물을 수 없으면 null
@@ -1444,7 +1502,7 @@ async function waitForHttp(cfg, timeoutMs) {
throw new SetupError(`메일 엔진이 응답하지 않습니다: ${cfg.adminUrl} (${lastError})`, { hint: unreachableHint(cfg) });
}
/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치(또는 허용 IP 밖) */
/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치 · 402 비밀번호는 맞고 2단계 인증이 켜짐 · 403 허용 IP 밖(security.ip-unauthorized) */
async function loginStatus(cfg, user, password) {
const res = await request(cfg, '/jmap/session', { headers: { authorization: basicAuth(user, password) } });
await res.body?.cancel();
@@ -1790,6 +1848,8 @@ async function detectRenamedAdmin(cfg) {
async function ensureAdmin(cfg) {
/** 이번 실행에서 bootstrap 뒤 재시작을 했다 — 엔진의 관리자를 아직 아무도 쓰지 않았다 */
let restarted = false;
/** 이번 실행에서 복구 관리자로 관리자 허용 IP 를 지금 대역으로 고쳤다 — 한 번만 (고친 뒤에도 403 이면 다른 원인이다) */
let allowedIpsReset = false;
for (let step = 0; step < MAX_ADMIN_STEPS; step += 1) {
const admin = await openClient(cfg, cfg.adminAddress, cfg.adminPassword);
if (admin?.accountId) {
@@ -1831,6 +1891,23 @@ async function ensureAdmin(cfg) {
continue;
}
// 관리자 로그인이 403 — 비밀번호가 틀린 것(401)이 아니라 허용 IP(13단계 — 앱 네트워크) 밖이거나 로그인 권한이 없다.
// 비밀번호를 다시 맞춰도 풀리지 않는다 (ADMIN_DISABLED_PERMISSION 머리말 2). 복구 관리자가 있고 이 실행의 주소가 지금 대역 안이면
// 허용 IP 가 옛 대역(APP_NETWORK_SUBNET 을 바꾼 뒤 등)인 경우라 지금 대역으로 고쳐 잇는다 — 한 번만, 그래도 403 이면 멈춘다
if (!restarted && (await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword)) === 403) {
if (recovery?.accountId && !allowedIpsReset && adminUrlInsideAppNetwork(cfg) && (await resetAdminAllowedIps(recovery, cfg))) {
allowedIpsReset = true;
continue;
}
throw new SetupError(`관리자(${cfg.adminAddress}) 로그인이 HTTP 403 입니다 — 비밀번호가 아니라 접속 주소가 관리자 허용 IP(앱 네트워크 ${cfg.appNetworkSubnet}) 밖이다.`, {
hint:
`이 실행의 관리 API 주소는 ${cfg.adminUrl} — 운영은 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080)로 둔다 (mail/README.md "운영 배포" 3). ` +
`엔진이 본 주소는 docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)에 있다. ` +
'APP_NETWORK_SUBNET 을 바꿨다면 복구 관리자를 일회성으로 주고 --recovery 로 실행하면 허용 IP 를 지금 대역으로 고친다(mail/README.md "관리자 계정 단단히"). ' +
'관리자의 로그인 권한(authenticate)을 뺐다면 복구 관리자로 되돌린다.',
});
}
// 정상 모드인데 MAIL_ADMIN_PASSWORD 로는 로그인되지 않는다 → env 값으로 맞춘다
const saved = readState();
const temporary = saved ? await openClient(cfg, saved.username, saved.secret) : null;
@@ -2345,6 +2422,193 @@ async function ensureUserRoleWithoutPrincipals(client) {
log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`);
}
/**
* 관리자 역할에서 대리 접근(impersonate)을 뺀다 — 빼는 권한(disabledPermissions)에 둔다 (ADMIN_DISABLED_PERMISSION 머리말).
* 관리자로 된다. 역할 권한은 캐시되므로 InvalidateCaches 로 반영한다(직원 역할과 같다). 돌려주는 값: 맞췄으면(이미 맞았으면) true
*/
async function ensureAdminRoleWithoutImpersonate(client) {
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions', 'disabledPermissions']);
const role = roles.find((item) => item.description === ADMIN_ROLE_DESCRIPTION);
const label = `관리자 역할("${ADMIN_ROLE_DESCRIPTION}") — 대리 접근(${ADMIN_DISABLED_PERMISSION}) 뺌 (<직원>%<관리자> 로그인 · 남의 메일함 직접 열기 막힘, 관리 API 는 그대로)`;
if (!role) {
problems.push(`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못해 대리 접근(${ADMIN_DISABLED_PERMISSION})을 빼지 못했습니다 — 관리 화면 › 역할을 확인하세요 (mail/README.md "관리자 계정 단단히").`);
return false;
}
if (role.disabledPermissions?.[ADMIN_DISABLED_PERMISSION] === true) {
log(`[유지] ${label}`);
return true;
}
await setObjects(client, 'Role', { update: { [role.id]: { [`disabledPermissions/${ADMIN_DISABLED_PERMISSION}`]: true } } });
await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
log(`[변경] ${label}`);
return true;
}
/**
* 이 실행이 관리 API 에 붙는 주소가 앱 네트워크 안으로 보이는가 — 관리자 허용 IP 를 걸기 전에 본다(밖이면 걸자마자 이 실행이 막힌다).
* 루프백(개발 — 포트 매핑은 앱 네트워크의 게이트웨이 주소로 보인다) · compose 이름 mail(앱 네트워크에 붙은 컨테이너) · 앱 네트워크 안의 IPv4(운영 — 호스트에서
* 컨테이너 주소로 붙으면 그 네트워크의 게이트웨이 주소로 나간다)면 true. 그 밖(다른 네트워크의 주소 · 바깥 이름)은 false
*/
function adminUrlInsideAppNetwork(cfg) {
let host;
try {
host = new URL(cfg.adminUrl).hostname.toLowerCase();
} catch {
return false;
}
if (LOOPBACK_HOSTS.includes(host) || host === new URL(INTERNAL_ADMIN_URL).hostname) return true;
return parseIpv4(host) !== null && parseCidr(cfg.appNetworkSubnet) !== null && cidrContains(cfg.appNetworkSubnet, host);
}
/** 허용 IP 가 모두 그 대역 안인가 — 비었으면 false(어디서나 된다), IPv6 · 읽지 못하는 항목은 밖으로 본다 */
function allowedIpsWithin(allowedIps, subnet) {
const entries = trueKeys(allowedIps);
return entries.length > 0 && entries.every((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, subnet));
}
/**
* 영구 관리자 비밀번호 자격의 허용 IP 를 앱 네트워크로 (ADMIN_DISABLED_PERMISSION 머리말 2).
* 이미 모두 대역 안이면 그대로 둔다 — 운영자가 더 좁힌 값(/32 등)을 넓히지 않는다.
* 걸기 전에 이 실행의 주소가 대역 안으로 보이는지 보고(adminUrlInsideAppNetwork), 운영에서 처음 걸 때는 복구 관리자가 있어야 건다 —
* 걸자마자 이 실행이 막히면 되돌릴 길이 복구 관리자뿐이다. 걸지 못하면 운영은 실패(problems — 종료 1), 개발은 [주의].
* 건 뒤에는 관리자로 다시 로그인해 보고, 막혔으면(403) 복구 관리자로 되돌린다.
* 돌려주는 값: { ready — 관리자가 허용 IP 안에서 로그인된다, ips — 지금의 허용 IP(좁힌 값이면 그 값) }
*/
/** ensureAdminAllowedIps 가 허용 IP 를 걸지 못했을 때 */
const NOT_READY = Object.freeze({ ready: false, ips: '' });
async function ensureAdminAllowedIps(client, cfg) {
const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']);
const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
if (!admin) {
problems.push(`관리자 ${cfg.adminAddress} 를 찾지 못해 허용 IP 를 걸지 못했습니다.`);
return NOT_READY;
}
const key = passwordKey(admin);
const current = admin.credentials[key].allowedIps ?? {};
const label = `관리자 ${cfg.adminAddress} — 허용 IP ${cfg.appNetworkSubnet} (앱 네트워크 — 바깥 993 · 465 · 443 에서는 관리자 비밀번호로 로그인되지 않는다)`;
if (allowedIpsWithin(current, cfg.appNetworkSubnet)) {
if (sameTrueSet(current, [cfg.appNetworkSubnet])) log(`[유지] ${label}`);
else log(`[유지] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(current).join(', ')} (앱 네트워크 ${cfg.appNetworkSubnet} 안으로 좁힌 값 — 넓히지 않는다)`);
return { ready: true, ips: trueKeys(current).join(', ') };
}
const notApplied = (message) => {
if (cfg.isProduction) problems.push(message);
else warn(message);
return NOT_READY;
};
if (!adminUrlInsideAppNetwork(cfg)) {
return notApplied(
`관리자 허용 IP 를 걸지 않았습니다 — 이 실행의 관리 API 주소(${cfg.adminUrl})가 앱 네트워크(${cfg.appNetworkSubnet}) 안으로 보이는지 알 수 없어, 걸면 이 실행이 막힐 수 있다. ` +
`MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080 — mail/README.md "운영 배포" 3)로 두고 다시 실행하세요.`,
);
}
const recovery = await openRecoveryClient(cfg);
if (cfg.isProduction && !recovery) {
return notApplied(
`관리자 허용 IP 를 걸지 않았습니다 — 운영에서 처음 걸 때는 복구 관리자가 있어야 한다(걸자마자 이 실행이 막히면 되돌릴 길이 그것뿐이다). ` +
'복구 관리자를 일회성으로 주고 --recovery 로 다시 실행하세요 (mail/README.md "13단계 전부터 운영 중인 서버").',
);
}
await setObjects(client, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } });
log(`[변경] ${label}${trueKeys(current).length > 0 ? ` (전 ${trueKeys(current).join(', ')})` : ''}`);
const status = await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword);
if (status >= 200 && status < 300) return { ready: true, ips: cfg.appNetworkSubnet };
// 걸자마자 이 실행이 막혔다 — 엔진이 이 실행의 주소를 대역 밖으로 본다 (403 · security.ip-unauthorized)
const previous = current;
if (recovery) {
await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: previous } } });
problems.push(
`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 라 복구 관리자로 되돌렸습니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다. ` +
`docker logs ${cfg.container} 의 security.ip-unauthorized 줄의 remoteIp 를 보고 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두세요.`,
);
return NOT_READY;
}
throw new SetupError(`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 입니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다.`, {
hint:
`docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)을 본다. 되돌리기 — 복구 관리자를 일회성으로 주고(운영: mail/README.md "운영 배포" 1 · 2) ` +
'복구 관리자로 관리 화면 › 계정 › 관리자 › Credentials 의 Allowed IPs 를 비운 뒤, MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두고 다시 실행한다.',
});
}
/**
* 관리자 로그인이 403 일 때(ensureAdmin) 복구 관리자로 관리자 비밀번호의 허용 IP 를 지금 대역으로 고친다 — APP_NETWORK_SUBNET 을 바꾸면
* 허용 IP 가 옛 대역에 남아 관리자가 막힌다. 지금 대역 안의 항목이 하나라도 있으면(그대로이거나 운영자가 좁힌 값) 고치지 않고 false —
* 403 은 다른 원인이다(이 실행의 주소가 좁힌 값 밖 등 — 부른 쪽이 멈춘다)
*/
async function resetAdminAllowedIps(recovery, cfg) {
const accounts = await listAll(recovery, 'Account', ['emailAddress', 'credentials']);
const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
if (!admin) return false;
const key = passwordKey(admin);
const previous = admin.credentials?.[key]?.allowedIps ?? {};
if (trueKeys(previous).some((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, cfg.appNetworkSubnet))) return false;
await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } });
log(
`[변경] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(previous).join(', ') || '없음'} → ${cfg.appNetworkSubnet} ` +
'(관리자 로그인이 403 이었다 — 앱 네트워크 대역이 바뀐 것으로 보고 복구 관리자로 고쳤다)',
);
return true;
}
/**
* 관리자 단단히가 걸렸는지 — 관리자 세션의 실제 권한(GET /api/account 의 permissions — 역할 · 계정 권한을 합친 결과)에 impersonate 가 없고,
* 관리자 로그인이 이 실행(앱 네트워크 안)에서 된다. 그 밖에 [주의] 둘 (setup 은 그 비밀을 모른다 — 걸지 않는다):
* - 관리자의 다른 자격(두 번째 비밀번호 · 앱 비밀번호 · API 키)의 허용 IP 가 앱 네트워크 밖이거나 없다 — 그 자격으로 바깥에서 관리자가 된다
* - 다른 관리자 역할 계정의 비밀번호에 앱 네트워크 안의 허용 IP 가 없다
*/
async function checkAdminHardening(admin, cfg, ipState) {
let permissions = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
const res = await request(cfg, '/api/account', { headers: { authorization: admin.auth } });
let body = null;
if (res.ok) body = await res.json().catch(() => null);
else await res.body?.cancel();
permissions = Array.isArray(body?.permissions) ? body.permissions : null;
if (permissions && !permissions.includes(ADMIN_DISABLED_PERMISSION)) break;
await sleep(POLL_INTERVAL_MS / 2);
}
if (!permissions) {
problems.push(`관리자 ${cfg.adminAddress} 의 권한(GET /api/account)을 읽지 못해 대리 접근이 빠졌는지 확인하지 못했습니다.`);
} else if (permissions.includes(ADMIN_DISABLED_PERMISSION)) {
problems.push(
`관리자 ${cfg.adminAddress} 에 아직 대리 접근(${ADMIN_DISABLED_PERMISSION}) 권한이 있습니다 — 관리자 비밀번호로 <직원>%<관리자> 로그인이 된다. ` +
'관리 화면 › 역할에서 System Administrator 의 Disabled permissions 를 확인하세요.',
);
} else {
const ips = ipState?.ready ? ` · 허용 IP ${ipState.ips} 안에서 로그인됨` : '';
log(`[확인] 관리자 ${cfg.adminAddress} — 대리 접근 권한 없음 (권한 ${permissions.length}개에 ${ADMIN_DISABLED_PERMISSION} 없음)${ips}`);
}
const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles', 'credentials']);
const self = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
if (self) {
const primary = passwordKey(self);
const loose = Object.entries(self.credentials ?? {})
.filter(([key, credential]) => key !== primary && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet))
.map(([key, credential]) => `${credential['@type'] ?? '?'} ${key}`);
if (loose.length > 0) {
warn(
`관리자 ${cfg.adminAddress} 의 다른 자격(${loose.join(', ')})은 허용 IP 가 앱 네트워크(${cfg.appNetworkSubnet}) 밖이거나 없어 바깥(993 · 465)에서도 관리자로 로그인된다. ` +
`쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지우고, 쓴다면 Allowed IPs 를 ${cfg.appNetworkSubnet} 안으로 두세요 (mail/README.md "관리자 계정 단단히").`,
);
}
}
const others = accounts
.filter((account) => isAdminAccount(account) && account.emailAddress?.toLowerCase() !== cfg.adminAddress)
.filter((account) =>
Object.values(account.credentials ?? {}).some(
(credential) => credential['@type'] === 'Password' && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet),
),
)
.map((account) => account.emailAddress?.toLowerCase());
if (others.length > 0) {
warn(
`관리자 역할 계정 ${others.join(', ')} 의 비밀번호에 허용 IP 가 없어 바깥(993 · 465)에서도 로그인된다 — 대리 접근은 역할에서 빠졌지만 관리 권한은 그대로다. ` +
`관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 ${cfg.appNetworkSubnet} 로 두거나 쓰지 않는 계정이면 지우세요 (mail/README.md "관리자 계정 단단히").`,
);
}
}
// ============================================================
// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만)
// ============================================================
@@ -2879,6 +3143,54 @@ async function ensureUploadQuota(client, cfg) {
log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`);
}
/** 시 · 분 → "HH:MM" */
function formatClock(hour, minute) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/**
* 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각 (TRASH_RETENTION_MS · RETENTION_SCHEDULES_KST 머리말) — 저장만 한다(main 의 리로드로 반영).
* 정리 시각은 메일 컨테이너의 현지 시각으로 넣는다 — 컨테이너의 UTC 차이를 읽지 못하면 UTC 로 본다(compose 는 mail 에 TZ 를 주지 않는다).
* 관리 화면에서 바꾼 값은 되돌린다. 돌려주는 값: 요약 글
*/
async function ensureDataRetention(client, cfg) {
const fields = ['expungeTrashAfter', ...RETENTION_SCHEDULES_KST.map((item) => item.field)];
const current = (await call(client, 'x:DataRetention/get', { ids: ['singleton'], properties: fields })).list[0] ?? {};
const read = mailUtcOffsetMinutes(cfg);
const offset = read ?? 0;
const patch = {};
const lines = [];
const days = TRASH_RETENTION_MS / 86_400_000;
const trashSame = current.expungeTrashAfter === TRASH_RETENTION_MS;
if (!trashSame) patch.expungeTrashAfter = TRASH_RETENTION_MS;
lines.push(
`${trashSame ? '[유지]' : '[변경]'} 휴지통 · 스팸메일함 자동 비우기 — 들어온 지 ${days}일이 지나면 영구 삭제 (x:DataRetention 의 expungeTrashAfter` +
`${trashSame ? '' : ` · 전 ${current.expungeTrashAfter === null ? '꺼짐(null)' : formatDuration(current.expungeTrashAfter)}`})`,
);
for (const item of RETENTION_SCHEDULES_KST) {
const local = (((item.hour * 60 + item.minute - KST_OFFSET_MINUTES + offset) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
const desired = { '@type': 'Daily', hour: Math.floor(local / 60), minute: local % 60 };
const same = stableJson(current[item.field]) === stableJson(desired);
if (!same) patch[item.field] = desired;
const was = current[item.field]?.['@type'] === 'Daily' ? formatClock(current[item.field].hour, current[item.field].minute) : stableJson(current[item.field]);
lines.push(
`${same ? '[유지]' : '[변경]'} 엔진 정리 시각 — ${item.label} 매일 KST ${formatClock(item.hour, item.minute)} ` +
`(컨테이너 시각 ${formatClock(desired.hour, desired.minute)}${same ? '' : ` · 전 ${was}`})`,
);
}
if (Object.keys(patch).length > 0) await setObjects(client, 'DataRetention', { update: { singleton: patch } });
for (const line of lines) log(line);
if (read === null) {
log(`[참고] 메일 컨테이너의 시간대를 읽지 못해 UTC 로 보고 정리 시각을 넣었습니다 (docker 를 쓸 수 없거나 date 가 없음).`);
} else if (read !== 0) {
log(`[참고] 메일 컨테이너의 현지 시각이 UTC${read > 0 ? '+' : '-'}${formatClock(Math.floor(Math.abs(read) / 60), Math.abs(read) % 60)} 라 그 시각으로 바꿔 넣었습니다.`);
}
if (Object.keys(patch).some((field) => field !== 'expungeTrashAfter')) {
log('[참고] 바뀐 정리 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 mail 컨테이너가 다시 뜬 뒤 — 밤 백업이 다시 띄운다)부터 걸린다.');
}
return `들어온 지 ${days}일 · 정리 KST ${RETENTION_SCHEDULES_KST.map((item) => formatClock(item.hour, item.minute)).join(' · ')}`;
}
/** 엔진 DNS(x:DnsResolver)를 사람이 읽는 글로 — @type 과 Custom 의 서버 */
function describeEngineDns(value) {
const servers = Object.values(value?.servers ?? {}).map((server) => `${server.address}:${server.port}/${server.protocol}`);
@@ -4939,6 +5251,9 @@ async function main() {
// 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API
await ensureUserRoleWithoutPrincipals(admin);
// 관리자 단단히 (13단계) — 관리자 역할의 대리 접근(impersonate) 빼기 · 관리자 비밀번호의 허용 IP = 앱 네트워크 (관리자로 된다)
await ensureAdminRoleWithoutImpersonate(admin);
const adminIps = await ensureAdminAllowedIps(admin, cfg);
await ensureHttpEndpointPolicy(admin);
// 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의])
const directory = await ensureDirectoryAccount(admin, cfg, domain);
@@ -4959,6 +5274,8 @@ async function main() {
// 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다)
await ensurePilotInboundTrust(admin, pilot);
await ensureUploadQuota(admin, cfg);
// 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영)
const retention = await ensureDataRetention(admin, cfg);
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
const engineDnsChanged = await ensureEngineDns(admin, cfg);
const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus);
@@ -4983,6 +5300,7 @@ async function main() {
if (pilot) await checkPilotSenderGuard(cfg);
checkEngineDnssec(cfg, engineDnsChanged);
await checkEngineWebhook(admin, cfg, webhook);
await checkAdminHardening(admin, cfg, adminIps);
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
const devUsers = [];
@@ -5020,7 +5338,9 @@ async function main() {
const adminScreen = cfg.isProduction
? '공개 443 에서는 막혀 있다 — SSH 터널로 내부 8080 (mail/README.md "운영 배포")'
: `${cfg.publicUrl ?? cfg.adminUrl}/admin/`;
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`);
const adminIpsSummary = adminIps.ready ? `허용 IP ${adminIps.ips}` : '허용 IP 걸지 않음(위 [주의] · [실패])';
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 대리 접근 없음 · ${adminIpsSummary} — 관리 화면 ${adminScreen}`);
log(` 휴지통 ${retention} (휴지통 · 스팸메일함 — 엔진이 지운다)`);
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
log(