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; }
|
||||
|
||||
Reference in New Issue
Block a user