/** * 전체 설정 화면(1.0.7, 요구사항 2)·초기 설정 페이지(1.0.0, 요구사항 4) — 운영 설정(config.json)과 도메인 등록부(domains.json)를 웹에서 다루는 화면과 API. * * 두 곳에서 같은 화면·API 를 씁니다. * 1) 전용 포트(SETUP_PORT, 기본 8791): 배포 스크립트가 출력하는 주소 http(s)://<기본 도메인 호스트>:8791/?token=<토큰> 으로 엽니다. 이것이 "전체 설정" 입니다. * 로그인 계정이 아직 없거나 nginx·인증서가 준비되기 전에도 쓸 수 있어야 하므로, 데이터 디렉터리의 setup-token 파일(배포 계정만 읽음)로 인증합니다. * 메일 포트 인증서(POP_TLS_CERT)를 읽을 수 있으면 같은 인증서로 HTTPS 를, 없으면 HTTP 를 씁니다(주소는 로그·setup-url.txt 에 남김). * 1.0.7: 도메인 목록·추가·중지·삭제(/api/setup/domains)와, ?domain=<도메인> 으로 고른 도메인의 설정(아래 config·plan…)을 모두 여기서 합니다. * 요구사항대로 기본 도메인의 주소로 열어야 하며, 추가 도메인의 호스트 이름으로 이 포트에 오면 안내 화면만 줍니다. * 2) 운영 중 수정: 관리자 페이지(/admin)의 "메일 서비스 설정" → /setup. 관리자 세션으로 /api/setup/* 를 부릅니다(api.mjs 가 이 모듈의 setupApi 로 넘김). * 이쪽은 그 도메인(Host 로 고른 테넌트)의 설정만 다루며 도메인 관리는 할 수 없습니다. * * API(/api/setup/…) — 전용 포트에서는 ?domain=<도메인> 으로 대상 도메인을 고릅니다(없으면 기본 도메인) * GET config 현재 설정(비밀값은 가림)·출처·스키마 + 메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 서비스 상태) * PUT config { values: { 키: 값 } } 저장 → 즉시 적용(재시작 없음) → GET 과 같은 응답 * POST check { what: 'outbound' } 발신 서비스 연결 확인(캐시 무시) * POST dkim { force?: true } DKIM 키 생성(이미 있으면 force 일 때만 교체) → DNS 레코드 * GET plan / POST plan/run / POST dns/probe 자동 설정(1.0.5, provision.mjs) * POST restart systemd 아래에서 돌고 있을 때만: 응답 뒤 프로세스를 끝내 systemd(Restart=always)가 다시 띄움 * GET/POST domains, PATCH/DELETE domains/<도메인> 도메인 등록부(1.0.7, 전용 포트 전용) — domains.mjs */ import { createServer as createHttpServer } from 'node:http'; import { createServer as createHttpsServer } from 'node:https'; import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { networkInterfaces } from 'node:os'; import { json, bad, safeEqual, escapeHtml, secret } from './util.mjs'; import { parseAllowList, invalidAllowEntries } from './usage.mjs'; import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs'; import { buildPlan, runPlan } from './provision.mjs'; import { probeDns } from './dns/index.mjs'; import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs'; import { noServicePage, normalizeHost } from './domains.mjs'; /** systemd 서비스 안에서 돌고 있는지(부모가 PID 1 이고 INVOCATION_ID 가 있음). 재시작 단추를 보일지 정합니다. */ export const underSystemd = () => process.ppid === 1 && !!process.env.INVOCATION_ID; // harakaDir / harakaStatus 는 1.0.5 에서 config.mjs 로 옮겼습니다(provision.mjs 와 공유). 예전 import 경로를 위해 다시 내보냅니다. export { harakaDir, harakaStatus }; /** 이 서버의 공개 IPv4 후보(초기 설정 주소 안내용). 루프백·내부 주소는 뺍니다. */ export function serverAddresses() { const out = []; for (const list of Object.values(networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address); return out; } /** 설정 화면이 쓰는 메타 정보. 비용이 있는 발신 상태 확인은 60초 캐시(outboundStatus)를 씁니다. */ async function meta(env) { const host = env.PUBLIC_HOST || 'mail.arihakdang.com'; return { // loginMode(1.0.8): standalone | www | webmaker. publicPath: 이 도메인 첫 호스트의 경로 접두사('' 또는 '/mail'). host, mailbox: String(env.MAILBOX || ''), domain: mailDomain(env), harakaHost: harakaHostname(env), version: env.APP_VERSION || null, loginMode: env.LOGIN_MODE === 'webmaker' && env.WEBMAKER_DIR ? 'webmaker' : env.WWW_DB_FILE ? 'www' : 'standalone', publicPath: env.PUBLIC_PATH || '', webhookUrl: `https://${host}${env.PUBLIC_PATH || ''}/api/webhooks/resend`, configFile: configFile(env), setupPort: Number(env.SETUP_PORT ?? 8791), gatewayPort: Number(env.PORT || 8790), inbound: inboundMode(env), outbound: outboundMode(env), usesResend: usesResend(env), usesHaraka: usesHaraka(env), haraka: harakaStatus(env), dns: dnsRecords(env), outboundStatus: await outboundStatus(env).catch((e) => ({ provider: e.message })), systemd: underSystemd(), tlsActive: !!env.POP_TLS_ACTIVE, // 1.0.7: 이 설정이 기본 도메인의 것인지(Haraka 포트 같은 서버 공통 값은 기본 도메인만 바꿀 수 있음), 메일 프로그램이 접속할 서버 이름(기본 도메인의 호스트). primary: !env.TENANTS || env.TENANTS.primary.env === env, serverHost: env.MAIL_SERVER_HOST || host, domains: env.TENANTS ? env.TENANTS.all().length : 1, // 사용량 API(1.0.9): 키는 기본 도메인 설정에만 있으므로 기본 도메인 env 로 상태를 봅니다. 키 자체는 주지 않고 있음/없음과 가린 값, 허용 IP, 주소만. usageApi: (() => { const p = env.TENANTS ? env.TENANTS.primary.env : env; const key = String(p.USAGE_API_KEY || ''); return { enabled: !!key, keyMasked: key ? maskSecret(key) : '', allow: parseAllowList(p.USAGE_API_ALLOW), endpoint: `https://${normalizeHost(p.PUBLIC_HOST || host)}${p.PUBLIC_PATH || ''}/api/usage`, updatedAt: p.CONFIG?._meta?.usageKeyAt || null }; })(), }; } /** * /api/setup/* 처리(인증은 호출자가 끝낸 뒤 부릅니다: 전용 서버는 토큰, api.mjs 는 관리자 세션). * @param {string[]} path 'setup' 뒤의 경로 조각 */ export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) { const method = request.method, action = path[0] || ''; const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) }); // 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다. if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); } if (action === 'config' && method === 'GET') return respond(); if (action === 'config' && method === 'PUT') { const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request'); // 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지). if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); } try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; } console.log(`설정 저장(${user ? user.login : 'setup-token'} ${ip}): ${Object.keys(body.values).join(', ')} → 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`); return respond(); } if (action === 'check' && method === 'POST') { const body = await request.json().catch(() => ({})); if (body.what === 'outbound') return json({ ok: true, outboundStatus: await outboundStatus(env, { force: true }) }); if (body.what === 'haraka') return json({ ok: true, haraka: harakaStatus(env) }); return bad('bad_request'); } // 자동 설정(1.0.5, 요구사항 2·4·5): 계획(단계 목록·상태) 조회와 실행. 실행은 전부(자동 가능한 단계) 또는 한 단계(step)이며, 사용자 입력(input)은 먼저 설정에 저장합니다. if (action === 'plan' && !path[1] && method === 'GET') return json({ ok: true, plan: await buildPlan(env) }); if (action === 'plan' && path[1] === 'run' && method === 'POST') { const body = await request.json().catch(() => ({})); try { const result = await runPlan(env, { step: body.step || null, stepAction: body.action || null, input: body.input && typeof body.input === 'object' ? body.input : {} }); console.log(`자동 설정 실행(${user ? user.login : 'setup-token'} ${ip}): ${body.step || '전체'}${body.action ? '/' + body.action : ''} → ${result.log.map((l) => l.id + ':' + l.result).join(', ') || '변화 없음'}`); return json({ ok: true, ...result }); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; } } if (action === 'dns' && path[1] === 'probe' && method === 'POST') return json({ ok: true, probe: await probeDns(env) }); // 사용량 API 키(1.0.9): { action:'generate' } 새 키를 만들어(있으면 교체) 한 번만 돌려줍니다 · { action:'revoke' } 키를 지워 API 를 끕니다. 기본 도메인에서만(키는 서버 하나에 하나). if (action === 'usage-key' && method === 'POST') { const primary = env.TENANTS ? env.TENANTS.primary.env : env; if (primary !== env) return bad('primary_only', 409); const body = await request.json().catch(() => ({})); if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); } if (body.action !== 'generate') return bad('bad_request'); const key = 'au_' + secret(32); // _meta.usageKeyAt: 언제 만든 키인지(화면 안내). saveConfig 의 _meta 갱신 뒤에 덧붙입니다. saveConfig(env, { USAGE_API_KEY: key }, { allowHidden: true }); env.CONFIG._meta = { ...(env.CONFIG._meta || {}), usageKeyAt: new Date().toISOString() }; saveConfig(env, {}); console.log(`사용량 API 키 생성(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: true, key, endpoint: `https://${normalizeHost(env.PUBLIC_HOST || 'mail.arihakdang.com')}${env.PUBLIC_PATH || ''}/api/usage` }); } if (action === 'dkim' && method === 'POST') { const body = await request.json().catch(() => ({})); return json({ ok: true, dns: generateDkim(env, { force: !!body.force }) }); } if (action === 'restart' && method === 'POST') { // systemd 서비스로 돌 때만(부모가 PID 1). INVOCATION_ID 는 사용자 셸에도 있을 수 있어 쓰지 않습니다. 개발 실행·시험에서는 거절합니다. if (!underSystemd()) return bad('restart_unavailable', 409); console.log(`재시작 요청(${user ? user.login : 'setup-token'} ${ip}) — systemd 가 다시 띄웁니다`); setTimeout(() => process.exit(0), 500).unref(); return json({ ok: true, restarting: true }); } return bad('not_found', 404); } /** * 도메인 등록부 API(1.0.7, 요구사항 2). 전용 포트(토큰)에서만 부릅니다. * GET domains 목록(기본 도메인 먼저; 계정·메일 수, 수·발신 방식, 로그인 방식, 사용 여부) * POST domains { domain, hosts?, mailbox?, auth?:{mode:'local'|'www', wwwDbFile?, ownerLogin?}, adminPassword?, copyFromPrimary?, note? } → 추가(즉시 동작) * PATCH domains/<도메인> { enabled?, hosts?, auth?, note? } → 변경(기본 도메인은 hosts·note 만) * DELETE domains/<도메인> 등록 해제(데이터 디렉터리는 남김). 기본 도메인은 불가 * 오류: validation(422, field) · domain_exists(409) · host_taken(409, detail) · www_db_unreadable(422) · primary_domain(409) · not_found(404) */ async function domainsApi(tenants, request, path, ip) { const method = request.method, target = path[0] ? decodeURIComponent(path[0]) : ''; const list = async () => ({ domains: await tenants.list(), primary: tenants.registry.primary, serverHost: tenants.primary.env.MAIL_SERVER_HOST || '' }); try { if (method === 'GET' && !target) return json({ ok: true, ...(await list()) }); if (method === 'POST' && !target) { const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); const r = await tenants.add(body); console.log(`도메인 추가(setup-token ${ip}): ${r.entry.domain}`); return json({ ok: true, domain: r.entry, initialAdmin: r.initialAdmin, ...(await list()) }, 201); } if (method === 'PATCH' && target) { const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); const entry = tenants.update(target, body); console.log(`도메인 변경(setup-token ${ip}): ${entry.domain} ${JSON.stringify(Object.keys(body))}`); return json({ ok: true, domain: entry, ...(await list()) }); } if (method === 'DELETE' && target) { const r = tenants.remove(target); console.log(`도메인 삭제(setup-token ${ip}): ${r.domain}`); return json({ ok: true, removed: r, ...(await list()) }); } return bad('not_found', 404); } catch (e) { const status = e.message === 'validation' || e.message === 'www_db_unreadable' || e.message === 'webmaker_dir_unreadable' ? 422 : ['domain_exists', 'host_taken', 'primary_domain'].includes(e.message) ? 409 : e.message === 'not_found' ? 404 : 500; if (status === 500) console.error('도메인 등록부 오류', e); return bad(status === 500 ? 'server_error' : e.message, status, { ...(e.field ? { field: e.field } : {}), ...(e.detail ? { detail: e.detail } : {}) }); } } /** * 초기 설정 전용 HTTP(S) 서버. 화면 파일(web/setup.html·setup.js·setup.css)과 토큰 인증 API 만 제공합니다. * @param {object} o * @param {string} o.webDir web/ 디렉터리 * @param {object|null} [o.tlsOptions] { key, cert } 가 있으면 HTTPS * @param {object|null} [o.tenants] 도메인 관리자(domains.mjs openTenants). 있으면 전체 설정(도메인 목록·?domain= 선택)으로 동작합니다(1.0.7). */ export function createSetupServer(env, { webDir, tlsOptions = null, tenants = null }) { const SECURITY = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store' }; const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; const STATIC = { '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml' }; let server = null, bound = null; const secure = !!tlsOptions; const handler = async (req, res) => { const url = new URL(req.url, 'http://x'); const ip = req.socket.remoteAddress || ''; try { // 1.0.7: 전체 설정은 기본 도메인의 주소로 엽니다. 추가 도메인의 호스트 이름으로 오면(인증서도 맞지 않음) 안내만 줍니다. IP·localhost 는 기본 도메인으로 봅니다. if (tenants) { const r = tenants.resolveHost(String(req.headers.host || '')); if (r.tenant && r.tenant !== tenants.primary) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY }); return res.end(noServicePage({ host: r.host, strings: { domainMissingTitle: '전체 설정은 기본 도메인 주소로 엽니다', domainMissing: `이 주소(${r.host})로는 전체 설정 화면을 열 수 없습니다.`, domainAskAdmin: `기본 도메인의 주소 https://${normalizeHost(env.PUBLIC_HOST || '')}:${bound?.port || Number(env.SETUP_PORT ?? 8791)}/ 로 여세요(배포 출력에 토큰 포함 주소가 있습니다).` }, version: env.APP_VERSION })); } } if (url.pathname === '/' || url.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(setupPage(env, webDir, 'token')); } if (url.pathname === '/favicon.ico') { res.writeHead(301, { Location: '/favicon.svg' }); return res.end(); } if (STATIC[url.pathname]) { res.writeHead(200, { 'Content-Type': STATIC[url.pathname], ...SECURITY }); return res.end(readFileSync(join(webDir, url.pathname))); } if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, version: env.APP_VERSION || null, setup: true })); } if (!url.pathname.startsWith('/api/setup/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); } // 토큰 인증: 헤더(X-Setup-Token) 또는 주소(?token=). 틀리면 IP 당 15분 10회만 허용합니다(토큰 추측 방지). const rateKey = 'setup:' + ip; if (env.RATE?.blocked(rateKey, { limit: 10 })) { res.writeHead(429, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'rate_limited' })); } const token = req.headers['x-setup-token'] || url.searchParams.get('token') || ''; if (!safeEqual(String(token), setupToken(env))) { env.RATE?.hit(rateKey, { limit: 10, windowSec: 900 }); res.writeHead(401, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'setup_token_invalid' })); } const chunks = []; let size = 0; if (!['GET', 'HEAD'].includes(req.method)) for await (const c of req) { size += c.length; if (size > 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); } const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers.set(k, v); const request = new Request(`${secure ? 'https' : 'http'}://${req.headers.host || 'localhost'}${req.url}`, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined }); // 대상 도메인(1.0.7): ?domain=<도메인> 이 있으면 그 테넌트의 env, 없으면 기본 도메인. 모르는 도메인은 404. let target = env; const want = url.searchParams.get('domain'); if (want && tenants) { const t = tenants.byDomain(want); if (!t) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'domain_not_found' })); } target = t.env; } const response = await setupApi(target, request, url.pathname.slice('/api/setup/'.length).split('/').filter(Boolean), { ip, tenants }); const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); const buf = Buffer.from(await response.arrayBuffer()); res.writeHead(response.status, out); res.end(buf); } catch (e) { console.error('초기 설정 페이지 오류', req.method, url.pathname, e.message); if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json', ...SECURITY }); res.end(JSON.stringify({ ok: false, error: 'server_error' })); } }; return { /** 포트를 엽니다. 실패(이미 쓰는 포트 등)는 예외로 알립니다. */ listen({ port, host = '0.0.0.0' }) { return new Promise((resolve, reject) => { server = secure ? createHttpsServer({ ...tlsOptions, minVersion: 'TLSv1.2' }, handler) : createHttpServer(handler); server.once('error', reject); server.listen(port, host, () => { bound = { port: server.address().port, host, secure }; resolve(bound); }); }); }, /** 접속 주소. HTTPS 면 인증서와 맞는 공개 호스트 이름을, HTTP 면 서버 IP 를 씁니다(DNS 가 아직 안 바뀐 새 서버에서도 열 수 있게). */ urls(token) { if (!bound) return []; const scheme = bound.secure ? 'https' : 'http', host = env.PUBLIC_HOST || 'localhost'; const hosts = bound.secure ? [host, ...serverAddresses()] : [...serverAddresses(), host]; return [...new Set(hosts)].map((h) => `${scheme}://${h}:${bound.port}/?token=${encodeURIComponent(token)}`); }, /** 인증서 갱신(index.mjs 의 1시간 점검)을 반영합니다. HTTP 로 시작했으면 바꾸지 않습니다(주소 체계가 달라지므로 다음 재시작부터 HTTPS). */ setTls(opts) { if (server && bound?.secure && opts) server.setSecureContext(opts); }, close() { if (server) server.close(); }, get bound() { return bound; }, }; } /** * 설정 화면 HTML. web/setup.html 의 자리표시자를 채웁니다: __MODE__(token | admin — 인증 방식), __VERSION__, __HOST__, __TITLE__, __BASE__(1.0.8: 기준 경로, 보통 "/"). * index.mjs 가 /setup 에서(경로 접두사 아래면 base 가 "/mail/"), 전용 서버가 / 에서 같은 함수를 씁니다. */ export function setupPage(env, webDir, mode, { base = '/' } = {}) { const title = (env.STRINGS?.appName || 'AriMail') + ' 설정'; return readFileSync(join(webDir, 'setup.html'), 'utf8').replace(/__MODE__/g, mode).replace(/__VERSION__/g, escapeHtml(env.APP_VERSION || '')).replace(/__HOST__/g, escapeHtml(env.PUBLIC_HOST || '')).replace(/__TITLE__/g, escapeHtml(title)).replace(/__BASE__/g, escapeHtml(base)); } /** 배포 스크립트가 읽어 출력할 접속 주소 파일(데이터 디렉터리의 setup-url.txt). 쓸 수 없어도 서버는 계속 뜹니다. */ export function writeSetupUrls(env, urls) { try { writeFileSync(join(configDir(env), 'setup-url.txt'), urls.join('\n') + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-url.txt 를 쓸 수 없습니다:', e.message); } }