사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:56:38 +00:00
co-authored by Claude Fable 5.1
parent a1cde81332
commit b1c06a0304
32 changed files with 1900 additions and 3 deletions
+33
View File
@@ -0,0 +1,33 @@
/**
* portal/domains/api.mjs — 도메인 라우트. 화면 /domains, /domains/connect, /domains/:id · API /api/domains(GET,POST) /api/domains/:id(GET,PATCH,DELETE) /records /check(POST) /primary(POST) /jobs/:jobId/:action
* 운영자: /internal/ops/domains (전체 도메인·반복 실패·만료). 권한: 도메인 변경은 사이트 권한 domain_mail(그룹 admin 이상 포함).
*/
import { requireGroup, requireSitePerm, requireRole } from '../auth/guard.mjs';
import { HttpError } from '../../lib/http.mjs';
import { verifyCtx } from '../../lib/serviceauth.mjs';
import { describe as describeJob } from '../../lib/jobs.mjs';
import { createDomainsService } from './service.mjs';
import * as ui from './ui.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function register(app, deps) {
const domains = createDomainsService(deps); deps.domains = domains;
app.static('/ui/domains', join(HERE, 'public'));
const domainOf = (ctx, perm = 'view') => { requireGroup(ctx); const d = domains.getForGroup(ctx.group.id, ctx.params.id); if (!d) throw new HttpError(404, 'not_found', '도메인을 찾을 수 없습니다'); if (perm === 'view') return d; if (d.site_id) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', d.site_id); if (s) requireSitePerm(ctx, s, perm); } else requireRole(ctx, 'admin'); return d; };
app.get('/domains', (ctx) => { requireGroup(ctx); return ctx.html(ui.listPage(ctx, deps)); });
app.get('/domains/connect', (ctx) => { requireGroup(ctx); return ctx.html(ui.connectPage(ctx, deps, { siteId: ctx.query.site || '' })); });
app.get('/domains/:id', (ctx) => ctx.html(ui.detailPage(ctx, deps, domainOf(ctx))));
app.get('/api/domains', (ctx) => { requireGroup(ctx); return { ok: true, domains: domains.list(ctx.group.id, { siteId: ctx.query.site || null, mailServiceId: ctx.query.mail || null }) }; });
app.post('/api/domains', async (ctx) => { requireGroup(ctx); const b = await ctx.body(); if (b.siteId) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', b.siteId); if (!s || s.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); requireSitePerm(ctx, s, 'domain_mail'); } else requireRole(ctx, 'member'); const d = domains.add({ group: ctx.group, user: ctx.user, ip: ctx.ip }, b); const job = b.startCheck !== false ? domains.startCheck(d, ctx.user, { makePrimary: !!b.makePrimary }) : null; return { ok: true, domain: domains.detail(domains.get(d.id)), records: await domains.requiredRecords(d), job }; });
app.get('/api/domains/:id', async (ctx) => { const d = domainOf(ctx); return { ok: true, domain: domains.detail(d), records: await domains.requiredRecords(d) }; });
app.get('/api/domains/:id/records', async (ctx) => ({ ok: true, records: await domains.requiredRecords(domainOf(ctx)) }));
app.patch('/api/domains/:id', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); return { ok: true, domain: domains.detail(domains.update(d, await ctx.body(), ctx.user)) }; });
app.delete('/api/domains/:id', (ctx) => { const d = domainOf(ctx, 'domain_mail'); return { ok: true, ...domains.release(d, ctx.user) }; });
app.post('/api/domains/:id/check', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const b = await ctx.body(); return { ok: true, job: domains.startCheck(d, ctx.user, { makePrimary: !!b.makePrimary }) }; });
app.post('/api/domains/:id/primary', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const b = await ctx.body(); return { ok: true, domain: domains.detail(domains.setPrimary(d, { redirectOld: !!b.redirectOld, actor: ctx.user })) }; });
app.post('/api/domains/:id/jobs/:jobId/:action', async (ctx) => { const d = domainOf(ctx, 'domain_mail'); const j = deps.jobs.get(ctx.params.jobId); if (!j || j.subject_id !== d.id) throw new HttpError(404, 'not_found', '작업을 찾을 수 없습니다'); const a = ctx.params.action; const r = a === 'retry' ? deps.jobs.retry(j.id, ctx.user.id) : a === 'cancel' ? deps.jobs.cancel(j.id, ctx.user.id) : a === 'resume' ? deps.jobs.resume(j.id, ctx.user.id) : null; if (!r) throw new HttpError(400, 'bad_action', '알 수 없는 동작'); return { ok: true, job: describeJob(r) }; });
const secretOf = (id) => deps.vault.get('service:' + id);
app.get('/internal/ops/domains', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); const rows = deps.store.all('SELECT * FROM domains ORDER BY updated_at DESC LIMIT 1000'); const soon = new Date(Date.now() + 14 * 86400000).toISOString(); return { ok: true, domains: rows.map((r) => ({ ...domains.summarize(domains.get(r.id)), groupId: r.group_id })), expiring: rows.filter((r) => r.cert_expires_at && r.cert_expires_at < soon).map((r) => ({ id: r.id, name: r.name, expiresAt: r.cert_expires_at })), repeatedFailures: rows.map((r) => domains.summarize(domains.get(r.id))).filter((s) => s.recentFailures >= 3) }; });
app.post('/internal/ops/domains/sweep', async (ctx) => { const v = await verifyCtx(ctx, (id) => (['hal', 'portal'].includes(id) ? secretOf(id) : null)); if (!v.ok) throw new HttpError(401, 'service_auth_failed', v.reason); return { ok: true, ...domains.sweep() }; });
}
+15
View File
@@ -0,0 +1,15 @@
/**
* portal/domains/model.mjs — 도메인 영역 세부 표(중심 표 domains 는 portal/core/model.mjs).
* domain_checks 확인 기록(소유·DNS·인증서·외부 접속)의 시계열 — 반복 실패·만료 표시(§30.1)
* domains 표에 확인 결과 세부(dns_detail_json)·인증서 실패 사유·외부 확인 결과 열을 더한다.
*/
export const MIGRATIONS = [{ id: 'domains_0001', sql: `
CREATE TABLE IF NOT EXISTS domain_checks (id TEXT PRIMARY KEY, domain_id TEXT NOT NULL, kind TEXT NOT NULL, ok INTEGER NOT NULL, detail_json TEXT, checked_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS domain_checks_domain ON domain_checks(domain_id, checked_at);
ALTER TABLE domains ADD COLUMN dns_detail_json TEXT;
ALTER TABLE domains ADD COLUMN cert_error TEXT;
ALTER TABLE domains ADD COLUMN external_check_json TEXT;
ALTER TABLE domains ADD COLUMN expected_ip TEXT;
ALTER TABLE domains ADD COLUMN redirect_from TEXT;
ALTER TABLE domains ADD COLUMN created_by TEXT;
` }];
+22
View File
@@ -0,0 +1,22 @@
/* portal/domains/public/domains.js — 도메인 화면 스크립트(목록·연결·상세). */
(() => {
const $ = (s, r = document) => r.querySelector(s); const esc = aw.esc; const fmt = aw.fmt;
const OWN = { pending: ['warning', '확인 전'], verified: ['ok', '확인됨'] }; const DNS = { pending: ['warning', '미반영'], wrong: ['failed', '잘못된 값'], ok: ['ok', '정상'], customer: ['neutral', '고객 관리'], 'n/a': ['neutral', '해당 없음'] }; const CERT = { none: ['neutral', '없음'], issuing: ['running', '발급 중'], valid: ['ok', '정상'], expiring_soon: ['warning', '만료 임박'], failed: ['failed', '실패'], customer_managed: ['neutral', '고객 관리'] }; const MDNS = { ok: ['ok', '정상'], warn: ['warning', '권장 개선'], error: ['failed', '오류'], unknown: ['neutral', '확인 전'] };
const badge = (m, k) => { const v = m[k] || ['neutral', k || '-']; return `<span class="badge ${v[0]}">${({ ok: '●', failed: '✘', warning: '!', running: '◌', neutral: '○' })[v[0]]} ${esc(v[1])}</span>`; };
const onErr = (el, e, retry) => aw.state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' || e.status === 503 ? 'stale' : 'failed', e.message, { retry });
async function list() { const el = $('#list'); try { const r = await aw.api('GET', '/api/domains'); if (!r.domains.length) return aw.state(el, 'empty', '연결된 도메인이 없습니다', { href: '/domains/connect', hrefLabel: '도메인 연결' }); el.innerHTML = `<table class="r"><thead><tr><th>도메인</th><th>연결</th><th>용도</th><th>소유 확인</th><th>DNS</th><th>HTTPS</th><th>메일 보안</th><th>마지막 확인</th></tr></thead><tbody>${r.domains.map((d) => `<tr><td data-l="도메인"><a href="/domains/${d.id}"><b>${esc(d.name)}</b></a>${d.isPrimary ? ' ★' : ''}${d.actionNeeded ? `<br><a href="${esc(d.actionNeeded.href)}">! ${esc(d.actionNeeded.text)}</a>` : ''}</td><td data-l="연결">${d.site ? `사이트 <a href="/sites/${d.site.id}">${esc(d.site.name)}</a>` : ''}${d.mailService ? `<br>메일 <a href="/mail/${d.mailService.id}">${esc(d.mailService.name)}</a>` : ''}${!d.site && !d.mailService ? '<span class="muted">없음</span>' : ''}</td><td data-l="용도">${esc({ web: '웹', mail: '메일', both: '웹+메일' }[d.purpose])}</td><td data-l="소유">${badge(OWN, d.ownershipState)}</td><td data-l="DNS">${badge(DNS, d.dnsState)}</td><td data-l="HTTPS">${badge(CERT, d.certState)}${d.certExpiresAt ? `<br><span class="sub">만료 ${esc(d.certExpiresAt.slice(0, 10))}</span>` : ''}</td><td data-l="메일 보안">${d.mailDnsState ? badge(MDNS, d.mailDnsState) : '-'}</td><td data-l="확인">${d.lastCheckedAt ? esc(fmt(d.lastCheckedAt)) : '-'}${d.recentFailures ? `<br><span class="err">최근 실패 ${d.recentFailures}회</span>` : ''}</td></tr>`).join('')}</tbody></table>`; } catch (e) { onErr(el, e, list); } }
async function connect() { const f = $('#connect'); const sel = $('#siteId'); try { const r = await aw.api('GET', '/api/sites'); sel.innerHTML = '<option value="">선택 안 함</option>' + r.sites.map((s) => `<option value="${s.id}" ${f.dataset.site === s.id ? 'selected' : ''}>${esc(s.name)} (${esc(s.lifecycle)})</option>`).join(''); } catch (_) {}
f.onsubmit = async (e) => { e.preventDefault(); f.querySelectorAll('.err').forEach((x) => { x.textContent = ''; }); const fd = new FormData(f); try { const r = await aw.api('POST', '/api/domains', { name: fd.get('name'), purpose: fd.get('purpose'), siteId: fd.get('siteId') || null, makePrimary: fd.get('makePrimary') === 'on' }); location.href = `/domains/${r.domain.id}`; } catch (err) { (f.querySelector(`[data-err="${err.detail && err.detail.field || 'form'}"]`) || f.querySelector('[data-err=form]')).innerHTML = esc(err.message) + (err.detail && err.detail.href ? ` <a href="${esc(err.detail.href)}">열기</a>` : ''); } }; }
async function detail() { const el = $('#detail'); const id = el.dataset.domain;
const load = async () => { try { const r = await aw.api('GET', `/api/domains/${id}`); const d = r.domain; const job = d.job;
el.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>용도</dt><dd>${esc({ web: '웹', mail: '메일', both: '웹+메일' }[d.purpose])}</dd><dt>연결</dt><dd>${d.site ? `사이트 <a href="/sites/${d.site.id}">${esc(d.site.name)}</a>${d.site.deployMode === 'external' ? ' (외부 배포 — DNS·인증서는 고객 관리)' : ''}` : '사이트 없음'}${d.mailService ? ` · 메일 <a href="/mail/${d.mailService.id}">${esc(d.mailService.name)}</a>` : ''}</dd><dt>대표 도메인</dt><dd>${d.isPrimary ? '★ 대표' : d.site ? '<button class="btn" id="btnPrimary">대표로 지정</button>' : '-'}${d.redirectFrom ? `<br><span class="sub">이전 주소 ${esc(d.redirectFrom)} 는 이 도메인으로 이동 처리</span>` : ''}</dd><dt>소유 확인</dt><dd>${badge(OWN, d.ownershipState)}</dd><dt>DNS</dt><dd>${badge(DNS, d.dnsState)}${d.dnsDetail && d.dnsDetail.got ? ` <span class="sub">지금 값: ${esc(d.dnsDetail.got.join(', ') || '없음')} / 필요 ${esc(d.dnsDetail.expected)}</span>` : ''}</dd><dt>HTTPS 인증서</dt><dd>${badge(CERT, d.certState)}${d.certExpiresAt ? ` <span class="sub">만료 ${esc(d.certExpiresAt.slice(0, 10))}</span>` : ''}${d.certError ? `<br><span class="err">${esc(d.certError)}</span>` : ''}</dd>${d.mailDnsState ? `<dt>메일 보안 레코드</dt><dd>${badge(MDNS, d.mailDnsState)} <a href="/mail?domain=${esc(d.name)}">메일 화면에서 확인</a></dd>` : ''}<dt>마지막 외부 확인</dt><dd>${d.externalCheck ? `${d.externalCheck.ok ? '정상' : '실패'} (${d.externalCheck.status || '-'}, TLS ${d.externalCheck.tls && d.externalCheck.tls.valid ? '유효' : '무효/미확인'})` : '-'}</dd><dt>마지막 확인</dt><dd>${d.lastCheckedAt ? esc(fmt(d.lastCheckedAt)) : '-'}</dd></dl><div class="actions"><button class="btn primary" id="btnCheck">다시 확인</button><button class="btn danger" id="btnRelease">연결 해제</button></div></div>
<div class="card"><h2>필요한 DNS 값</h2><p class="hint">DNS 를 직접 관리하면 아래 값을 그대로 넣으세요. 미반영(레코드 없음)과 잘못된 값(다른 값)을 구분해 표시합니다.</p><table class="r"><thead><tr><th>종류</th><th>이름</th><th>값</th><th>용도</th><th>상태</th></tr></thead><tbody>${r.records.map((x) => `<tr><td data-l="종류">${esc(x.type)}</td><td data-l="이름"><span class="mono copy" data-action="copy" data-copy="${esc(x.name)}">${esc(x.name)}</span></td><td data-l="값">${x.value ? `<span class="mono copy" data-action="copy" data-copy="${esc(x.value)}">${esc(x.value)}</span>` : `<span class="muted">${esc(x.note || '-')}</span>`}</td><td data-l="용도">${esc(x.purpose)}</td><td data-l="상태">${esc({ ok: '정상', pending: '미반영', wrong: '잘못된 값', optional: '선택', customer: '고객 관리', unknown: '확인 전', 'n/a': '-' }[x.state] || x.state || '-')}</td></tr>`).join('')}</tbody></table></div></div>
<div class="card"><h2>연결 진행</h2><div id="job"></div></div><div class="card"><h2>확인 기록</h2>${d.checks.length ? `<table class="r"><thead><tr><th>시각</th><th>종류</th><th>결과</th></tr></thead><tbody>${d.checks.map((c) => `<tr><td data-l="시각">${esc(fmt(c.checked_at))}</td><td data-l="종류">${esc(c.kind)}</td><td data-l="결과">${c.ok ? '✔ 성공' : '✘ 실패'} <span class="sub">${esc(JSON.stringify(c.detail || {})).slice(0, 160)}</span></td></tr>`).join('')}</tbody></table>` : '<p class="muted">기록 없음</p>'}</div>`;
if (job) aw.job($('#job'), job, { onAction: async (a, j) => { try { await aw.api('POST', `/api/domains/${id}/jobs/${j.id}/${a === 'go' ? 'resume' : a}`); load(); } catch (e) { aw.toast(e.message); } } }); else $('#job').innerHTML = '<p class="muted">진행 중인 확인 작업이 없습니다. "다시 확인" 으로 시작하세요</p>';
$('#btnCheck').onclick = async () => { try { await aw.api('POST', `/api/domains/${id}/check`, {}); aw.toast('확인을 시작했습니다'); load(); } catch (e) { aw.toast(e.message); } };
const bp = $('#btnPrimary'); if (bp) bp.onclick = async () => { const redirect = confirm('기존 대표 주소를 새 대표 도메인으로 이동(301) 처리할까요?'); try { await aw.api('POST', `/api/domains/${id}/primary`, { redirectOld: redirect }); aw.toast('대표 도메인으로 지정했습니다'); load(); } catch (e) { aw.toast(e.message + (e.detail ? ` (소유 ${e.detail.ownership}, DNS ${e.detail.dns}, 인증서 ${e.detail.cert})` : '')); } };
$('#btnRelease').onclick = async () => { if (!(await aw.confirmDanger({ title: '도메인 연결 해제', target: d.name, impacts: [d.isPrimary ? '대표 도메인이라 사이트가 "도메인 설정 필요" 상태가 됩니다' : '이 도메인으로의 접속이 끊깁니다', '인증서·DNS 상태 기록이 지워집니다(도메인 자체는 등록기관에 그대로 있음)'], typeToConfirm: d.name }))) return; try { await aw.api('DELETE', `/api/domains/${id}`); location.href = '/domains'; } catch (e) { aw.toast(e.message); } };
return !!(job && ['queued', 'running'].includes(job.status)); } catch (e) { onErr(el, e, load); return false; } };
if (await load()) aw.poll(load, 4000); }
const page = document.querySelector('[data-page]')?.dataset.page; ({ 'domains-list': list, 'domains-connect': connect, 'domains-detail': detail }[page] || (() => {}))();
})();
+167
View File
@@ -0,0 +1,167 @@
/**
* portal/domains/service.mjs — 도메인과 HTTPS 업무 규칙(개발 지시 §12·§30.1).
*
* 연결 흐름(§12.2)을 lib/jobs 작업 'domain.connect' 로 구현한다: 소유 확인(TXT) → DNS 반영 확인 → HTTPS 인증서 발급 → 외부 접속 확인 → (요청 시) 대표 도메인 지정.
* 사용자가 DNS 를 직접 바꾸는 동안은 '외부 작업 대기'(AWAITING_EXTERNAL)로 멈추고 "다시 확인" 으로 이어 간다. 미반영(레코드 없음)과 잘못된 값(다른 값)을 구분한다.
* 규칙
* - 같은 도메인은 한 그룹에만 속한다(UNIQUE). 다른 그룹에 이미 있으면 누가 쓰는지 드러내지 않고 거부한다(§30.1·§37).
* - 한 사이트에 여러 웹 도메인을 연결해도 운영 사이트 수는 하나(§12.3). 대표 도메인은 하나이며, DNS·HTTPS 가 정상이어야 지정할 수 있다.
* - 유효하지 않은 인증서를 '정상 HTTPS' 로 표시하지 않는다: cert_state 는 none | issuing | valid | expiring_soon | failed | customer_managed(외부 배포).
* - 등록기관·네임서버·레코드 자동화는 Webmaker 의 도메인 탭(SSO)에서 그대로 쓰고, 포털은 상태와 검증만 가진다(§0.1).
*/
import { newId, randomToken } from '../../lib/crypto.mjs';
import { HttpError } from '../../lib/http.mjs';
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
import { JobError, describe as describeJob } from '../../lib/jobs.mjs';
import { notify, resolveIncident } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
const NAME_RE = /^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
export const CONNECT_STEPS = [{ id: 'ownership', title: '소유 확인' }, { id: 'dns', title: 'DNS 반영 확인' }, { id: 'cert', title: 'HTTPS 인증서 발급' }, { id: 'external_check', title: '외부 접속 확인' }, { id: 'primary', title: '대표 도메인 지정' }];
const EXPIRING_DAYS = 14;
export function createDomainsService(deps) {
const { store, jobs, log = () => {} } = deps;
const hal = () => { if (!deps.hal) throw Object.assign(new Error('HAL 연결이 설정되지 않았습니다'), { code: 'service_unreachable' }); return deps.hal; };
const row = (r) => (r ? { ...r, dnsDetail: parseJson(r.dns_detail_json, null), externalCheck: parseJson(r.external_check_json, null), check: parseJson(r.check_json, null) } : null);
const get = (id) => row(store.get('SELECT * FROM domains WHERE id=?', id));
const getForGroup = (groupId, id) => { const d = get(id); return d && d.group_id === groupId ? d : null; };
const byName = (name) => row(store.get('SELECT * FROM domains WHERE name=?', normalize(name)));
const save = (id, patch) => { const sets = []; const vals = []; for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } sets.push('updated_at=?'); vals.push(nowIso()); store.run(`UPDATE domains SET ${sets.join(', ')} WHERE id=?`, ...vals, id); return get(id); };
const record = (domainId, kind, ok, detail) => store.run('INSERT INTO domain_checks(id, domain_id, kind, ok, detail_json, checked_at) VALUES(?,?,?,?,?,?)', newId('dc_'), domainId, kind, ok ? 1 : 0, toJson(detail || null), nowIso());
function normalize(name) { return String(name || '').trim().toLowerCase().replace(/\.$/, '').replace(/^https?:\/\//, '').split('/')[0]; }
/** 그룹의 도메인 목록(§12.1). site 로 좁힐 수 있다 */
function list(groupId, { siteId = null, mailServiceId = null } = {}) {
const rows = store.all(`SELECT * FROM domains WHERE group_id=? ${siteId ? 'AND site_id=?' : ''} ${mailServiceId ? 'AND mail_service_id=?' : ''} ORDER BY is_primary DESC, name`, ...[groupId, ...(siteId ? [siteId] : []), ...(mailServiceId ? [mailServiceId] : [])]).map(row);
return rows.map(summarize);
}
function summarize(d) {
const site = d.site_id ? store.get('SELECT id, name, deploy_mode, lifecycle FROM sites WHERE id=?', d.site_id) : null; const ms = d.mail_service_id ? store.get('SELECT id, name, status FROM mail_services WHERE id=?', d.mail_service_id) : null;
const fails = store.get('SELECT COUNT(*) AS n FROM domain_checks WHERE domain_id=? AND ok=0 AND checked_at>?', d.id, new Date(Date.now() - 7 * 86400000).toISOString()).n;
return { id: d.id, name: d.name, purpose: d.purpose, site: site ? { id: site.id, name: site.name, deployMode: site.deploy_mode } : null, mailService: ms ? { id: ms.id, name: ms.name, status: ms.status } : null, isPrimary: !!d.is_primary, ownershipState: d.ownership_state, dnsState: d.dns_state, certState: d.cert_state, certExpiresAt: d.cert_expires_at, certError: d.cert_error, mailDnsState: d.mail_dns_state || (['mail', 'both'].includes(d.purpose) ? 'unknown' : null), lastCheckedAt: d.last_checked_at, recentFailures: fails, jobId: d.job_id, actionNeeded: actionFor(d) };
}
function actionFor(d) {
if (d.ownership_state !== 'verified') return { code: 'ownership', text: '소유 확인 TXT 레코드를 넣고 "다시 확인"', href: `/domains/${d.id}` };
if (d.dns_state === 'wrong') return { code: 'dns_wrong', text: 'DNS 값이 다릅니다(안내 값으로 수정)', href: `/domains/${d.id}` };
if (d.dns_state !== 'ok') return { code: 'dns', text: 'DNS 반영 대기 중', href: `/domains/${d.id}` };
if (d.cert_state === 'failed') return { code: 'cert', text: `인증서 발급 실패${d.cert_error ? ': ' + d.cert_error : ''}`, href: `/domains/${d.id}` };
if (d.cert_state === 'expiring_soon') return { code: 'cert_exp', text: '인증서 만료 임박(자동 갱신 진행)', href: `/domains/${d.id}` };
return null;
}
/** 필요한 DNS 값(§12.2 4단계). 사용자가 복사할 수 있는 정확한 값 */
async function requiredRecords(d) {
const recs = [{ type: 'TXT', name: `_ariweb-verify.${d.name}`, value: `ariweb-verify=${d.ownership_token}`, purpose: '소유 확인', state: d.ownership_state === 'verified' ? 'ok' : 'pending' }];
if (['web', 'both'].includes(d.purpose)) {
let ip = d.expected_ip; if (!ip && d.site_id) { const s = store.get('SELECT server_id, deploy_mode FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) { try { const srv = (await hal().listServers({ role: 'web' })).find((x) => x.id === s.server_id); ip = srv ? srv.publicIp : null; if (ip) save(d.id, { expected_ip: ip }); } catch (_) {} } else if (s && s.deploy_mode === 'external') ip = null; }
const siteExt = d.site_id ? store.get('SELECT deploy_mode FROM sites WHERE id=?', d.site_id)?.deploy_mode === 'external' : false;
recs.push({ type: 'A', name: d.name, value: ip || null, purpose: '웹 접속', note: siteExt ? '외부 배포: 고객 서버의 공인 IP 를 넣으세요(포털이 값을 확인하지 않습니다)' : ip ? null : '관리형 배포를 시작하면 서버 IP 가 정해져 표시됩니다', state: d.dns_state });
if (!d.name.startsWith('www.')) recs.push({ type: 'CNAME', name: `www.${d.name}`, value: d.name, purpose: 'www 별칭(선택)', state: 'optional' });
}
if (['mail', 'both'].includes(d.purpose)) { let mail = null; try { mail = deps.mailDns && deps.mailDns.recordsFor ? await deps.mailDns.recordsFor(d.name, { groupId: d.group_id, mailServiceId: d.mail_service_id }) : null; } catch (_) {} if (mail && mail.length) recs.push(...mail.map((r) => ({ ...r, purpose: r.purpose || '메일' }))); else recs.push({ type: 'MX/SPF/DKIM/DMARC', name: d.name, value: null, purpose: '메일', note: '메일 서비스 화면의 DNS 설정에서 정확한 값을 확인하세요', state: d.mail_dns_state || 'unknown' }); }
return recs;
}
/** 도메인 추가(§12.2 1~2단계). 중복은 거부(다른 고객 정보 비노출) */
function add({ group, user, ip = '' }, { name, purpose = 'web', siteId = null, mailServiceId = null }) {
name = normalize(name); if (!NAME_RE.test(name)) throw new HttpError(400, 'bad_domain', '도메인 형식이 올바르지 않습니다 (예: example.com)', { field: 'name' });
if (!['web', 'mail', 'both'].includes(purpose)) throw new HttpError(400, 'bad_purpose', '용도는 웹·메일·둘 다 중 하나입니다');
if (byName(name)) { const ex = byName(name); throw new HttpError(409, 'domain_taken', ex.group_id === group.id ? '이미 등록한 도메인입니다' : '이 도메인은 이미 다른 고객에게 연결되어 있어 활성화할 수 없습니다. 소유자라면 문의하세요', { href: ex.group_id === group.id ? `/domains/${ex.id}` : null }); }
if (['web', 'both'].includes(purpose) && siteId) { const s = store.get('SELECT id, group_id, lifecycle FROM sites WHERE id=?', siteId); if (!s || s.group_id !== group.id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); if (s.lifecycle === 'deleted') throw new HttpError(409, 'site_deleted', '삭제된 사이트에는 연결할 수 없습니다'); }
if (['mail', 'both'].includes(purpose) && mailServiceId) { const m = store.get('SELECT id, group_id FROM mail_services WHERE id=?', mailServiceId); if (!m || m.group_id !== group.id) throw new HttpError(404, 'not_found', '메일 서비스를 찾을 수 없습니다'); }
const id = newId('d_'); const now = nowIso();
store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, mail_service_id, is_primary, ownership_state, ownership_token, dns_state, cert_state, dns_mode, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,0,\'pending\',?,\'pending\',\'none\',\'manual\',?,?,?)', id, group.id, name, purpose, siteId || null, mailServiceId || null, randomToken(18), user.id, now, now);
audit(store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'domain', subjectId: id, action: 'domain.add', detail: { name, purpose, siteId, mailServiceId }, ip });
return get(id);
}
/** 확인 작업 시작(또는 이어서). makePrimary 면 마지막 단계에서 대표 지정 */
function startCheck(d, user, { makePrimary = false } = {}) {
const active = jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['queued', 'running', 'awaiting_external', 'needs_user'], limit: 5 }).find((j) => j.kind === 'domain.connect');
if (active) { if (['awaiting_external', 'needs_user'].includes(active.status)) { if (makePrimary && !active.subject.makePrimary) store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...active.subject, makePrimary: true }), active.id); jobs.resume(active.id, user?.id); } return describeJob(jobs.get(active.id)); }
const { job } = jobs.enqueue({ kind: 'domain.connect', subject: { domainId: d.id, makePrimary }, idempotencyKey: `domain.connect:${d.id}:${jobs.list({ subjectType: 'domain', subjectId: d.id, kind: 'domain.connect', limit: 1000 }).length}`, createdBy: user?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, label: `도메인 연결 확인 (${d.name})`, maxAttempts: 3 });
save(d.id, { job_id: job.id }); return describeJob(job);
}
/** 확인 작업에서 쓸 HAL 대상: 사이트 서버 → 아무 web 서버 */
async function probeTarget(d) { if (d.site_id) { const s = store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) return { target: s.server_id, external: false, agent: null }; const a = s && s.deploy_mode === 'external' ? store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'', d.site_id) : null; const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: s && s.deploy_mode === 'external', agent: a ? a.hal_agent_id : null }; } const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: false, agent: null }; }
/** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */
async function halJobFor(ctx, job, stepId, factory) {
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
const fresh = (job.steps || []).some((x) => x.id === stepId && x.state === 'failed'); // 이 단계가 실패한 뒤의 재시도면 새 HAL 작업
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
}
async function awaitHal(ctx, halJobId) { let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (hj.status === 'done') return hj; if (['failed', 'cancelled'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || '서버 작업 실패'); ctx.retryLater(5000, '서버 작업 진행 중'); }
jobs.register('domain.connect', { title: '도메인 연결 확인', steps: CONNECT_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
async run(job, ctx) {
const d0 = get(job.subject.domainId); if (!d0) throw new JobError('domain_missing', '도메인이 없습니다'); const pt = await probeTarget(d0); if (!pt.target) throw new JobError('no_server', '확인에 쓸 서버가 없습니다', { retryable: true });
// 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다)
const d1 = get(d0.id);
if (d1.ownership_state !== 'verified') { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:own:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } }); const hj = await awaitHal(ctx, r.halJobId); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
await ctx.step('ownership', () => ({ verified: true }));
// 2 DNS 반영(웹 용도만). 미반영(레코드 없음) 과 잘못된 값(다른 IP) 을 구분
const d2 = get(d0.id);
if (['web', 'both'].includes(d2.purpose)) {
const recs = await requiredRecords(d2); const a = recs.find((x) => x.type === 'A');
if (pt.external) { save(d2.id, { dns_state: 'customer', dns_detail_json: toJson({ note: '외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다' }) }); }
else if (!a.value) { save(d2.id, { dns_state: 'pending', check_json: { stage: 'dns', detail: '관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다' } }); ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다'); }
else { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:dns:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: d2.name, expectA: [a.value] } }); const hj = await awaitHal(ctx, r.halJobId); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); }
} else save(d2.id, { dns_state: 'n/a' });
await ctx.step('dns', () => ({ state: get(d0.id).dns_state }));
// 3 인증서(관리형 웹 도메인만). 외부 배포는 고객 관리
const d3 = get(d0.id);
if (['web', 'both'].includes(d3.purpose) && !pt.external && d3.site_id) {
await ctx.step('cert', async () => { save(d3.id, { cert_state: d3.cert_state === 'valid' ? 'valid' : 'issuing' }); const email = deps.config.operatorEmail || deps.config.mailFrom; const hid = await halJobFor(ctx, job, 'cert', (key) => hal().createJob({ kind: 'domain.cert', target: pt.target, idempotencyKey: key, payload: { siteId: d3.site_id, domains: [d3.name], email } })); const hj = await awaitHal(ctx, hid); const res = hj.result || {}; const failed = (res.failed || []).find((f) => f.domain === d3.name); if (failed || !(res.issued || []).includes(d3.name)) { record(d3.id, 'cert', false, res); save(d3.id, { cert_state: 'failed', cert_error: failed ? failed.reason : '발급 결과에 도메인이 없습니다' }); throw new JobError('cert_failed', `인증서 발급 실패: ${failed ? failed.reason : '알 수 없음'}`); } record(d3.id, 'cert', true, { expiresAt: res.expiresAt }); save(d3.id, { cert_state: 'valid', cert_expires_at: res.expiresAt || null, cert_error: null }); const site = store.get('SELECT * FROM sites WHERE id=?', d3.site_id); const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d3.site_id).map((x) => x.name); const v = await hal().createJob({ kind: 'domain.vhost', target: pt.target, idempotencyKey: `portal:${job.id}:vhost:${hid}`, payload: { siteId: d3.site_id, domains: all, primaryDomain: site.primary_domain, redirectOld: false, maintenance: !!site.maintenance } }); await awaitHal(ctx, v.halJobId); return { issued: true, expiresAt: res.expiresAt }; });
} else { await ctx.step('cert', () => { if (pt.external) save(d3.id, { cert_state: 'customer_managed' }); return { skipped: pt.external ? '외부 배포(고객 관리)' : '웹 용도 아님' }; }); }
// 4 외부 접속 확인
const d4 = get(d0.id);
if (['web', 'both'].includes(d4.purpose) && d4.site_id && store.get('SELECT current_version FROM sites WHERE id=?', d4.site_id)?.current_version) {
await ctx.step('external_check', async () => { const target = pt.external ? pt.agent : pt.target; if (!target) throw new JobError('agent_not_connected', '외부 서버 Agent 가 연결되어 있지 않아 외부 확인을 할 수 없습니다'); const cid = await halJobFor(ctx, job, 'external_check', (key) => hal().createJob({ kind: 'site.check', target, idempotencyKey: key, payload: { siteId: d4.site_id, urls: [`https://${d4.name}/`] } })); const hj = await awaitHal(ctx, cid); const c = ((hj.result && hj.result.checks) || [])[0] || null; record(d4.id, 'external', !!(c && c.ok), c); const tlsOk = !!(c && c.tls && c.tls.valid); const patch = { external_check_json: toJson(c), last_checked_at: nowIso() }; if (!pt.external) { if (!tlsOk) { patch.cert_state = 'failed'; patch.cert_error = '외부 확인에서 인증서가 유효하지 않습니다'; } } save(d4.id, patch); if (!(c && c.ok)) throw new JobError('external_check_failed', `외부 접속 확인 실패 (${c ? c.status || '응답 없음' : '결과 없음'})`); if (!pt.external && !tlsOk) throw new JobError('tls_invalid', '인증서가 유효하지 않습니다'); return c; });
} else await ctx.step('external_check', () => ({ skipped: '배포된 사이트가 없어 외부 확인을 건너뜀(배포 후 다시 확인)' }));
// 5 대표 지정(요청 시)
await ctx.step('primary', () => { const d5 = get(d0.id); if (job.subject.makePrimary && d5.site_id) { setPrimaryNow(d5, { redirectOld: false, actor: null }); return { primary: true }; } return { primary: !!d5.is_primary }; });
const fin = get(d0.id); notify(store, { groupId: fin.group_id, category: 'domain', severity: 'info', incidentKey: `domain:${fin.id}`, title: `도메인 ${fin.name} 연결 확인 완료`, body: `소유 확인 ${fin.ownership_state} · DNS ${fin.dns_state} · 인증서 ${fin.cert_state}`, link: `/domains/${fin.id}`, subjectType: 'domain', subjectId: fin.id });
if (fin.is_primary && fin.site_id && deps.sites) deps.sites.onPrimaryDomainVerified(fin.site_id, fin.name);
return { ownership: fin.ownership_state, dns: fin.dns_state, cert: fin.cert_state, primary: !!fin.is_primary };
} });
jobs.onChange((j) => { if (j.kind !== 'domain.connect' || j.status !== 'failed') return; const d = get(j.subject.domainId); if (!d) return; notify(store, { groupId: d.group_id, category: 'domain', severity: 'warning', incidentKey: `domain:${d.id}`, title: `도메인 ${d.name} 연결 확인 실패`, body: `${j.error || ''} (문의 번호 ${j.id})`, link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id, actionRequired: '안내 값 확인 후 다시 시도' }); });
/** 대표 도메인 지정(§12.3). DNS·HTTPS 정상(또는 외부 배포의 고객 관리) 조건 */
function setPrimaryNow(d, { redirectOld = false, actor = null } = {}) {
if (!d.site_id) throw new HttpError(409, 'no_site', '사이트에 연결된 도메인만 대표로 지정할 수 있습니다');
const siteExt = store.get('SELECT deploy_mode, server_id, maintenance FROM sites WHERE id=?', d.site_id);
const ready = d.ownership_state === 'verified' && (siteExt.deploy_mode === 'external' ? true : d.dns_state === 'ok' && ['valid', 'expiring_soon'].includes(d.cert_state));
if (!ready) throw new HttpError(409, 'domain_not_ready', '대표 도메인은 소유 확인·DNS·HTTPS 가 정상일 때 지정할 수 있습니다', { ownership: d.ownership_state, dns: d.dns_state, cert: d.cert_state });
const prev = store.get('SELECT id, name FROM domains WHERE site_id=? AND is_primary=1 AND id<>?', d.site_id, d.id);
store.tx(() => { store.run('UPDATE domains SET is_primary=0, updated_at=? WHERE site_id=?', nowIso(), d.site_id); store.run('UPDATE domains SET is_primary=1, redirect_from=?, updated_at=? WHERE id=?', redirectOld && prev ? prev.name : null, nowIso(), d.id); });
if (siteExt.deploy_mode === 'managed' && siteExt.server_id) { const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d.site_id).map((x) => x.name); jobs.enqueue({ kind: 'domain.vhost_apply', subject: { siteId: d.site_id, serverId: siteExt.server_id, domains: all, primaryDomain: d.name, redirectOld: !!redirectOld, maintenance: !!siteExt.maintenance }, idempotencyKey: `vhost:${d.site_id}:${d.name}:${redirectOld}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: d.group_id, subjectType: 'site', subjectId: d.site_id, label: `대표 도메인 적용 (${d.name})` }); }
audit(store, { actorType: actor ? 'user' : 'system', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.primary', detail: { name: d.name, siteId: d.site_id, redirectOld, previous: prev ? prev.name : null }, automatic: !actor });
if (deps.sites) deps.sites.onPrimaryDomainVerified(d.site_id, d.name);
return get(d.id);
}
jobs.register('domain.vhost_apply', { title: 'vhost 적용', steps: [{ id: 'vhost', title: '웹서버 설정 적용' }], maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { return ctx.step('vhost', async () => { const s = job.subject; const id = await halJobFor(ctx, job, 'vhost', (key) => hal().createJob({ kind: 'domain.vhost', target: s.serverId, idempotencyKey: key, payload: { siteId: s.siteId, domains: s.domains, primaryDomain: s.primaryDomain, redirectOld: s.redirectOld, maintenance: s.maintenance } })); return (await awaitHal(ctx, id)).result; }); } });
/** 연결 해제(§12.1). 대표였으면 사이트는 '도메인 설정 필요' 로 */
function release(d, actor) {
const active = jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['queued', 'running'], limit: 3 }); if (active.length) throw new HttpError(409, 'job_running', '확인 작업이 진행 중입니다. 끝난 뒤 해제하세요');
for (const j of jobs.list({ subjectType: 'domain', subjectId: d.id, status: ['awaiting_external', 'needs_user'], limit: 5 })) { try { jobs.cancel(j.id, actor?.id); } catch (_) {} }
const wasPrimary = !!d.is_primary; store.run('DELETE FROM domains WHERE id=?', d.id); store.run('DELETE FROM domain_checks WHERE domain_id=?', d.id);
if (wasPrimary && d.site_id && deps.sites) deps.sites.onPrimaryDomainLost(d.site_id);
const s = d.site_id ? store.get('SELECT deploy_mode, server_id, maintenance, primary_domain FROM sites WHERE id=?', d.site_id) : null;
if (s && s.deploy_mode === 'managed' && s.server_id) { const all = store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')', d.site_id).map((x) => x.name); jobs.enqueue({ kind: 'domain.vhost_apply', subject: { siteId: d.site_id, serverId: s.server_id, domains: all, primaryDomain: wasPrimary ? null : s.primary_domain, redirectOld: false, maintenance: !!s.maintenance }, idempotencyKey: `vhost:${d.site_id}:release:${d.name}:${Date.now() / 60000 | 0}`, createdBy: actor?.id, groupId: d.group_id, subjectType: 'site', subjectId: d.site_id, label: `도메인 해제 적용 (${d.name})` }); }
audit(store, { actorType: 'user', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.release', detail: { name: d.name, siteId: d.site_id, wasPrimary } });
return { released: true, wasPrimary };
}
/** 용도·연결 대상 변경 */
function update(d, patch, actor) { const p = {}; if (patch.purpose && ['web', 'mail', 'both'].includes(patch.purpose)) p.purpose = patch.purpose; if (patch.siteId !== undefined) { if (patch.siteId) { const s = store.get('SELECT id, group_id FROM sites WHERE id=?', patch.siteId); if (!s || s.group_id !== d.group_id) throw new HttpError(404, 'not_found', '사이트를 찾을 수 없습니다'); } if (d.is_primary && patch.siteId !== d.site_id) { p.is_primary = 0; if (deps.sites && d.site_id) deps.sites.onPrimaryDomainLost(d.site_id); } p.site_id = patch.siteId || null; p.dns_state = 'pending'; p.cert_state = 'none'; p.expected_ip = null; } if (patch.mailServiceId !== undefined) p.mail_service_id = patch.mailServiceId || null; if (Object.keys(p).length) { save(d.id, p); audit(store, { actorType: 'user', actorId: actor?.id, groupId: d.group_id, subjectType: 'domain', subjectId: d.id, action: 'domain.update', detail: p }); } return get(d.id); }
/** 메일 보안 레코드 상태 갱신(메일 영역이 부른다) */
function setMailDnsState(name, state, detail = null) { const d = byName(name); if (!d) return null; return save(d.id, { mail_dns_state: state, check_json: detail ? { ...(d.check || {}), mailDns: detail } : d.check_json }); }
/** 주기 작업: 인증서 만료 임박 표시·갱신 작업, 오래된 확인 기록 정리 */
function sweep(now = Date.now()) {
const soon = new Date(now + EXPIRING_DAYS * 86400000).toISOString(); let renew = 0;
for (const d of store.all('SELECT * FROM domains WHERE cert_state IN (\'valid\',\'expiring_soon\') AND cert_expires_at IS NOT NULL AND cert_expires_at<=?', soon).map(row)) {
if (Date.parse(d.cert_expires_at) <= now) { save(d.id, { cert_state: 'failed', cert_error: '인증서가 만료되었습니다' }); notify(store, { groupId: d.group_id, category: 'domain', severity: 'critical', mandatory: true, incidentKey: `cert:${d.id}`, title: `도메인 ${d.name} 인증서 만료`, body: '갱신 작업을 다시 시도합니다. 실패가 계속되면 DNS 값을 확인하세요', link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id, actionRequired: 'DNS 확인 후 다시 확인' }); }
else if (d.cert_state !== 'expiring_soon') { save(d.id, { cert_state: 'expiring_soon' }); notify(store, { groupId: d.group_id, category: 'domain', severity: 'warning', incidentKey: `cert:${d.id}`, title: `도메인 ${d.name} 인증서 만료 임박 (${d.cert_expires_at.slice(0, 10)})`, body: '자동 갱신을 진행합니다', link: `/domains/${d.id}`, subjectType: 'domain', subjectId: d.id }); }
const r = jobs.enqueue({ kind: 'domain.connect', subject: { domainId: d.id, makePrimary: false }, idempotencyKey: `domain.renew:${d.id}:${new Date(now).toISOString().slice(0, 10)}`, actorType: 'system', groupId: d.group_id, subjectType: 'domain', subjectId: d.id, label: `인증서 갱신 (${d.name})`, maxAttempts: 3 }); if (r.created) renew++;
}
store.run('DELETE FROM domain_checks WHERE checked_at<?', new Date(now - 90 * 86400000).toISOString());
return { renew };
}
function detail(d) { return { ...summarize(d), ownershipToken: d.ownership_token, dnsDetail: d.dnsDetail, externalCheck: d.externalCheck, check: d.check, redirectFrom: d.redirect_from, checks: store.all('SELECT kind, ok, detail_json, checked_at FROM domain_checks WHERE domain_id=? ORDER BY checked_at DESC LIMIT 30', d.id).map((c) => ({ ...c, detail: parseJson(c.detail_json, null) })), job: d.job_id ? describeJob(jobs.get(d.job_id)) : null }; }
return { CONNECT_STEPS, normalize, get, getForGroup, byName, list, summarize, detail, requiredRecords, add, startCheck, setPrimary: setPrimaryNow, release, update, setMailDnsState, sweep, save };
}
+12
View File
@@ -0,0 +1,12 @@
/** portal/domains/ui.mjs — 도메인 화면 뼈대(목록·연결·상세). 데이터는 /ui/domains/domains.js 가 채운다. */
import { esc } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
const shell = (ctx, deps, o) => deps.page({ ...o, user: ctx.user, group: ctx.group, groups: ctx.groups || [], unread: ctx.unread || 0, csrf: ctx.session ? ctx.session.csrf : '', scripts: ['/ui/domains/domains.js'] });
export const listPage = (ctx, deps) => shell(ctx, deps, { title: t('domains.title'), nav: 'domains', crumbs: [{ label: t('nav.dashboard'), href: '/' }, { label: t('domains.title') }], actions: [{ label: t('domains.connect'), href: '/domains/connect', kind: 'primary' }, { label: '도메인 구매·DNS 자동화(Webmaker)', href: '/sso/webmaker?path=/' }], body: `<div id="list" data-page="domains-list">${deps.statePanel('loading')}</div><p class="hint">도메인 등록·갱신·네임서버·레코드 자동화는 Webmaker 의 도메인 탭에서 기존 기능 그대로 제공됩니다. 도메인 등록 기능이 제공되지 않는 환경에서는 외부 등록기관에서 구매한 도메인을 여기서 연결하세요.</p>` });
export const connectPage = (ctx, deps, { siteId = '' } = {}) => shell(ctx, deps, { title: t('domains.connect'), nav: 'domains', crumbs: [{ label: t('domains.title'), href: '/domains' }, { label: t('domains.connect') }], body: `<form id="connect" class="card" data-page="domains-connect" data-site="${esc(siteId)}"><ol class="hint"><li>도메인 입력</li><li>웹·메일 용도 선택</li><li>소유 확인(TXT)</li><li>필요한 DNS 값 안내</li><li>DNS 반영 확인</li><li>HTTPS 인증서 발급</li><li>외부 접속 확인</li><li>대표 도메인 지정</li></ol>
<div class="f"><label for="name">도메인</label><input id="name" name="name" placeholder="example.com" required><span class="hint">외부 등록기관에서 구매한 도메인도 연결할 수 있습니다. www 없이 입력하세요</span><span class="err" data-err="name"></span></div>
<fieldset class="f"><legend>용도</legend><label><input type="radio" name="purpose" value="web" checked> 웹</label><label><input type="radio" name="purpose" value="mail"> 메일</label><label><input type="radio" name="purpose" value="both"> 웹 + 메일</label></fieldset>
<div class="f"><label for="siteId">연결할 사이트</label><select id="siteId" name="siteId"><option value="">선택 안 함</option></select><span class="hint">한 사이트에 여러 도메인을 연결해도 운영 사이트 수는 하나만 씁니다</span></div>
<div class="f"><label><input type="checkbox" name="makePrimary"> 확인이 끝나면 대표 웹 도메인으로 지정</label></div>
<div class="actions"><button class="btn primary" type="submit">도메인 추가</button></div><div class="err" data-err="form"></div></form>` });
export const detailPage = (ctx, deps, d) => shell(ctx, deps, { title: d.name, nav: 'domains', crumbs: [{ label: t('domains.title'), href: '/domains' }, { label: d.name }], body: `<div id="detail" data-page="domains-detail" data-domain="${esc(d.id)}">${deps.statePanel('loading')}</div>` });