Files
AriWeb-Suite/modules/webmaker/lib/dns/gcloud.js

60 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };