/** * hal/settings/index.mjs — 통합 설정 관제(0.4.0) 저장소·유효값·적용 상태. * * 저장: managed_settings(scope, module, key, value_json). scope 는 'global'(전체) 또는 'server:'(서버 덮어쓰기). * 비밀값(type secret)은 행에 두지 않고 Vault 에 `managed:::` 로만 둔다. 행의 value_json 은 { vault: <이름> } 참조. * 유효값 = 전체 ← 서버 덮어쓰기. hash 는 모듈·유효값(비밀값 포함)의 정규 JSON sha256 — Agent 가 받은 내용과 HAL 이 보낸 내용이 같은지 검증하고, 적용 상태(managed_settings_state)와 비교해 "적용 필요"를 판단한다. * 감사 기록에는 키 이름만 남긴다(값·비밀값 없음). */ import { nowIso } from '../../lib/db.mjs'; import { audit } from '../../lib/audit.mjs'; import { JobError } from '../../lib/jobs.mjs'; import { createHash } from 'node:crypto'; import { catalogFor, keyDef, allKeys, MANAGED_MODULES } from './catalog.mjs'; import { normalizeValue } from './catalog.mjs'; export { MANAGED_MODULES }; const vaultName = (scope, module, key) => `managed:${scope}:${module}:${key}`; export function parseScope(scope) { const s = String(scope || 'global'); if (s === 'global') return { scope: 'global', serverId: null }; const m = /^server:([A-Za-z0-9_-]+)$/.exec(s); if (!m) throw new JobError('bad_scope', '범위는 global 또는 server:<서버 id> 이어야 합니다', { status: 400 }); return { scope: s, serverId: m[1] }; } function requireModule(module) { const c = catalogFor(module); if (!c) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`, { status: 404 }); return c; } /** 범위 하나의 저장된 값(비밀값은 있음/없음) */ export function getSettings(d, module, scope = 'global') { requireModule(module); const { scope: sc } = parseScope(scope); const values = {}; const secrets = {}; for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false; for (const r of d.store.all('SELECT key, value_json, is_secret, updated_at, updated_by FROM managed_settings WHERE scope=? AND module=?', sc, module)) { if (!keyDef(module, r.key)) continue; if (r.is_secret) secrets[r.key] = d.vault.has(vaultName(sc, module, r.key)); else values[r.key] = JSON.parse(r.value_json); } return { module, scope: sc, values, secrets, updatedAt: d.store.get('SELECT MAX(updated_at) AS t FROM managed_settings WHERE scope=? AND module=?', sc, module)?.t || null }; } /** 저장(여러 항목). 비밀값: 빈 값·null 이면 삭제, 아니면 Vault 에 저장. 일반 값: null 이면 삭제. 검증을 모두 통과한 뒤에만 쓴다. */ export function setSettings(d, { module, scope = 'global', values = {}, by = null, actorType = 'operator', via = null }) { requireModule(module); const { scope: sc, serverId } = parseScope(scope); if (!values || typeof values !== 'object' || Array.isArray(values)) throw new JobError('bad_setting', '값은 객체여야 합니다', { status: 400 }); const plan = []; for (const [key, raw] of Object.entries(values)) { const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 }); if (def.scope === 'server' && !serverId) throw new JobError('bad_setting', `${def.label}(${key})은(는) 서버별 항목이라 전체 범위에 둘 수 없습니다`, { status: 400 }); let v; try { v = normalizeValue(def, raw); } catch (e) { throw new JobError('bad_setting', e.message, { status: 400, detail: { key } }); } plan.push({ key, def, value: v }); } const now = nowIso(); const changed = []; for (const { key, def, value } of plan) { const name = vaultName(sc, module, key); if (value === null) { const had = d.store.run('DELETE FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key).changes > 0; if (def.type === 'secret') { if (d.vault.remove(name) || had) changed.push(key); } else if (had) changed.push(key); continue; } if (def.type === 'secret') { d.vault.set(name, value, { kind: 'managed-setting' }); d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,1,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=1, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, JSON.stringify({ vault: name }), by, now); changed.push(key); continue; } const prev = d.store.get('SELECT value_json FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key); const vj = JSON.stringify(value); if (prev && prev.value_json === vj) continue; d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,0,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=0, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, vj, by, now); changed.push(key); } if (changed.length) audit(d.store, { actorType, actorId: by, action: 'settings.set', subjectType: 'settings', subjectId: `${module}:${sc}`, detail: { module, scope: sc, keys: changed, ...(via ? { via } : {}) } }); return { module, scope: sc, changed, restartNeeded: plan.some((p) => changed.includes(p.key) && p.def.restart) }; } export function deleteSetting(d, { module, scope = 'global', key, by = null, actorType = 'operator' }) { requireModule(module); const { scope: sc } = parseScope(scope); const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 }); return setSettings(d, { module, scope: sc, values: { [key]: null }, by, actorType }); } /** 서버 하나의 유효값(전체 ← 서버). includeSecrets 면 비밀값을 values 에 포함(Agent 전송·hash 계산용). hash 는 언제나 비밀값을 포함해 계산한다. */ export function effectiveSettings(d, module, serverId, { includeSecrets = false } = {}) { requireModule(module); const scopes = ['global', ...(serverId ? [`server:${serverId}`] : [])]; const values = {}; const sources = {}; const secretVals = {}; const secrets = {}; for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false; for (const sc of scopes) for (const r of d.store.all('SELECT key, value_json, is_secret FROM managed_settings WHERE scope=? AND module=?', sc, module)) { if (!keyDef(module, r.key)) continue; const src = sc === 'global' ? 'global' : 'server'; if (r.is_secret) { const v = d.vault.get(vaultName(sc, module, r.key)); if (v === null) continue; secretVals[r.key] = v; secrets[r.key] = true; sources[r.key] = src; } else { values[r.key] = JSON.parse(r.value_json); sources[r.key] = src; } } const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]])); const hash = 'sha256:' + createHash('sha256').update(`${module}|${canon(values)}|${canon(secretVals)}`).digest('hex'); // 'sha256:' 접두어 — 긴 hex 는 비밀값 형태로 가려지므로(lib/redact) 작업 결과·로그에서 비교할 수 있게 return { module, serverId: serverId || null, values: includeSecrets ? { ...values, ...secretVals } : values, secrets, sources, hash }; } /** Agent 에 보내는 내용(비밀값 포함) */ export function payloadForAgent(d, module, serverId) { const e = effectiveSettings(d, module, serverId, { includeSecrets: true }); return { module, serverId, hash: e.hash, generatedAt: nowIso(), values: e.values }; } /** 모듈이 배치된 서버(적용 대상). agent 는 활성 Agent 가 있는 모든 서버. */ export function placements(d, module) { requireModule(module); if (module === 'agent') return d.store.all("SELECT s.id, s.name, s.status FROM servers s WHERE EXISTS (SELECT 1 FROM agents a WHERE a.server_id=s.id AND a.status='active') ORDER BY s.created_at"); return d.store.all('SELECT s.id, s.name, s.status FROM module_targets t JOIN servers s ON s.id=t.server_id WHERE t.module=? AND t.active=1 ORDER BY s.created_at', module); } export function recordState(d, { serverId, module, status, hash = null, error = null, jobId = null }) { const now = nowIso(); if (status === 'ok') d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET applied_hash=excluded.applied_hash, applied_at=excluded.applied_at, status=excluded.status, error=NULL, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, hash, now, 'ok', null, jobId, now); else d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,NULL,NULL,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET status=excluded.status, error=excluded.error, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, status, error, jobId, now); } /** 서버 하나의 적용 상태(현재 유효값 hash 와 비교) */ export function stateFor(d, module, serverId) { const r = d.store.get('SELECT * FROM managed_settings_state WHERE server_id=? AND module=?', serverId, module); const cur = effectiveSettings(d, module, serverId).hash; return { serverId, module, status: r ? r.status : 'unknown', appliedHash: r?.applied_hash || null, appliedAt: r?.applied_at || null, error: r?.error || null, jobId: r?.job_id || null, currentHash: cur, upToDate: !!(r && r.status === 'ok' && r.applied_hash === cur) }; } const hasAnySettings = (d, module) => !!d.store.get('SELECT 1 AS x FROM managed_settings WHERE module=? LIMIT 1', module); /** 모듈별 개요: 배치 서버·적용 상태·적용 필요 수(설정이 하나도 없는 모듈은 적용 필요로 세지 않는다) */ export function settingsOverview(d) { const out = {}; for (const m of MANAGED_MODULES) { const any = hasAnySettings(d, m); const servers = placements(d, m).map((s) => { const st = stateFor(d, m, s.id); return { serverId: s.id, serverName: s.name, serverStatus: s.status, ...st, upToDate: any ? st.upToDate : (st.status === 'ok' || st.status === 'unknown') }; }); out[m] = { module: m, hasSettings: any, servers, stale: servers.filter((s) => !s.upToDate).length, globalCount: d.store.get('SELECT COUNT(*) AS n FROM managed_settings WHERE module=? AND scope=?', m, 'global').n }; } return out; } export const staleCount = (d) => Object.values(settingsOverview(d)).reduce((n, m) => n + m.stale, 0);