/** * 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>/) || [])[1] || String(r.status), message = unesc((text.match(/([^<]*)<\/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>/) || [])[1] || ''), dnsName: unesc((text.match(/([^<]+)<\/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(/[\s\S]*?<\/HostedZone>/g) || []) { const name = unesc((block.match(/([^<]+)<\/Name>/) || [])[1] || ''), id = ((block.match(/([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/hostedzone\//, ''); const priv = /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(/[\s\S]*?<\/ResourceRecordSet>/g) || [])[0]; if (!block) return null; const n = unesc((block.match(/([^<]+)<\/Name>/) || [])[1] || ''), t = (block.match(/([^<]+)<\/Type>/) || [])[1]; if (n.toLowerCase() !== fqdn(name).toLowerCase() || t !== type) return null; // maxitems=1 은 "그 이름부터" 돌려주므로 다른 레코드일 수 있음 return { name: n, type: t, ttl: Number((block.match(/(\d+)<\/TTL>/) || [])[1] || 300), values: [...block.matchAll(/([^<]*)<\/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(`UPSERT${esc(fqdn(r.name))}${esc(r.type)}${Number(r.ttl || 300)}${want.map((v) => `${esc(v)}`).join('')}`); changed.push(`${r.type} ${r.name}`); } if (!changes.length) return { zone: zone.name, changed, unchanged }; const body = `AriMail mail records${changes.join('')}`; 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>/) || [])[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 }; }