Files

60 lines
7.7 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ============================================================================
// Google Cloud DNS 공급자 (v1.5.36, 요구 5)
//
// 접속 정보: 보관소 종류 'gcloud-dns' — 키(secret) = 서비스 계정 키 JSON 전체(project_id 포함), 설정 PROJECT(선택, 키의 project_id 대신).
// IAM 역할: roles/dns.admin (관리 영역·레코드 만들기·지우기).
// API: https://dns.googleapis.com/dns/v1/projects/{project}/managedZones … (JSON). 토큰은 lib/google.js(서비스 계정 JWT → OAuth2) 에 cloud-platform 범위로 받는다.
// 시험용 대체 주소: 환경 변수 GCLOUD_DNS_API_BASE. 레코드 모델은 Route 53 과 같은 RRSet(이름·종류 묶음에 rrdatas 여러 개)이며 변경은 changes(additions/deletions)로 한다.
// 관리 영역 이름(managedZone.name)은 영문·숫자·하이픈만 허용 → "wm-" + 도메인의 점을 하이픈으로 바꾼 이름을 쓴다.
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
const base = () => (process.env.GCLOUD_DNS_API_BASE || 'https://dns.googleapis.com').replace(/\/+$/, '');
function creds() {
let x = null; try { x = require('../vault').resolve('gcloud-dns'); } catch (_) {}
if (!x || !x.secret) return null; let j = null; try { j = JSON.parse(x.secret); } catch (_) { return null; }
const project = (x.settings && (x.settings.PROJECT || x.settings.PROJECT_ID)) || j.project_id; if (!j.client_email || !j.private_key || !project) return null;
return { secret: x.secret, project };
}
const ready = () => !!creds();
async function call(method, path, body, query) {
const c = creds(); if (!c) throw httpError('Google Cloud DNS 접속 정보가 없습니다 (관리자 콘솔 › 설정 › 외부 연동 접속 정보 › Google Cloud DNS: 서비스 계정 키 JSON)', 503);
const token = await require('../google').accessToken(c.secret, [SCOPE]);
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
let r; try { r = await fetch(`${base()}/dns/v1/projects/${encodeURIComponent(c.project)}${path}${qs}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Google Cloud DNS 연결 실패: ${e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw httpError(`Google Cloud DNS 오류 (HTTP ${r.status}): ${(j.error && j.error.message) || JSON.stringify(j).slice(0, 200)}`, r.status === 403 ? 403 : r.status === 404 ? 404 : r.status >= 500 ? 502 : 400);
return j;
}
const fq = (d) => String(d).toLowerCase().replace(/\.$/, '') + '.';
const zoneName = (domain) => 'wm-' + String(domain).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
async function listZones() { const out = []; let pageToken = null; do { const j = await call('GET', '/managedZones', null, pageToken ? { pageToken } : null); for (const z of j.managedZones || []) out.push({ id: z.name, name: String(z.dnsName || '').replace(/\.$/, ''), nameServers: z.nameServers || [], private: z.visibility === 'private' }); pageToken = j.nextPageToken || null; } while (pageToken); return out; }
async function getZone(domain) { return (await listZones()).find((z) => z.name === String(domain).toLowerCase() && !z.private) || null; }
async function createZone(domain) { const cur = await getZone(domain); if (cur) return cur; const j = await call('POST', '/managedZones', { name: zoneName(domain), dnsName: fq(domain), description: 'webmaker', visibility: 'public' }); return { id: j.name, name: String(domain).toLowerCase(), nameServers: j.nameServers || [] }; }
async function rrsets(zoneId) { const out = []; let pageToken = null; do { const j = await call('GET', `/managedZones/${encodeURIComponent(zoneId)}/rrsets`, null, pageToken ? { pageToken } : null); for (const s of j.rrsets || []) out.push({ name: s.name, type: s.type, ttl: s.ttl || 300, values: s.rrdatas || [] }); pageToken = j.nextPageToken || null; } while (pageToken); return out; }
async function deleteZone(domain) {
const z = await getZone(domain); if (!z) return { ok: true, existed: false };
const sets = (await rrsets(z.id)).filter((s) => !((s.type === 'NS' || s.type === 'SOA') && s.name === fq(domain)));
if (sets.length) await call('POST', `/managedZones/${encodeURIComponent(z.id)}/changes`, { deletions: sets.map((s) => ({ name: s.name, type: s.type, ttl: s.ttl, rrdatas: s.values })) });
await call('DELETE', `/managedZones/${encodeURIComponent(z.id)}`); return { ok: true, existed: true };
}
const toVal = (r) => (r.type === 'TXT' ? `"${String(r.value).replace(/"/g, '\\"')}"` : r.type === 'MX' || r.type === 'SRV' ? `${r.prio == null ? 10 : r.prio} ${String(r.value).replace(/\.?$/, '.')}` : r.type === 'CNAME' || r.type === 'NS' ? String(r.value).replace(/\.?$/, '.') : String(r.value));
const fromVal = (type, v) => { if (type === 'TXT') return { value: String(v).replace(/^"|"$/g, '').replace(/\\"/g, '"') }; if (type === 'MX' || type === 'SRV') { const m = String(v).match(/^(\d+)\s+(.*)$/); return m ? { prio: Number(m[1]), value: m[2].replace(/\.$/, '') } : { value: v }; } return { value: String(v).replace(/\.$/, '') }; };
const relName = (zone, name) => { const n = String(name).replace(/\.$/, '').toLowerCase(); const z = String(zone).toLowerCase(); return n === z ? '' : n.endsWith('.' + z) ? n.slice(0, -(z.length + 1)) : n; };
const absName = (zone, rel) => fq(rel ? `${rel}.${zone}` : zone);
async function listRecords(domain) { const z = await getZone(domain); if (!z) throw httpError(`Google Cloud DNS 에 ${domain} 영역이 없습니다`, 404); const out = []; for (const s of await rrsets(z.id)) for (const v of s.values) out.push({ name: relName(domain, s.name), type: s.type, ttl: s.ttl, ...fromVal(s.type, v) }); return out; }
async function updateRecords(domain, { add = [], remove = [] }) {
const z = await getZone(domain); if (!z) throw httpError(`Google Cloud DNS 에 ${domain} 영역이 없습니다`, 404);
const sets = await rrsets(z.id); const key = (n, t) => `${n}|${t}`; const map = new Map(sets.map((s) => [key(s.name, s.type), { ...s, values: s.values.slice() }])); const touched = new Set();
for (const r of add) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k) || { name: n, type: r.type, ttl: r.ttl || 300, values: [] }; const v = toVal(r); if (!s.values.includes(v)) s.values.push(v); if (r.ttl) s.ttl = r.ttl; map.set(k, s); touched.add(k); }
for (const r of remove) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k); if (!s) continue; const v = toVal(r); s.values = s.values.filter((x) => x !== v && fromVal(r.type, x).value !== r.value); touched.add(k); }
const deletions = []; const additions = [];
for (const k of touched) { const s = map.get(k); const orig = sets.find((x) => key(x.name, x.type) === k); if (orig) deletions.push({ name: orig.name, type: orig.type, ttl: orig.ttl, rrdatas: orig.values }); if (s.values.length) additions.push({ name: s.name, type: s.type, ttl: s.ttl, rrdatas: s.values }); }
if (deletions.length || additions.length) await call('POST', `/managedZones/${encodeURIComponent(z.id)}/changes`, { deletions, additions });
return { ok: true, changes: deletions.length + additions.length };
}
async function nameServers(domain) { const z = await getZone(domain); return z ? z.nameServers : []; }
module.exports = { id: 'gcloud', label: 'Google Cloud DNS', ready, creds, listZones, getZone, createZone, deleteZone, listRecords, updateRecords, nameServers, call, SCOPE };