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
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "arimail",
|
||||
"version": "1.0.11",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(server/hal-managed.mjs)" },
|
||||
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server/index.mjs",
|
||||
"migrate": "node server/db-migrate.mjs",
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs"
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs && node test/hal-managed.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 1.1.0 시험(Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정(server/hal-managed.mjs) — 적용·결과 파일(비밀값 없음)·도메인별 항목 제외·설정 페이지 잠금.
|
||||
* 실행: node test/hal-managed.test.mjs (npm test 가 함께 돌립니다). 비밀값 시험 값은 실행할 때마다 만듭니다(고정 문자열 없음).
|
||||
*/
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { applyManaged, lockedKeys, managedKeys, publicManaged, managedFile, appliedFile, HAL_COMPAT } from '../server/hal-managed.mjs';
|
||||
import { setupApi } from '../server/setup.mjs';
|
||||
|
||||
const { t, done } = runner();
|
||||
const rnd = (p) => `${p}-${randomBytes(12).toString('hex')}`;
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-halm-'));
|
||||
const writeManaged = (env, values) => { const hash = 'sha256:' + createHash('sha256').update(JSON.stringify(values)).digest('hex'); writeFileSync(managedFile(env), JSON.stringify({ format: 1, module: 'arimail', hash, values }), { mode: 0o600 }); return hash; };
|
||||
const setup = (env, path, method = 'GET', body) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1' });
|
||||
async function freshEnv() { const dir = tmp(); const env = await makeEnv({ CONFIG_FILE: join(dir, 'config.json'), RESEND_FETCH: fakeResend() }); /* 외부 호출 없음 */ loadConfig(env); return env; }
|
||||
|
||||
await t('파일이 없으면 아무것도 하지 않는다', async () => {
|
||||
const env = await freshEnv(); eq(applyManaged(env).skipped, 'no_file', '무동작'); eq(managedKeys(env).length, 0, '관리 항목 없음'); eq(publicManaged(env).managed, false, '표시'); eq(HAL_COMPAT, '0.4.0', 'HAL 대응 버전');
|
||||
});
|
||||
await t('서버 설정에 적용(hidden 포함)·결과 파일 비밀값 없음·도메인별(LIMIT_*)·모르는 항목은 오류로 기록', async () => {
|
||||
const env = await freshEnv(); const rk = rnd('re'); const uk = rnd('au');
|
||||
const hash = writeManaged(env, { MAIL_FROM_NAME: '아리메일', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: 2525, RESEND_API_KEY: rk, USAGE_API_KEY: uk, USAGE_API_ALLOW: '10.0.0.0/8', LIMIT_ACCOUNTS: 5, NOPE_KEY: 1 });
|
||||
const r = applyManaged(env); eq(r.hash, hash, 'hash'); eq(r.errors.map((x) => x.key).sort().join(','), 'LIMIT_ACCOUNTS,NOPE_KEY', '제외 항목');
|
||||
eq(env.CONFIG.MAIL_FROM_NAME, '아리메일', '설정 파일'); eq(env.MAIL_FROM_NAME, '아리메일', 'env 즉시 반영'); eq(env.RESEND_API_KEY, rk, '비밀값 적용'); eq(env.USAGE_API_KEY, uk, 'hidden 항목 적용'); eq(Number(env.CONFIG.SMTP_RELAY_PORT), 2525, '숫자');
|
||||
ok(env.CONFIG.LIMIT_ACCOUNTS === undefined, '도메인별 제한은 건드리지 않음');
|
||||
const a = readFileSync(appliedFile(env), 'utf8'); ok(!a.includes(rk) && !a.includes(uk), '결과 파일에 비밀값 없음'); eq(JSON.parse(a).halCompat, '0.4.0', '결과 파일 HAL 대응');
|
||||
const cfg = readFileSync(join(env.CONFIG_FILE), 'utf8'); ok(cfg.includes('아리메일'), '설정 파일 저장'); eq(statSync(env.CONFIG_FILE).mode & 0o777, 0o600, '설정 파일 0600');
|
||||
writeManaged(env, { MAIL_FROM_NAME: '아리' }); eq(applyManaged(env).errors.length, 0, '두 번째 적용'); eq(applyManaged(env).skipped, 'same_hash', '같은 hash 생략');
|
||||
const pub = publicManaged(env); eq(pub.keys.join(','), 'MAIL_FROM_NAME', '관리 키'); ok(!JSON.stringify(pub).includes(rk), '표시에 비밀 없음');
|
||||
});
|
||||
await t('설정 페이지 잠금: 관리 항목의 값 변경은 409 managed_by_hal, 같은 값(폼 전체 전송)·관리하지 않는 항목은 저장, 사용량 키 생성도 잠김', async () => {
|
||||
const env = await freshEnv(); writeManaged(env, { MAIL_FROM_NAME: '아리', SMTP_RELAY_PASS: rnd('pw'), USAGE_API_KEY: rnd('au') }); applyManaged(env);
|
||||
eq(lockedKeys(env, { MAIL_FROM_NAME: '아리', SITE_TITLE: 'x' }).length, 0, '같은 값은 변경 아님'); eq(lockedKeys(env, { MAIL_FROM_NAME: '다른' }).join(','), 'MAIL_FROM_NAME', '변경'); eq(lockedKeys(env, { SMTP_RELAY_PASS: '' }).join(','), 'SMTP_RELAY_PASS', '비밀 비우기도 변경');
|
||||
let r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '다른' } }); eq(r.status, 409, '잠금'); let j = await r.json(); eq(j.error, 'managed_by_hal', '코드'); eq(j.keys.join(','), 'MAIL_FROM_NAME', '키');
|
||||
r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '아리', SITE_TITLE: '메일' } }); eq(r.status, 200, '같은 값 + 관리하지 않는 항목'); j = await r.json(); eq(j.halManaged.managed, true, '응답에 halManaged'); eq(env.CONFIG.SITE_TITLE, '메일', '저장');
|
||||
r = await setup(env, 'usage-key', 'POST', { action: 'generate' }); eq(r.status, 409, '사용량 키 잠금');
|
||||
});
|
||||
done();
|
||||
@@ -148,6 +148,9 @@ function dnsCard() {
|
||||
|
||||
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
|
||||
const [label, help] = FIELD[key] || [key, ''];
|
||||
// 1.1.0 (HAL 0.4.0 대응): HAL 이 관리하는 항목은 잠그고 표시합니다(저장해도 서버가 409 로 거부). 잠긴 칸은 collect 가 건너뜁니다.
|
||||
const halKeys = (typeof D !== 'undefined' && D && D.halManaged && D.halManaged.keys) || [];
|
||||
if (halKeys.includes(key)) return `<label class="field"><span>${esc(label)} <span class="tag off">HAL 관리</span></span>${secret ? `<input type="password" disabled placeholder="${value ? '저장됨: ' + esc(value) : '(없음)'} — HAL 통합 설정에서 관리">` : `<input type="text" disabled value="${esc(value)}">`}<span class="help">HAL 운영자 콘솔 › 통합 설정에서 바꿉니다</span></label>`;
|
||||
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
|
||||
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||
@@ -166,8 +169,9 @@ function render() {
|
||||
const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기');
|
||||
const choices = (name, current) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
|
||||
const missing = checklist(D);
|
||||
const hal = D.halManaged && D.halManaged.managed ? `<div class="card"><b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(D.halManaged.halCompat || '')})이 관리합니다: <code>${D.halManaged.keys.map(esc).join(', ')}</code>. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다. 도메인별 설정은 그대로 여기서 관리합니다.${D.halManaged.appliedAt ? `<div class="meta muted">마지막 적용 ${esc(D.halManaged.appliedAt)}${(D.halManaged.errors || []).length ? ` · 적용 오류: ${D.halManaged.errors.map((x) => esc(x.key)).join(', ')}` : ''}</div>` : ''}</div>` : ''; // 1.1.0
|
||||
$('#main').innerHTML = `
|
||||
${domainsCard()}
|
||||
${hal}${domainsCard()}
|
||||
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
|
||||
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
|
||||
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
|
||||
@@ -222,6 +226,7 @@ function checklist(d) {
|
||||
function collect(form) {
|
||||
const patch = {};
|
||||
for (const el of form.elements) {
|
||||
if (el.disabled) continue; // 1.1.0: HAL 관리 칸
|
||||
if (!el.name) continue;
|
||||
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; }
|
||||
if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.8.0
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||
|
||||
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
||||
|
||||
## 변경 이력
|
||||
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
||||
- **1.9.0** **HAL 0.4.0 대응 — 통합 설정 관제** — Ariweb HAL 이 이 서버의 관리 콘솔 중 서버 단위 설정(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 관리한다. Node Agent 가 쓴 `DATA_ROOT/hal-managed.json` 을 기동 때·변경 때 적용하고(`lib/hal-managed.js`), HAL 관리 항목은 관리자 콘솔·설정 페이지에서 잠긴다(409 `managed_by_hal`, 설정 탭 맨 위 "HAL 관리" 안내). 파일이 없으면 예전처럼 단독 운영.
|
||||
- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61.
|
||||
- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)).
|
||||
- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'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 };
|
||||
@@ -107,6 +107,8 @@ async function aiAction(kind, body, by) {
|
||||
function save(body, by) {
|
||||
const config = require('./config');
|
||||
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 관리하는 설정 파일 항목은 설정 페이지·관리자 콘솔에서 바꿀 수 없다(409 managed_by_hal). HAL 의 적용(by 'hal')은 lib/hal-managed 가 config.set 을 직접 부른다
|
||||
{ const hm = require('./hal-managed'); const mk = hm.managedKeys(); hm.assertNotManaged(Object.keys(values || {}).filter((k) => mk.includes(k))); }
|
||||
const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산
|
||||
return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "webhosting-ai-agent",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(lib/hal-managed.js)" },
|
||||
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -958,8 +958,17 @@ for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'f
|
||||
$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||
$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||
|
||||
/** v1.9.0: HAL 통합 설정이 관리하는 항목 안내(설정 탭 맨 위). 그 항목을 저장하면 서버가 409 로 거부한다 — HAL 에서 바꾼다 */
|
||||
function renderHalManaged(h) {
|
||||
const tab = document.getElementById('tab-settings'); if (!tab) return; let el = document.getElementById('halManagedNote');
|
||||
if (!h || !h.managed || !(h.keys || []).length) { if (el) el.remove(); return; }
|
||||
if (!el) { el = document.createElement('div'); el.id = 'halManagedNote'; el.className = 'msg'; el.style.cssText = 'border:1px solid #c9a227;background:#fff8e1;color:#5d4500;padding:10px 12px;border-radius:8px;margin:0 0 12px'; tab.prepend(el); }
|
||||
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
el.innerHTML = `<b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(h.halCompat || '')})이 관리합니다. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다.<br><span style="font-family:monospace;font-size:12px">${h.keys.map(esc).join(', ')}</span>${h.appliedAt ? `<br><small>마지막 적용 ${esc(h.appliedAt)}${(h.errors || []).length ? ` · 적용 오류: ${h.errors.map((x) => esc(x.key)).join(', ')}` : ''}</small>` : ''}`;
|
||||
}
|
||||
async function loadSettings() {
|
||||
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
||||
renderHalManaged(s.halManaged); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||
loadVault();
|
||||
$('ver').textContent = s.version ? `v${s.version}` : '';
|
||||
renderTiers(s);
|
||||
|
||||
@@ -48,7 +48,8 @@ const modules = require('./lib/modules');
|
||||
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
||||
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
||||
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
|
||||
const vault = require('./lib/vault'); // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
||||
const vault = require('./lib/vault');
|
||||
const halManaged = require('./lib/hal-managed'); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응) HAL 통합 설정 관제 — 관리 항목 적용·잠금 // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
||||
const logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
|
||||
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
||||
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
|
||||
@@ -2275,11 +2276,12 @@ async function handle(req, res) {
|
||||
if (req.method === 'GET' && arg === 'preview-page' && ['index', 'login', 'site-admin'].includes(sub2)) return send(res, 200, { html: fs.readFileSync(path.join(PUBLIC_DIR, `${sub2}.html`), 'utf8') });
|
||||
if (req.method === 'POST' && arg === 'theme-preview') { const { fields: b } = await readBody(req); return send(res, 200, { css: settings.themeCss(b.theme || null) }); }
|
||||
if (req.method === 'GET' && arg === 'theme') return send(res, 200, { theme: settings.getTheme(), saved: !!settings.load().theme, defaults: settings.THEME_DEFAULTS, presets: settings.THEME_PRESETS });
|
||||
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), halManaged: halManaged.publicInfo() /* v1.9.0 */, availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
if (req.method === 'PUT') {
|
||||
const { fields: b } = await readBody(req);
|
||||
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
|
||||
if (b.welcomeSiteId) { const c = welcomeCandidates().find((x) => x.id === String(b.welcomeSiteId)); if (!c) return send(res, 400, { error: '첫 화면 페이지는 관리자 계정이 만든 사이트 중에서 고르세요' }); if (!c.published && !c.hasIndex) return send(res, 400, { error: '그 사이트에는 아직 첫 페이지(index.html)가 없습니다. 생성을 마친 뒤 고르세요' }); }
|
||||
halManaged.assertNotManaged(halManaged.lockedKeys(b)); // v1.9.0: HAL 이 관리하는 항목은 409 managed_by_hal
|
||||
settings.save(b); audit.log({ by: user.id, action: 'settings.update', targetType: 'settings', target: 'settings', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
}
|
||||
if (req.method === 'POST' && arg === 'test-connection') {
|
||||
@@ -2376,12 +2378,14 @@ async function handle(req, res) {
|
||||
if (what === 'credentials') {
|
||||
const kinds = Object.fromEntries(Object.entries(vault.KINDS).map(([k, v]) => [k, { label: v.label, fixedId: v.fixedId, defaultUrl: v.defaultUrl, secretLabel: v.secretLabel }]));
|
||||
if (req.method === 'GET' && !arg) return send(res, 200, { items: vault.list(), kinds });
|
||||
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
||||
// v1.9.0: HAL 이 관리하는 비밀값의 보관소 종류는 여기서 바꾸거나 지울 수 없다(HAL 통합 설정에서만)
|
||||
const vaultLocked = (kind) => { if (kind && halManaged.lockedVaultKinds().includes(String(kind))) throw halManaged.managedError(Object.entries(halManaged.VAULT_KIND).filter(([, v]) => v === String(kind)).map(([k]) => k)); };
|
||||
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
||||
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
|
||||
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); vaultLocked(b.kind); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await vault.test(arg));
|
||||
if (req.method === 'DELETE' && arg) { vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
||||
if (req.method === 'DELETE' && arg) { const it0 = vault.list().find((x) => x.id === arg); vaultLocked(it0 && it0.kind); vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
||||
}
|
||||
// ---- 메일 시스템 (v1.3.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
|
||||
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
||||
@@ -3219,10 +3223,12 @@ function staticFile(res, name, status = 200) {
|
||||
|
||||
http.createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
if (!res.headersSent) send(res, err.status || 500, { error: err.message });
|
||||
if (!res.headersSent) send(res, err.status || 500, { error: err.message, ...(err.code === 'managed_by_hal' ? { code: err.code, keys: err.keys } : {}) }); // v1.9.0 HAL 관리 항목 잠금은 코드·키를 함께 알린다
|
||||
else res.end();
|
||||
});
|
||||
}).listen(PORT, HOST, () => {
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 Agent 로 내려준 통합 설정(DATA_ROOT/hal-managed.json)을 적용하고, 파일이 바뀌면 다시 적용한다
|
||||
try { halManaged.start(); } catch (e) { console.error(`[hal-managed] 시작 실패: ${e.message}`); }
|
||||
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
|
||||
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
||||
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응) 자동 검증 — HAL 통합 설정(lib/hal-managed.js): 적용·결과 파일·잠금(라이브러리 + 서버 API). 외부 서비스 없음.
|
||||
// 비밀값 시험 값은 실행할 때마다 crypto 로 만든다(고정 문자열을 저장소에 두지 않는다).
|
||||
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||
const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { spawn } = require('child_process');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www'); fs.mkdirSync(process.env.SITES_ROOT, { recursive: true });
|
||||
const rnd = (p) => `${p}-${crypto.randomBytes(12).toString('hex')}`;
|
||||
const writeManaged = (dir, values) => { const hash = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex'); fs.writeFileSync(path.join(dir, 'hal-managed.json'), JSON.stringify({ format: 1, module: 'webmaker', hash, values }), { mode: 0o600 }); return hash; };
|
||||
|
||||
test('hal-managed: 파일이 없으면 아무것도 하지 않는다', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); assert.deepEqual(hm.apply(), { skipped: 'no_file' }); assert.deepEqual(hm.managedKeys(), []); assert.deepEqual(hm.lockedKeys({ marginPct: 1 }), []); assert.equal(hm.publicInfo().managed, false);
|
||||
});
|
||||
test('hal-managed: settings·설정 파일·보관소에 적용하고 결과 파일(비밀값 없음)을 남긴다 · 같은 hash 는 건너뜀 · 모르는 항목은 오류로 기록', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); const settings = require(ROOT + '/lib/settings'); const config = require(ROOT + '/lib/config'); const vault = require(ROOT + '/lib/vault');
|
||||
const key = rnd('sk-ant'); const svc = rnd('svc');
|
||||
const hash = writeManaged(tmp, { marginPct: 35, 'fx.mode': 'manual', 'fx.manualRate': 1400, signupCredit: 500, maxEdits: 7, 'auth.mode': 'api-key', 'auth.apiKey': key, serviceApiKey: svc, 'service.allowSignup': false, 'ai.maxConcurrentJobs': 3, 'nope.key': 1 });
|
||||
const r = hm.apply(); assert.equal(r.hash, hash); assert.deepEqual(r.errors.map((x) => x.key), ['nope.key']);
|
||||
const s = settings.load(); assert.equal(s.marginPct, 35); assert.equal(s.fx.mode, 'manual'); assert.equal(s.fx.manualRate, 1400); assert.equal(s.signupCredit, 500); assert.equal(s.maxEdits, 7); assert.equal(s.auth.mode, 'api-key'); assert.equal(s.serviceApiKey, svc);
|
||||
assert.equal(vault.secretOf('anthropic-api-key'), key, 'Claude 키는 보관소에'); assert.equal(config.get('service.allowSignup'), false); assert.equal(Number(config.get('ai.maxConcurrentJobs')), 3);
|
||||
const applied = fs.readFileSync(hm.APPLIED, 'utf8'); assert.ok(!applied.includes(key) && !applied.includes(svc), '결과 파일에 비밀값 없음'); const aj = JSON.parse(applied); assert.equal(aj.hash, hash); assert.equal(aj.halCompat, '0.4.0'); assert.ok(aj.keys.includes('auth.apiKey'));
|
||||
assert.equal(hm.apply().skipped, undefined, '오류가 남은 적용은 다음에 다시 시도'); writeManaged(tmp, { marginPct: 36 }); const r2 = hm.apply(); assert.deepEqual(r2.errors, []); assert.equal(settings.load().marginPct, 36); assert.equal(hm.apply().skipped, 'same_hash');
|
||||
const info = hm.publicInfo(); assert.equal(info.managed, true); assert.deepEqual(info.keys, ['marginPct']); assert.ok(!JSON.stringify(info).includes(key));
|
||||
});
|
||||
test('hal-managed: 잠금 — 관리 항목 변경은 409 managed_by_hal(마스크·빈 비밀값은 변경 아님), 설정 페이지 저장도 잠김', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); writeManaged(tmp, { marginPct: 30, 'auth.mode': 'api-key', 'auth.apiKey': rnd('k'), 'service.allowSignup': true });
|
||||
assert.deepEqual(hm.lockedKeys({ marginPct: 40, signupCredit: 1, auth: { mode: 'server-login', apiKey: '••••' } }).sort(), ['auth.mode', 'marginPct']); assert.deepEqual(hm.lockedKeys({ auth: { apiKey: '' } }), []); assert.deepEqual(hm.lockedKeys({ signupCredit: 1 }), []);
|
||||
assert.throws(() => hm.assertNotManaged(['marginPct']), (e) => e.status === 409 && e.code === 'managed_by_hal'); assert.deepEqual(hm.lockedVaultKinds(), ['anthropic-api-key']);
|
||||
assert.throws(() => require(ROOT + '/lib/setupapi').save({ values: { 'service.allowSignup': false } }, 'test'), (e) => e.status === 409 && e.code === 'managed_by_hal');
|
||||
});
|
||||
test('서버: 관리 항목 저장 409·설정 응답의 halManaged·기동 때 적용', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-srv-')); fs.writeFileSync(path.join(dir, 'setup.token'), crypto.randomBytes(16).toString('hex') + '\n', { mode: 0o600 }); writeManaged(dir, { marginPct: 33 });
|
||||
const PORT = 4000 + Math.floor(Math.random() * 90); const B = `http://127.0.0.1:${PORT}`; const jar = {};
|
||||
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/` }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
|
||||
const child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: dir, DATA_DIR: path.join(dir, 'sites'), SITES_ROOT: path.join(dir, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
try {
|
||||
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await new Promise((r) => setTimeout(r, 100)); assert.match(out, /listening on/);
|
||||
for (let i = 0; i < 50 && !fs.existsSync(path.join(dir, 'hal-managed.applied.json')); i++) await new Promise((r) => setTimeout(r, 100)); assert.ok(fs.existsSync(path.join(dir, 'hal-managed.applied.json')), '기동 때 적용');
|
||||
const tok = fs.readFileSync(path.join(dir, 'setup.token'), 'utf8').trim(); const pw = rnd('Pw1!'); assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: tok }) }, 's')).status, 200);
|
||||
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: pw }) }, 's')).status < 300); assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: pw }) }, 'r')).status, 200);
|
||||
const g = await api('/api/admin/settings', {}, 'r'); assert.equal(g.status, 200); assert.equal(g.b.marginPct, 33); assert.deepEqual(g.b.halManaged.keys, ['marginPct']); assert.equal(g.b.halManaged.halCompat, '0.4.0');
|
||||
const p = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ marginPct: 50 }) }, 'r'); assert.equal(p.status, 409); assert.equal(p.b.code, 'managed_by_hal'); assert.deepEqual(p.b.keys, ['marginPct']);
|
||||
assert.equal((await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupCredit: 10 }) }, 'r')).status, 200, '관리하지 않는 항목은 그대로 저장');
|
||||
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
|
||||
});
|
||||
Reference in New Issue
Block a user