Files
AriWeb-Suite/modules/arimail/server/hal-managed.mjs
T

64 lines
6.0 KiB
JavaScript
Raw Normal View History

/**
* 1.1.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
*
* Ariweb HAL 이 이 메일 서버의 "서버 단위 설정"(수·발신 방식, Resend, DNS 제공자, SMTP 중계, 내장 Haraka, 표시·동기화, 사용량 API, Ariweb SSO)을 관리합니다.
* Node Agent 가 HAL 에서 받은 값을 기본 도메인 설정 파일 옆 hal-managed.json(0600, { hash, values })으로 쓰면, 기동 때와 파일이 바뀔 때
* saveConfig(…, { allowHidden: true })로 기본 도메인(서버) 설정에 적용하고 hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남깁니다.
* 도메인별 관리(추가 도메인의 설정·제한 LIMIT_*·계정·메일함)는 HAL 이 다루지 않습니다 — 메일 서버 사용자(도메인 관리자)가 직접 관리합니다.
* HAL 이 관리하는 항목은 설정 페이지(/setup, 기본 도메인)에서 값을 바꾸면 409 managed_by_hal 로 거부됩니다. 파일이 없으면 아무것도 하지 않습니다.
*/
import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { configDir, saveConfig, CONFIG_SCHEMA } from './config.mjs';
export const HAL_COMPAT = '0.4.0';
const BY_KEY = new Map(CONFIG_SCHEMA.map((i) => [i.key, i]));
export const managedFile = (env) => join(configDir(env), 'hal-managed.json');
export const appliedFile = (env) => join(configDir(env), 'hal-managed.applied.json');
/** 이 env 가 서버(기본 도메인)인지 — 추가 도메인 env 는 HAL 관리 대상이 아닙니다. */
export const isServerEnv = (env) => !env.TENANTS || env.TENANTS.primary.env === env;
const cache = new WeakMap();
export function readManaged(env) {
const f = managedFile(env); let st; try { st = statSync(f); } catch { return null; }
const c = cache.get(env); if (c && c.mtimeMs === st.mtimeMs) return c.doc;
try { const doc = JSON.parse(readFileSync(f, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache.set(env, { mtimeMs: st.mtimeMs, doc }); return doc; }
catch (e) { console.error(`[hal-managed] ${f} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
}
const readApplied = (env) => { try { return JSON.parse(readFileSync(appliedFile(env), 'utf8')); } catch { return null; } };
export function managedKeys(env) { if (!isServerEnv(env)) return []; const d = readManaged(env); return d ? Object.keys(d.values) : []; }
/**
* 설정 저장 요청(values)에서 HAL 관리 항목의 값을 바꾸려는 키. 화면은 폼의 모든 칸을 보내므로 지금 값과 같은 칸은 변경이 아닙니다.
* 비밀 칸은 입력했거나('...') 비우기('')를 누른 경우만 오므로 그대로 변경입니다.
*/
export function lockedKeys(env, values) {
const keys = managedKeys(env); if (!keys.length || !values || typeof values !== 'object') return [];
return Object.keys(values).filter((k) => keys.includes(k)).filter((k) => { const item = BY_KEY.get(k); if (item && item.type === 'secret') return true; const cur = env.CONFIG && env.CONFIG[k] !== undefined && env.CONFIG[k] !== null ? env.CONFIG[k] : ''; return String(values[k]) !== String(cur); });
}
/** hal-managed.json 을 적용합니다. 같은 hash 를 오류 없이 이미 적용했으면 건너뜁니다(force 면 다시). 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않습니다. */
export function applyManaged(env, { force = false, log = console.log } = {}) {
if (!isServerEnv(env)) return { skipped: 'not_server_env' };
const doc = readManaged(env); if (!doc) return { skipped: 'no_file' };
const prev = readApplied(env); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
const keys = []; const errors = [];
for (const [key, value] of Object.entries(doc.values)) {
const item = BY_KEY.get(key);
if (!item) { errors.push({ key, error: '이 AriMail 버전이 모르는 항목입니다' }); continue; }
if (/^LIMIT_/.test(key) || item.group === 'setup') { errors.push({ key, error: '도메인별·설치 항목은 HAL 이 관리하지 않습니다' }); continue; }
try { saveConfig(env, { [key]: value }, { allowHidden: true }); keys.push(key); } catch (e) { errors.push({ key, error: e.message === 'validation' ? '값 형식이 올바르지 않습니다' : String(e.message || e).slice(0, 200) }); }
}
let version = null; try { version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { /* 버전 표시만 */ }
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys, errors, halCompat: HAL_COMPAT, version };
try { const tmp = `${appliedFile(env)}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); renameSync(tmp, appliedFile(env)); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
log(`[hal-managed] HAL 통합 설정 적용: ${keys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}`);
return out;
}
/** 화면용(비밀값 없음) */
export function publicManaged(env) { const d = isServerEnv(env) ? readManaged(env) : null; const a = d ? readApplied(env) : null; return { managed: !!d, halCompat: HAL_COMPAT, keys: d ? Object.keys(d.values) : [], hash: d ? d.hash : null, appliedHash: a ? a.hash : null, appliedAt: a ? a.appliedAt : null, errors: a ? a.errors || [] : [] }; }
/** 기동 때 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용합니다. */
export function startHalManaged(env, { intervalMs = 10000 } = {}) {
try { applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
const t = setInterval(() => { try { const d = readManaged(env); const a = readApplied(env); if (d && (!a || a.hash !== d.hash)) applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs);
if (t.unref) t.unref(); return t;
}