Files
AriWeb-Suite/portal/domains/service.mjs
T

172 lines
30 KiB
JavaScript
Raw Normal View History

/**
* 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) {
let st = ctx.steps().find((x) => x.id === stepId); if (!st) { st = { id: stepId, title: stepId, state: 'pending' }; ctx.steps().push(st); } // 단계 밖 탐침도 단계 결과에 HAL 작업 id 를 보관
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; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 단계 실패 뒤 재시도 때만)
const seq = (st && st.result && st.result.seq) || 0;
const r = await factory(`portal:${job.id}:${stepId}${seq ? ':s' + seq : ''}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
}
/** [E2E 결함 수정] 외부 대기(사용자 DNS 변경) 직전에 단계에 적어 둔 탐침 작업 id 를 지워, 재개 때 새 탐침을 만들게 한다(옛 결과 재사용 금지). 단계가 없으면 만들어 둔다 */
function probeReset(ctx, stepId) { const st = ctx.steps().find((x) => x.id === stepId); if (st && st.result) { st.result = { ...st.result, halJobId: null, seq: (st.result.seq || 0) + 1 }; ctx.flush(); } } // seq 로 멱등 키를 바꿔 HAL 이 옛 탐침을 돌려주지 않게 한다
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 hid = await halJobFor(ctx, job, 'ownership', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } })); const hj = await awaitHal(ctx, hid); 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 레코드가 아직 보이지 않습니다' } }); probeReset(ctx, 'ownership'); 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 hid = await halJobFor(ctx, job, 'dns', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: d2.name, expectA: [a.value] } })); const hj = await awaitHal(ctx, hid); 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') { probeReset(ctx, 'dns'); 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 };
}