Files

351 lines
30 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ============================================================================
// 메일 서버(Arimail) 등록부 — v1.6.38 (요구 2)
//
// 1.6.35 까지는 Arimail 을 "한 대" 만, 그것도 설정 파일(lib/config.js mail.url / mail.apiUrl / mail.domain / mail.adminLogin,
// 비밀번호는 보관소 'arimail')로만 연결할 수 있었다. 웹메이커(예: webmaker.ibit.kr = 121.152.149.45)와 Arimail
// (예: webmail.ibit.kr = 115.68.194.192)을 다른 서버에 나눠 설치하고, 메일 도메인마다 다른 Arimail 을 붙이려면
// 여러 대를 등록하고 사이트마다 고를 수 있어야 한다. 이 모듈이 그 등록부다.
//
// 구성
// - 기본 서버(id 'primary') : 설정 파일 mail.* 에 적힌 서버. deploy/install-arimail.sh 가 등록하는 곳이라 그대로 둔다.
// 닉네임·표시용 IP 는 mail.nickname / mail.ip (1.6.38 추가 항목).
// - 추가 서버 : data/mail-servers.json 의 items (비밀 아닌 값만). 관리자 비밀번호는 보관소(lib/vault.js)
// 종류 'arimail', 식별자 'arimail-<id>' 로 암호화 보관한다 — 비밀값은 이 파일에 쓰지 않는다.
// - defaultId : 사이트가 서버를 따로 고르지 않고 메일 도메인이 맞는 서버도 없을 때 쓰는 서버.
//
// 서버 고르기(pick): 사이트 메일 설정(site.webmail.mailServer)에 고른 서버 > 사이트 메일 도메인과 같은 도메인을 다루는 서버 > 기본 서버.
// Arimail 한 설치는 한 도메인만 다루므로(환경 MAILBOX=admin@도메인) "도메인이 같은 서버" 가 자연스러운 기본 선택이다.
//
// 비밀값(관리자 비밀번호)은 pub() 에서 빼고 hasPassword 만 내보낸다 — 화면·작업 기록·오류 문구에 비밀번호가 나가지 않게.
// ============================================================================
const fs = require('fs');
const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'mail-servers.json');
const PRIMARY = 'primary'; // 설정 파일(mail.*)의 서버를 가리키는 고정 id
const ID_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
const IP_RE = /^(\d{1,3})(\.\d{1,3}){3}$|^[0-9a-f]*:[0-9a-f:.]+$/i; // IPv4 또는 IPv6 (표시·구분용이라 느슨하게)
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
// ---------- 저장소 ----------
// v1.6.39 (요구 2): lib/jsonstore 로 읽고 쓴다 — 직전 정상본을 .bak 으로 남기고, 깨졌으면 .bak 을 읽는다
const jsx = require('./jsonstore');
function load() { const d = jsx.readJson(FILE, null); if (!d || typeof d !== 'object') return { version: 1, items: {}, defaultId: '' }; d.items = d.items || {}; d.defaultId = d.defaultId || ''; return d; }
function save(d) { jsx.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true, indent: 1 }); }
/** 추가 서버의 비밀번호가 들어 있는 보관소 항목 id */
const vaultId = (id) => (id === PRIMARY ? 'arimail' : `arimail-${id}`);
/**
* v1.6.39 (요구 2): "이전 비밀번호" 보관소 항목 id. 비밀번호를 새 값으로 덮어쓸 때 직전 값을 여기 옮겨 두어 "이전 비밀번호로 되돌리기" 가 되게 한다.
* 등록부 서버 id 는 vaultId 로 arimail-<id> 가 되므로 prev-arimail-… 는 어떤 서버 id 와도 겹치지 않는다.
*/
const prevVaultId = (id) => (id === PRIMARY ? 'prev-arimail' : `prev-arimail-${id}`);
/**
* v1.7.3 (요구 2): 사용량 API 키(Arimail 1.0.9 `au_…`)의 보관소 항목 id. 관리자 비밀번호와 다른 항목에 두어 비밀번호 되돌리기·초기화와 섞이지 않게 한다.
* 기본 서버는 'arimail-usage', 추가 서버는 'arimail-usage-<id>'. 키 원문은 등록부 파일(mail-servers.json)에 쓰지 않는다.
*/
const usageVaultId = (id) => (id === PRIMARY ? 'arimail-usage' : `arimail-usage-${id}`);
/** 보관소에서 사용량 API 키 원문 (없으면 '') — 서버 내부 전용 */
function usageKeyOf(id) { try { return require('./vault').secretOf(usageVaultId(id)); } catch (_) { return ''; } }
/** 보관소 항목의 힌트(앞뒤 몇 글자) — 화면 표시용 */
function usageKeyHint(id) { try { const v = require('./vault').get(usageVaultId(id)); return v && v.hasSecret ? v.secretHint : ''; } catch (_) { return ''; } }
/** Arimail 로그인 잠금(15분 10회 제한) 길이 — Arimail 의 제한 창과 같게 */
const LOCK_MS = 15 * 60 * 1000;
/** 주소에서 호스트 이름만 */
function hostOf(url) { try { return new URL(url).hostname; } catch (_) { return ''; } }
/** 설정 파일(mail.*)의 기본 서버. 주소도 도메인도 없으면 null(등록 안 됨) */
function primary() {
const config = require('./config');
const url = String(config.get('mail.url') || '').replace(/\/+$/, '');
const domain = String(config.get('mail.domain') || '').trim().toLowerCase();
if (!url && !domain) return null;
const host = hostOf(url); const d = load();
return {
id: PRIMARY, source: 'config',
nickname: String(config.get('mail.nickname') || '').trim() || (host ? `${host} (설정 파일)` : '기본 메일 서버'),
ip: String(config.get('mail.ip') || '').trim(),
url, apiUrl: String(config.get('mail.apiUrl') || '').replace(/\/+$/, '') || url, host, domain,
adminLogin: String(config.get('mail.adminLogin') || 'admin').trim(), adminPassword: config.get('mail.adminPassword') || '',
lastTest: d.primaryTest || null,
lockedUntil: d.primaryLockedUntil || null, prevPasswordAt: d.primaryPrevAt || null, // v1.6.39: 로그인 잠금 해제 시각 · 이전 비밀번호를 보관한 시각
usageKey: usageKeyOf(PRIMARY), usageKeyAt: d.primaryUsageKeyAt || null, // v1.7.3: 사용량 API 키(보관소) · 키를 저장한 시각
};
}
/** 등록부 항목 → 서버 객체(비밀번호 포함, 서버 내부 전용) */
function fromItem(x) {
let pw = ''; try { pw = require('./vault').secretOf(vaultId(x.id)); } catch (_) {}
const url = String(x.url || '').replace(/\/+$/, '');
return { id: x.id, source: 'registry', nickname: x.nickname || x.id, ip: x.ip || '', url, apiUrl: String(x.apiUrl || '').replace(/\/+$/, '') || url, host: hostOf(url), domain: String(x.domain || '').toLowerCase(), adminLogin: x.adminLogin || 'admin', adminPassword: pw, createdAt: x.createdAt, updatedAt: x.updatedAt, lastTest: x.lastTest || null, lockedUntil: x.lockedUntil || null, prevPasswordAt: x.prevPasswordAt || null, usageKey: usageKeyOf(x.id), usageKeyAt: x.usageKeyAt || null /* v1.7.3 */ };
}
/** v1.6.39: 이전 비밀번호가 보관되어 있는지 (원문은 돌려주지 않는다) */
function hasPrev(id) { try { return !!require('./vault').secretOf(prevVaultId(id)); } catch (_) { return false; } }
/** v1.6.39: 지금 잠금 중이면 해제 시각(ISO), 아니면 null — 지난 잠금은 무시 */
function lockActive(s) { return s && s.lockedUntil && Date.parse(s.lockedUntil) > Date.now() ? s.lockedUntil : null; }
/**
* 모든 서버(비밀번호 포함). isDefault: defaultId 로 지정한 서버, 없으면 첫 서버(기본 서버가 있으면 그것).
* @returns {Array<object>}
*/
function all() {
const d = load(); const list = [];
const p = primary(); if (p) list.push(p);
for (const x of Object.values(d.items)) list.push(fromItem(x));
const defId = list.some((s) => s.id === d.defaultId) ? d.defaultId : (list[0] ? list[0].id : '');
for (const s of list) s.isDefault = s.id === defId;
return list;
}
/** 화면용(비밀번호 제외) */
function pub(s) {
if (!s) return null; const { adminPassword, usageKey, ...rest } = s;
// v1.6.39: hasPrevPassword(되돌리기 가능) · lockedUntil(연결 확인 버튼을 막을 해제 시각) · adminUrl(보기/숨기기로 본 비밀번호로 들어갈 Arimail 관리자 페이지)
// v1.7.3: hasUsageKey·usageKeyHint(사용량 API 키 유무·앞뒤 글자) · usageEndpoint(부르는 주소) — 키 원문은 내려보내지 않는다
return { ...rest, hasPassword: !!adminPassword, adminSync: adminSyncView(s.id) /* v1.7.3 추가: 관리자 비밀번호 동기화 기록 */, hasUsageKey: !!usageKey, usageKeyHint: usageKey ? usageKeyHint(s.id) : '', usageEndpoint: s.apiUrl ? `${s.apiUrl}/api/usage` : null, hasPrevPassword: hasPrev(s.id), lockedUntil: lockActive(s), adminUrl: s.url ? `${s.url}/admin` : null, configured: !!(s.url && s.domain && s.adminLogin && adminPassword), label: `${s.nickname}${s.ip ? ` — ${s.ip}` : ''}${s.domain ? ` (${s.domain})` : ''}` };
}
function list() { return all().map(pub); }
function get(id) { return all().find((s) => s.id === String(id || '')) || null; }
function defaultServer() { return all().find((s) => s.isDefault) || null; }
/** 메일 도메인을 다루는 서버 (여러 대면 기본 서버 우선) */
function forDomain(domain) { const d = String(domain || '').toLowerCase().replace(/^www\./, ''); if (!d) return null; const hits = all().filter((s) => s.domain === d); return hits.find((s) => s.isDefault) || hits[0] || null; }
/**
* 사이트에 쓸 메일 서버. 사이트가 고른 서버(site.webmail.mailServer) > 메일 도메인이 같은 서버 > 기본 서버.
* @param {object} [site] 없으면 기본 서버
*/
function pick(site) {
const w = (site && site.webmail) || {};
if (w.mailServer) { const s = get(w.mailServer); if (s) return s; }
if (w.domain) { const s = forDomain(w.domain); if (s) return s; }
return defaultServer();
}
// ---------- 추가·수정·삭제 ----------
/**
* 서버 등록·수정.
* @param {{id?, nickname, ip?, url, apiUrl?, domain, adminLogin?, password?}} input
* id 비우면 닉네임에서 만든다(영문 소문자·숫자·-). 'primary' 면 설정 파일(mail.*)에 쓴다.
* password 비우면 기존 비밀번호 유지(새 서버는 필수 — 없으면 계정 관리가 안 되므로 등록은 되되 "보기 전용" 안내)
* @param {string} by 작업자(설정 파일 updatedBy 와 보관소 source 에 남김)
*/
function upsert(input, by = 'admin') {
if (!input || typeof input !== 'object') throw httpError('저장할 값이 없습니다');
const nickname = String(input.nickname || '').trim().slice(0, 40);
const url = String(input.url || '').trim().replace(/\/+$/, '');
const apiUrl = String(input.apiUrl || '').trim().replace(/\/+$/, '');
const ip = String(input.ip || '').trim();
const domain = String(input.domain || '').trim().toLowerCase().replace(/\.$/, '');
const adminLogin = String(input.adminLogin || '').trim() || 'admin';
const password = typeof input.password === 'string' ? input.password : '';
if (!url) throw httpError('Arimail 주소를 입력하세요 (예: https://webmail.example.com) — 비워 두면 웹메일 주소를 안내할 수 없고 계정 관리 API 도 부를 수 없습니다');
if (!/^https?:\/\/[^\s/]+/i.test(url)) throw httpError('Arimail 주소는 http:// 또는 https:// 로 시작해야 합니다');
if (apiUrl && !/^https?:\/\/[^\s/]+/i.test(apiUrl)) throw httpError('API 주소는 http:// 또는 https:// 로 시작해야 합니다 (같은 서버면 http://127.0.0.1:8790 처럼 로컬 주소)');
if (!domain) throw httpError('메일 도메인을 입력하세요 (예: example.com) — 이 서버의 계정은 아이디@메일도메인 이 되고, 같은 메일 도메인의 사이트에 자동으로 연결됩니다');
if (!DOMAIN_RE.test(domain)) throw httpError('메일 도메인 형식이 올바르지 않습니다 (예: example.com)');
if (ip && !IP_RE.test(ip)) throw httpError('서버 IP 형식이 올바르지 않습니다 (예: 115.68.194.192)');
let id = String(input.id || '').trim().toLowerCase();
if (id === PRIMARY) {
// 기본 서버는 설정 파일에 쓴다 (install-arimail.sh 와 같은 자리). provider 가 none 이면 함께 켠다
const config = require('./config');
const patch = { 'mail.provider': 'arimail', 'mail.url': url, 'mail.apiUrl': apiUrl, 'mail.domain': domain, 'mail.adminLogin': adminLogin, 'mail.nickname': nickname, 'mail.ip': ip };
if (password) { keepPrevious(PRIMARY, password, by); patch['mail.adminPassword'] = password; }
config.set(patch, by);
if (typeof input.usageKey === 'string' && input.usageKey.trim()) writeUsageKey(PRIMARY, input.usageKey, by); else if (input.clearUsageKey === true || input.clearUsageKey === 'true') clearUsageKey(PRIMARY); // v1.7.3
return pub(get(PRIMARY));
}
if (!id) id = (nickname || hostOf(url) || domain).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || `srv${Date.now().toString(36)}`;
if (id.length < 2) id = `${id}-srv`;
if (!ID_RE.test(id)) throw httpError('서버 id 는 영문 소문자·숫자·- 2~41자로 입력하세요');
const d = load(); const prev = d.items[id] || null;
if (!nickname) throw httpError('닉네임을 입력하세요 — 메일 서버를 고를 때 IP 와 함께 이 이름으로 구분합니다');
const dup = all().find((s) => s.id !== id && s.url === url && s.domain === domain); if (dup) throw httpError(`같은 주소·도메인의 서버가 이미 등록되어 있습니다: ${dup.nickname}`, 409);
if (!prev && !password) throw httpError('관리자 비밀번호를 입력하세요 — 없으면 Arimail 에 로그인해 메일 계정을 만들고 지울 수 없습니다 (Arimail 관리자 페이지 /admin 의 관리자 역할 계정)');
const now = new Date().toISOString();
d.items[id] = { ...(prev || {}), id, nickname, ip, url, apiUrl, domain, adminLogin, createdAt: prev ? prev.createdAt : now, updatedAt: now, lastTest: prev && !password ? prev.lastTest : null };
if (password) { const pa = keepPrevious(id, password, by); if (pa) d.items[id].prevPasswordAt = pa; require('./vault').set({ kind: 'arimail', id: vaultId(id), label: `Arimail 관리자 비밀번호 — ${nickname}`, url, settings: { ADMIN_LOGIN: adminLogin, MAIL_DOMAIN: domain }, secret: password }, `mailserver:${by}`); }
if (!d.defaultId && !primary()) d.defaultId = id; // 첫 서버는 기본 서버로
save(d);
// v1.7.3 (요구 2): 사용량 API 키 — 폼에 넣었으면 저장(비우면 유지), clearUsageKey 면 지운다
if (typeof input.usageKey === 'string' && input.usageKey.trim()) writeUsageKey(id, input.usageKey, by); else if (input.clearUsageKey === true || input.clearUsageKey === 'true') clearUsageKey(id);
// 연동 모듈이 꺼져 있으면(mail.provider=none) 서버를 등록한 뜻이 분명하므로 켠다
try { const config = require('./config'); if ((config.get('mail.provider') || 'none') === 'none') config.set({ 'mail.provider': 'arimail' }, by); } catch (_) {}
return pub(get(id));
}
/** 추가 서버 삭제 (기본 서버 'primary' 는 설정 파일이라 여기서 지우지 않는다). 이 서버를 고른 사이트는 자동 선택(도메인·기본)으로 돌아간다 */
function remove(id) {
if (id === PRIMARY) throw httpError('설정 파일의 기본 서버는 여기서 지울 수 없습니다 — 시스템 설정 › 메일 연동에서 주소를 비우거나 연동 모듈을 "연동 안 함" 으로 바꾸세요');
const d = load(); if (!d.items[id]) throw httpError('그 메일 서버가 없습니다', 404);
delete d.items[id]; if (d.defaultId === id) d.defaultId = '';
save(d);
try { require('./vault').remove(vaultId(id)); } catch (_) {}
try { require('./vault').remove(usageVaultId(id)); } catch (_) {} // v1.7.3: 사용량 API 키도 함께
try { require('./mailusage').invalidate(id); } catch (_) {}
return true;
}
function setDefault(id) { if (!get(id)) throw httpError('그 메일 서버가 없습니다', 404); const d = load(); d.defaultId = id; save(d); return list(); }
/**
* 연결 확인 결과를 서버에 기록. v1.6.39: 결과 코드가 rate_limited(Arimail 로그인 잠금)면 해제 시각을 함께 적고, 성공하면 잠금을 지운다
*/
function recordTest(id, result) {
const d = load(); const r = { ok: !!result.ok, detail: String(result.detail || '').slice(0, 300), at: new Date().toISOString(), code: result.code || null };
const lock = result.code === 'rate_limited' ? (result.lockedUntil || new Date(Date.now() + LOCK_MS).toISOString()) : (result.ok ? null : undefined); // undefined = 그대로
if (id === PRIMARY) { d.primaryTest = r; if (lock !== undefined) d.primaryLockedUntil = lock; }
else if (d.items[id]) { d.items[id].lastTest = r; if (lock !== undefined) d.items[id].lockedUntil = lock; }
else return;
save(d);
}
/**
* v1.6.39 (요구 2): Arimail 이 "로그인 시도가 너무 많다(rate_limited)" 고 답하면 연동 모듈이 호출한다 — 해제 시각(지금 + 15분)을 적어 두고
* 그때까지 "연결 확인" 을 받지 않는다(그 전에 다시 시도하면 잠금이 길어지기만 한다). 등록부에 없는 서버 id(임시 접속)면 무시.
* @returns {string|null} 해제 시각(ISO)
*/
function noteLocked(id) {
const d = load(); const until = new Date(Date.now() + LOCK_MS).toISOString();
if (id === PRIMARY) d.primaryLockedUntil = until; else if (d.items[id]) d.items[id].lockedUntil = until; else return null;
save(d); return until;
}
/** 잠금 해제 시각을 사람이 읽는 시:분 (서버 시간대) */
const hhmm = (iso) => { const t = new Date(iso); return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`; };
/** 연결 확인 (Arimail health → 관리자 로그인 → 계정 목록). 결과는 lastTest 로 남는다. 잠금 중이면 Arimail 을 부르지 않고 바로 알린다 */
async function test(id) {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
const lock = lockActive(s);
if (lock) return { ok: false, locked: true, lockedUntil: lock, code: 'rate_limited', detail: `Arimail 로그인 잠금 중 — ${hhmm(lock)} 이후 다시 확인하세요 (그 전에 시도하면 잠금이 길어집니다). 비밀번호가 틀렸던 경우 잠금 해제 전에 "수정" 으로 맞는 비밀번호를 저장해 두세요`, server: pub(s) };
const st = await require('./connectors/arimail').forServer(s).status();
recordTest(id, st);
return { ...st, locked: st.code === 'rate_limited', server: pub(get(id)) };
}
// ---------- v1.6.39 (요구 2·4): 비밀번호 보관·되돌리기·보기·초기화 ----------
/** 지금 저장된 비밀번호 쓰기 (기본 서버는 설정 파일 → 보관소 arimail, 추가 서버는 보관소 arimail-<id>) */
function writePassword(id, password, by) {
if (id === PRIMARY) { require('./config').set({ 'mail.adminPassword': password }, by); return; }
const s = get(id); require('./vault').set({ kind: 'arimail', id: vaultId(id), label: `Arimail 관리자 비밀번호 — ${s ? s.nickname : id}`, url: s ? s.url : '', settings: s ? { ADMIN_LOGIN: s.adminLogin, MAIL_DOMAIN: s.domain } : {}, secret: password }, `mailserver:${by}`);
}
/**
* 비밀번호를 새 값으로 바꾸기 직전에 호출 — 지금 값이 있고 새 값과 다르면 "이전 비밀번호" 로 옮겨 둔다.
* (2026-10-21 webmaker.ibit.kr: 동작하던 비밀번호를 틀린 값으로 덮어써 되돌릴 길이 없었다)
* @returns {string|null} 보관한 시각(ISO) — 보관하지 않았으면 null
*/
function keepPrevious(id, newPassword, by) {
const s = get(id); const cur = s ? s.adminPassword : '';
if (!cur || cur === newPassword) return null;
const at = new Date().toISOString();
require('./vault').set({ kind: 'arimail', id: prevVaultId(id), label: `이전 Arimail 관리자 비밀번호 — ${s.nickname}`, url: s.url, settings: { ADMIN_LOGIN: s.adminLogin, REPLACED_AT: at }, secret: cur }, `mailserver:${by}`);
const d = load(); if (id === PRIMARY) d.primaryPrevAt = at; else if (d.items[id]) d.items[id].prevPasswordAt = at; save(d);
return at;
}
/**
* "이전 비밀번호로 되돌리기" — 보관해 둔 이전 값을 현재로, 지금 값은 이전으로 맞바꾼다(되돌리기를 다시 되돌릴 수 있게). 잠금 기록은 그대로(Arimail 쪽 잠금이라 바뀌지 않는다)
*/
function revertPassword(id, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
let prev = ''; try { prev = require('./vault').secretOf(prevVaultId(id)); } catch (_) {}
if (!prev) throw httpError('보관된 이전 비밀번호가 없습니다 — 비밀번호를 바꾼 적이 없거나(1.6.39 이전에 바꿈) 이미 되돌렸습니다. "비밀번호 초기화" 로 새 비밀번호를 만들 수 있습니다', 404);
const cur = s.adminPassword; const at = new Date().toISOString();
writePassword(id, prev, by);
if (cur && cur !== prev) require('./vault').set({ kind: 'arimail', id: prevVaultId(id), label: `이전 Arimail 관리자 비밀번호 — ${s.nickname}`, url: s.url, settings: { ADMIN_LOGIN: s.adminLogin, REPLACED_AT: at }, secret: cur }, `mailserver:${by}`);
else { try { require('./vault').remove(prevVaultId(id)); } catch (_) {} }
const d = load(); if (id === PRIMARY) d.primaryPrevAt = cur && cur !== prev ? at : null; else if (d.items[id]) d.items[id].prevPasswordAt = cur && cur !== prev ? at : null; save(d);
return pub(get(id));
}
/**
* v1.6.39 (요구 4): 관리자 콘솔 [보이기] — 저장된 Arimail 관리자 비밀번호 원문. 관리자 전용 경로에서만 부르고 호출은 작업 기록(mailserver.reveal)에 남긴다(비밀번호는 기록하지 않음).
* 초기 설정 뒤 운영자가 Arimail /admin 에 직접 들어갈 때 필요하다.
*/
function revealPassword(id) {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.adminPassword) throw httpError('저장된 비밀번호가 없습니다 — "수정" 에서 입력하거나 "비밀번호 초기화" 로 새로 만드세요', 404);
return { id: s.id, adminLogin: s.adminLogin, password: s.adminPassword, adminUrl: s.url ? `${s.url}/admin` : null, webmailUrl: s.url ? `${s.url}/` : null };
}
/** 무작위 비밀번호 — 대문자·소문자·숫자·기호가 모두 들어가는 16자 (Arimail 기본 비밀번호 정책을 넘도록) */
function generatePassword() {
const crypto = require('crypto'); const sets = ['ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnopqrstuvwxyz', '23456789', '!@#$%^*-_+='];
const pick = (str) => str[crypto.randomInt(str.length)];
const chars = sets.map(pick); const all = sets.join('');
while (chars.length < 16) chars.push(pick(all));
for (let i = chars.length - 1; i > 0; i--) { const j = crypto.randomInt(i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } // 섞기
return chars.join('');
}
/**
* v1.6.39 (요구 4): 연동 관리자 계정(adminLogin)의 비밀번호 초기화 — 새 무작위 비밀번호를 만들어 Arimail 에 설정하고 보관소에 저장한다.
* - 기본: 저장된 비밀번호로 로그인해 자기 계정의 비밀번호를 바꾼다(운영자가 비밀번호를 잊었지만 웹메이커는 알고 있는 경우).
* - via {login,password}: 저장된 비밀번호도 틀릴 때, Arimail 의 다른 관리자 계정(예: 초기 admin)으로 한 번 로그인해 연동 계정의 비밀번호를 바꾼다.
* via 의 비밀번호는 저장하지 않는다. 연동 계정이 Arimail 에 없으면 관리자 역할로 새로 만든다.
* 바꾼 뒤 이전 비밀번호는 "이전 비밀번호" 로 보관되고, 새 비밀번호는 응답으로 한 번 돌려준다(화면의 보기/숨기기로도 다시 볼 수 있다).
* @returns {Promise<{server:object, password:string, created:boolean, how:string}>}
*/
async function resetPassword(id, { via } = {}, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.url) throw httpError('Arimail 주소가 없어 접속할 수 없습니다 — 먼저 "수정" 에서 주소를 넣으세요');
const arimail = require('./connectors/arimail');
const useVia = !!(via && String(via.login || '').trim());
if (!useVia && !s.adminPassword) throw httpError('저장된 비밀번호가 없어 로그인할 수 없습니다 — "다른 관리자 계정으로 재설정" 에 Arimail 관리자(예: admin) 아이디·비밀번호를 넣으세요', 409);
const actor = useVia ? arimail.forServer({ ...s, adminLogin: String(via.login).trim(), adminPassword: String(via.password || '') }) : arimail.forServer(s);
const how = useVia ? `다른 관리자 계정(${String(via.login).trim()})으로 로그인해` : '저장된 비밀번호로 로그인해';
const newPw = generatePassword(); let created = false;
let u; try { u = await actor.findUser(s.adminLogin); }
catch (e) { throw Object.assign(httpError(`Arimail 로그인 실패 — ${e.message}${useVia ? '' : ' (저장된 비밀번호가 틀리면 "다른 관리자 계정으로 재설정" 을 쓰세요)'}`, e.status === 502 ? 502 : 400), { code: e.code || null }); }
if (!u) {
if (!useVia) throw httpError(`Arimail 에 연동 관리자 계정 "${s.adminLogin}" 이 없습니다 (로그인은 됐지만 목록에 없음)`, 409);
const r = await actor.createUser({ login: s.adminLogin, name: 'Webmaker 연동 관리자', password: newPw, role: 'admin' }); u = { id: r.id }; created = true;
} else await actor.updateUser(u.id, { password: newPw, role: 'admin', status: 'active' }); // 관리자 역할·활성 상태를 함께 보장 (역할을 올리는 쪽이라 보호 규칙에 걸리지 않음)
keepPrevious(id, newPw, by); writePassword(id, newPw, by);
// 새 비밀번호로 실제 로그인되는지 바로 확인해 lastTest 에 남긴다 (실패해도 초기화 자체는 끝난 것이므로 예외로 올리지 않는다)
let test = null; try { test = await arimail.forServer(get(id)).status(); recordTest(id, test); } catch (_) {}
return { server: pub(get(id)), password: newPw, created, how, test: test ? { ok: !!test.ok, detail: test.detail } : null };
}
// ---------- v1.7.3 추가(운영자 요청 2026-10-24): 웹메이커 관리자 ↔ Arimail 같은 ID 계정 비밀번호 동기화 기록 ----------
/** 서버별 동기화 기록 { <로그인>: { fp, at, error, errorAt } } — 비밀번호 원문·해시는 넣지 않는다(지문만) */
function adminSyncOf(serverId) { const d = load(); return (serverId === PRIMARY ? d.primaryAdminSync : (d.items[serverId] || {}).adminSync) || {}; }
function setAdminSync(serverId, login, patch) {
const d = load(); const cur = serverId === PRIMARY ? (d.primaryAdminSync || {}) : ((d.items[serverId] || {}).adminSync || {});
cur[login] = { ...(cur[login] || {}), ...patch };
if (serverId === PRIMARY) d.primaryAdminSync = cur; else if (d.items[serverId]) d.items[serverId].adminSync = cur; else return null;
save(d); return cur[login];
}
/** 화면용 요약: [{login, at, error}] */
function adminSyncView(serverId) { return Object.entries(adminSyncOf(serverId)).map(([login, x]) => ({ login, at: x.at || null, error: x.error || null, errorAt: x.errorAt || null, skipped: x.skipped || null })); }
// ---------- v1.7.3 (요구 2): 사용량 API 키 ----------
/** 키 모양 검사 — Arimail 은 'au_' + 32바이트(base64url) 를 만들지만 다른 길이도 허용(최소 16자, 공백·따옴표 없이) */
const USAGE_KEY_RE = /^[A-Za-z0-9_\-]{16,200}$/;
/**
* 사용량 API 키 저장 (보관소 arimail-usage[-<id>]) + 저장 시각을 등록부에 기록. 키 원문은 등록부·작업 기록·오류 문구에 쓰지 않는다.
* @param {string} id 서버 id ('primary' 포함)
* @param {string} key Arimail 전체 설정 › 사용량 API 카드에서 만든 키(au_…) 또는 issueUsageKey 가 받은 키
*/
function writeUsageKey(id, key, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
const k = String(key || '').trim(); if (!USAGE_KEY_RE.test(k)) throw httpError('사용량 API 키 형식이 올바르지 않습니다 — Arimail 전체 설정 › 사용량 API 카드에서 만든 키(au_ 로 시작, 공백 없이)를 그대로 붙여 넣으세요');
require('./vault').set({ kind: 'arimail-usage', id: usageVaultId(id), label: `Arimail 사용량 API 키 — ${s.nickname}`, url: s.url, settings: { MAIL_DOMAIN: s.domain }, secret: k }, `mailserver:${by}`);
const d = load(); const at = new Date().toISOString(); if (id === PRIMARY) d.primaryUsageKeyAt = at; else if (d.items[id]) d.items[id].usageKeyAt = at; save(d);
try { require('./mailusage').invalidate(id); } catch (_) {}
return pub(get(id));
}
/** 사용량 API 키 삭제 (Arimail 쪽 키는 그대로 — Arimail 에서 지우려면 전체 설정 화면의 "키 삭제") */
function clearUsageKey(id) {
if (!get(id)) throw httpError('그 메일 서버가 없습니다', 404);
try { require('./vault').remove(usageVaultId(id)); } catch (_) {}
const d = load(); if (id === PRIMARY) d.primaryUsageKeyAt = null; else if (d.items[id]) d.items[id].usageKeyAt = null; save(d);
try { require('./mailusage').invalidate(id); } catch (_) {}
return pub(get(id));
}
/**
* 사용량 API 키 발급 — 저장된 관리자 계정으로 Arimail 에 로그인해 `POST /api/setup/usage-key {action:'generate'}` 를 부르고(기본 도메인 관리자 세션으로 허용됨)
* 받은 키를 바로 보관한다. Arimail 은 키를 만들 때 한 번만 돌려주므로 이 흐름이면 운영자가 키를 옮겨 적을 일이 없다. 이미 키가 있으면 Arimail 쪽 키가 새 키로 바뀐다(이전 키 즉시 무효).
* 실패 사유: Arimail 1.0.9 미만(API 없음) · 등록 계정이 기본 도메인 관리자가 아님(primary_only) · 로그인 실패/잠금.
* @returns {Promise<{server:object, endpoint:string|null, replaced:boolean}>}
*/
async function issueUsageKey(id, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.url) throw httpError('Arimail 주소가 없어 접속할 수 없습니다 — 먼저 "수정" 에서 주소를 넣으세요');
if (!s.adminPassword) throw httpError('저장된 관리자 비밀번호가 없어 Arimail 에 로그인할 수 없습니다 — 비밀번호를 넣거나 Arimail 전체 설정 화면에서 만든 키를 직접 붙여 넣으세요', 409);
const replaced = !!s.usageKey;
const r = await require('./connectors/arimail').forServer(s).issueUsageKey();
const server = writeUsageKey(id, r.key, by);
return { server, endpoint: r.endpoint || null, replaced };
}
module.exports = { FILE, PRIMARY, LOCK_MS, load, all, list, get, pub, defaultServer, forDomain, pick, upsert, remove, setDefault, recordTest, noteLocked, test, vaultId, prevVaultId, revertPassword, revealPassword, resetPassword, generatePassword, usageVaultId, usageKeyOf, writeUsageKey, clearUsageKey, issueUsageKey, adminSyncOf, setAdminSync, adminSyncView };