Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
108 lines
8.5 KiB
JavaScript
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
|
const unesc = (v) => String(v).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 }; }
|