Files
AriWeb-Suite/modules/webmaker/lib/hal-managed.js
T

87 lines
8.1 KiB
JavaScript
Raw Normal View History

'use strict';
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
// HAL 이 이 서버의 관리 콘솔 중 "서버 단위 설정"(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 대신 관리한다.
// Node Agent 가 HAL 에서 받은 값을 DATA_ROOT/hal-managed.json(0600, { hash, values })로 쓰면, 이 모듈이 기동 때와 파일이 바뀔 때
// 기존 저장 경로(lib/settings.save · lib/config.set · 보관소)로 적용하고 DATA_ROOT/hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남긴다.
// HAL 이 관리하는 항목은 관리자 콘솔·설정 페이지 API 에서 409 managed_by_hal 로 잠긴다(HAL 에서만 바꾼다). 파일이 없으면 아무것도 하지 않는다(단독 운영 그대로).
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, 'hal-managed.json');
const APPLIED = path.join(DATA_ROOT, 'hal-managed.applied.json');
const HAL_COMPAT = '0.4.0';
// lib/settings.js 로 저장하는 항목(점 = 중첩 객체). 나머지는 lib/config.js 의 설정 파일 항목(config.KEYS)이다.
const SETTINGS_KEYS = new Set(['auth.mode', 'auth.apiKey', 'auth.oauthToken', 'serviceApiKey', 'backlogKey', 'mail.apiKey', 'marginPct', 'fx.mode', 'fx.manualRate', 'fx.spreadPct', 'signupCredit', 'maxEdits', 'defaultTier', 'tiers.basic', 'tiers.standard', 'tiers.premium', 'signupApproval', 'internalRef', 'defaultModel', 'claudeModels', 'letsEncryptEmail', 'mail.fromName', 'mail.defaultDomain', 'mail.formBase']);
const NESTED = new Set(['auth', 'fx', 'tiers', 'mail']);
// 보관소 항목 종류 ↔ HAL 관리 비밀값(관리 중이면 관리자 콘솔 › 연동 정보에서 그 종류를 바꿀 수 없다)
const VAULT_KIND = { 'auth.apiKey': 'anthropic-api-key', 'auth.oauthToken': 'anthropic-oauth', 'mail.apiKey': 'resend', 'source.token': 'gitea', 'ariweb.secret': 'ariweb', 'mail.adminPassword': 'arimail' };
const MASK = '••••';
let cache = { mtimeMs: -1, doc: null };
function read() {
let st; try { st = fs.statSync(FILE); } catch (_) { cache = { mtimeMs: -1, doc: null }; return null; }
if (st.mtimeMs === cache.mtimeMs && cache.doc) return cache.doc;
try { const doc = JSON.parse(fs.readFileSync(FILE, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache = { mtimeMs: st.mtimeMs, doc }; return doc; }
catch (e) { console.error(`[hal-managed] ${FILE} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
}
function readApplied() { try { return JSON.parse(fs.readFileSync(APPLIED, 'utf8')); } catch (_) { return null; } }
/** HAL 이 관리하는 항목 이름 목록 */
function managedKeys() { const d = read(); return d ? Object.keys(d.values) : []; }
const isManaged = (key) => managedKeys().includes(key);
/** 관리자 콘솔이 보낸 settings PUT 본문에서 HAL 관리 항목을 바꾸려는 키(마스크·빈 비밀값은 "바꾸지 않음"이라 제외) */
function lockedKeys(body) {
const keys = managedKeys(); if (!keys.length || !body || typeof body !== 'object') return [];
const touched = [];
for (const [k, v] of Object.entries(body)) {
if (NESTED.has(k) && v && typeof v === 'object' && !Array.isArray(v)) { for (const [k2, v2] of Object.entries(v)) { if (v2 === MASK || (VAULT_KIND[`${k}.${k2}`] && (v2 === '' || v2 == null))) continue; touched.push(`${k}.${k2}`); } }
else touched.push(k);
}
return touched.filter((k) => keys.includes(k));
}
/** 관리 중인 비밀값의 보관소 종류 */
const lockedVaultKinds = () => managedKeys().map((k) => VAULT_KIND[k]).filter(Boolean);
const managedError = (keys) => Object.assign(new Error(`HAL 이 관리하는 항목은 여기서 바꿀 수 없습니다: ${keys.join(', ')} — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요`), { status: 409, code: 'managed_by_hal', keys });
function assertNotManaged(keys) { if (keys && keys.length) throw managedError(keys); }
function setNested(o, key, v) { const [a, b] = key.split('.'); if (b && NESTED.has(a)) { o[a] = o[a] || {}; o[a][b] = v; } else o[key] = v; }
/**
* hal-managed.json 을 적용한다. 같은 hash 를 이미 적용했으면 건너뛴다(force 면 다시).
* 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않게 하고, 오류는 applied 파일에 키와 이유로 남긴다(값은 남기지 않는다).
*/
function apply({ force = false, log = console.log } = {}) {
const doc = read(); if (!doc) return { skipped: 'no_file' };
const prev = readApplied(); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
const settings = require('./settings'); const config = require('./config'); const errors = []; const appliedKeys = []; let restart = false;
// 묶음 단위: 서로 기대는 항목(fx.mode=manual 은 fx.manualRate 가 있어야 함 등)은 auth·fx·tiers·mail 묶음 하나로 함께 저장하고, 나머지는 항목마다 따로 저장한다
const units = new Map();
for (const [key, value] of Object.entries(doc.values)) { const head = key.split('.')[0]; const id = SETTINGS_KEYS.has(key) && NESTED.has(head) && key.includes('.') ? `settings:${head}` : `key:${key}`; if (!units.has(id)) units.set(id, []); units.get(id).push([key, value]); }
for (const [id, entries] of units) {
const keys = entries.map(([k]) => k);
try {
if (id.startsWith('settings:') || SETTINGS_KEYS.has(keys[0])) { const patch = {}; for (const [k, v] of entries) setNested(patch, k, v); settings.save(patch); }
else if (config.KEYS && config.KEYS[keys[0]]) { const v = entries[0][1]; const r = config.set({ [keys[0]]: Array.isArray(v) ? v.join(',') : v }, 'hal'); if (r.restart) restart = true; }
else throw new Error('이 Webmaker 버전이 모르는 항목입니다');
appliedKeys.push(...keys);
} catch (e) { for (const k of keys) errors.push({ key: k, error: String(e.message || e).slice(0, 200) }); }
}
try { require('./setupstate').invalidate(); } catch (_) {}
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys: appliedKeys, errors, restartNeeded: restart, halCompat: HAL_COMPAT, version: require('../package.json').version };
try { const tmp = `${APPLIED}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); fs.renameSync(tmp, APPLIED); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
try { require('./audit').log({ by: 'hal', action: 'hal.settings.apply', targetType: 'settings', target: 'hal-managed', ok: !errors.length, detail: { hash: doc.hash, keys: appliedKeys, errors: errors.map((x) => x.key) } }); } catch (_) {}
log(`[hal-managed] HAL 통합 설정 적용: ${appliedKeys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}${restart ? ' — 재시작이 필요한 항목이 있습니다' : ''}`);
return out;
}
/** 화면용 정보(비밀값 없음) */
function publicInfo() { const d = read(); const a = readApplied(); 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 || [] : [] }; }
let timer = null;
/** 기동 때 한 번 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용한다 */
function start({ intervalMs = 10000 } = {}) {
try { apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
if (timer) clearInterval(timer);
timer = setInterval(() => { try { const d = read(); const a = readApplied(); if (d && (!a || a.hash !== d.hash)) apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs); if (timer.unref) timer.unref();
return timer;
}
module.exports = { FILE, APPLIED, HAL_COMPAT, SETTINGS_KEYS, VAULT_KIND, read, managedKeys, isManaged, lockedKeys, lockedVaultKinds, assertNotManaged, managedError, apply, publicInfo, start };