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파일 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54db663531
commit
9f1e490b51
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { systemLimits } from './limits.mjs';
|
||||
import { recoverQueue } from './sendqueue.mjs';
|
||||
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
||||
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
||||
import { startHalManaged } from './hal-managed.mjs';
|
||||
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
||||
import { autoVerifyDomain } from './providers/resend.mjs';
|
||||
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
|
||||
@@ -45,7 +46,7 @@ const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite')
|
||||
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
|
||||
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
||||
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
|
||||
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
||||
loadConfig(env); startHalManaged(env); /* 1.1.0 (HAL 0.4.0 대응): HAL 통합 설정(hal-managed.json) 적용·변경 감시 */ console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
||||
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
|
||||
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
|
||||
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 { lockedKeys, managedKeys, publicManaged } from './hal-managed.mjs'; // 1.1.0 (HAL 0.4.0 대응) HAL 통합 설정 관제
|
||||
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
|
||||
import { noServicePage, normalizeHost } from './domains.mjs';
|
||||
|
||||
@@ -67,12 +68,14 @@ async function meta(env) {
|
||||
*/
|
||||
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) });
|
||||
const respond = async () => json({ ok: true, ...publicConfig(env), halManaged: publicManaged(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');
|
||||
// 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 운영자 콘솔 › 통합 설정에서 바꾸세요` }); }
|
||||
// 사용량 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; }
|
||||
@@ -97,6 +100,7 @@ export async function setupApi(env, request, path, { ip = '', user = null, tenan
|
||||
if (action === 'usage-key' && method === 'POST') {
|
||||
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
||||
if (primary !== env) return bad('primary_only', 409);
|
||||
if (managedKeys(env).includes('USAGE_API_KEY')) return bad('managed_by_hal', 409, { keys: ['USAGE_API_KEY'], message: '사용량 API 키는 HAL 이 관리합니다 — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요' }); // 1.1.0
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user