generated from ttipo/project-base
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:
+322
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user