'use strict'; // ============================================================================ // DNS 관리 자동화 — 공급자 공통 창구 (v1.5.36, 요구 5) // // 공급자: openprovider(1.4.0 부터) · porkbun · route53(Amazon) · gcloud(Google Cloud DNS). 모두 같은 모양의 함수를 가진다(lib/dns/.js): // ready() · listZones() · getZone(domain) · createZone(domain, records?) · deleteZone(domain) · listRecords(domain) · updateRecords(domain, {add, remove}) · nameServers(domain) // 레코드 모양(공통): { name: 영역 기준 상대 이름('' = 도메인 자신, 'www', '_acme-challenge' …), type, value, ttl, prio }. // // 어느 공급자를 쓰나 // - 도메인 기록(data/domains.json)에 dnsProvider 가 있으면 그것 (구매 때 정해지고, 관리자가 바꿀 수 있다) // - 없으면 설정 › 도메인 서비스 › DNS 공급자(settings.domains.dnsProvider; 1.5.31 까지의 platformDns 'openprovider' 는 그대로 openprovider 로 읽는다) // - 'manual' 이면 자동 DNS 없음(운영자가 직접) — 각 기능은 안내 모드로 돌아간다 // // 보안(요구 5 "여러 도메인의 여러 사용자") // - 공급자 접속 정보(API 키·서비스 계정)는 관리자만 넣고(보관소), 사용자 API 응답에는 절대 나가지 않는다. // - 사용자는 lib/domains.js 가 "그 사용자 소유" 로 확인한 도메인의 영역만, 그 영역 기준 상대 이름으로만 만진다(다른 영역·상위 이름 불가 — normRecord). // - 관리자 전용 영역 관리(목록·만들기·지우기·이전된 영역 정리)는 /api/admin/dns/* 이며 모든 변경은 감사 기록(data/dns-audit.json: 누가·언제·어느 공급자·영역·무엇)에 남는다. // - 사용자 소유 도메인의 영역을 관리자가 지우려면 명시적 확인(force)이 필요하다. // ============================================================================ const fs = require('fs'); const path = require('path'); const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); const AUDIT = path.join(DATA_ROOT, 'dns-audit.json'); const PROVIDERS = { openprovider: require('./dns/openprovider'), porkbun: require('./dns/porkbun'), route53: require('./dns/route53'), gcloud: require('./dns/gcloud') }; const IDS = Object.keys(PROVIDERS); const LABELS = { manual: '수동 (운영자가 직접 관리)', openprovider: 'Openprovider DNS', porkbun: 'Porkbun DNS', route53: 'Amazon Route 53', gcloud: 'Google Cloud DNS' }; /** 공급자 모듈. 모르는 id 면 오류 */ function get(id) { const p = PROVIDERS[id]; if (!p) throw httpError(`알 수 없는 DNS 공급자: ${id}`); return p; } /** 설정의 기본 DNS 공급자 id ('manual' 포함). 1.5.31 까지의 platformDns 와 호환 */ function defaultId() { let c = {}; try { c = require('./domains').config(); } catch (_) {} const v = c.dnsProvider || (c.platformDns === 'openprovider' ? 'openprovider' : 'manual'); return PROVIDERS[v] ? v : 'manual'; } /** 공급자 id 가 쓸 수 있는 상태인지 (접속 정보 있음) */ function ready(id) { const p = PROVIDERS[id]; try { return !!(p && p.ready()); } catch (_) { return false; } } /** 화면·API 용 공급자 목록: id·이름·준비 여부·기본 여부 */ function catalog() { const d = defaultId(); return ['manual', ...IDS].map((id) => ({ id, label: LABELS[id], ready: id === 'manual' ? true : ready(id), isDefault: id === d })); } /** * 어떤 영역(도메인)에 쓸 공급자 고르기: 도메인 기록의 dnsProvider → 기본 공급자. 준비되지 않았으면 null (안내 모드). * @param {object|string|null} domainRec data/domains.json 의 기록 또는 영역 이름 * @returns {{id:string, p:object}|null} */ function forZone(domainRec) { let rec = domainRec; if (typeof domainRec === 'string') { try { rec = require('./domains').byName(domainRec); } catch (_) { rec = null; } } const id = (rec && rec.dnsProvider) || defaultId(); if (id === 'manual' || !ready(id)) return null; return { id, p: PROVIDERS[id] }; } /** 감사 기록 (최근 2000건 보관) */ function audit(entry) { try { let d = []; try { d = JSON.parse(fs.readFileSync(AUDIT, 'utf8')); } catch (_) {} d.push({ at: new Date().toISOString(), ...entry }); if (d.length > 2000) d = d.slice(-2000); fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(AUDIT, JSON.stringify(d)); } catch (_) {} } function auditList(n = 200) { try { return JSON.parse(fs.readFileSync(AUDIT, 'utf8')).slice(-n).reverse(); } catch (_) { return []; } } // ---- 영역 작업 (공급자 지정 또는 자동) ---- const pick = (providerId, zone) => { if (providerId) { if (!ready(providerId)) throw httpError(`${LABELS[providerId] || providerId} 접속 정보가 없거나 올바르지 않습니다 (관리자 콘솔 › 설정 › 외부 연동 접속 정보)`, 503); return { id: providerId, p: get(providerId) }; } const f = forZone(zone); if (!f) throw httpError('이 도메인에 자동 DNS 공급자가 없습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)', 503); return f; }; async function listZones(providerId) { const { p } = pick(providerId, null); return p.listZones(); } async function getZone(zone, providerId) { const { p } = pick(providerId, zone); return p.getZone(zone); } async function ensureZone(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const z = await p.createZone(zone, records || []); if (records && records.length && id !== 'openprovider') await p.updateRecords(zone, { add: records }); audit({ by, action: 'zone.ensure', provider: id, zone, records: (records || []).length }); return { provider: id, ...z }; } async function deleteZone(zone, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.deleteZone(zone); audit({ by, action: 'zone.delete', provider: id, zone, existed: !!r.existed }); return { provider: id, ...r }; } async function listRecords(zone, providerId) { const { id, p } = pick(providerId, zone); return { provider: id, records: await p.listRecords(zone) }; } async function addRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.updateRecords(zone, { add: records }); audit({ by, action: 'records.add', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'} ${String(x.value).slice(0, 80)}`) }); return { provider: id, ...r }; } async function removeRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.updateRecords(zone, { remove: records }); audit({ by, action: 'records.remove', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'} ${String(x.value).slice(0, 80)}`) }); return { provider: id, ...r }; } /** 같은 이름·종류의 레코드를 주어진 값으로 바꾼다(기존 값 제거 후 추가) — MX 교체 등 */ async function setRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const cur = await p.listRecords(zone); const keys = new Set(records.map((r) => `${r.name || ''}|${r.type}`)); const remove = cur.filter((r) => keys.has(`${r.name || ''}|${r.type}`)); await p.updateRecords(zone, { add: records, remove: remove.filter((r) => !records.some((x) => x.type === r.type && (x.name || '') === (r.name || '') && String(x.value) === String(r.value))) }); audit({ by, action: 'records.set', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'}`) }); return { provider: id, ok: true }; } async function nameServers(zone, providerId) { const { id, p } = pick(providerId, zone); return { provider: id, nameServers: await p.nameServers(zone) }; } /** * 이전된(다른 곳에서 넘어온) 영역 받기: 지금 공용 DNS 에 보이는 레코드(A·AAAA·MX·TXT·CNAME)를 읽어 새 영역에 넣는다 — 네임서버를 바꾸기 전에 내용을 옮겨 두는 용도. * @returns {{provider, zone, imported:number, nameServers:string[]}} */ /** * v1.6.33 (요구 1): 공용 DNS 조회 오류 코드 → 사람이 읽는 원인. 이전된 영역 받기가 "실패: not found" 처럼 뭉뚱그리지 않고 어디서 막혔는지 말하게 한다. * ENOTFOUND(NXDOMAIN) = 등록되지 않았거나 상위(등록 업체)에 위임(NS)이 없음 · ESERVFAIL = 위임된 네임서버가 응답을 못 줌(영역 설정 없음·DNSSEC 오류) · ETIMEOUT/ECONNREFUSED = 네임서버에 닿지 않음 · ENODATA = 이름은 있으나 그 종류 레코드 없음 */ function describeDnsError(code, zone) { const c = String(code || ''); if (c === 'ENOTFOUND') return `${zone} 은(는) 공용 DNS 에 존재하지 않습니다(NXDOMAIN) — 도메인이 등록되어 있지 않거나, 등록 업체에 네임서버(NS)가 지정되지 않았습니다. 등록 상태와 네임서버를 먼저 확인하세요`; if (c === 'ESERVFAIL') return `${zone} 의 네임서버가 응답을 주지 못했습니다(SERVFAIL) — 지정된 네임서버에 이 영역이 없거나 DNSSEC 설정이 깨져 있습니다`; if (c === 'ETIMEOUT' || c === 'ECONNREFUSED' || c === 'EREFUSED') return `${zone} 의 네임서버에 연결할 수 없습니다(${c}) — 네임서버가 꺼져 있거나 쿼리를 거부합니다`; if (c === 'ENODATA') return ''; // 이름은 있으나 그 종류의 레코드가 없음 — 오류가 아님 return c ? `DNS 조회 오류(${c})` : ''; } /** * 이전된(다른 곳에서 넘어온) 영역 받기: 지금 공용 DNS 에 보이는 레코드(A·AAAA·MX·TXT·CNAME)를 읽어 새 영역에 넣는다 — 네임서버를 바꾸기 전에 내용을 옮겨 두는 용도. * v1.6.33: 먼저 NS 조회로 "영역이 공용 DNS 에 살아 있는지" 를 확인한다. 등록되지 않았거나 위임이 없거나 네임서버가 죽어 있으면 **실패**(원인 설명). 살아 있는데 레코드가 하나도 없으면 * 실패가 아니라 "zone 파일 구성을 찾을 수 없습니다" 안내와 함께 빈 영역을 만든다(운영자가 레코드를 직접 넣을 수 있게). 실패도 감사 기록에 남긴다. * @param {{resolver?:object}} [opts] 시험용 — dns.Resolver 와 같은 모양(resolveNs/resolve4/…)의 객체를 넣으면 공용 DNS 대신 그것을 쓴다 * @returns {{provider, zone, imported:number, nameServers:string[], currentNs:string[], message:string, empty:boolean}} */ async function importZone(zone, by, providerId, opts = {}) { const dns = require('dns').promises; let r = opts.resolver; if (!r) { r = new dns.Resolver({ timeout: 4000, tries: 1 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {} } const { id: provId } = pick(providerId, zone); // 공급자 준비 여부를 먼저 — 접속 정보가 없으면 DNS 를 읽기 전에 503 안내 // 1) 영역이 공용 DNS 에 살아 있는지: NS 조회. 실패 원인별로 다른 메시지 let currentNs = []; try { currentNs = (await r.resolveNs(zone)).map((x) => String(x).toLowerCase().replace(/\.$/, '')); } catch (e) { const why = describeDnsError(e.code, zone); if (why) { audit({ by, action: 'zone.import', provider: provId, zone, ok: false, reason: why }); throw httpError(`이전된 영역을 받을 수 없습니다 — ${why}`, 409); } } if (!currentNs.length) { try { await r.resolveSoa(zone); } catch (e) { const why = describeDnsError(e.code, zone) || `${zone} 에 NS·SOA 레코드가 없습니다 — 이 이름은 독립된 영역(zone)이 아닙니다(상위 도메인의 레코드일 수 있음)`; audit({ by, action: 'zone.import', provider: provId, zone, ok: false, reason: why }); throw httpError(`이전된 영역을 받을 수 없습니다 — ${why}`, 409); } } const recs = []; const names = ['', 'www', 'mail', 'webmail', 'ftp', '_dmarc']; for (const n of names) { const fq = n ? `${n}.${zone}` : zone; for (const [type, fn] of [['A', 'resolve4'], ['AAAA', 'resolve6'], ['CNAME', 'resolveCname'], ['TXT', 'resolveTxt'], ['MX', 'resolveMx']]) { if (n && type === 'MX') continue; 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: x.exchange, prio: x.priority, ttl: 900 }); else recs.push({ name: n, type, value: x, ttl: 900 }); } if (type === 'CNAME' && v.length) break; } } // 2) 레코드가 하나도 없으면 실패가 아니라 "zone 파일 구성을 찾을 수 없습니다" — 빈 영역을 만들어 두고 운영자가 채우게 한다 const z = await ensureZone(zone, recs, by, providerId); audit({ by, action: 'zone.import', provider: z.provider, zone, imported: recs.length, currentNs }); const empty = recs.length === 0; const message = empty ? `zone 파일 구성을 찾을 수 없습니다 — ${zone} 은(는) 공용 DNS 에 위임되어 있지만(네임서버 ${currentNs.join(', ') || '확인 안 됨'}) A·AAAA·CNAME·MX·TXT 레코드가 하나도 보이지 않습니다. 빈 영역을 만들었으니 레코드를 직접 추가하세요` : `이전된 영역 받음 — 레코드 ${recs.length}개 (지금 네임서버: ${currentNs.join(', ') || '확인 안 됨'})`; return { provider: z.provider, zone, imported: recs.length, nameServers: z.nameServers || [], currentNs, message, empty }; } module.exports = { describeDnsError, PROVIDERS, IDS, LABELS, get, defaultId, ready, catalog, forZone, audit, auditList, listZones, getZone, ensureZone, deleteZone, listRecords, addRecords, removeRecords, setRecords, nameServers, importZone };