2026-10-26 11:02:58 +00:00
/**
* 전체 설정 화면(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' ;
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
import { lockedKeys , managedKeys , publicManaged } from './hal-managed.mjs' ; // 1.1.0 (HAL 0.4.0 대응) HAL 통합 설정 관제
2026-10-26 11:02:58 +00:00
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 ] || '' ;
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
const respond = async () => json ({ ok : true , ... publicConfig ( env ), halManaged : publicManaged ( env ), meta : await meta ( env ) });
2026-10-26 11:02:58 +00:00
// 도메인 등록부(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' );
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
// 1.1.0: HAL 이 관리하는 서버 설정은 여기서 바꿀 수 없습니다(HAL 운영자 콘솔 › 통합 설정). 추가 도메인의 설정은 잠그지 않습니다.
{ const locked = lockedKeys ( env , body . values ); if ( locked . length ) return bad ( 'managed_by_hal' , 409 , { keys : locked , message : `HAL 이 관리하는 항목입니다: ${ locked . join ( ', ' ) } — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요` }); }
2026-10-26 11:02:58 +00:00
// 사용량 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 );
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
if ( managedKeys ( env ). includes ( 'USAGE_API_KEY' )) return bad ( 'managed_by_hal' , 409 , { keys : [ 'USAGE_API_KEY' ], message : '사용량 API 키는 HAL 이 관리합니다 — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요' }); // 1.1.0
2026-10-26 11:02:58 +00:00
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 ); }
}