Files
AriWeb-Suite/modules/webmaker/lib/domains.js
T

711 lines
84 KiB
JavaScript

'use strict';
// ============================================================================
// 도메인 관리 (v1.4.0) — 사용자 "도메인" 탭의 업무 규칙
//
// 계정 종류 (관리자 콘솔 › 회원에서 사용자마다 지정, users[].accountType)
// - subdomain : 서비스가 제공하는 플랫폼 도메인(예: ibit.kr)의 서브 도메인만 쓴다.
// mycompany 를 등록하면 홈페이지 https://mycompany.ibit.kr, 기본 메일 admin@mycompany.ibit.kr,
// 메일 접속 https://mycompany.ibit.kr/mail. 도메인 구매·DNS 직접 관리는 할 수 없다(검색·등록만).
// - domain : 자기 도메인을 사고(Openprovider) DNS 레코드·MX 를 직접 관리한다. 서브 도메인도 쓸 수 있다.
//
// 도메인 기록의 종류 (type) — v1.6.35
// - subdomain : 플랫폼 서브 도메인(무료) - registered : 이 서비스에서 구매(등록 업체 API)
// - external : 사용자가 다른 곳에서 구매한 도메인. mode 'ns'(네임서버를 우리 DNS 공급자로 바꿔 영역을 우리가 관리) 또는 'manual'(사용자가 자기 DNS 에서 직접 관리)
// 프로세스(요구 1-2: 구매 → 이전/수동 입력 → 자동 설정)는 아래 "도메인 준비 프로세스" 절. 관리자 콘솔은 접속 정보·플랫폼 도메인·서브 도메인 허용 여부만 정하고,
// 검색·구매·장바구니·영역 만들기·레코드 관리는 사용자 "도메인" 탭에서 사용자가 스스로 한다.
//
// 비용: Openprovider 가 주는 가격(리셀러 가격) × 관리자가 정한 요율(기본 250% = 2.5배)을 원화로 바꿔 캐시에서 차감한다.
// 사용 전 화면에 먼저 보이고(quote), 실제 등록이 성공한 뒤에만 차감한다(실패하면 차감 없음).
// DNS 전파: 레코드를 만들거나 새 도메인을 등록하면 pending 을 남기고, 관련 서비스(사이트 연결·메일)를 쓰려 할 때
// 공용 DNS(1.1.1.1·8.8.8.8)에서 확인해 아직이면 "DNS 연결이 진행 중입니다. 약 N분 후" 안내를 보인다.
// 저장: data/domains.json { domains: [...], contacts: { userId: {...연락처, handle} } }
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dnsLib = require('dns');
// v1.5.36 (요구 4·5): 등록 업체·DNS 공급자는 공통 창구를 거친다 — Openprovider 외에 Porkbun(등록·DNS), Amazon Route 53·Google Cloud DNS(DNS)
const registrar = require('./registrar');
const dnszone = require('./dnszone');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'domains.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const nowIso = () => new Date().toISOString();
/** DNS 전파 예상 시간(분): 레코드 변경은 TTL(900초)+여유, 새 도메인 위임은 보통 수 시간(최대 48시간) */
const ETA = { records: 20, delegation: 240, registration: 30, transfer: 240 }; // v1.6.32: registration = 등록 업체(Route 53)가 등록을 비동기로 처리하는 동안 · v1.6.35: transfer = 네임서버 변경(이전) 전파
/** 도메인 이름(영역) 형식 — 두 라벨 이상, 마지막은 영문 TLD */
const DOMAIN_RE = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/;
/** 기본 예약 이름 (서브 도메인으로 쓸 수 없음) — 관리자 설정 reserved 와 합친다 */
const RESERVED = ['www', 'mail', 'webmail', 'smtp', 'imap', 'pop', 'pop3', 'mx', 'ns', 'ns1', 'ns2', 'dns', 'ftp', 'admin', 'api', 'app', 'webmaker', 'static', 'cdn', 'assets', 'help', 'support', 'blog', 'shop', 'test', 'dev', 'staging', 'root', 'localhost', 'autodiscover', 'autoconfig', '_dmarc'];
// ---------- 설정 ----------
/**
* 도메인 설정 (settings.domains) + 기본값.
* marginPct : API 가격 대비 청구 요율(%) — 250 = 2.5배
* platformDomain : 서브 도메인을 나눠 주는 도메인 (비우면 서비스 주소 host 에서 첫 라벨을 뺀 값, webmaker.ibit.kr → ibit.kr)
* platformDns : (1.5.31 까지) 'manual' | 'openprovider' — v1.5.36 부터는 dnsProvider 로 대체되며 호환을 위해 함께 유지한다
* dnsProvider : (v1.5.36) 'manual' | 'openprovider' | 'porkbun' | 'route53' | 'gcloud' — 플랫폼 도메인과 새 도메인의 영역을 관리할 공급자
* registrar : (v1.5.36) 'openprovider' | 'porkbun' — 도메인 검색·구매·갱신 업체
* targetIp : 사이트 주소가 가리킬 IP (비우면 서비스 주소의 IP)
* tlds : 검색할 확장자 reserved: 추가 예약 이름 defaultAccountType: 새 가입자의 계정 종류 userMail: 도메인 연결 시 메일 시스템 자동 켜기
*/
function config() {
let s = {}; try { s = require('./settings').load().domains || {}; } catch (_) {}
// v1.5.17: 서비스 안에서는 config.apply() 가 설정 파일 값을 환경 변수로 올려 두지만, 명령줄 도구(scripts/ssl-wildcard.js 등)에서는 환경 변수가 없다
// → 설정 파일(service.publicBaseUrl)을 두 번째로 본다. (1.5.17 배포 때 확인: 설치 스크립트의 감지가 "플랫폼 도메인 없음" 으로 나왔던 원인)
const pubUrl = process.env.PUBLIC_BASE_URL || (() => { try { return require('./config').get('service.publicBaseUrl'); } catch (_) { return ''; } })();
const pubHost = (() => { try { return new URL(pubUrl || 'http://localhost').hostname; } catch (_) { return ''; } })();
/* 서비스 주소가 IP(192.168.20.66 등)·localhost 면 플랫폼 도메인을 추측하지 않는다(관리자가 직접 입력) */
const derived = require('net').isIP(pubHost) || !pubHost.includes('.') ? '' : pubHost.split('.').length > 2 ? pubHost.split('.').slice(1).join('.') : pubHost;
// v1.6.35 (요구 1-1): allowSubdomain — 서브 도메인 등록 허용 여부(관리자 콘솔). false 면 서브 도메인 계정도 새 서브 도메인을 등록할 수 없다(이미 등록된 것은 유지)
const out = { marginPct: 250, platformDomain: derived, platformDns: 'manual', dnsProvider: null, registrar: 'openprovider', targetIp: '', tlds: ['com', 'net', 'org', 'co', 'io', 'shop'], reserved: [], defaultAccountType: 'subdomain', userMail: true, allowSubdomain: true, ...s };
if (!out.dnsProvider) out.dnsProvider = out.platformDns === 'openprovider' ? 'openprovider' : 'manual'; // 1.5.31 설정 호환
return out;
}
/** 관리자 입력 검증 (settings.save 가 부름) */
function normalizeConfig(input, prev) {
const c = { ...(prev || {}) }; const i = input || {};
if (i.marginPct !== undefined && String(i.marginPct).trim() !== '') { const n = Number(i.marginPct); if (!Number.isFinite(n) || n < 100 || n > 1000) throw httpError('도메인 요율은 100~1000 % 사이로 정하세요 (100 = 원가, 250 = 2.5배)'); c.marginPct = n; }
if (i.platformDomain !== undefined) { const d = String(i.platformDomain || '').trim().toLowerCase(); if (d && !/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/.test(d)) throw httpError('플랫폼 도메인은 ibit.kr 같은 도메인 이름이어야 합니다'); c.platformDomain = d; }
// v1.5.36: dnsProvider 가 오면 그것을, 옛 화면이 platformDns 만 보내면 그것을 읽는다. platformDns 는 호환용으로 함께 맞춘다
if (i.dnsProvider !== undefined) { const v = String(i.dnsProvider || 'manual'); if (v !== 'manual' && !dnszone.IDS.includes(v)) throw httpError(`DNS 공급자는 manual·${dnszone.IDS.join('·')} 중 하나입니다`); c.dnsProvider = v; c.platformDns = v === 'openprovider' ? 'openprovider' : 'manual'; }
else if (i.platformDns !== undefined) { c.platformDns = i.platformDns === 'openprovider' ? 'openprovider' : 'manual'; c.dnsProvider = c.platformDns; }
if (i.registrar !== undefined) { const v = String(i.registrar || 'openprovider'); if (!registrar.IDS.includes(v)) throw httpError(`등록 업체는 ${registrar.IDS.join('·')} 중 하나입니다`); c.registrar = v; }
if (i.targetIp !== undefined) { const ip = String(i.targetIp || '').trim(); if (ip && !require('net').isIP(ip)) throw httpError('사이트 IP 는 IPv4·IPv6 주소여야 합니다'); c.targetIp = ip; }
if (i.tlds !== undefined) c.tlds = (Array.isArray(i.tlds) ? i.tlds : String(i.tlds).split(/[,\s]+/)).map((x) => x.trim().toLowerCase().replace(/^\./, '')).filter((x) => /^[a-z]{2,24}(\.[a-z]{2,24})?$/.test(x)).slice(0, 20);
if (i.reserved !== undefined) c.reserved = (Array.isArray(i.reserved) ? i.reserved : String(i.reserved).split(/[,\s]+/)).map((x) => x.trim().toLowerCase()).filter(Boolean).slice(0, 200);
if (i.defaultAccountType !== undefined) c.defaultAccountType = i.defaultAccountType === 'domain' ? 'domain' : 'subdomain';
if (i.userMail !== undefined) c.userMail = i.userMail === true || i.userMail === 'true';
if (i.allowSubdomain !== undefined) c.allowSubdomain = i.allowSubdomain === true || i.allowSubdomain === 'true'; // v1.6.35
return c;
}
// ---------- 저장소 ----------
// v1.6.31 (요구 10): 원자적 쓰기·백업·손상 strict (도메인 기록이 "빈 목록" 으로 보이면 중복 구매로 이어진다)
const js = require('./jsonstore');
function load() { const d = js.readJson(FILE, null, { strict: true }); const o = d && typeof d === 'object' ? d : {}; o.domains = o.domains || []; o.contacts = o.contacts || {}; o.purchases = o.purchases || []; o.carts = o.carts || {}; /* v1.6.35: 사용자별 장바구니 { userId: [{domain, addedAt, quote, available}] } */ return o; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
/**
* v1.6.31 (요구 18) 구매·갱신 원장: 업체 호출 전에 작업 기록(pending)을 남기고, 업체 성공 → 차감 → 저장을 단계별로 표시한다.
* state: pending_provider(업체 호출 중) → provider_succeeded → charged → persisted(완료) / unknown(응답 없음·시간 초과 — 확인 필요) / failed
* 같은 사용자·같은 도메인·같은 작업이 pending/unknown 이면 새 구매를 시작하지 않는다(중복 구매·이중 차감 방지). reconcile() 이 업체에 조회해 상태를 맞춘다.
*/
function purchaseKey(userId, name, op) { return `${userId}|${name}|${op}`; }
function openPurchase(userId, name, op, extra) { const dd = load(); const open = dd.purchases.find((p) => p.key === purchaseKey(userId, name, op) && ['pending_provider', 'provider_succeeded', 'charged', 'unknown'].includes(p.state)); if (open) return { dup: open }; const p = { id: crypto.randomBytes(6).toString('hex'), key: purchaseKey(userId, name, op), userId, name, op, state: 'pending_provider', at: nowIso(), ...extra }; dd.purchases.push(p); if (dd.purchases.length > 2000) dd.purchases.splice(0, dd.purchases.length - 2000); save(dd); return { p }; }
function setPurchase(id, patch) { const dd = load(); const p = dd.purchases.find((x) => x.id === id); if (p) { Object.assign(p, patch, { updatedAt: nowIso() }); save(dd); } return p; }
function purchases(filter = {}) { const dd = load(); return dd.purchases.filter((p) => (!filter.userId || p.userId === filter.userId) && (!filter.open || ['pending_provider', 'provider_succeeded', 'charged', 'unknown'].includes(p.state))).slice().reverse(); }
/**
* 화면용 도메인 기록. v1.6.35: type 'external'(사용자가 다른 곳에서 구매한 도메인) 과 그 관리 방식 mode('ns' = 네임서버를 우리 DNS 공급자로 바꿔 영역을 우리가 관리 /
* 'manual' = 사용자가 자기 DNS 에서 직접 관리), 이전 진행 상태(transfer), 수동 확인 상태(manual), 레코드 수정 잠금(dnsLocked — 네임서버 이전 전파 중 이중 수정 방지)을 함께 보낸다.
*/
const pub = (x) => ({ id: x.id, name: x.name, type: x.type, mode: x.mode || (x.type === 'registered' ? 'ns' : x.type === 'subdomain' ? 'platform' : null), status: x.status, siteId: x.siteId || null, createdAt: x.createdAt, expiresAt: x.expiresAt || null, period: x.period || null, pending: x.pending || null, mail: x.mail || null, lastCharge: x.charges && x.charges.length ? x.charges[x.charges.length - 1] : null, registrar: x.registrar || (x.type === 'registered' ? 'openprovider' : null), dnsProvider: x.dnsProvider || null, nameServers: x.nameServers || null, currentNs: x.currentNs || null, transfer: x.transfer || null, manual: x.manual || null, imported: x.imported || null, subdomains: x.subdomains || null, zoneError: x.zoneError || null, dnsLocked: !!(x.pending && x.pending.kind === 'transfer'), host: x.host || null /* v1.7.2: 사이트를 연결한 하위 이름(없으면 @·www) */, siteNames: x.siteNames || null });
/** v1.5.36 관리자용 전체 목록 (소유자 포함) */
function adminList() { return load().domains.map((x) => ({ ...pub(x), userId: x.userId })); }
/** v1.5.36 관리자: 도메인의 DNS 공급자 지정 ('' 이면 기본값으로) */
function setDnsProvider(id, provider) { const d = load(); const x = d.domains.find((y) => y.id === id); if (!x) throw httpError('도메인이 없습니다', 404); const v = String(provider || ''); if (v && v !== 'manual' && !dnszone.IDS.includes(v)) throw httpError('알 수 없는 DNS 공급자'); x.dnsProvider = v || null; save(d); return pub(x); }
/** 화면용: 등록 업체·DNS 공급자 목록과 준비 여부 */
function publicCatalog() { return { registrar: registrar.catalog(), dns: dnszone.catalog() }; }
/** 플랫폼 도메인의 자동 DNS 공급자 ({id, p}) — 없으면 null */
function platformZone() { const c = config(); if (!c.platformDomain) return null; return dnszone.forZone({ dnsProvider: c.dnsProvider }); }
/** 구입 도메인의 자동 DNS 공급자 — 없으면 503 안내 */
function zoneOf(x) { const z = dnszone.forZone(x); if (!z) throw httpError('이 도메인에 자동 DNS 공급자가 없습니다. 관리자에게 문의하세요 (관리자: 설정 › 도메인 서비스 › DNS 공급자)', 503); return z; }
function listFor(userId) { return load().domains.filter((x) => x.userId === userId).map(pub); }
function getOwned(userId, id) { const d = load(); const x = d.domains.find((y) => y.id === id && y.userId === userId); if (!x) throw httpError('도메인이 없습니다', 404); return { d, x }; }
/** 사이트 도메인 이름 → 기록 (게이트·연결용) */
function byName(name) { return load().domains.find((x) => x.name === String(name || '').toLowerCase()) || null; }
// ---------- 계정 종류 ----------
/** 사용자 계정 종류. 기록이 없으면(1.4.0 이전 가입자) 'domain' — 예전처럼 도메인을 직접 쓰던 사용자의 동작을 바꾸지 않는다 */
function accountTypeOf(userOrId) { const u = typeof userOrId === 'string' ? require('./auth').getUser(userOrId) : userOrId; if (!u) return 'subdomain'; if (u.role === 'admin') return 'domain'; return u.accountType === 'subdomain' || u.accountType === 'domain' ? u.accountType : 'domain'; }
const requireDomainUser = (user) => { if (accountTypeOf(user) !== 'domain') throw httpError('서브 도메인 계정은 도메인 구매·DNS 관리를 할 수 없습니다. 서브 도메인 검색·등록만 할 수 있습니다 (관리자에게 계정 종류 변경을 요청하세요)', 403); };
// ---------- 서브 도메인 ----------
const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
/**
* 서브 도메인 이름 확인: 형식(영문 소문자·숫자·하이픈 1~32자) → 예약 이름 → 이 서비스의 기존 등록 → (자동 DNS 면) 영역에 이미 있는 이름.
* @returns {{ available: boolean, fqdn: string, reason: string }}
*/
async function checkSubdomain(label) {
const c = config(); const l = String(label || '').trim().toLowerCase();
if (!c.platformDomain) throw httpError('플랫폼 도메인이 설정되지 않았습니다. 관리자에게 문의하세요', 503);
if (c.allowSubdomain === false) throw httpError('이 서비스는 서브 도메인 등록을 받지 않습니다 (관리자 설정). 자기 도메인을 구매하거나 이미 가진 도메인을 등록해 쓰세요', 403); // v1.6.35 (요구 1-1)
const fqdn = `${l}.${c.platformDomain}`;
if (!LABEL_RE.test(l)) return { available: false, fqdn, reason: '영문 소문자·숫자·하이픈(-) 1~32자로, 하이픈으로 시작·끝나지 않게 입력하세요' };
if ([...RESERVED, ...(c.reserved || [])].includes(l)) return { available: false, fqdn, reason: '서비스가 쓰는 이름이라 등록할 수 없습니다' };
if (byName(fqdn)) return { available: false, fqdn, reason: '이미 등록된 이름입니다' };
const pz = platformZone(); if (pz) { try { const recs = await pz.p.listRecords(c.platformDomain); if (recs.some((r) => r.name === l || r.name === fqdn || r.name.startsWith(`${l}.`))) return { available: false, fqdn, reason: '이미 쓰고 있는 이름입니다' }; } catch (_) {} }
return { available: true, fqdn, reason: '' };
}
/** 서브 도메인 등록 (무료). 사용자당 10개까지 */
async function registerSubdomain(user, label) {
const r = await checkSubdomain(label); if (!r.available) throw httpError(r.reason, 409);
const d = load(); if (d.domains.filter((x) => x.userId === user.id && x.type === 'subdomain').length >= 10) throw httpError('서브 도메인은 계정당 10개까지 등록할 수 있습니다', 409);
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name: r.fqdn, label: String(label).toLowerCase(), type: 'subdomain', status: 'active', createdAt: nowIso(), charges: [] };
d.domains.push(x); save(d); return pub(x);
}
// ---------- 가격 ----------
/** 리셀러 가격 → 사용자 청구 원화 (요율 적용, 100원 단위 올림) */
async function toUserKrw(price) {
const c = config(); const fx = require('./fx'); const k = await fx.toKrw(price.amount, price.currency);
const krw = Math.ceil((k.krw * (c.marginPct / 100)) / 100) * 100;
return { costKrw: krw, base: { currency: price.currency, amount: price.amount }, usd: Math.round(k.usd * 100) / 100, rate: k.rate, marginPct: c.marginPct };
}
/** 도메인 검색: 이름 하나 × 확장자 여러 개 → 등록 가능 여부와 사용자 가격 */
async function search(user, name, tlds) {
requireDomainUser(user); const c = config();
const n = String(name || '').trim().toLowerCase().replace(/\s+/g, ''); const base = n.includes('.') ? n : null;
const list = base ? [base] : (tlds && tlds.length ? tlds : c.tlds).filter((t) => c.tlds.includes(t)).map((t) => `${n}.${t}`);
if (!list.length) throw httpError('검색할 확장자를 고르세요');
const reg = registrar.current(); for (const f of list) reg.split(f); // 형식 확인 (잘못되면 안내 오류)
const res = await reg.check(list); const out = [];
for (const r of res) out.push({ domain: r.domain, available: r.status === 'free', status: r.status, premium: r.premium, reason: r.reason, registrar: reg.id, quote: (r.status === 'free' || r.status === 'unknown') && r.price ? await toUserKrw(r.price) : null });
return out;
}
/** 작업 견적 (사용 전에 보이는 금액) */
async function quote(user, fqdn, operation = 'create', period = 1, regId) { requireDomainUser(user); const reg = registrar.current(regId); const p = await reg.price(fqdn, operation, period); return { domain: fqdn, operation, period, registrar: reg.id, ...(await toUserKrw(p)) }; }
// ---------- 등록자 연락처 ----------
const contactFields = ['firstName', 'lastName', 'company', 'email', 'phoneCountry', 'phoneArea', 'phoneNumber', 'street', 'number', 'zipcode', 'city', 'state', 'country'];
function getContact(userId) { const c = load().contacts[userId]; if (!c) return null; const { handle, ...rest } = c; return { ...rest, hasHandle: !!handle }; }
/** 등록자 정보 저장 → Openprovider 고객(handle) 생성. 바꾸면 새 handle 을 만든다(다음 등록부터 적용) */
async function saveContact(user, input) {
requireDomainUser(user); const c = {}; for (const k of contactFields) c[k] = String((input || {})[k] || '').trim().slice(0, 120);
c.country = (c.country || 'KR').toUpperCase(); c.phoneCountry = c.phoneCountry || '+82';
const miss = ['firstName', 'lastName', 'email', 'phoneNumber', 'street', 'zipcode', 'city', 'country'].filter((k) => !c[k]);
if (miss.length) throw httpError(`등록자 정보를 모두 입력하세요 (빠진 항목: ${miss.join(', ')})`);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) throw httpError('등록자 이메일 형식을 확인하세요');
c.handle = await registrar.current().createCustomer(c); c.registrar = registrar.current().id; c.updatedAt = nowIso();
const d = load(); d.contacts[user.id] = c; save(d); return getContact(user.id);
}
// ---------- 구매·갱신 ----------
/**
* 도메인 구매. 순서: 견적 다시 받기 → 화면에서 본 금액(expectedKrw)보다 비싸지면 다시 확인 요청 → 잔액 확인 → 등록 → 성공하면 차감 → DNS 영역 만들기.
* @returns {object} 등록된 도메인 (pending: 위임 전파 중)
*/
async function purchase(user, fqdn, { period = 1, expectedKrw } = {}) {
requireDomainUser(user); const credits = require('./credits');
const reg = registrar.current(); const name = String(fqdn || '').toLowerCase(); reg.split(name);
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다', 409); // 이름은 서비스 전체에서 하나 — 다른 사용자의 도메인을 가로채지 못한다
const d0 = load(); const contact = d0.contacts[user.id]; if (reg.needsContact !== false && (!contact || !contact.handle)) throw httpError('먼저 등록자 정보(이름·주소·전화·이메일)를 저장하세요', 409);
const chk = (await reg.check([name]))[0]; if (!chk || (chk.status !== 'free' && chk.status !== 'unknown')) throw httpError(`등록할 수 없는 도메인입니다 (${(chk && (chk.reason || chk.status)) || '확인 실패'})`, 409);
const q = await quote(user, name, 'create', period);
if (Number.isFinite(Number(expectedKrw)) && q.costKrw > Number(expectedKrw)) throw Object.assign(httpError(`가격이 바뀌었습니다 (${credits.fmt(expectedKrw)} → ${credits.fmt(q.costKrw)}). 금액을 확인하고 다시 구매하세요`, 409), { quote: q });
if (user.role !== 'admin' && credits.balance(user.id) < q.costKrw) throw httpError(`캐시가 부족합니다 (필요 ${credits.fmt(q.costKrw)}, 잔액 ${credits.fmt(credits.balance(user.id))})`, 402);
// v1.6.31 (요구 18): 원장에 pending 을 남기고 업체 호출 — 응답이 없으면 unknown 으로 남겨 "확인 중" 으로 보이게(성공·실패로 임의 표시하지 않음)
const op0 = openPurchase(user.id, name, 'create', { period, quoteKrw: q.costKrw, registrar: reg.id });
if (op0.dup) throw Object.assign(httpError(`이 도메인의 구매가 이미 진행 중이거나 확인 중입니다 (${op0.dup.state}, ${op0.dup.at}). 관리자 콘솔 › 운영 복구에서 상태를 확인한 뒤 다시 시도하세요`, 409), { purchase: op0.dup });
let r;
try { r = await Promise.race([reg.createDomain(name, { handle: contact && contact.handle, period, contact }), new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error('timeout'), { timeout: true })), 90000))]); }
catch (e) { if (e.timeout || /timeout|ECONNRESET|fetch failed|socket hang up/i.test(e.message)) { setPurchase(op0.p.id, { state: 'unknown', error: e.message }); throw httpError('등록 업체의 응답을 받지 못해 결과를 알 수 없습니다. 비용은 차감하지 않았으며, 관리자가 확인(운영 복구 › 도메인 구매 확인)한 뒤 반영됩니다. 다시 구매하지 마세요', 504); } setPurchase(op0.p.id, { state: 'failed', error: e.message }); throw e; }
setPurchase(op0.p.id, { state: 'provider_succeeded', opId: r.id, expiresAt: r.expiresAt });
const charge = user.role === 'admin' ? null : credits.charge(user.id, q.costKrw, { domain: name, op: 'create', purchaseId: op0.p.id }, `도메인 등록 ${name} (${period}년)`);
setPurchase(op0.p.id, { state: 'charged', charged: !!charge });
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name, type: 'registered', opId: r.id, status: r.status, period, expiresAt: r.expiresAt, createdAt: nowIso(), charges: [{ at: nowIso(), op: 'create', krw: q.costKrw, base: q.base, charged: !!charge }], pending: { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [{ type: 'NS', name }] } };
x.registrar = reg.id;
// DNS 영역: 사이트 주소(@, www)가 서비스 서버를 가리키게 만든다 — v1.5.36: 설정의 DNS 공급자(Route 53·Google Cloud DNS·Porkbun·Openprovider)에 영역을 만들고,
// 공급자가 등록 업체와 다르면(예: Porkbun 등록 + Route 53 DNS) 등록 업체에 그 영역의 네임서버를 지정한다
const zp = dnszone.forZone(null); x.dnsProvider = zp ? zp.id : 'manual';
// v1.6.32: Route 53 Domains 는 등록을 비동기로 처리하고 완료 때 호스팅 영역을 스스로 만든다 — 아직 처리 중(PENDING)이면 영역을 지금 만들지 않고
// '등록 처리 중' 으로 남겨 두었다가 finalizeRegistration() 이 완료를 확인한 뒤 영역·레코드·네임서버를 맞춘다(같은 이름의 영역이 둘 생기는 일 방지).
if (r.status === 'PENDING') { x.pending = { kind: 'registration', since: nowIso(), etaMinutes: ETA.registration, checks: [], operationId: r.operationId || null }; x.zoneError = '등록 업체가 아직 등록을 처리하고 있습니다 — 완료되면 DNS 영역을 자동으로 만듭니다'; }
else await setupZone(x, user.id, reg, r.id);
const d = load(); d.domains.push(x); const pp = d.purchases.find((p) => p.id === op0.p.id); if (pp) Object.assign(pp, { state: 'persisted', domainId: x.id, updatedAt: nowIso() });
if (d.carts[user.id]) d.carts[user.id] = d.carts[user.id].filter((i) => i.domain !== name); // v1.6.35: 구매한 도메인은 장바구니에서 빠진다
save(d); return pub(x);
}
/**
* 구매한 도메인의 DNS 영역 만들기(@·www A) + 등록 업체와 DNS 공급자가 다르면 네임서버 지정. purchase()·finalizeRegistration() 이 부른다. 실패는 x.zoneError/x.nsError 에 남긴다.
* @param {object} x 도메인 기록(수정됨) @param {string} userId 감사 기록용 @param {object} reg 등록 업체 모듈 @param {string} ref 업체 쪽 도메인 참조(opId)
*/
async function setupZone(x, userId, reg, ref) {
const name = x.name; const zp = dnszone.forZone(null); x.dnsProvider = zp ? zp.id : 'manual';
if (!zp) { x.zoneError = '자동 DNS 공급자가 없어 영역을 만들지 않았습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)'; return; }
try { const ip = await targetIp(); const z = await dnszone.ensureZone(name, [{ name: '', type: 'A', value: ip, ttl: 900 }, { name: 'www', type: 'A', value: ip, ttl: 900 }], userId, zp.id); x.zone = { createdAt: nowIso(), ip, provider: zp.id, id: z.id || null }; x.nameServers = z.nameServers || null; x.zoneError = null; x.pending = x.pending && x.pending.kind === 'delegation' ? x.pending : { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [{ type: 'NS', name }] }; x.pending.checks.push({ type: 'A', name, value: ip }); if (zp.id !== reg.id && (z.nameServers || []).length && reg.setNameServers) { try { await reg.setNameServers(name, ref, z.nameServers); x.nsDelegated = true; } catch (e) { x.nsError = e.message; } } }
catch (e) { x.zoneError = e.message; }
}
/**
* v1.6.32 (요구 1): 비동기 등록(Route 53 Domains)의 완료 확인. 업체에 도메인이 생겼으면 상태·만료일을 채우고 DNS 영역을 만든다(멱등 — 이미 ACT 면 아무것도 안 함).
* 사용자 쪽 propagation()(도메인 탭을 열 때)과 관리자 운영 복구 "등록 상태 확인" 이 부른다.
* @returns {{done:boolean, detail:string}}
*/
async function finalizeRegistration(id, by) {
const d = load(); const x = d.domains.find((z) => z.id === id); if (!x) throw httpError('도메인 기록이 없습니다', 404);
if (x.status !== 'PENDING') return { done: true, detail: '이미 등록 완료' };
const reg = registrar.get(x.registrar); let own = null;
if (x.pending && x.pending.operationId && reg.call) { try { const st = await reg.call('GetOperationDetail', { OperationId: x.pending.operationId }); if (st.Status === 'ERROR' || st.Status === 'FAILED') { x.status = 'FAILED'; x.pending = null; x.zoneError = `등록 실패: ${st.Message || st.Status}`; save(d); return { done: false, failed: true, detail: x.zoneError }; } } catch (_) {} }
try { own = reg.ownsDomain ? await reg.ownsDomain(x.name) : await reg.getDomain(x.opId); } catch (e) { return { done: false, detail: `업체 조회 실패: ${e.message}` }; }
if (!own || !own.expiresAt) return { done: false, detail: '등록 업체가 아직 처리 중입니다' };
x.status = 'ACT'; x.expiresAt = own.expiresAt; x.pending = null;
await setupZone(x, by || x.userId, reg, x.opId); save(d);
return { done: true, detail: `등록 완료 (만료 ${x.expiresAt})${x.zone ? ' · DNS 영역 준비됨' : ''}${x.zoneError ? ' · ' + x.zoneError : ''}` };
}
/**
* v1.6.31 (요구 18·20) 미완 구매 재조정: 업체에 그 도메인이 있는지 조회해 — 있으면(우리 계정 소유) 기록·차감을 완성하고, 없으면 failed 로 닫는다. 관리자가 운영 복구 화면에서 부른다.
*/
async function reconcilePurchase(id, by) {
const dd = load(); const p = dd.purchases.find((x) => x.id === id); if (!p) throw httpError('구매 기록이 없습니다', 404);
if (p.state === 'persisted' || p.state === 'failed') return p;
const reg = registrar.get(p.registrar); const credits = require('./credits');
let found = null; try { const z = await reg.check([p.name]); found = z[0] && z[0].status !== 'free' ? z[0] : null; } catch (e) { setPurchase(id, { lastCheck: nowIso(), checkError: e.message }); throw httpError(`업체 조회 실패: ${e.message}`, 502); }
// 업체 계정에 그 도메인이 실제로 있는지(우리 소유인지)까지 확인 — porkbun 은 목록, route53 은 GetDomainDetail(v1.6.32)
let own = null; const canOwn = reg.id === 'porkbun' || !!reg.ownsDomain; try { if (reg.ownsDomain) own = await reg.ownsDomain(p.name); else if (reg.id === 'porkbun') own = await reg.getDomain(p.name); } catch (_) {}
if (!found || (canOwn && !(own && (own.status || own.expiresAt)))) { setPurchase(id, { state: 'failed', lastCheck: nowIso(), note: '업체에 등록되지 않음 — 다시 구매할 수 있습니다' }); return setPurchase(id, {}); }
if (byName(p.name)) { setPurchase(id, { state: 'persisted', lastCheck: nowIso(), note: '이미 기록됨' }); return setPurchase(id, {}); }
const u = { id: p.userId, role: 'user' }; const charge = credits.charge(p.userId, p.quoteKrw, { domain: p.name, op: p.op, purchaseId: p.id }, `도메인 등록 ${p.name} (${p.period}년, 재조정)`);
const x = { id: crypto.randomBytes(8).toString('hex'), userId: p.userId, name: p.name, type: 'registered', opId: p.opId || p.name, status: 'ACT', period: p.period, expiresAt: p.expiresAt || (own && own.expiresAt) || null, createdAt: nowIso(), registrar: reg.id, dnsProvider: 'manual', charges: [{ at: nowIso(), op: p.op, krw: p.quoteKrw, charged: !!(charge && charge.entry), reconciled: true }], pending: { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [] } };
const d2 = load(); d2.domains.push(x); const pp = d2.purchases.find((q) => q.id === id); if (pp) Object.assign(pp, { state: 'persisted', domainId: x.id, reconciledBy: by, updatedAt: nowIso() }); save(d2); return pp;
}
async function renew(user, id, { period = 1, expectedKrw } = {}) {
requireDomainUser(user); const credits = require('./credits'); const { x } = getOwned(user.id, id); if (x.type !== 'registered') throw httpError('서브 도메인은 갱신이 필요 없습니다');
const q = await quote(user, x.name, 'renew', period);
if (Number.isFinite(Number(expectedKrw)) && q.costKrw > Number(expectedKrw)) throw Object.assign(httpError('가격이 바뀌었습니다. 금액을 확인하고 다시 갱신하세요', 409), { quote: q });
if (user.role !== 'admin' && credits.balance(user.id) < q.costKrw) throw httpError(`캐시가 부족합니다 (필요 ${credits.fmt(q.costKrw)})`, 402);
const reg = registrar.current(x.registrar); await reg.renewDomain(x.opId, x.name, period);
const charge = user.role === 'admin' ? null : credits.charge(user.id, q.costKrw, { domain: x.name, op: 'renew' }, `도메인 갱신 ${x.name} (${period}년)`);
const d = load(); const y = d.domains.find((z) => z.id === id); y.charges = y.charges || []; y.charges.push({ at: nowIso(), op: 'renew', krw: q.costKrw, base: q.base, charged: !!charge });
try { const info = await reg.getDomain(y.opId); if (info && info.expiresAt) y.expiresAt = info.expiresAt; } catch (_) {}
save(d); return pub(y);
}
// ---------- DNS 레코드 ----------
/** 사이트가 가리킬 IP: 설정 → 서비스 주소의 IP */
async function targetIp() {
const c = config(); if (c.targetIp) return c.targetIp;
const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').hostname; } catch (_) { return ''; } })();
if (!host) throw httpError('사이트 IP 를 정할 수 없습니다. 관리자 콘솔 › 설정 › 도메인 에 사이트 IP 를 넣으세요', 503);
if (require('net').isIP(host)) return host;
const a = await dnsLib.promises.lookup(host, { family: 4 }).catch(() => null); if (!a) throw httpError('서비스 주소의 IP 를 찾지 못했습니다. 관리자 콘솔 › 설정 › 도메인 에 사이트 IP 를 넣으세요', 503);
return a.address;
}
/** 레코드 목록 (구입 도메인: 영역 전체 / 서브 도메인: 플랫폼 영역에서 그 이름에 해당하는 것) */
async function records(user, id) {
const { x } = getOwned(user.id, id); const c = config();
if (x.type === 'registered' || (x.type === 'external' && x.mode === 'ns')) { requireDomainUser(user); return zoneOf(x).p.listRecords(x.name); } // 자기 영역만 (x 는 getOwned 로 소유 확인됨) · v1.6.35: 네임서버 이전 도메인도 우리 영역
if (x.type === 'external') throw httpError('이 도메인의 DNS 는 사용자가 자기 DNS 서비스에서 관리합니다 — 여기서는 "필요한 DNS 설정" 과 확인 결과만 보입니다', 409);
const pz = platformZone(); if (!pz) return [];
return (await pz.p.listRecords(c.platformDomain)).filter((r) => r.name === x.label || r.name.endsWith(`.${x.label}`) || r.name === x.name || r.name.endsWith(`.${x.name}`));
}
const REC_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'CAA', 'SRV'];
/** 레코드 입력 검증 (이름은 도메인 기준 상대 이름: '' = 도메인 자신, 'www', 'mail' …) */
function normRecord(r) {
const type = String(r.type || '').toUpperCase(); if (!REC_TYPES.includes(type)) throw httpError(`레코드 종류는 ${REC_TYPES.join('·')} 중 하나입니다`);
const name = String(r.name || '').trim().toLowerCase().replace(/^@$/, ''); if (name && !/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?$|^\*$/.test(name)) throw httpError('레코드 이름은 www·mail 처럼 입력하세요 (도메인 자신은 비우거나 @)');
const value = String(r.value || '').trim(); if (!value || value.length > 2000) throw httpError('레코드 값을 입력하세요');
if (type === 'A' && require('net').isIP(value) !== 4) throw httpError('A 레코드 값은 IPv4 주소입니다'); if (type === 'AAAA' && require('net').isIP(value) !== 6) throw httpError('AAAA 레코드 값은 IPv6 주소입니다');
const ttl = Math.min(86400, Math.max(300, Number(r.ttl) || 900)); const prio = type === 'MX' || type === 'SRV' ? Math.min(65535, Math.max(0, Number(r.prio) || 10)) : undefined;
return { name, type, value, ttl, prio };
}
/** 레코드 더하기 (구입 도메인만 — 서브 도메인 사용자는 DNS 를 직접 관리하지 않음) */
/**
* v1.6.35 (요구 1-2): 사용자가 레코드를 직접 고칠 수 있는 도메인인지 — 구입 도메인과 네임서버 이전(mode 'ns') 외부 도메인만. 수동 관리(mode 'manual') 도메인은 사용자가 자기 DNS 에서 고친다.
* 네임서버 이전이 전파 중(pending.kind 'transfer')이면 409 — 옛 DNS 와 새 영역을 동시에 고쳐 생기는 혼란(이중 수정)을 막는다.
*/
function assertEditable(x) {
if (x.type === 'subdomain') throw httpError('서브 도메인은 DNS 를 직접 관리할 수 없습니다', 403);
if (x.type === 'external' && x.mode === 'manual') throw httpError('이 도메인의 DNS 는 사용자가 자기 DNS 서비스에서 직접 관리합니다 — "필요한 DNS 설정" 을 보고 그곳에서 고치세요', 409);
if (x.pending && x.pending.kind === 'transfer') throw httpError(`DNS 변경 진행 중 — ${x.name} 의 네임서버 이전이 확인될 때까지 레코드를 수정할 수 없습니다 (이중 수정 방지). "네임서버 변경 확인" 으로 상태를 확인하세요`, 409);
}
async function addRecord(user, id, r) {
requireDomainUser(user); const { x } = getOwned(user.id, id); assertEditable(x);
const rr = normRecord(r); await dnszone.addRecords(x.name, [rr], user.id, zoneOf(x).id); markPending(id, 'records', [{ type: rr.type, name: rr.name ? `${rr.name}.${x.name}` : x.name, value: rr.value }]); return records(user, id);
}
async function removeRecord(user, id, r) {
requireDomainUser(user); const { x } = getOwned(user.id, id); assertEditable(x);
const rr = normRecord(r); await dnszone.removeRecords(x.name, [rr], user.id, zoneOf(x).id); return records(user, id);
}
function markPending(id, kind, checks) { const d = load(); const y = d.domains.find((z) => z.id === id); if (!y) return; const prev = y.pending && y.pending.kind === 'delegation' ? y.pending : null; y.pending = { kind: prev ? 'delegation' : kind, since: prev ? prev.since : nowIso(), etaMinutes: prev ? prev.etaMinutes : ETA[kind], checks: [...((prev && prev.checks) || []), ...checks].slice(-20) }; save(d); }
// ---------- 사이트 연결 ----------
/**
* 도메인(또는 서브 도메인)을 사이트에 연결: site.domains 를 바꾸고, 자동 DNS 가 가능하면 A 레코드를 만든다.
* 실제 웹서버 설정(vhost·SSL)은 다음 배포(발행) 때 lib/deploy.js 가 한다.
* @returns {{ domains: string[], dns: 'auto'|'manual', guide: string|null }}
*/
/**
* v1.7.2 (요구 1): 사이트를 도메인에 "어떤 이름으로" 올릴지 — host 가 없으면 도메인 자신(@)과 www, host 가 있으면 그 하위 이름 하나(예: shop → shop.example.com).
* 서브 도메인(플랫폼 아래 이름)은 이미 하나의 이름이라 host 를 받지 않는다. 하위 이름 규칙: 영문 소문자·숫자·하이픈 1~32자.
* @param {object} user @param {string} id 도메인 ID @param {object} site 사이트(저장소 기록, site.domains 를 바꾼다) @param {{host?: string}} [opts]
*/
async function connect(user, id, site, opts = {}) {
const { x } = getOwned(user.id, id); if (site.ownerId !== user.id && user.role !== 'admin') throw httpError('내 사이트에만 연결할 수 있습니다', 403);
const host = x.type === 'subdomain' ? '' : String(opts.host || '').trim().toLowerCase().replace(/\.$/, '');
if (host && (host === 'www' || host === '@')) throw httpError('www 와 @ 는 "기본(@ · www)" 으로 연결하세요');
if (host && !/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(host)) throw httpError('하위 이름은 영문 소문자·숫자·하이픈 1~32자입니다 (예: shop)');
const names = x.type === 'subdomain' ? [x.name] : (host ? [`${host}.${x.name}`] : [x.name, `www.${x.name}`]);
const d = load(); for (const y of d.domains) if (y.siteId === site.id && y.id !== id) { y.siteId = null; y.host = null; y.siteNames = null; } const me = d.domains.find((z) => z.id === id); me.siteId = site.id; me.host = host || null; me.siteNames = names; save(d);
site.domains = names; let dnsMode = 'manual'; let guide = null; const c = config();
try {
const ip = await targetIp();
const pz = platformZone();
if (x.type === 'subdomain' && pz) { await pz.p.updateRecords(c.platformDomain, { add: [{ name: x.label, type: 'A', value: ip, ttl: 900 }] }).catch((e) => { if (!/exist|duplicate/i.test(e.message)) throw e; }); dnsMode = 'auto'; markPending(id, 'records', [{ type: 'A', name: x.name, value: ip }]); }
else if (x.type === 'registered') { dnsMode = 'auto'; if (host) { const zp = dnszone.forZone(x); if (zp) await ensureSiteRecords(x, ip, user.id, [host]); else guide = 'DNS 공급자 접속 정보가 없어 하위 이름 레코드를 만들 수 없습니다 (관리자)'; } } // 구매 때 @·www A 레코드를 이미 만들었다 — v1.7.2: 하위 이름이면 그 A 레코드를 더한다
// v1.6.35 (요구 1-2): 네임서버 이전 도메인 — 우리 영역에 사이트 레코드(@·www A)를 넣는다. 네임서버가 아직 바뀌지 않았으면 전파가 끝난 뒤에 살아난다(이전 진행 중 안내는 status 가 한다)
else if (x.type === 'external' && x.mode === 'ns') { const zp = dnszone.forZone(x); if (zp) { await ensureSiteRecords(x, ip, user.id, host ? [host] : ['', 'www']); dnsMode = 'auto'; if (x.pending && x.pending.kind === 'transfer') guide = '네임서버 이전이 아직 확인되지 않았습니다 — 네임서버 변경이 전파된 뒤 이 주소로 열립니다'; } else guide = 'DNS 공급자 접속 정보가 없어 레코드를 만들 수 없습니다 (관리자)'; }
// v1.6.35: 수동 관리 도메인 — 사용자가 자기 DNS 에 넣어야 할 레코드를 안내하고, 확인(verify)되기 전에는 배포가 되지 않는다고 알린다
else if (x.type === 'external') { const ok = x.manual && x.manual.verifiedAt && x.manual.siteOk; guide = ok ? `DNS 확인됨 (${fmtT(x.manual.verifiedAt)}) — 다음 발행 때 이 주소로 배포됩니다` : `${names.join(' 과 ')} 의 A 레코드가 ${ip} 를 가리켜야 합니다 — "필요한 DNS 설정" 을 자기 DNS 에 넣고 "설정 완료 — 확인" 을 누르세요. 확인되기 전에는 이 주소로 배포되지 않습니다`; }
else guide = `${x.name} 이 ${ip} 를 가리키도록 플랫폼 DNS 에 와일드카드(*.${c.platformDomain}) 또는 ${x.label} A 레코드가 있어야 합니다 (운영자 설정)`;
} catch (e) { guide = e.message; }
return { domains: names, dns: dnsMode, guide, host: host || null };
}
/**
* v1.7.2 (요구 1): 사이트 연결 끊기 — 도메인 기록의 siteId 를 비우고 사이트의 domains 에서 그 도메인 이름들을 뺀다. DNS 레코드는 지우지 않는다(다시 연결하면 그대로 쓴다).
* @returns {{domains: string[]}} 사이트에 남은 도메인 이름
*/
function disconnect(user, id, site) {
const { d, x } = getOwned(user.id, id); if (site.ownerId !== user.id && user.role !== 'admin') throw httpError('내 사이트만 다룰 수 있습니다', 403);
if (x.siteId !== site.id) throw httpError('이 도메인은 그 사이트에 연결되어 있지 않습니다', 409);
const names = new Set(x.siteNames || (x.type === 'subdomain' ? [x.name] : [x.name, `www.${x.name}`, ...(x.host ? [`${x.host}.${x.name}`] : [])]));
x.siteId = null; x.host = null; x.siteNames = null; save(d);
site.domains = (site.domains || []).filter((n) => !names.has(n)); return { domains: site.domains };
}
// ---------- 메일(MX) 자동 설정 ----------
/**
* 메일용 DNS 레코드 자동 생성: 받기 MX(메일 서버) + 보내기 SPF·DMARC + Resend(보내기 서명 DKIM·반송 MX) 레코드.
* 구입 도메인은 그 영역에, 서브 도메인은 플랫폼 영역이 Openprovider 일 때 그 이름 아래에 만든다. 그 밖은 수동 안내.
* @returns {{ records: Array, mode: 'auto'|'manual' }}
*/
async function setupMail(user, id, site) {
const { x } = getOwned(user.id, id); const c = config(); const mailsys = require('./mailsys');
if (x.type === 'registered') requireDomainUser(user);
const resendInfo = await mailsys.ensureResendDomain(x.name, site && site.webmail && site.webmail.domain === x.name ? site.webmail.resend : null).catch((e) => { throw httpError(`메일 발송(Resend) 도메인을 준비하지 못했습니다: ${e.message}`, 502); });
// v1.6.38 (요구 2·3): 사이트가 없어도 도메인에 맞는 메일 서버(등록부)의 호스트가 MX 기본값이 되게 siteConfig 에 가짜 사이트(메일 도메인만)를 넘긴다
const want = mailsys.dnsRecords(x.name, resendInfo, mailsys.siteConfig(site || { webmail: { domain: x.name } }));
// 레코드 이름을 영역 기준 상대 이름으로 (구입 도메인: x.name 기준, 서브 도메인: 플랫폼 도메인 기준)
const zone = x.type === 'subdomain' ? c.platformDomain : x.name; // v1.6.35: 외부 도메인(ns·manual)도 자기 이름이 영역
const rel = (fqdn) => { const f = String(fqdn).toLowerCase().replace(/\.$/, ''); return f === zone ? '' : f.endsWith(`.${zone}`) ? f.slice(0, -(zone.length + 1)) : f; };
const recs = want.map((r) => ({ name: rel(r.name), type: r.type, value: r.value, ttl: 900, prio: r.type === 'MX' ? (r.priority || 10) : undefined }));
// 어느 영역에 넣나: 서브 도메인 → 플랫폼 영역, 구입·네임서버 이전 도메인 → 그 도메인의 영역, 수동 관리 도메인 → 자동 없음(안내 모드: 사용자가 자기 DNS 에 직접 넣는다)
const zp = x.type === 'subdomain' ? platformZone() : (x.type === 'external' && x.mode === 'manual') ? null : dnszone.forZone(x);
if (!zp) { const dd0 = load(); const y0 = dd0.domains.find((z) => z.id === id); if (y0 && y0.type === 'external') { y0.mail = { ...(y0.mail || {}), requestedAt: nowIso(), manual: true, resendId: resendInfo && resendInfo.id || null }; save(dd0); } return { records: want, mode: 'manual', resend: resendInfo }; }
await dnszone.addRecords(zone, recs, user.id, zp.id);
const dd = load(); const y = dd.domains.find((z) => z.id === id); y.mail = { setupAt: nowIso(), resendId: resendInfo && resendInfo.id || null }; save(dd);
markPending(id, 'records', want.filter((r) => r.required && (r.type === 'MX' || r.type === 'TXT')).map((r) => ({ type: r.type, name: r.name, value: r.value })));
return { records: want, mode: 'auto', resend: resendInfo };
}
// ============================================================================
// v1.6.35 (요구 1-2) 도메인 준비 프로세스 — 사용자가 스스로 "자기 홈페이지 주소" 를 마련하고 관리한다
// ① 구매 : 검색(가격 포함) → 장바구니(보관) → 최종 선택 → 구매(purchase). 장바구니 가격은 열 때마다 다시 받아 바뀐 값을 표시한다.
// ② 이전 : 등록 업체 이전(registrar transfer)은 지원하지 않는다. 다른 곳에서 산 도메인의 "네임서버" 를 우리 DNS 공급자로 바꾸는 방식(mode 'ns').
// 사용자가 쓰는 하위 이름(subdomains)을 받아 지금 공용 DNS 의 레코드를 읽어 새 영역에 옮기고 → 바꿀 네임서버 목록을 보여 주고 → 사용자가 "바꿨다" 고 확인하면
// 공용 DNS 의 NS 가 실제로 바뀌었는지 확인(transferCheck)한다. 확인될 때까지 pending.kind='transfer' 로 두어 레코드 수정을 잠근다(이중 수정 방지).
// 영역을 먼저 만드는 이유: 공급자(Route 53 등)는 영역을 만들어야 그 영역의 네임서버 주소를 알려 주기 때문.
// ③ 수동 입력 : 사용자가 자기 DNS 서비스에서 레코드를 직접 관리(mode 'manual'). 배포(A)·메일(MX·SPF·DMARC·DKIM)에 필요한 레코드를 보여 주고(requiredRecords),
// 사용자가 "넣었다" 고 확인하면 공용 DNS 에서 확인(verifyManual)해 사용 가능 여부를 보인다. 확인되지 않으면 "DNS 변경이 완료되지 않았거나 반영되지 않아 배포가 되지 않는다" 고 알린다.
// ④ 자동 설정 : 위가 끝나면 사이트 연결(connect) → 발행 때 vhost·SSL 이 적용되고 메일(setupMail)을 쓸 수 있다. 어디서 막혔는지는 status() 가 단계별로 설명한다.
// ============================================================================
// ---------- ① 장바구니 ----------
/** 사용자의 장바구니 (저장된 그대로 — 가격은 담을 때 값. 최신 가격은 cartRefresh) */
function cart(userId) { return (load().carts[userId] || []).slice(); }
/** 검색 결과의 도메인을 장바구니에 담는다. 등록 가능 여부와 가격을 그 시점 값으로 함께 저장. 같은 도메인은 한 번만, 20개까지 */
async function cartAdd(user, domain) {
requireDomainUser(user); const reg = registrar.current(); const name = String(domain || '').trim().toLowerCase(); reg.split(name);
const d = load(); const c = d.carts[user.id] || []; if (c.find((i) => i.domain === name)) return c;
if (c.length >= 20) throw httpError('장바구니에는 20개까지 담을 수 있습니다 — 쓰지 않을 도메인을 먼저 빼세요');
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다', 409);
const chk = (await reg.check([name]))[0]; const available = !!chk && (chk.status === 'free' || chk.status === 'unknown');
const quote = available && chk.price ? await toUserKrw(chk.price) : null;
c.push({ domain: name, addedAt: nowIso(), available, status: chk ? chk.status : 'unknown', premium: !!(chk && chk.premium), quote, checkedAt: nowIso(), registrar: reg.id });
d.carts[user.id] = c; save(d); return c;
}
/** 장바구니에서 빼기 */
function cartRemove(user, domain) { const d = load(); const name = String(domain || '').trim().toLowerCase(); d.carts[user.id] = (d.carts[user.id] || []).filter((i) => i.domain !== name); save(d); return d.carts[user.id]; }
/** 장바구니의 등록 가능 여부·가격을 다시 받는다(화면을 열 때). 가격이 바뀌면 priceChanged·prevKrw 로 표시해 사용자가 알고 고르게 한다 */
async function cartRefresh(user) {
requireDomainUser(user); const items = cart(user.id); if (!items.length) return items;
const reg = registrar.current(); let res = [];
try { res = await reg.check(items.map((i) => i.domain)); } catch (e) { return items.map((i) => ({ ...i, refreshError: `가격을 다시 받지 못했습니다: ${e.message}` })); }
const out = [];
for (const i of items) {
const r = res.find((z) => z.domain === i.domain); if (!r) { out.push(i); continue; }
const available = r.status === 'free' || r.status === 'unknown'; const quote = available && r.price ? await toUserKrw(r.price) : null;
out.push({ ...i, available, status: r.status, premium: !!r.premium, prevKrw: i.quote ? i.quote.costKrw : null, quote, priceChanged: !!(i.quote && quote && i.quote.costKrw !== quote.costKrw), checkedAt: nowIso(), refreshError: undefined });
}
const d = load(); d.carts[user.id] = out; save(d); return out;
}
// ---------- ②·③ 이미 가진 도메인 등록 ----------
const SUB_RE = /^(\*|[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?)(\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?)*$/;
/** 사용자가 적은 하위 이름 목록(쉼표·공백 구분, 'www', 'blog', 'shop.kr' …) 정리 — 잘못된 이름은 안내 오류, 중복 제거, 30개까지 */
function normSubdomains(list) {
const arr = Array.isArray(list) ? list : String(list || '').split(/[,\s]+/); const out = [];
for (let s of arr) { s = String(s).trim().toLowerCase().replace(/^@$/, '').replace(/\.$/, ''); if (!s) continue; if (!SUB_RE.test(s)) throw httpError(`하위 이름이 올바르지 않습니다: "${s}" — www, blog 처럼 도메인 앞부분만 적으세요`); if (!out.includes(s)) out.push(s); }
return out.slice(0, 30);
}
/**
* 지금 공용 DNS 에 보이는 레코드를 읽어 영역 기준 상대 이름으로 모은다(A·AAAA·CNAME·TXT, 도메인 자신은 MX 도). CNAME 이 있으면 그 이름의 다른 종류는 보지 않는다(RFC 상 공존 불가).
* @param {string} zone 도메인 @param {string[]} names 상대 이름들('' 포함) @param {object} r 리졸버
*/
async function collectLive(zone, names, r) {
const recs = [];
for (const n of names) {
const fq = n ? `${n}.${zone}` : zone;
for (const [type, fn] of [['CNAME', 'resolveCname'], ['A', 'resolve4'], ['AAAA', 'resolve6'], ['TXT', 'resolveTxt'], ['MX', 'resolveMx']]) {
if (type === 'MX' && n) continue; if (type === 'CNAME' && !n) continue; // 도메인 자신은 CNAME 불가
let v = []; try { v = await r[fn](fq); } catch (_) { v = []; }
for (const x of v) { if (type === 'TXT') recs.push({ name: n, type, value: x.join(''), ttl: 900 }); else if (type === 'MX') recs.push({ name: n, type, value: normNs(x.exchange), prio: x.priority, ttl: 900 }); else recs.push({ name: n, type, value: type === 'CNAME' ? normNs(x) : x, ttl: 900 }); }
if (type === 'CNAME' && v.length) break;
}
}
return recs;
}
/**
* 이미 가진(다른 곳에서 구매한) 도메인 등록.
* @param {object} user @param {string} fqdn 도메인
* @param {{mode:'ns'|'manual', subdomains?:string[]|string, owned:boolean, resolver?:object}} opts
* owned : 사용자가 "이 도메인을 이미 구매(소유)했다" 고 확인했는지 — 확인 없이는 진행하지 않는다(요구: 구매 여부를 확인해서 진행)
* mode 'ns' : 네임서버 이전 — 공용 DNS 레코드를 읽어 우리 공급자에 영역을 만들고, 바꿀 네임서버를 돌려준다(전파 확인 전까지 레코드 수정 잠금)
* mode 'manual' : 사용자가 자기 DNS 에서 직접 관리 — 필요한 레코드 안내(requiredRecords)와 확인(verifyManual)만 한다
* @returns {{domain, imported:Array, nameServers:string[], currentNs:string[], message:string, next:string}}
*/
async function addExternal(user, fqdn, opts = {}) {
requireDomainUser(user); const name = String(fqdn || '').trim().toLowerCase().replace(/\.$/, '').replace(/^www\./, '');
if (!DOMAIN_RE.test(name)) throw httpError('도메인 이름이 올바르지 않습니다 (예: mycompany.com)');
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다 — 내 도메인 목록을 확인하세요', 409);
if (opts.owned !== true) throw httpError('이 도메인을 이미 구매(소유)했는지 확인해 주세요 — 아직 없으면 "도메인 검색·구매" 로 구매하세요', 409);
const mode = opts.mode === 'manual' ? 'manual' : opts.mode === 'ns' ? 'ns' : null; if (!mode) throw httpError('관리 방식을 고르세요: 네임서버 이전(ns) 또는 직접 DNS 관리(manual)');
const subs = normSubdomains(opts.subdomains); const r = opts.resolver || resolver;
// 1) 공용 DNS 에 살아 있는 도메인인지(등록·위임) — 미등록이면 실패하고 이유를 설명한다(1.6.33 describeDnsError)
let currentNs = []; try { currentNs = (await r.resolveNs(name)).map(normNs); }
catch (e) { const why = dnszone.describeDnsError(e.code, name); if (why) throw httpError(`${name} 을(를) 등록할 수 없습니다 — ${why}. 도메인을 아직 구매하지 않았다면 "도메인 검색·구매" 를 이용하세요`, 409); }
if (!currentNs.length) { try { await r.resolveSoa(name); } catch (e) { throw httpError(`${name} 을(를) 등록할 수 없습니다 — ${dnszone.describeDnsError(e.code, name) || 'NS·SOA 레코드가 없어 독립된 도메인(영역)이 아닙니다. 상위 도메인을 등록하세요'}`, 409); } }
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name, type: 'external', mode, status: 'active', createdAt: nowIso(), charges: [], currentNs, subdomains: subs };
let message; let next; let imported = [];
if (mode === 'ns') {
const zp = dnszone.forZone(null); if (!zp) throw httpError('이 서비스의 DNS 공급자가 준비되지 않아 네임서버 이전을 받을 수 없습니다 — "직접 DNS 관리" 로 등록하거나 관리자에게 문의하세요 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)', 503);
// 2) 지금 레코드를 읽어(도메인 자신·www·사용자가 적은 하위 이름) 새 영역에 넣는다 — 네임서버를 바꾼 뒤에도 기존 서비스(메일 등)가 끊기지 않게
imported = await collectLive(name, ['', 'www', ...subs.filter((s) => s !== 'www')], r);
const z = await dnszone.ensureZone(name, imported, user.id, zp.id);
x.dnsProvider = zp.id; x.zone = { createdAt: nowIso(), provider: zp.id, id: z.id || null }; x.nameServers = (z.nameServers || []).map(normNs); x.imported = imported.length; x.importedRecords = imported.slice(0, 100);
// 3) 이미 우리 네임서버를 가리키면(앉은 자리 재등록) 이전 절차가 필요 없다
const already = sameNs(currentNs, x.nameServers);
x.transfer = { step: already ? 'done' : 'ns-change', since: nowIso(), userConfirmedAt: null, completedAt: already ? nowIso() : null, checks: [] };
if (!already) x.pending = { kind: 'transfer', since: nowIso(), etaMinutes: ETA.transfer, checks: [{ type: 'NS', name, expect: x.nameServers }] };
dnszone.audit({ by: user.id, action: 'zone.transfer-start', provider: zp.id, zone: name, imported: imported.length, currentNs, nameServers: x.nameServers });
message = already ? `${name} 의 네임서버가 이미 이 서비스의 DNS(${x.nameServers.join(', ')})를 가리킵니다 — 바로 사용할 수 있습니다. 기존 레코드 ${imported.length}개를 확인했습니다`
: `${name} 의 현재 DNS 레코드 ${imported.length}개를 새 영역에 옮겨 두었습니다. 도메인을 구매한 등록 업체에서 네임서버를 아래 목록으로 바꾸세요. 바꾼 뒤 "네임서버 변경했습니다" 를 누르면 실제로 바뀌었는지 확인합니다. 확인될 때까지(보통 수 분~수 시간, 최대 48시간) 이 도메인의 DNS 레코드 수정은 잠깁니다`;
next = already ? 'connect' : 'ns-change';
} else {
x.dnsProvider = 'manual'; x.manual = { confirmedAt: null, verifiedAt: null, siteOk: false, mailOk: null, lastCheck: null };
message = `${name} 을(를) 등록했습니다(DNS 는 직접 관리). "필요한 DNS 설정" 의 레코드를 도메인의 DNS 서비스에 넣은 뒤 "설정 완료 — 확인" 을 누르세요. 확인되기 전에는 이 주소로 배포되지 않습니다`;
next = 'manual-records';
}
const d = load(); d.domains.push(x); save(d);
return { domain: pub(x), imported, nameServers: x.nameServers || [], currentNs, message, next };
}
/** 네임서버 이전 도메인의 사이트 레코드(@·www A)를 우리 영역에 맞춘다(같은 이름·종류는 바꿈). connect() 가 부른다 */
async function ensureSiteRecords(x, ip, by, labels = ['', 'www']) {
const zp = dnszone.forZone(x); if (!zp) throw httpError('DNS 공급자 접속 정보가 없습니다', 503);
await dnszone.setRecords(x.name, labels.map((l) => ({ name: l, type: 'A', value: ip, ttl: 900 })), by, zp.id); // v1.7.2: 하위 이름(labels)을 받는다 — 기본 @·www
const d = load(); const y = d.domains.find((z) => z.id === x.id); if (y) { y.siteRecordsAt = nowIso(); y.siteIp = ip; save(d); }
}
/**
* 네임서버 이전 판정(내부): 공용 DNS 의 NS 가 우리 영역의 네임서버 집합과 같으면 완료(pending 해제·레코드 수정 열림). 아니면 단계별 안내.
* ns-change : 사용자가 아직 "바꿨다" 고 확인하지 않음 → 바꿀 네임서버를 다시 안내
* propagating : 확인했지만 공용 DNS 에는 옛/일부 네임서버가 보임 → "DNS 변경 진행 중" (수정 잠금 유지)
* error : NS 조회 자체가 실패(NXDOMAIN·SERVFAIL 등) → 원인 설명
* @param {object} y 저장소의 도메인 기록(수정됨) @param {object} d 저장소 문서(save 에 쓴다)
*/
async function transferCheckRecord(y, d) {
const expect = (y.nameServers || []).map(normNs); let got = []; let err = null;
try { got = (await resolver.resolveNs(y.name)).map(normNs); } catch (e) { err = dnszone.describeDnsError(e.code, y.name) || `DNS 조회 오류(${e.code || e.message})`; }
y.transfer = y.transfer || { step: 'ns-change', since: y.pending ? y.pending.since : nowIso(), userConfirmedAt: null, checks: [] };
if (got.length) y.currentNs = got;
y.transfer.lastCheck = nowIso(); y.transfer.checks = [...(y.transfer.checks || []), { at: nowIso(), ns: got, error: err }].slice(-10);
if (expect.length && sameNs(got, expect)) {
y.transfer.step = 'done'; y.transfer.completedAt = nowIso(); y.pending = null; y.propagatedAt = nowIso(); save(d);
dnszone.audit({ by: y.userId, action: 'zone.transfer-done', provider: y.dnsProvider, zone: y.name, nameServers: got });
return { done: true, step: 'done', currentNs: got, expect, etaMinutes: 0, message: `네임서버 변경이 확인되었습니다 — ${y.name} 의 DNS 를 이 서비스에서 관리합니다. 레코드 수정이 열렸고, 사이트에 연결하면 이 주소로 배포됩니다` };
}
const since = (y.pending && y.pending.since) || y.transfer.since || nowIso(); const elapsed = (Date.now() - Date.parse(since)) / 60000; const left = Math.max(5, Math.ceil(ETA.transfer - elapsed));
const partial = got.some((n) => expect.includes(n)); let step; let message;
if (err) { step = 'error'; message = `네임서버를 확인할 수 없습니다 — ${err}`; }
else if (!y.transfer.userConfirmedAt) { step = 'ns-change'; message = `네임서버가 아직 바뀌지 않았습니다 (지금: ${got.join(', ') || '없음'}). 등록 업체에서 네임서버를 ${expect.join(', ')} 로 바꾼 뒤 "네임서버 변경했습니다" 를 누르세요`; }
else if (partial) { step = 'propagating'; message = `DNS 변경 진행 중 — 네임서버가 일부만 바뀌어 보입니다 (지금: ${got.join(', ')}). 전파가 끝날 때까지(약 ${left}분, 최대 48시간) 레코드 수정은 잠깁니다`; }
else { step = 'propagating'; message = `DNS 변경 진행 중 — 공용 DNS 에는 아직 옛 네임서버(${got.join(', ') || '없음'})가 보입니다. 등록 업체에서 변경을 저장했는지 확인하고 전파(약 ${left}분, 최대 48시간)를 기다려 주세요. 그동안 레코드 수정은 잠깁니다`; }
y.transfer.step = step; save(d);
return { done: false, step, currentNs: got, expect, etaMinutes: left, message };
}
/**
* 사용자 쪽 "네임서버 변경했습니다"(confirmed) / "다시 확인". confirmed 면 사용자 확인 시각을 기록하고 바로 공용 DNS 를 확인한다.
* @returns {{done, step, currentNs, expect, etaMinutes, message}}
*/
async function transferCheck(user, id, { confirmed } = {}) {
requireDomainUser(user); const d = load(); const y = d.domains.find((z) => z.id === id && z.userId === user.id); if (!y) throw httpError('도메인이 없습니다', 404);
if (y.type !== 'external' || y.mode !== 'ns') throw httpError('네임서버 이전으로 등록한 도메인이 아닙니다');
if (confirmed) { y.transfer = y.transfer || { step: 'ns-change', since: nowIso(), checks: [] }; y.transfer.userConfirmedAt = nowIso(); if (!y.pending && y.transfer.step !== 'done') y.pending = { kind: 'transfer', since: nowIso(), etaMinutes: ETA.transfer, checks: [{ type: 'NS', name: y.name, expect: y.nameServers || [] }] }; }
if (y.transfer && y.transfer.step === 'done' && !y.pending && !confirmed) return { done: true, step: 'done', currentNs: y.currentNs || [], expect: y.nameServers || [], etaMinutes: 0, message: '네임서버 변경이 이미 확인되었습니다' };
return transferCheckRecord(y, d);
}
/** 레코드 하나를 공용 DNS 에서 찾아 기대 값과 비교 — {values:[], ok} (SPF 는 "v=spf1 … mx" 포함이면 통과: 기존 SPF 에 mx 를 더한 경우) */
async function lookupFound(rec) {
try {
if (rec.type === 'A') { const v = await resolver.resolve4(rec.name); return { values: v, ok: v.includes(rec.value) }; }
if (rec.type === 'AAAA') { const v = await resolver.resolve6(rec.name); return { values: v, ok: v.includes(rec.value) }; }
if (rec.type === 'MX') { const v = (await resolver.resolveMx(rec.name)).map((m) => normNs(m.exchange)); return { values: v, ok: v.includes(normNs(rec.value)) }; }
if (rec.type === 'CNAME') { const v = (await resolver.resolveCname(rec.name)).map(normNs); return { values: v, ok: v.includes(normNs(rec.value)) }; }
if (rec.type === 'TXT') { const v = (await resolver.resolveTxt(rec.name)).map((t) => t.join('')); const want = String(rec.value); const ok = /^v=spf1/i.test(want) ? v.some((t) => /^v=spf1/i.test(t) && (t === want || /\smx(\s|$)/i.test(t))) : /^v=DMARC1/i.test(want) ? v.some((t) => /^v=DMARC1/i.test(t)) : v.includes(want); return { values: v, ok }; }
} catch (e) { return { values: [], ok: false, error: e.code || e.message }; }
return { values: [], ok: false };
}
/**
* 이 도메인으로 배포·메일을 쓰려면 DNS 에 있어야 하는 레코드 목록(수동 관리 사용자에게 보여 주는 "필요한 DNS 설정"; 다른 방식의 도메인도 상태 확인에 같이 쓴다).
* site 그룹 : A @ · A www → 사이트 IP (필수)
* mail 그룹 : MX · SPF · DMARC (+ Resend DKIM 등, 메일 도메인이 준비된 뒤) — opts.mail 이거나, 사이트 메일이 이 도메인으로 켜져 있거나, 전에 메일 설정을 요청한 경우
* @param {{site?:object, mail?:boolean}} opts
*/
async function requiredRecords(user, id, opts = {}) {
const { x } = getOwned(user.id, id); if (x.type === 'subdomain') throw httpError('서브 도메인은 플랫폼이 DNS 를 관리합니다 — 따로 넣을 레코드가 없습니다', 409);
const ip = await targetIp(); const site = opts.site || null;
const items = [
{ key: 'a-root', group: 'site', type: 'A', name: x.name, host: '@', value: ip, required: true, why: '홈페이지 주소가 이 서비스의 서버를 가리킵니다 (없으면 배포해도 열리지 않음)' },
{ key: 'a-www', group: 'site', type: 'A', name: `www.${x.name}`, host: 'www', value: ip, required: true, why: 'www 를 붙인 주소도 같은 서버로 (인증서도 www 를 포함해 발급)' },
];
let mailNote = null; const wantMail = !!opts.mail || !!(site && site.webmail && site.webmail.enabled && site.webmail.domain === x.name) || !!(x.mail && x.mail.requestedAt);
if (wantMail) {
try {
const mailsys = require('./mailsys'); const resendInfo = site && site.webmail && site.webmail.domain === x.name ? site.webmail.resend : null;
for (const r of mailsys.dnsRecords(x.name, resendInfo, mailsys.siteConfig(site || { webmail: { domain: x.name } }) /* v1.6.38: 등록된 메일 서버 호스트가 MX 기본값 */)) items.push({ key: r.key, group: 'mail', type: r.type, name: String(r.name).toLowerCase(), host: r.host, value: r.value, priority: r.priority || null, required: !!r.required, why: r.why, purpose: r.purpose || 'receive' });
if (!resendInfo) mailNote = '보내는 메일 인증(DKIM 등) 레코드는 사이트를 연결하고 "메일(MX) 자동 설정" 을 눌러 메일 도메인을 준비한 뒤에 추가로 안내됩니다';
} catch (e) { mailNote = `메일용 레코드를 만들 수 없습니다: ${e.message}`; }
} else mailNote = '메일 서비스도 쓰려면 "메일용 레코드 포함" 을 켜세요 (MX·SPF·DMARC)';
return { domain: x.name, mode: x.mode || (x.type === 'registered' ? 'ns' : null), ip, records: items, mailNote, wantMail, verified: x.manual ? { at: x.manual.verifiedAt || null, confirmedAt: x.manual.confirmedAt || null, lastCheck: x.manual.lastCheck || null, siteOk: !!x.manual.siteOk, mailOk: x.manual.mailOk === undefined ? null : x.manual.mailOk } : null };
}
/**
* 수동 관리 도메인의 "설정 완료 — 확인": 필요한 레코드가 공용 DNS 에 실제로 보이는지 확인한다(사용자 확인 → 실제 확인 → 사용 가능 여부).
* siteOk(A @·www 모두) 면 배포 가능 — manual.verifiedAt 기록. 아니면 "DNS 변경이 완료되지 않았거나 반영되지 않았음 — 배포가 정상적으로 되지 않음" 을 분명히 알린다.
* mailOk 는 받기용 필수 레코드(MX·SPF) 기준(보내기 Resend 레코드는 공급자 확인 결과를 따름). 메일 레코드를 보지 않았으면 null.
* @param {{site?:object, confirmed?:boolean, mail?:boolean}} opts confirmed = 사용자가 "레코드를 넣었다" 고 확인(시각 기록)
*/
async function verifyManual(user, id, opts = {}) {
const { x } = getOwned(user.id, id); const req = await requiredRecords(user, id, opts);
const results = []; for (const r of req.records) { const f = r.purpose === 'send' && r.key.startsWith('resend-') ? { values: [], ok: false, provider: true } : await lookupFound(r); results.push({ ...r, found: f.values, ok: f.ok, lookupError: f.error || null, providerChecked: !!f.provider }); }
const siteOk = results.filter((r) => r.group === 'site').every((r) => r.ok);
const mailReq = results.filter((r) => r.group === 'mail' && r.required && r.purpose !== 'send'); const mailOk = mailReq.length ? mailReq.every((r) => r.ok) : null;
const d = load(); const y = d.domains.find((z) => z.id === id);
if (y) { const prev = y.manual || {}; y.manual = { ...prev, confirmedAt: opts.confirmed ? nowIso() : prev.confirmedAt || null, lastCheck: nowIso(), siteOk, mailOk, verifiedAt: siteOk ? nowIso() : null }; save(d); }
const miss = results.filter((r) => !r.ok && r.required);
const missSite = miss.filter((r) => r.group === 'site').map((r) => `${r.type} ${r.host} (지금: ${r.found.join(', ') || '없음'}, 필요: ${r.value})`);
const missMail = miss.filter((r) => r.group === 'mail' && r.purpose !== 'send').map((r) => `${r.type} ${r.host}`);
let message;
if (!siteOk) message = `DNS 변경이 완료되지 않았거나 변경된 내용이 아직 반영되지 않았습니다 — ${missSite.join(' · ')}. 이 상태로는 ${x.name} 으로 배포가 정상적으로 되지 않습니다. 도메인의 DNS 서비스에서 레코드를 확인하고, 반영(보통 몇 분~최대 48시간)된 뒤 다시 확인하세요`;
else if (mailOk === false) message = `사이트용 DNS 는 확인되었습니다 — ${x.name} 으로 배포할 수 있습니다. 메일용 레코드(${missMail.join(', ')})는 아직 확인되지 않아 메일 서비스는 쓸 수 없습니다`;
else if (mailOk === true) message = `DNS 설정이 확인되었습니다 — ${x.name} 으로 배포하고 메일 서비스를 쓸 수 있습니다`;
else message = `사이트용 DNS 가 확인되었습니다 — ${x.name} 으로 배포할 수 있습니다 (메일 서비스를 쓰려면 메일용 레코드도 넣고 확인하세요)`;
return { ok: siteOk, siteOk, mailOk, records: results, message, checkedAt: nowIso() };
}
/** ISO 시각을 화면용 'YYYY-MM-DD HH:MM UTC' 로 */
const fmtT = (s) => (s ? String(s).replace('T', ' ').slice(0, 16) + ' UTC' : '-');
const PHASE_LABEL = { ready: '운영 주소 접속·HTTPS 정상', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 적용 대기', 'dns-wait': 'DNS 설정 대기(도메인이 아직 이 서버를 가리키지 않음)', 'cert-wait': '인증서 준비 중(HTTP 로는 열림)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결(웹서버 설정) 실패', failed: '배포 실패' };
/**
* 도메인 상태 — 어디까지 됐고 어디서 막혔는지 단계별로(요구 ④: 배포가 안 되거나 메일을 못 쓰는 이유를 알린다).
* steps[]: { key, label, ok: true|false|null(해당 없음), state: ok|fail|wait|warn|na, detail }
* own(도메인 등록·소유) → zone/dns(DNS 영역·네임서버 또는 수동 설정 확인) → site-dns(A 레코드가 실제로 이 서버를 가리키나) → deploy(연결된 사이트의 마지막 배포 단계) → mail(메일 시스템 상태)
* @param {object|null} site 연결된 사이트(없으면 배포·메일은 "연결 후")
*/
async function status(user, id, site) {
const { x } = getOwned(user.id, id); const steps = [];
const add = (key, label, ok, detail, state) => steps.push({ key, label, ok, state: state || (ok === null ? 'na' : ok ? 'ok' : 'fail'), detail });
// ① 소유·등록
if (x.type === 'subdomain') add('own', '도메인', true, `${x.name} — 플랫폼 서브 도메인(무료)`);
else if (x.type === 'registered') add('own', '도메인 등록', x.status !== 'PENDING' && x.status !== 'FAILED', x.status === 'PENDING' ? '등록 업체가 아직 등록을 처리하고 있습니다' : x.status === 'FAILED' ? (x.zoneError || '등록 실패') : `이 서비스에서 구매 · 만료 ${x.expiresAt ? String(x.expiresAt).slice(0, 10) : '-'}`, x.status === 'PENDING' ? 'wait' : undefined);
else add('own', '도메인 소유', true, `${x.name} — 다른 등록 업체에서 구매한 도메인 (${x.mode === 'ns' ? '네임서버 이전으로 DNS 를 이 서비스가 관리' : 'DNS 를 사용자가 직접 관리'})`);
// ② DNS 영역·네임서버 / 수동 확인
let p = null; if (x.pending) p = await propagation(x.id).catch(() => null);
if (x.type === 'subdomain') { const pz = platformZone(); add('zone', 'DNS 영역', pz ? true : null, pz ? `플랫폼 영역에서 자동 관리 (${pz.id})` : '플랫폼 DNS 는 운영자가 직접 관리합니다(와일드카드 또는 개별 A 레코드)'); }
else if (x.type === 'external' && x.mode === 'manual') { const m = x.manual || {}; add('zone', 'DNS 설정(직접 관리)', !!m.siteOk, m.siteOk ? `사이트용 레코드 확인됨 (${fmtT(m.verifiedAt)})` : m.lastCheck ? `마지막 확인 ${fmtT(m.lastCheck)}: 사이트용 A 레코드가 아직 확인되지 않았습니다 — DNS 변경이 완료되지 않았거나 반영되지 않았습니다 (이 상태로는 배포가 정상적으로 되지 않습니다)` : '아직 확인하지 않았습니다 — "필요한 DNS 설정" 을 자기 DNS 에 넣고 "설정 완료 — 확인" 을 누르세요'); }
else {
const zoneOk = !x.zoneError && !!(x.zone || (x.dnsProvider && x.dnsProvider !== 'manual'));
add('zone', 'DNS 영역', zoneOk, x.zoneError || (zoneOk ? `${x.dnsProvider} 영역 · 네임서버 ${(x.nameServers || []).join(', ') || '-'}` : '자동 DNS 영역이 없습니다 (관리자: 설정 › 도메인 서비스 › DNS 공급자)'));
if (x.type === 'external') add('ns', '네임서버 변경', !(p && p.pending), p && p.pending ? p.message : `네임서버가 이 서비스의 DNS 를 가리킵니다 (${(x.currentNs || x.nameServers || []).join(', ')})`, p && p.pending ? 'wait' : undefined);
else if (p && p.pending) add('ns', 'DNS 전파', false, p.message, 'wait');
}
// ③ 사이트 레코드가 실제로 이 서버를 가리키나
const ip = await targetIp().catch(() => null);
if (ip) {
// v1.7.2: 하위 이름으로 연결한 도메인은 그 이름만 본다 (예: shop.example.com)
const first = x.host ? `${x.host}.${x.name}` : x.name;
const a = await lookupFound({ type: 'A', name: first, value: ip }); const w = x.type === 'subdomain' || x.host ? { ok: true, values: [] } : await lookupFound({ type: 'A', name: `www.${x.name}`, value: ip });
const ok = a.ok && w.ok;
add('site-dns', '사이트 레코드(A)', ok, ok ? `${first}${x.type === 'subdomain' || x.host ? '' : ' · www'} → ${ip}` : `${x.name}: ${a.values.join(', ') || '없음'}${x.type === 'subdomain' ? '' : ` · www: ${w.values.join(', ') || '없음'}`} (필요: ${ip}) — ${x.type === 'subdomain' ? '사이트에 연결하면 자동으로 만들어집니다' : x.mode === 'manual' ? '자기 DNS 에 A 레코드를 넣고 반영을 기다리세요' : p && p.pending ? '네임서버 변경이 전파되면 반영됩니다' : '공용 DNS 에 아직 반영되지 않았거나 레코드가 없습니다 — 사이트에 연결하면 레코드를 다시 맞춥니다'}`, ok ? 'ok' : (p && p.pending ? 'wait' : 'fail'));
} else add('site-dns', '사이트 레코드(A)', false, '사이트 IP 를 정할 수 없습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › 사이트 IP)');
// ④ 배포
if (!site) add('deploy', '배포', null, '연결된 사이트가 없습니다 — "사이트에 연결" 뒤 빌더에서 발행하세요');
else {
const last = [...(site.deployments || [])].reverse().find((dd) => dd.kind !== 'unpublish');
if (!last) add('deploy', '배포', false, `${site.title}: 아직 발행하지 않았습니다 — 빌더에서 발행하면 이 주소에 웹서버 설정과 인증서가 적용됩니다`);
else if (!last.ok) add('deploy', '배포', false, `${site.title}: 마지막 배포 실패 — ${last.detail || ''}`);
else { const ph = last.phase || 'ready'; const wait = ['dns-wait', 'cert-wait', 'vhost-wait'].includes(ph); add('deploy', '배포', ph === 'ready', `${site.title}: ${PHASE_LABEL[ph] || ph}${last.phaseDetail ? ' — ' + last.phaseDetail : ''}${ph !== 'ready' ? ' · 빌더 발행 카드의 "다시 확인" 으로 현재 상태를 다시 판정할 수 있습니다' : ''}`, ph === 'ready' ? 'ok' : wait ? 'wait' : 'fail'); }
}
// ⑤ 메일
if (!site) add('mail', '메일 서비스', null, '사이트에 연결한 뒤 "메일(MX) 자동 설정" 으로 켤 수 있습니다');
else { const ms = require('./mailsys').status(site); if (!ms.enabled) add('mail', '메일 서비스', null, '이 사이트는 메일 시스템을 쓰지 않습니다 — "메일(MX) 자동 설정" 으로 켤 수 있습니다'); else if (ms.domain !== x.name) add('mail', '메일 서비스', null, `이 사이트의 메일 도메인은 ${ms.domain} 입니다`); else add('mail', '메일 서비스', ms.level === 'ready', ms.message, ms.level === 'ready' ? 'ok' : ms.level === 'setting-up' ? 'wait' : ms.level === 'send-only' ? 'warn' : 'fail'); }
const blockers = steps.filter((s) => s.ok === false); const waiting = steps.filter((s) => s.state === 'wait');
const summary = !blockers.length ? `${x.name} 은(는) 사용할 준비가 되었습니다` : waiting.length === blockers.length ? `진행 중: ${waiting.map((s) => s.label).join(', ')} — 전파·처리가 끝나면 자동으로 반영됩니다` : `아직 쓸 수 없는 항목: ${blockers.map((s) => s.label).join(', ')} — 각 항목의 설명대로 조치하세요`;
return { domain: pub(x), steps, ok: !blockers.length, summary };
}
// ---------- DNS 전파 ----------
let resolver = new dnsLib.promises.Resolver({ timeout: 4000, tries: 1 }); try { resolver.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
/** 시험용: 공용 DNS 대신 쓸 리졸버(resolveNs/resolve4/… 같은 모양)를 바꿔 넣는다. null 이면 공용 DNS 로 되돌린다 */
function _setResolver(r) { if (r) resolver = r; else { resolver = new dnsLib.promises.Resolver({ timeout: 4000, tries: 1 }); try { resolver.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {} } }
/** 네임서버 이름 정규화(소문자·끝 점 제거) 와 집합 비교 — 이전 확인에 쓴다 */
const normNs = (s) => String(s || '').toLowerCase().replace(/\.$/, '');
const sameNs = (a, b) => { const A = [...new Set(a.map(normNs))].sort(); const B = [...new Set(b.map(normNs))].sort(); return A.length > 0 && A.length === B.length && A.every((v, i) => v === B[i]); };
/** 레코드 하나가 공용 DNS 에 보이는지 */
async function visible(ch) {
try {
if (ch.type === 'A') return (await resolver.resolve4(ch.name)).includes(ch.value);
if (ch.type === 'MX') return (await resolver.resolveMx(ch.name)).some((m) => m.exchange.replace(/\.$/, '').toLowerCase() === String(ch.value).toLowerCase());
if (ch.type === 'TXT') return (await resolver.resolveTxt(ch.name)).some((t) => t.join('') === ch.value);
// v1.6.35: expect 가 있으면(네임서버 이전) 공용 DNS 의 NS 가 기대한 네임서버 집합과 같아야 "보인다" 로 친다 — 하나라도 옛 네임서버가 남아 있으면 아직 전파 중
if (ch.type === 'NS') { const got = (await resolver.resolveNs(ch.name)).map(normNs); return Array.isArray(ch.expect) && ch.expect.length ? sameNs(got, ch.expect) : got.length > 0; }
if (ch.type === 'CNAME') return (await resolver.resolveCname(ch.name)).length > 0;
} catch (_) { return false; }
return true;
}
/**
* 전파 확인: 모두 보이면 pending 을 지운다. 아니면 남은 예상 시간(분)을 돌려준다.
* @returns {{ pending: boolean, etaMinutes: number, message: string }}
*/
async function propagation(id) {
const d0 = load(); const y0 = d0.domains.find((z) => z.id === id);
// v1.6.32: 등록 처리 중(Route 53)이면 완료 여부를 먼저 확인한다 — 끝났으면 영역을 만들고 위임 전파 확인으로 넘어간다
if (y0 && y0.pending && y0.pending.kind === 'registration') { const f = await finalizeRegistration(id, y0.userId).catch((e) => ({ done: false, detail: e.message })); if (!f.done) { const left = Math.max(5, Math.ceil(ETA.registration - (Date.now() - Date.parse(y0.pending.since)) / 60000)); return { pending: true, etaMinutes: left, message: `도메인 등록을 처리하고 있습니다 (${y0.name}). ${f.detail}. 약 ${left}분 후에 다시 확인해 주세요${f.failed ? '' : ' — 등록 업체(AWS)가 등록자 이메일로 보낸 확인 메일이 있으면 승인해 주세요'}` }; } }
const d = load(); const y = d.domains.find((z) => z.id === id); if (!y || !y.pending) return { pending: false, etaMinutes: 0, message: '' };
// v1.6.35: 네임서버 이전 전파는 transferCheck 가 판정한다(현재 NS 를 기록하고, 사용자가 변경을 확인하기 전에는 "변경 전" 으로 안내)
if (y.pending.kind === 'transfer') { const t = await transferCheckRecord(y, d); return { pending: !t.done, etaMinutes: t.etaMinutes || 0, message: t.done ? '' : t.message, transfer: t }; }
const res = await Promise.all((y.pending.checks || []).map(visible));
if (res.every(Boolean)) { y.pending = null; y.propagatedAt = nowIso(); save(d); return { pending: false, etaMinutes: 0, message: '' }; }
const elapsed = (Date.now() - Date.parse(y.pending.since)) / 60000; const left = Math.max(5, Math.ceil(y.pending.etaMinutes - elapsed));
const longer = elapsed > y.pending.etaMinutes;
return { pending: true, etaMinutes: left, message: `DNS 연결이 진행 중입니다 (${y.name}). 약 ${left}분 후에 다시 이용해 주세요${y.pending.kind === 'delegation' ? ' — 새 도메인은 전 세계에 알려지는 데 최대 48시간이 걸릴 수 있습니다' : ''}${longer ? ' (예상보다 오래 걸리고 있습니다)' : ''}` };
}
/** 사이트의 도메인 중 전파 중인 것이 있으면 안내 문구 (없으면 null) — 사이트 연결·발행·메일 사용 전에 부른다 */
async function gate(site) {
// v1.6.35: 네임서버 이전(transfer) 전파 중에는 발행을 막지 않는다 — 발행은 되고, 배포 판정(dns-wait)과 도메인 상태(status)가 "아직 이 서버를 가리키지 않음" 을 알린다. 레코드 수정만 잠근다(assertEditable)
for (const n of site.domains || []) { const y = byName(n) || byName(String(n).replace(/^www\./, '')); if (y && y.pending && y.pending.kind !== 'transfer') { const p = await propagation(y.id); if (p.pending) return p.message; } }
return null;
}
/** 도메인 이름 하나(메일 도메인 등)가 전파 중이면 안내 문구 (없으면 null) — 웹메일 화면·메일 보내기 전에 부른다 */
async function gateName(name) {
const y = byName(name) || byName(String(name || '').replace(/^www\./, '')); if (!y || !y.pending) return null;
const p = await propagation(y.id); return p.pending ? p.message : null;
}
/** 서브 도메인 해제 (구입 도메인은 해제하지 않음 — 만료까지 유지) */
/** 서브 도메인·외부 도메인 해제 (구입 도메인은 해제하지 않음 — 만료까지 유지). v1.6.35: 외부 도메인(ns)은 기록만 지우고 공급자의 영역은 남긴다(사용자가 네임서버를 아직 그 영역으로 두고 있을 수 있음 — 관리자가 DNS 영역 현황에서 정리) */
function release(user, id) {
const { d, x } = getOwned(user.id, id); if (x.type === 'registered') throw httpError('구입한 도메인은 지울 수 없습니다 (만료되면 자동으로 끝납니다)');
d.domains = d.domains.filter((y) => y.id !== id); save(d);
return { ok: true, note: x.type === 'external' && x.mode === 'ns' ? `${x.name} 의 DNS 영역은 공급자에 그대로 남아 있습니다 — 네임서버를 다른 곳으로 옮긴 뒤 관리자에게 영역 정리를 요청하세요` : null };
}
module.exports = { disconnect /* v1.7.2 */, finalizeRegistration, _openPurchase: openPurchase /* 테스트용 */, _setResolver /* 테스트용 */, purchases, reconcilePurchase, setPurchase, adminList, setDnsProvider, publicCatalog, platformZone, config, normalizeConfig, accountTypeOf, listFor, checkSubdomain, registerSubdomain, search, quote, getContact, saveContact, purchase, renew, records, addRecord, removeRecord, connect, setupMail, propagation, gate, gateName, release, byName, targetIp, ETA, DOMAIN_RE,
// v1.6.35 (요구 1-2): 장바구니 · 외부 도메인(네임서버 이전 / 수동 입력) · 이전 확인 · 필요한 DNS 설정 · 수동 확인 · 도메인 상태
cart, cartAdd, cartRemove, cartRefresh, addExternal, transferCheck, requiredRecords, verifyManual, status };