'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-' 로 암호화 보관한다 — 비밀값은 이 파일에 쓰지 않는다. // - 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- 가 되므로 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-'. 키 원문은 등록부 파일(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} */ 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-) */ 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[-]) + 저장 시각을 등록부에 기록. 키 원문은 등록부·작업 기록·오류 문구에 쓰지 않는다. * @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 };