Files
AriWeb-Suite/modules/arimail/server/dns/route53.mjs

108 lines
8.5 KiB
JavaScript

/**
* AWS Route 53 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 액세스 키로 SigV4 서명을 만들어 Route 53 REST API(2013-04-01, XML)를 부릅니다.
*
* 인증: DNS_AWS_ACCESS_KEY_ID / DNS_AWS_SECRET_ACCESS_KEY (IAM 사용자 또는 역할 키). 필요한 권한: route53:ListHostedZonesByName, GetHostedZone,
* ListResourceRecordSets, ChangeResourceRecordSets, GetChange. Route 53 은 글로벌 서비스라 서명 지역은 us-east-1 입니다.
* 존 찾기: ListHostedZonesByName 으로 도메인(또는 상위 도메인)과 이름이 같은 공개 호스팅 존을 고릅니다(DNS_AWS_HOSTED_ZONE_ID 로 고정 가능).
* 적용: ChangeResourceRecordSets 의 UPSERT 로 같은 이름·형식을 새 값으로 바꿉니다. TXT 는 따옴표 + 255자 분할, MX 는 "우선순위 호스트".
* XML 은 이 API 가 쓰는 단순한 모양만 다루므로 정규식으로 만들고 읽습니다(외부 XML 라이브러리 없음).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createHash, createHmac } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const HOST = 'route53.amazonaws.com', REGION = 'us-east-1', SERVICE = 'route53';
const sha256 = (v) => createHash('sha256').update(v).digest('hex');
const hmac = (key, v, enc) => createHmac('sha256', key).update(v).digest(enc);
const esc = (v) => String(v).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
const unesc = (v) => String(v).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
export function credentials(env) {
const id = String(env.DNS_AWS_ACCESS_KEY_ID || '').trim(), secret = String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim();
if (!id || !secret) throw dnsError('dns_not_configured', 'AWS 액세스 키가 없습니다');
return { id, secret };
}
/**
* SigV4 서명 요청. 쿼리 문자열은 미리 정규화(키 정렬·인코딩)해서 넘깁니다.
* @returns {Promise<{status:number, text:string}>}
*/
async function request(env, method, path, query = '', body = '') {
const c = credentials(env), now = new Date(), amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''), date = amzDate.slice(0, 8);
const payloadHash = sha256(body);
const headers = { host: HOST, 'x-amz-date': amzDate, 'x-amz-content-sha256': payloadHash, ...(body ? { 'content-type': 'application/xml' } : {}) };
const signedHeaders = Object.keys(headers).sort().join(';');
const canonical = [method, path.split('/').map((seg) => encodeURIComponent(decodeURIComponent(seg))).join('/'), query, ...Object.keys(headers).sort().map((k) => `${k}:${String(headers[k]).trim()}`), '', signedHeaders, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
const kSigning = hmac(hmac(hmac(hmac('AWS4' + c.secret, date), REGION), SERVICE), 'aws4_request');
const signature = hmac(kSigning, toSign, 'hex');
const authorization = `AWS4-HMAC-SHA256 Credential=${c.id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const r = await (env.DNS_FETCH || fetch)(`https://${HOST}${path}${query ? '?' + query : ''}`, { method, headers: { ...headers, Authorization: authorization }, body: body || undefined, signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Route 53 연결 실패: ' + e.message); });
const text = await r.text().catch(() => '');
if (!r.ok) {
const code = (text.match(/<Code>([^<]+)<\/Code>/) || [])[1] || String(r.status), message = unesc((text.match(/<Message>([^<]*)<\/Message>/) || [])[1] || '');
throw dnsError(/InvalidClientTokenId|SignatureDoesNotMatch|InvalidSignature|AuthFailure|UnrecognizedClient/.test(code) ? 'dns_auth_failed' : /AccessDenied|NotAuthorized/.test(code) ? 'dns_forbidden' : /NoSuchHostedZone/.test(code) ? 'dns_zone_not_found' : 'dns_unavailable', `Route 53 ${code} ${message}`.trim());
}
return { status: r.status, text };
}
/** 호스팅 존 찾기(DNS_AWS_HOSTED_ZONE_ID 가 있으면 그 존). 사설 존은 뺍니다. */
export async function findZone(env, domain) {
if (env.DNS_AWS_HOSTED_ZONE_ID) {
const id = String(env.DNS_AWS_HOSTED_ZONE_ID).replace(/^\/hostedzone\//, '');
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(id)}`);
return { id, name: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), dnsName: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || '') };
}
const want = fqdn(domain); const labels = want.split('.').filter(Boolean);
// 상위 도메인까지 차례로: mail.example.com. → example.com. → com.
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join('.') + '.';
const { text } = await request(env, 'GET', '/2013-04-01/hostedzonesbyname', `dnsname=${encodeURIComponent(candidate)}&maxitems=5`);
for (const block of text.match(/<HostedZone>[\s\S]*?<\/HostedZone>/g) || []) {
const name = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), id = ((block.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/hostedzone\//, '');
const priv = /<PrivateZone>true<\/PrivateZone>/.test(block);
if (name === candidate && !priv) return { id, name, dnsName: name };
}
}
throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 호스팅 존이 계정에 없습니다`);
}
/** 레코드 값 → Route 53 ResourceRecord Value 목록. */
export function values(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, `maxitems=1&name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
const block = (text.match(/<ResourceRecordSet>[\s\S]*?<\/ResourceRecordSet>/g) || [])[0]; if (!block) return null;
const n = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), t = (block.match(/<Type>([^<]+)<\/Type>/) || [])[1];
if (n.toLowerCase() !== fqdn(name).toLowerCase() || t !== type) return null; // maxitems=1 은 "그 이름부터" 돌려주므로 다른 레코드일 수 있음
return { name: n, type: t, ttl: Number((block.match(/<TTL>(\d+)<\/TTL>/) || [])[1] || 300), values: [...block.matchAll(/<Value>([^<]*)<\/Value>/g)].map((m) => unesc(m[1])) };
}
/**
* UPSERT. 바뀐 것이 없으면 요청을 보내지 않습니다.
* @returns {Promise<{zone:string, changed:string[], unchanged:string[], changeId?:string}>}
*/
export async function upsert(env, zone, records) {
const changes = [], changed = [], unchanged = [];
for (const r of records) {
const want = values(r), cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.values].sort()) === JSON.stringify([...want].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
changes.push(`<Change><Action>UPSERT</Action><ResourceRecordSet><Name>${esc(fqdn(r.name))}</Name><Type>${esc(r.type)}</Type><TTL>${Number(r.ttl || 300)}</TTL><ResourceRecords>${want.map((v) => `<ResourceRecord><Value>${esc(v)}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet></Change>`);
changed.push(`${r.type} ${r.name}`);
}
if (!changes.length) return { zone: zone.name, changed, unchanged };
const body = `<?xml version="1.0" encoding="UTF-8"?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Comment>AriMail mail records</Comment><Changes>${changes.join('')}</Changes></ChangeBatch></ChangeResourceRecordSetsRequest>`;
const { text } = await request(env, 'POST', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, '', body);
return { zone: zone.name, changed, unchanged, changeId: ((text.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/change\//, '') };
}
/** 연결 확인: 서명이 맞는지와 존이 있는지. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'route53', zone: zone.name, zoneId: zone.id }; }