기존 AriMail 인스턴스 ↔ HAL 관리형 MailService/Node 가져오기: hal/mailsvc.mjs(등록부·dry-run·트랜잭션 적용·되돌리기·heartbeat 동기화) + scripts/mail-discover.mjs(읽기 전용 discovery) + CLI/운영자 API/화면, 테스트 9건, RUNBOOK §9
- mail_services(도메인 단위 논리 리소스, 불변 id, 출처 권한, 도메인별 active owner 1 — 부분 유일 인덱스)·mail_imports 표 추가(기존 표 변경 없음) - discovery 문서에 비밀값이 있으면 거부(참조만 저장), 충돌·중복·누락은 전체 거부, 적용은 트랜잭션 + 감사(immutable), 재적용 멱등, 적용 단위 되돌리기 - hal/index.mjs: mail-import · mail-import-rollback · mail-services · server-token(시스템 신원 토큰, 감사) / app.mjs: /ops/api/mail-services* / ui: /ops/mail HAL 관리형 표 + 포털 중계(#portal-mail) - core.heartbeat → syncFromHeartbeat(Agent 의 arimail 모듈 상태를 last_sync·health 로 반영, 행 없으면 무관) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
03cb9c0777
commit
79b45ca961
@@ -24,6 +24,7 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
|
||||
@@ -128,6 +129,12 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
||||
// HAL 관리형 메일 서비스(legacy 가져오기, hal/mailsvc.mjs). 응답에 비밀 없음(참조만 저장).
|
||||
app.get('/ops/api/mail-services', () => { const rb = mailsvc.readback(d); return { ok: true, services: rb.services, readback: { counts: rb.counts, duplicates: rb.duplicates, perServer: rb.perServer, secretLeaks: rb.secretLeaks, generatedAt: rb.generatedAt } }; });
|
||||
app.get('/ops/api/mail-services/imports', () => ({ ok: true, imports: mailsvc.listImports(d) }));
|
||||
app.post('/ops/api/mail-services/import', wrap(async (ctx) => { const b = await ctx.body(); if (b.apply) requireRecent(ctx); const v = mailsvc.validateDiscovery(b.discovery); if (!v.ok) throw new HttpError(400, 'bad_discovery', 'discovery 문서 검증 실패', { errors: v.errors }); return { ok: true, result: mailsvc.applyImport(d, b.discovery, { serverId: b.serverId, by: ctx.operator.id, dryRun: !b.apply }) }; }));
|
||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
||||
// 릴리스·모듈 배포(§32)
|
||||
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
|
||||
@@ -12,6 +12,7 @@ import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
|
||||
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
||||
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
|
||||
@@ -148,6 +149,8 @@ export function heartbeat(d, agent, body) {
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
||||
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
||||
}
|
||||
// [legacy 메일 서비스] Agent 가 보고한 arimail 모듈 상태를 그 서버의 HAL 관리형 메일 서비스 행에 비춘다(행이 없으면 무관)
|
||||
try { syncFromHeartbeat(d, agent.server_id, body.modules || {}); } catch (e) { d.log && d.log('[hal] mail_services sync 실패', e.message); }
|
||||
// 시계 차이·디스크 고갈 경고(§28.1)
|
||||
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
|
||||
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
|
||||
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
|
||||
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
|
||||
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
|
||||
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
|
||||
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
|
||||
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
|
||||
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
|
||||
*/
|
||||
import { loadConfig } from './config.mjs';
|
||||
import { createHalDeps, VERSION } from './deps.mjs';
|
||||
@@ -15,6 +20,7 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { purgeSessions } from '../lib/session.mjs';
|
||||
import { randomToken } from '../lib/crypto.mjs';
|
||||
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
@@ -42,6 +48,23 @@ if (cmd === 'setup-token') {
|
||||
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
||||
} else if (cmd === 'bootstrap-release') {
|
||||
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
||||
} else if (cmd === 'server-token') {
|
||||
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
|
||||
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
|
||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
|
||||
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
|
||||
} else if (cmd === 'mail-import') {
|
||||
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
|
||||
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
|
||||
let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`;
|
||||
if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); }
|
||||
if (!serverId) { console.error('--server <srv_id> 또는 --create-server 가 필요합니다'); process.exit(1); }
|
||||
try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); }
|
||||
catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
||||
} else if (cmd === 'mail-import-rollback') {
|
||||
try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
||||
} else if (cmd === 'mail-services') {
|
||||
console.log(JSON.stringify(mailsvc.readback(d), null, 1));
|
||||
} else if (cmd === 'serve') {
|
||||
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
|
||||
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* hal/mailsvc.mjs — HAL 관리형 메일 서비스 등록부와 기존(legacy) AriMail 인스턴스 가져오기(importer).
|
||||
*
|
||||
* 왜 있나: 이미 운영 중인 AriMail(mail.arihakdang.com · webmail.ibit.kr 같은)을 교체·중단·재구성하지 않고, HAL 이 **등록·감시·통제**할 수 있는
|
||||
* 논리 리소스(MailService = 도메인 단위)와 노드(servers 행)로 가져온다. Data Plane(AriMail 의 송수신·계정·메일·DNS/MX·TLS·provider)은 그대로이며
|
||||
* HAL 이 멈춰도 메일 서비스는 계속 동작한다(HAL 은 제어 평면, 이 표는 등록부일 뿐이다).
|
||||
*
|
||||
* 원칙
|
||||
* - 읽기 전용 discovery: 원본 호스트에서 `scripts/mail-discover.mjs` 가 만든 문서(JSON)만 입력으로 받는다. 비밀값(토큰·키·비밀번호·해시)은 문서에 있으면 거부하고,
|
||||
* 비밀은 `secretRefs`(file:/dir:/vault:/env: 참조)로만 둔다. HAL DB 에는 참조만 저장한다.
|
||||
* - 불변 id(`mls_…`), 출처 권한(authority = `arimail:<hostname>:<unit>`), 도메인별 active owner 정확히 하나(부분 유일 인덱스).
|
||||
* - dry-run 이 기본. 계획(plan)은 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락을 거부하며, 거부가 하나라도 있으면 아무것도 적용하지 않는다(전체 거부).
|
||||
* - 적용은 한 트랜잭션, 모든 변화는 감사 기록(immutable), 적용 단위(mail_imports)로 되돌리기(생성 행 삭제·갱신 행 복원) 가능.
|
||||
* - 같은 authority·서버의 재적용은 update(집계·증거 갱신, id 유지) — 멱등.
|
||||
* - 소유 조직이 증거로 확인되지 않으면 임의 매핑하지 않고 `unassigned_legacy_managed` 로 둔다(AriMail 등록부 `ariweb.groupId` 가 있어도 포털 그룹과 대조하기 전에는 추정하지 않는다).
|
||||
* - Agent heartbeat 가 arimail 모듈 상태를 보고하면 last_sync·health 가 갱신된다(core.heartbeat → syncFromHeartbeat). Agent 가 없는 서버의 행은 import 시각만 가진다.
|
||||
*/
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
import { looksSecret } from '../lib/redact.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import * as core from './core.mjs';
|
||||
|
||||
export const DISCOVERY_FORMAT = 1;
|
||||
export const MAIL_SERVER_ROLES = ['mail', 'control'];
|
||||
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||
const SECRET_KEY_RE = /password|passwd|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|^(pass|key)$/i;
|
||||
const REF_KEY_RE = /(ref|refs|path|file|files|dir|keys|names|at|mode|owner|hint|exists|count|url)$/i;
|
||||
const REF_RE = /^(file|dir|vault|env):\S+$/;
|
||||
/** git 커밋·artifact 해시처럼 "비밀처럼 보이지만 공개 식별자" 인 값: 키 이름이 commit/sha256 류이고 값이 40/64 hex 일 때만 허용 */
|
||||
const isPublicDigest = (k, v) => /^(commit|main_?commit|sha256|artifact_?sha256|digest)$/i.test(String(k)) && /^([a-f0-9]{40}|[a-f0-9]{64})$/i.test(String(v));
|
||||
|
||||
export const MIGRATIONS = [{ id: 'hal_0003_mail_services', sql: `
|
||||
-- HAL 관리형 메일 서비스(도메인 단위 논리 리소스). 비밀은 secret_refs_json 의 참조만. 도메인별 active 는 정확히 하나.
|
||||
CREATE TABLE IF NOT EXISTS mail_services (
|
||||
id TEXT PRIMARY KEY, domain TEXT NOT NULL, server_id TEXT NOT NULL, authority TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'legacy_managed',
|
||||
ownership TEXT NOT NULL DEFAULT 'unassigned_legacy_managed', owner_group_id TEXT, active INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'managed',
|
||||
public_host TEXT, web_url TEXT, instance_json TEXT NOT NULL, domain_json TEXT, evidence_json TEXT, aggregates_json TEXT, secret_refs_json TEXT, health TEXT,
|
||||
import_id TEXT, imported_at TEXT NOT NULL, imported_by TEXT, last_sync_at TEXT, last_sync_source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mail_services_active_domain ON mail_services(domain) WHERE active=1;
|
||||
CREATE INDEX IF NOT EXISTS mail_services_server ON mail_services(server_id);
|
||||
-- 가져오기 단위(dry-run 도 기록). status: planned(dry-run) | rejected | applied | rolled_back
|
||||
CREATE TABLE IF NOT EXISTS mail_imports (
|
||||
id TEXT PRIMARY KEY, mode TEXT NOT NULL, status TEXT NOT NULL, actor TEXT, server_id TEXT, authority TEXT NOT NULL, plan_json TEXT NOT NULL,
|
||||
created_ids_json TEXT, updated_json TEXT, created_at TEXT NOT NULL, applied_at TEXT, rolled_back_at TEXT, rolled_back_by TEXT
|
||||
);` }];
|
||||
|
||||
// ------------------------------------------------------------------ 검증
|
||||
/** discovery 문서 검증. 비밀값이 어디에든 있으면 거부한다. */
|
||||
export function validateDiscovery(doc) {
|
||||
const errors = [];
|
||||
if (!doc || typeof doc !== 'object') return { ok: false, errors: ['discovery 문서가 객체가 아닙니다'] };
|
||||
if (doc.format !== DISCOVERY_FORMAT) errors.push(`format 은 ${DISCOVERY_FORMAT} 이어야 합니다`);
|
||||
if (!doc.collectedAt || isNaN(Date.parse(doc.collectedAt))) errors.push('collectedAt(ISO 시각)이 필요합니다');
|
||||
const s = doc.source;
|
||||
if (!s || typeof s !== 'object') errors.push('source 가 필요합니다');
|
||||
else { for (const k of ['hostname', 'unit', 'appDir', 'user']) if (!s[k] || typeof s[k] !== 'string') errors.push(`source.${k} 가 필요합니다`); if (!s.revision || typeof s.revision !== 'object') errors.push('source.revision 이 필요합니다'); if (!s.health || typeof s.health !== 'object') errors.push('source.health 가 필요합니다'); }
|
||||
if (!Array.isArray(doc.domains) || !doc.domains.length) errors.push('domains 배열이 필요합니다');
|
||||
else {
|
||||
const seen = new Set();
|
||||
doc.domains.forEach((x, i) => {
|
||||
if (!x || typeof x !== 'object') { errors.push(`domains[${i}] 가 객체가 아닙니다`); return; }
|
||||
const dn = String(x.domain || '').toLowerCase(); if (!DOMAIN_RE.test(dn)) errors.push(`domains[${i}] 도메인 형식 오류: ${x.domain}`);
|
||||
if (seen.has(dn)) errors.push(`도메인 중복: ${dn}`); seen.add(dn);
|
||||
if (!Array.isArray(x.hosts)) errors.push(`domains[${i}].hosts 배열이 필요합니다`);
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(doc.listeners)) errors.push('listeners 배열이 필요합니다');
|
||||
if (!doc.aggregates || typeof doc.aggregates !== 'object') errors.push('aggregates 가 필요합니다');
|
||||
if (doc.secretRefs != null) {
|
||||
if (!Array.isArray(doc.secretRefs)) errors.push('secretRefs 는 배열이어야 합니다');
|
||||
else doc.secretRefs.forEach((r, i) => { if (!r || typeof r !== 'object') { errors.push(`secretRefs[${i}] 형식 오류`); return; } if ('value' in r || 'secret' in r) errors.push(`secretRefs[${i}] 에 비밀값을 넣을 수 없습니다 — 참조만`); if (!REF_RE.test(String(r.ref || ''))) errors.push(`secretRefs[${i}].ref 는 file:/dir:/vault:/env: 형식이어야 합니다`); });
|
||||
}
|
||||
// 비밀값 탐지(이름·형태). 참조 키(…Ref/Path/File/Dir/Keys)는 허용.
|
||||
const walk = (v, path, depth) => {
|
||||
if (depth > 8 || v == null) return;
|
||||
if (typeof v === 'string') { if (looksSecret(v)) errors.push(`비밀 형태의 값: ${path}`); return; }
|
||||
if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1)); return; }
|
||||
if (typeof v === 'object') for (const [k, val] of Object.entries(v)) { if (isPublicDigest(k, val)) continue; if (SECRET_KEY_RE.test(k) && !REF_KEY_RE.test(k) && typeof val === 'string' && val !== '') errors.push(`비밀 이름의 키에 값이 있습니다: ${path}.${k}`); walk(val, `${path}.${k}`, depth + 1); }
|
||||
};
|
||||
walk(doc, 'doc', 0);
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
export const authorityOf = (doc) => `arimail:${doc.source.hostname}:${doc.source.unit}`;
|
||||
|
||||
// ------------------------------------------------------------------ 조회
|
||||
function rowToPublic(d, r) {
|
||||
if (!r) return null;
|
||||
const srv = d.store.get('SELECT name, agent_id, status, last_seen_at FROM servers WHERE id=?', r.server_id);
|
||||
return { id: r.id, domain: r.domain, serverId: r.server_id, serverName: srv?.name || null, agentId: srv?.agent_id || null, serverStatus: srv?.status || null, authority: r.authority, kind: r.kind, ownership: r.ownership, ownerGroupId: r.owner_group_id, active: !!r.active, status: r.status,
|
||||
publicHost: r.public_host, webUrl: r.web_url, instance: parseJson(r.instance_json, {}), domainInfo: parseJson(r.domain_json, {}), evidence: parseJson(r.evidence_json, {}), aggregates: parseJson(r.aggregates_json, {}), secretRefs: parseJson(r.secret_refs_json, []), health: r.health,
|
||||
importId: r.import_id, importedAt: r.imported_at, importedBy: r.imported_by, lastSyncAt: r.last_sync_at, lastSyncSource: r.last_sync_source, createdAt: r.created_at, updatedAt: r.updated_at };
|
||||
}
|
||||
export function listMailServices(d, { serverId = null } = {}) { return d.store.all(`SELECT * FROM mail_services ${serverId ? 'WHERE server_id=?' : ''} ORDER BY domain, created_at`, ...(serverId ? [serverId] : [])).map((r) => rowToPublic(d, r)); }
|
||||
export function getMailService(d, id) { return rowToPublic(d, d.store.get('SELECT * FROM mail_services WHERE id=?', id)); }
|
||||
export function listImports(d, { limit = 50 } = {}) { return d.store.all('SELECT id, mode, status, actor, server_id, authority, created_at, applied_at, rolled_back_at, rolled_back_by, plan_json, created_ids_json FROM mail_imports ORDER BY created_at DESC LIMIT ?', limit).map((r) => ({ id: r.id, mode: r.mode, status: r.status, actor: r.actor, serverId: r.server_id, authority: r.authority, createdAt: r.created_at, appliedAt: r.applied_at, rolledBackAt: r.rolled_back_at, rolledBackBy: r.rolled_back_by, summary: parseJson(r.plan_json, {}).summary || null, createdIds: parseJson(r.created_ids_json, []) })); }
|
||||
/** 적용 뒤 정확성 readback: 총수·active·도메인별 중복·서버별·비밀 누출(형태 검사). */
|
||||
export function readback(d) {
|
||||
const rows = d.store.all('SELECT * FROM mail_services ORDER BY domain'); const services = rows.map((r) => rowToPublic(d, r));
|
||||
const byDomain = {}; for (const r of rows) if (r.active) byDomain[r.domain] = (byDomain[r.domain] || 0) + 1;
|
||||
const duplicates = Object.entries(byDomain).filter(([, n]) => n > 1).map(([dn]) => dn);
|
||||
const perServer = {}; for (const r of rows) perServer[r.server_id] = (perServer[r.server_id] || 0) + 1;
|
||||
let secretLeaks = 0; const scan = (v, depth = 0) => { if (depth > 8 || v == null) return; if (typeof v === 'string') { if (looksSecret(v)) secretLeaks++; return; } if (Array.isArray(v)) v.forEach((x) => scan(x, depth + 1)); else if (typeof v === 'object') Object.entries(v).forEach(([k, x]) => { if (!isPublicDigest(k, x)) scan(x, depth + 1); }); };
|
||||
for (const r of rows) scan([parseJson(r.instance_json, null), parseJson(r.evidence_json, null), parseJson(r.aggregates_json, null), parseJson(r.secret_refs_json, null), parseJson(r.domain_json, null)]);
|
||||
return { counts: { total: rows.length, active: rows.filter((r) => r.active).length, domains: Object.keys(byDomain).length, byDomain }, duplicates, perServer, secretLeaks, services, generatedAt: nowIso() };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 계획(dry-run)
|
||||
/** 계획 세우기. 거부(reject)가 하나라도 있으면 plan.ok=false. 서버 없음·역할·authority 불일치는 예외로 던진다(문서 전체가 대상이 아님). */
|
||||
export function planImport(d, doc, { serverId }) {
|
||||
const v = validateDiscovery(doc); if (!v.ok) throw new JobError('bad_discovery', `discovery 문서 검증 실패: ${v.errors.join(' · ')}`, { errors: v.errors });
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
if (!MAIL_SERVER_ROLES.includes(srv.role)) throw new JobError('bad_role', `메일 서비스를 둘 서버 역할은 ${MAIL_SERVER_ROLES.join('/')} 여야 합니다(현재 ${srv.role})`);
|
||||
const facts = srv.facts || {}; if (facts.hostname && facts.hostname !== doc.source.hostname) throw new JobError('authority_mismatch', `서버 facts 의 hostname(${facts.hostname})과 discovery 의 hostname(${doc.source.hostname})이 다릅니다`);
|
||||
if (facts.units && facts.units.arimail && facts.units.arimail !== doc.source.unit.replace(/\.service$/, '')) throw new JobError('authority_mismatch', `서버 facts 의 arimail 유닛(${facts.units.arimail})과 discovery 의 유닛(${doc.source.unit})이 다릅니다`);
|
||||
const authority = authorityOf(doc); const items = [];
|
||||
for (const x of doc.domains) {
|
||||
const domain = String(x.domain).toLowerCase();
|
||||
if (x.enabled === false) { items.push({ domain, action: 'skip', reason: 'disabled_in_source' }); continue; }
|
||||
const ex = d.store.get('SELECT * FROM mail_services WHERE domain=? AND active=1', domain);
|
||||
if (!ex) items.push({ domain, action: 'create' });
|
||||
else if (ex.authority === authority && ex.server_id === serverId) items.push({ domain, action: 'update', existingId: ex.id });
|
||||
else items.push({ domain, action: 'reject', reason: `collision: 도메인 ${domain} 의 active owner 는 이미 ${ex.id}(${ex.authority}, 서버 ${ex.server_id}) 입니다`, existingId: ex.id });
|
||||
}
|
||||
const present = new Set(doc.domains.map((x) => String(x.domain).toLowerCase()));
|
||||
const missingInSource = d.store.all('SELECT domain FROM mail_services WHERE authority=? AND server_id=? AND active=1', authority, serverId).map((r) => r.domain).filter((dn) => !present.has(dn));
|
||||
const summary = { create: 0, update: 0, reject: 0, skip: 0, missingInSource: missingInSource.length }; for (const i of items) summary[i.action]++;
|
||||
return { ok: summary.reject === 0, authority, serverId, serverName: srv.name, items, missingInSource, summary, collectedAt: doc.collectedAt };
|
||||
}
|
||||
|
||||
const domainView = (x) => ({ domain: String(x.domain).toLowerCase(), enabled: x.enabled !== false, primary: !!x.primary, hosts: (x.hosts || []).map(String), authMode: x.authMode || null, operating: x.operating || null, ariweb: x.ariweb || null, note: x.note || null, tenantDb: x.tenantDb || null, createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
||||
function buildRow(doc, x, serverId, authority) {
|
||||
const dv = domainView(x); const host = dv.hosts[0] ? String(dv.hosts[0]).split('/')[0] : null; const agg = (doc.aggregates.byDomain || {})[dv.domain] || null;
|
||||
const s = doc.source;
|
||||
const instance = { hostname: s.hostname, unit: s.unit, appDir: s.appDir, currentTarget: s.currentTarget || null, user: s.user, nodeBin: s.nodeBin || null, nodeVersion: s.nodeVersion || null, revision: s.revision || {}, packageVersion: s.packageVersion || null, healthUrl: s.healthUrl || null, health: s.health || null, unitState: s.unitState || null, harakaUnit: s.harakaUnit || null, dataDir: s.dataDir || null, listeners: doc.listeners, provider: doc.provider || null, collector: doc.collector || null, collectedAt: doc.collectedAt };
|
||||
return { domain: dv.domain, server_id: serverId, authority, public_host: host, web_url: host ? `https://${host}/` : null, instance_json: toJson(instance), domain_json: toJson(dv), evidence_json: toJson(doc.evidence || {}), aggregates_json: toJson(agg), secret_refs_json: toJson(doc.secretRefs || []), health: s.health && s.health.ok === false ? 'down' : s.unitState && s.unitState.active && s.unitState.active !== 'active' ? 'down' : 'ok' };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 적용
|
||||
/**
|
||||
* dry-run(기본) 또는 적용. 결과 { importId, mode: 'dry_run'|'rejected'|'apply', plan, created[], updated[] }.
|
||||
* 거부가 있으면 아무것도 적용하지 않고 mode='rejected' 로 기록만 남긴다.
|
||||
*/
|
||||
export function applyImport(d, doc, { serverId, by = null, dryRun = true }) {
|
||||
const plan = planImport(d, doc, { serverId }); const authority = plan.authority; const importId = newId('mimp_'); const now = nowIso();
|
||||
const actorType = by ? 'operator' : 'system';
|
||||
if (dryRun || !plan.ok) {
|
||||
const mode = plan.ok ? 'dry_run' : 'rejected';
|
||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_at) VALUES(?,?,?,?,?,?,?,?)', importId, mode, plan.ok ? 'planned' : 'rejected', by, serverId, authority, toJson(plan), now);
|
||||
audit(d.store, { actorType, actorId: by, action: plan.ok ? 'mail_service.import_dry_run' : 'mail_service.import_rejected', subjectType: 'server', subjectId: serverId, detail: { importId, authority, summary: plan.summary, rejected: plan.items.filter((i) => i.action === 'reject').map((i) => i.domain) } });
|
||||
return { importId, mode, plan, created: [], updated: [] };
|
||||
}
|
||||
return d.store.tx(() => {
|
||||
const created = []; const updated = [];
|
||||
for (const item of plan.items) {
|
||||
if (item.action === 'skip') continue;
|
||||
const x = doc.domains.find((y) => String(y.domain).toLowerCase() === item.domain); const row = buildRow(doc, x, serverId, authority);
|
||||
if (item.action === 'create') {
|
||||
const id = newId('mls_');
|
||||
d.store.run('INSERT INTO mail_services(id, domain, server_id, authority, kind, ownership, owner_group_id, active, status, public_host, web_url, instance_json, domain_json, evidence_json, aggregates_json, secret_refs_json, health, import_id, imported_at, imported_by, last_sync_at, last_sync_source, created_at, updated_at) VALUES(?,?,?,?,\'legacy_managed\',\'unassigned_legacy_managed\',NULL,1,\'managed\',?,?,?,?,?,?,?,?,?,?,?,?,\'import\',?,?)',
|
||||
id, row.domain, row.server_id, row.authority, row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, importId, now, by, now, now, now);
|
||||
created.push(id); item.createdId = id;
|
||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: id, immutable: true, detail: { importId, domain: row.domain, serverId, authority, publicHost: row.public_host, aggregates: parseJson(row.aggregates_json, null) } });
|
||||
} else if (item.action === 'update') {
|
||||
const before = d.store.get('SELECT * FROM mail_services WHERE id=?', item.existingId);
|
||||
d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=\'import\', updated_at=? WHERE id=?', row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, now, now, item.existingId);
|
||||
updated.push({ id: item.existingId, before });
|
||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: item.existingId, immutable: true, detail: { importId, domain: row.domain, serverId, authority, update: true } });
|
||||
}
|
||||
}
|
||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_ids_json, updated_json, created_at, applied_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', importId, 'apply', 'applied', by, serverId, authority, toJson(plan), toJson(created), toJson(updated), now, now);
|
||||
return { importId, mode: 'apply', plan, created, updated: updated.map((u) => u.id) };
|
||||
});
|
||||
}
|
||||
/** 되돌리기: 그 적용이 만든 행 삭제, 갱신한 행은 이전 값으로 복원. */
|
||||
export function rollbackImport(d, importId, by = null) {
|
||||
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', importId); if (!imp) throw new JobError('import_not_found', '가져오기 기록이 없습니다');
|
||||
if (imp.status === 'rolled_back') throw new JobError('already_rolled_back', '이미 되돌린 가져오기입니다');
|
||||
if (imp.status !== 'applied') throw new JobError('not_applied', `적용된 가져오기만 되돌릴 수 있습니다(상태 ${imp.status})`);
|
||||
return d.store.tx(() => {
|
||||
let deleted = 0, restored = 0;
|
||||
for (const id of parseJson(imp.created_ids_json, [])) deleted += d.store.run('DELETE FROM mail_services WHERE id=? AND import_id=?', id, importId).changes;
|
||||
for (const u of parseJson(imp.updated_json, [])) { const b = u.before; if (!b) continue; restored += d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=?, updated_at=? WHERE id=?', b.public_host, b.web_url, b.instance_json, b.domain_json, b.evidence_json, b.aggregates_json, b.secret_refs_json, b.health, b.last_sync_at, b.last_sync_source, nowIso(), u.id).changes; }
|
||||
d.store.run('UPDATE mail_imports SET status=\'rolled_back\', rolled_back_at=?, rolled_back_by=? WHERE id=?', nowIso(), by, importId);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'mail_service.import_rollback', subjectType: 'server', subjectId: imp.server_id, immutable: true, detail: { importId, deleted, restored } });
|
||||
return { importId, deleted, restored };
|
||||
});
|
||||
}
|
||||
/** heartbeat 연동(core.heartbeat 가 부른다): 그 서버의 메일 서비스 행에 Agent 관측 상태를 비춘다. 행이 없으면 아무것도 하지 않는다. */
|
||||
export function syncFromHeartbeat(d, serverId, modules = {}) {
|
||||
const m = modules && modules.arimail; if (!m) return 0;
|
||||
const rows = d.store.all('SELECT id, instance_json FROM mail_services WHERE server_id=?', serverId); if (!rows.length) return 0; const now = nowIso();
|
||||
for (const r of rows) { const inst = parseJson(r.instance_json, {}); inst.observedVersion = m.version || null; inst.observedActive = !!m.active; inst.observedAt = now; d.store.run('UPDATE mail_services SET health=?, last_sync_at=?, last_sync_source=\'agent\', instance_json=?, updated_at=? WHERE id=?', m.health || 'unknown', now, toJson(inst), now, r.id); }
|
||||
return rows.length;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as session from '../lib/session.mjs';
|
||||
import * as jobs from '../lib/jobs.mjs';
|
||||
import * as notify from '../lib/notify.mjs';
|
||||
import * as audit from '../lib/audit.mjs';
|
||||
import { MIGRATIONS as MAIL_MIGRATIONS } from './mailsvc.mjs';
|
||||
|
||||
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
|
||||
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
|
||||
@@ -85,4 +86,6 @@ CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_js
|
||||
` },
|
||||
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
|
||||
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
|
||||
// [legacy 메일 서비스 가져오기] HAL 관리형 메일 서비스 등록부·가져오기 단위(hal/mailsvc.mjs). 표 추가만(기존 표 변경 없음).
|
||||
...MAIL_MIGRATIONS,
|
||||
];
|
||||
|
||||
+20
-1
@@ -22,6 +22,7 @@ import * as core from './core.mjs';
|
||||
import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
|
||||
@@ -64,7 +65,9 @@ export function registerUi(app, d) {
|
||||
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
|
||||
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
|
||||
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
|
||||
relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||
// 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다).
|
||||
app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
|
||||
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
||||
// ---------------- 서버(§28)
|
||||
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
@@ -109,3 +112,19 @@ export function overviewLocal(d) {
|
||||
}
|
||||
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
|
||||
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
|
||||
|
||||
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
|
||||
function mailServicesView(rb) {
|
||||
const warn = []; if (rb.duplicates.length) warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
|
||||
const t = R.table([
|
||||
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
|
||||
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
|
||||
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
|
||||
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
|
||||
{ l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' },
|
||||
{ l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
|
||||
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
|
||||
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
|
||||
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
|
||||
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const PAGES = {
|
||||
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
mail: async () => { try { body().innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } },
|
||||
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
|
||||
Reference in New Issue
Block a user