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

105 lines
11 KiB
JavaScript

'use strict';
// ============================================================================
// Amazon Route 53 DNS 공급자 (v1.5.36, 요구 5)
//
// 접속 정보: 보관소(lib/vault.js) 종류 'route53' — 키(secret) = AWS Secret Access Key, 설정 ACCESS_KEY_ID=…, (선택) SESSION_TOKEN=…, REGION(기본 us-east-1).
// IAM 권한: route53:ListHostedZones*, GetHostedZone, CreateHostedZone, DeleteHostedZone, ListResourceRecordSets, ChangeResourceRecordSets, GetChange.
// API 는 XML 이다(https://route53.amazonaws.com/2013-04-01/…). 서명은 lib/awsv4.js. 시험용 대체 주소: 환경 변수 ROUTE53_API_BASE.
// 레코드 모델: Route 53 는 (이름, 종류) 한 묶음(RRSet)에 값 여러 개 → 우리 레코드(값 하나씩)를 더하거나 뺄 때는 묶음을 읽어 값을 합치고 UPSERT/DELETE 한다.
// TXT 값은 따옴표로 감싸 보내고 읽을 때 벗긴다. MX 값은 "우선순위 호스트" 한 줄이다. 이름은 끝에 점이 붙은 FQDN.
// ============================================================================
const awsv4 = require('../awsv4');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const HOST = 'route53.amazonaws.com'; const API = '/2013-04-01';
const base = () => (process.env.ROUTE53_API_BASE || `https://${HOST}`).replace(/\/+$/, '');
function creds() {
let x = null; try { x = require('../vault').resolve('route53'); } catch (_) {}
// v1.6.38: 운영 서버에서 설정 칸에 ACCESS_KEY= 로 넣은 사례가 있어 흔한 별칭도 받는다 (ACCESS_KEY, AWS_ACCESS_KEY, KEY_ID)
const id = x && x.settings && (x.settings.ACCESS_KEY_ID || x.settings.AWS_ACCESS_KEY_ID || x.settings.ACCESS_KEY || x.settings.AWS_ACCESS_KEY || x.settings.KEY_ID);
if (!x || !x.secret || !id) return null;
return { accessKeyId: id, secretAccessKey: x.secret, sessionToken: x.settings.SESSION_TOKEN || x.settings.AWS_SESSION_TOKEN || '', region: x.settings.REGION || 'us-east-1' };
}
const ready = () => !!creds();
/**
* v1.6.39 (요구 1): 접속 정보가 없을 때 "무엇이 빠졌는지" 를 말해 준다 — webmaker.ibit.kr 에서 설정 칸 이름을 ACCESS_KEY 로 넣어 503 이 난 사례(1.6.38 에서 별칭 허용).
* 비밀 키 자체(secret)가 없는지, 설정 칸에 액세스 키 ID 줄이 없는지(지금 들어 있는 설정 이름을 함께)를 구분해 안내한다. 값은 절대 넣지 않는다.
*/
function missingMessage() {
let x = null; try { x = require('../vault').resolve('route53'); } catch (_) {}
const where = '관리자 콘솔 › 설정 › 외부 연동 접속 정보 › Route 53';
if (!x) return `Route 53 접속 정보가 없습니다 — ${where} 에 항목을 추가하세요 (키 칸: Secret Access Key, 설정 칸: ACCESS_KEY_ID=액세스 키 ID)`;
const names = Object.keys(x.settings || {});
if (!x.secret) return `Route 53 비밀 키(Secret Access Key)가 없습니다 — ${where} 의 키 칸에 넣으세요${names.length ? ` (설정 칸에는 ${names.join(', ')} 만 있음)` : ''}`;
return `Route 53 액세스 키 ID 가 없습니다 — ${where} 의 설정 칸에 "ACCESS_KEY_ID=AKIA…" 한 줄을 넣으세요${names.length ? ` (지금 설정 칸: ${names.join(', ')} — ACCESS_KEY·AWS_ACCESS_KEY_ID·KEY_ID 도 인정)` : ' (설정 칸이 비어 있음)'}`;
}
const esc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
const unesc = (s) => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
/** 아주 작은 XML 읽기: 태그 이름으로 값·블록 뽑기 (응답 구조가 단순해 전체 파서를 두지 않는다) */
const tag = (xml, name) => { const m = String(xml).match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`)); return m ? unesc(m[1]) : null; };
const blocks = (xml, name) => { const out = []; const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g'); let m; while ((m = re.exec(String(xml)))) out.push(m[1]); return out; };
/** 서명된 호출. 오류는 Route 53 의 <Message> 를 담아 던진다 */
async function call(method, path, query, bodyXml) {
const c = creds(); if (!c) throw httpError(missingMessage(), 503);
const u = new URL(base()); const headers = awsv4.sign({ method, host: u.host, path: API + path, query, headers: bodyXml ? { 'content-type': 'application/xml' } : {}, body: bodyXml || '', service: 'route53', region: c.region, accessKeyId: c.accessKeyId, secretAccessKey: c.secretAccessKey, sessionToken: c.sessionToken });
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
let r; try { r = await fetch(`${base()}${API}${path}${qs}`, { method, headers, body: bodyXml || undefined, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Route 53 연결 실패: ${e.message}`, 502); }
const text = await r.text();
if (!r.ok) throw httpError(`Route 53 오류 (HTTP ${r.status}): ${tag(text, 'Message') || tag(text, 'Code') || text.slice(0, 200)}`, r.status === 403 ? 403 : r.status >= 500 ? 502 : 400);
return text;
}
const fq = (d) => String(d).toLowerCase().replace(/\.$/, '') + '.';
const idOf = (s) => String(s || '').replace(/^\/hostedzone\//, '');
/** 호스팅 영역 목록 */
async function listZones() {
const out = []; let marker = null;
do { const xml = await call('GET', '/hostedzone', marker ? { marker } : null); for (const b of blocks(xml, 'HostedZone')) out.push({ id: idOf(tag(b, 'Id')), name: String(tag(b, 'Name') || '').replace(/\.$/, ''), records: Number(tag(b, 'ResourceRecordSetCount') || 0), private: /<PrivateZone>true/.test(b) }); marker = tag(xml, 'IsTruncated') === 'true' ? tag(xml, 'NextMarker') : null; } while (marker);
return out;
}
async function getZone(domain) { const z = (await listZones()).find((x) => x.name === String(domain).toLowerCase() && !x.private); if (!z) return null; const xml = await call('GET', `/hostedzone/${z.id}`); return { ...z, nameServers: blocks(xml, 'NameServer').map(unesc) }; }
/** 영역 만들기 → { id, nameServers }. 이미 있으면 그것을 돌려준다 */
async function createZone(domain) {
const cur = await getZone(domain); if (cur) return cur;
const xml = await call('POST', '/hostedzone', null, `<?xml version="1.0" encoding="UTF-8"?><CreateHostedZoneRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><Name>${esc(fq(domain))}</Name><CallerReference>webmaker-${Date.now()}</CallerReference><HostedZoneConfig><Comment>webmaker</Comment></HostedZoneConfig></CreateHostedZoneRequest>`);
return { id: idOf(tag(xml, 'Id')), name: String(domain).toLowerCase(), nameServers: blocks(xml, 'NameServer').map(unesc) };
}
/** 영역 지우기 — Route 53 는 NS·SOA 외 레코드가 남아 있으면 거부하므로 먼저 모두 지운다 */
async function deleteZone(domain) {
const z = await getZone(domain); if (!z) return { ok: true, existed: false };
const sets = await rrsets(z.id); const del = sets.filter((s) => !(s.type === 'NS' || s.type === 'SOA') || s.name !== fq(domain));
if (del.length) await change(z.id, del.map((s) => ({ action: 'DELETE', set: s })));
await call('DELETE', `/hostedzone/${z.id}`); return { ok: true, existed: true };
}
/** 영역의 RRSet 전체 (원본 모양: { name(끝점), type, ttl, values[] }) */
async function rrsets(zoneId) {
const out = []; let q = null;
do { const xml = await call('GET', `/hostedzone/${zoneId}/rrset`, q); for (const b of blocks(xml, 'ResourceRecordSet')) { if (/<AliasTarget>/.test(b)) continue; out.push({ name: tag(b, 'Name'), type: tag(b, 'Type'), ttl: Number(tag(b, 'TTL') || 300), values: blocks(b, 'ResourceRecord').map((r) => tag(r, 'Value')) }); } q = tag(xml, 'IsTruncated') === 'true' ? { name: tag(xml, 'NextRecordName'), type: tag(xml, 'NextRecordType') } : null; } while (q);
return out;
}
function setXml(action, s) { return `<Change><Action>${action}</Action><ResourceRecordSet><Name>${esc(s.name)}</Name><Type>${s.type}</Type><TTL>${s.ttl || 300}</TTL><ResourceRecords>${s.values.map((v) => `<ResourceRecord><Value>${esc(v)}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet></Change>`; }
async function change(zoneId, changes) { if (!changes.length) return; await call('POST', `/hostedzone/${zoneId}/rrset`, null, `<?xml version="1.0" encoding="UTF-8"?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Changes>${changes.map((c) => setXml(c.action, c.set)).join('')}</Changes></ChangeBatch></ChangeResourceRecordSetsRequest>`); }
// ---- 우리 레코드 모양 ↔ Route 53 값 ----
const toVal = (r) => (r.type === 'TXT' ? `"${String(r.value).replace(/"/g, '\\"')}"` : r.type === 'MX' ? `${r.prio == null ? 10 : r.prio} ${r.value}` : r.type === 'SRV' ? `${r.prio == null ? 10 : r.prio} ${r.value}` : 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);
/** 레코드 목록 (우리 모양: name 은 영역 기준 상대 이름) */
async function listRecords(domain) {
const z = await getZone(domain); if (!z) throw httpError(`Route 53 에 ${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(`Route 53 에 ${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 changes = [];
for (const k of touched) { const s = map.get(k); const orig = sets.find((x) => key(x.name, x.type) === k); if (!s.values.length) { if (orig) changes.push({ action: 'DELETE', set: orig }); } else changes.push({ action: 'UPSERT', set: s }); }
await change(z.id, changes); return { ok: true, changes: changes.length };
}
async function nameServers(domain) { const z = await getZone(domain); return z ? z.nameServers : []; }
module.exports = { id: 'route53', label: 'Amazon Route 53', ready, creds, missingMessage, listZones, getZone, createZone, deleteZone, listRecords, updateRecords, nameServers, call };