Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* DNS 모듈 공통 도구(1.0.5): 이름 정규화, TXT 분할, 오류 객체, 공개 DNS 조회(반영 확인).
|
||||
*/
|
||||
import { Resolver } from 'node:dns/promises';
|
||||
|
||||
/** FQDN(끝에 점). 빈 값은 그대로. */
|
||||
export const fqdn = (name) => { const s = String(name || '').trim().toLowerCase(); return !s ? s : s.endsWith('.') ? s : s + '.'; };
|
||||
/** 끝 점 없는 소문자 이름(비교용). */
|
||||
export const bare = (name) => fqdn(name).replace(/\.$/, '');
|
||||
/** TXT 값을 255자 이하 조각으로 나눕니다(RFC 1035 문자열 한도). */
|
||||
export const txtChunks = (value) => { const s = String(value ?? ''); const out = []; for (let i = 0; i < Math.max(1, s.length); i += 255) out.push(s.slice(i, i + 255)); return out; };
|
||||
/** dns_* 코드를 가진 Error. detail 은 화면에 그대로 보입니다. */
|
||||
export function dnsError(code, detail) { const e = new Error(code); e.detail = String(detail || '').slice(0, 300); return e; }
|
||||
|
||||
/**
|
||||
* 공개 리졸버(Google 8.8.8.8, Cloudflare 1.1.1.1)로 레코드가 실제로 보이는지 확인합니다. 권위 서버가 아니라 "세상이 보는 값" 기준이라
|
||||
* DNS 업체에 적용한 직후에는 아직 안 보일 수 있습니다(캐시·전파). 그 경우 state 'wait' 로 알립니다.
|
||||
* @param {object} record { name, type, values[], priority? }
|
||||
* @param {object} [o] resolver: 시험용 대체 객체({ resolveTxt, resolveMx, resolveCname, resolve4 })
|
||||
* @returns {Promise<{ok:boolean, found:string[], error?:string}>}
|
||||
*/
|
||||
export async function lookup(record, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
|
||||
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
|
||||
const name = bare(record.name);
|
||||
try {
|
||||
if (record.type === 'TXT') {
|
||||
const found = (await r.resolveTxt(name)).map((chunks) => chunks.join(''));
|
||||
const ok = record.values.every((v) => found.some((f) => f.replace(/\s+/g, ' ').trim() === String(v).replace(/\s+/g, ' ').trim()));
|
||||
return { ok, found };
|
||||
}
|
||||
if (record.type === 'MX') {
|
||||
const found = (await r.resolveMx(name)).map((m) => `${m.priority} ${bare(m.exchange)}`);
|
||||
const want = record.values.map((v) => `${record.priority ?? 10} ${bare(v)}`);
|
||||
const ok = want.every((w) => found.includes(w));
|
||||
return { ok, found };
|
||||
}
|
||||
if (record.type === 'CNAME') { const found = (await r.resolveCname(name)).map(bare); return { ok: record.values.every((v) => found.includes(bare(v))), found }; }
|
||||
if (record.type === 'A') { const found = await r.resolve4(name); return { ok: record.values.every((v) => found.includes(v)), found }; }
|
||||
return { ok: false, found: [], error: 'unsupported_type' };
|
||||
} catch (e) {
|
||||
// ENOTFOUND/ENODATA = 아직 없음(전파 대기), 그 밖은 조회 실패
|
||||
return { ok: false, found: [], error: e.code === 'ENOTFOUND' || e.code === 'ENODATA' ? 'not_found' : e.code || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 호스트 이름의 A 레코드와 그 IP 의 PTR(역방향)을 함께 봅니다(Haraka 발신 준비 점검용). */
|
||||
export async function reverseCheck(hostname, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
|
||||
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
|
||||
let ips = []; try { ips = await r.resolve4(bare(hostname)); } catch (e) { return { ok: false, ips: [], ptr: [], error: 'a_' + (e.code || 'failed') }; }
|
||||
const ptr = []; for (const ip of ips) { try { ptr.push(...(await r.reverse(ip)).map(bare)); } catch { /* PTR 없음 */ } }
|
||||
return { ok: ips.length > 0 && ptr.includes(bare(hostname)), ips, ptr };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Google Cloud DNS 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 서비스 계정(JSON 키)으로 인증해 Cloud DNS REST API(v1)를 부릅니다.
|
||||
*
|
||||
* 인증: 서비스 계정 JSON 의 client_email / private_key 로 RS256 JWT 를 만들어 OAuth2 토큰(https://oauth2.googleapis.com/token)과 교환합니다.
|
||||
* 범위는 ndev.clouddns.readwrite. 토큰은 만료 1분 전까지 재사용합니다. 서비스 계정에는 프로젝트의 "DNS 관리자" 역할이 필요합니다.
|
||||
* 존 찾기: 프로젝트의 managedZones 목록에서 dnsName 이 도메인(또는 그 상위 도메인)과 같은 공개 존을 고릅니다(DNS_GOOGLE_ZONE 으로 고정 가능).
|
||||
* 적용: changes API 에 additions/deletions 를 한 번에 넘깁니다(같은 이름·형식의 기존 rrset 은 deletions 로 빼고 새 값으로 넣음 = UPSERT).
|
||||
* TXT 값은 따옴표로 감싸고 255자 단위로 나눕니다(DKIM 공개키처럼 긴 값). MX 는 "우선순위 호스트." 형식, CNAME/MX 의 호스트는 끝에 점을 붙입니다.
|
||||
*
|
||||
* 설정(config.json): DNS_GOOGLE_CREDENTIALS(서비스 계정 JSON 전체, 비밀), DNS_GOOGLE_PROJECT(비우면 JSON 의 project_id), DNS_GOOGLE_ZONE(비우면 자동).
|
||||
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
|
||||
*/
|
||||
import { createSign } from 'node:crypto';
|
||||
import { fqdn, txtChunks, dnsError } from './common.mjs';
|
||||
|
||||
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
|
||||
const tokenCache = new WeakMap(); // env.DB → { token, exp }
|
||||
|
||||
/** 서비스 계정 JSON 해석. 비어 있거나 모양이 틀리면 dns_not_configured. */
|
||||
export function credentials(env) {
|
||||
const raw = String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
|
||||
if (!raw) throw dnsError('dns_not_configured', 'Google 서비스 계정 JSON 이 없습니다');
|
||||
let json; try { json = JSON.parse(raw); } catch { throw dnsError('dns_bad_credentials', '서비스 계정 JSON 을 해석할 수 없습니다'); }
|
||||
if (!json.client_email || !json.private_key) throw dnsError('dns_bad_credentials', '서비스 계정 JSON 에 client_email / private_key 가 없습니다');
|
||||
const project = String(env.DNS_GOOGLE_PROJECT || json.project_id || '').trim();
|
||||
if (!project) throw dnsError('dns_not_configured', '프로젝트 id 가 없습니다(DNS_GOOGLE_PROJECT)');
|
||||
return { email: json.client_email, key: json.private_key, tokenUri: json.token_uri || 'https://oauth2.googleapis.com/token', project };
|
||||
}
|
||||
|
||||
const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');
|
||||
/** RS256 JWT 를 만들어 액세스 토큰을 받습니다(캐시). */
|
||||
async function accessToken(env) {
|
||||
const cached = tokenCache.get(env.DB || env); if (cached && cached.exp > Date.now() + 60000) return cached.token;
|
||||
const c = credentials(env), now = Math.floor(Date.now() / 1000);
|
||||
const unsigned = b64url({ alg: 'RS256', typ: 'JWT' }) + '.' + b64url({ iss: c.email, scope: SCOPE, aud: c.tokenUri, iat: now, exp: now + 3600 });
|
||||
let signature; try { signature = createSign('RSA-SHA256').update(unsigned).sign(c.key, 'base64url'); } catch (e) { throw dnsError('dns_bad_credentials', '개인키로 서명하지 못했습니다: ' + e.message); }
|
||||
const r = await (env.DNS_FETCH || fetch)(c.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: unsigned + '.' + signature }), signal: AbortSignal.timeout(20000) }).catch((e) => { throw dnsError('dns_unavailable', 'Google 인증 서버 연결 실패: ' + e.message); });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.access_token) throw dnsError(r.status === 400 || r.status === 401 ? 'dns_auth_failed' : 'dns_unavailable', `토큰 발급 실패 ${r.status} ${data.error_description || data.error || ''}`.trim());
|
||||
tokenCache.set(env.DB || env, { token: data.access_token, exp: Date.now() + Number(data.expires_in || 3600) * 1000 });
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Cloud DNS API 호출. 오류는 dns_* 코드로. */
|
||||
async function api(env, path, method = 'GET', body) {
|
||||
const token = await accessToken(env), c = credentials(env);
|
||||
const r = await (env.DNS_FETCH || fetch)(`https://dns.googleapis.com/dns/v1/projects/${encodeURIComponent(c.project)}${path}`, { method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Cloud DNS 연결 실패: ' + e.message); });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw dnsError(r.status === 401 ? 'dns_auth_failed' : r.status === 403 ? 'dns_forbidden' : r.status === 404 ? 'dns_zone_not_found' : 'dns_unavailable', `Cloud DNS ${r.status} ${data.error?.message || ''}`.trim());
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인을 담당하는 존을 찾습니다. DNS_GOOGLE_ZONE 이 있으면 그 존을, 없으면 dnsName 이 도메인과 같거나 상위인 공개 존 중 가장 긴 것.
|
||||
* @returns {Promise<{id:string, name:string, dnsName:string}>}
|
||||
*/
|
||||
export async function findZone(env, domain) {
|
||||
const want = fqdn(domain);
|
||||
if (env.DNS_GOOGLE_ZONE) { const z = await api(env, '/managedZones/' + encodeURIComponent(env.DNS_GOOGLE_ZONE)); return { id: z.name, name: z.name, dnsName: z.dnsName }; }
|
||||
const list = (await api(env, '/managedZones')).managedZones || [];
|
||||
const candidates = list.filter((z) => z.visibility !== 'private' && (want === z.dnsName || want.endsWith('.' + z.dnsName))).sort((a, b) => b.dnsName.length - a.dnsName.length);
|
||||
if (!candidates.length) throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 존이 프로젝트에 없습니다`);
|
||||
return { id: candidates[0].name, name: candidates[0].name, dnsName: candidates[0].dnsName };
|
||||
}
|
||||
|
||||
/** 레코드 값 → Cloud DNS rrdatas 문자열. */
|
||||
export function rrdata(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 data = await api(env, `/managedZones/${encodeURIComponent(zone.id)}/rrsets?name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
|
||||
return (data.rrsets || [])[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드들을 UPSERT 합니다(같은 이름·형식은 새 값으로 교체). 바뀐 것이 없으면 변경을 보내지 않습니다.
|
||||
* @param {object[]} records { name, type, ttl, values[], priority? }
|
||||
* @returns {Promise<{zone:string, changed:string[], unchanged:string[]}>}
|
||||
*/
|
||||
export async function upsert(env, zone, records) {
|
||||
const additions = [], deletions = [], changed = [], unchanged = [];
|
||||
for (const r of records) {
|
||||
const want = { name: fqdn(r.name), type: r.type, ttl: Number(r.ttl || 300), rrdatas: rrdata(r) };
|
||||
const cur = await existing(env, zone, r.name, r.type);
|
||||
if (cur && JSON.stringify([...cur.rrdatas].sort()) === JSON.stringify([...want.rrdatas].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
|
||||
if (cur) deletions.push({ name: cur.name, type: cur.type, ttl: cur.ttl, rrdatas: cur.rrdatas });
|
||||
additions.push(want); changed.push(`${r.type} ${r.name}`);
|
||||
}
|
||||
if (additions.length) await api(env, `/managedZones/${encodeURIComponent(zone.id)}/changes`, 'POST', { additions, deletions });
|
||||
return { zone: zone.name, changed, unchanged };
|
||||
}
|
||||
|
||||
/** 연결 확인: 토큰을 받고 존을 찾아 봅니다. */
|
||||
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'google', zone: zone.name, dnsName: zone.dnsName, project: credentials(env).project }; }
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* DNS 모듈 선택과 "필요한 레코드" 계산(1.0.5, 요구사항 1·2).
|
||||
*
|
||||
* 역할
|
||||
* - dnsProvider(env): 설정 DNS_PROVIDER(none | google | route53)에 따라 Google Cloud DNS / AWS Route 53 모듈을 고릅니다. none 이면 자동 적용 없이
|
||||
* 레코드를 사용자에게 보여 주기만 합니다(요구사항 1: 인증 정보가 없으면 DNS 설정 정보를 제공).
|
||||
* - requiredRecords(env, { resendDomain, existing }): 지금 설정(수·발신 방식, 도메인, Haraka 호스트 이름, DKIM, Resend 도메인의 요구 레코드)으로
|
||||
* 도메인에 있어야 하는 레코드 목록을 만듭니다. 기존 SPF 가 있으면 우리 메커니즘만 끼워 넣어(mergeSpf) 다른 발송 서비스 설정을 깨지 않습니다.
|
||||
* - applyRecords(env, records): 고른 모듈로 존을 찾고 UPSERT 합니다.
|
||||
* - checkRecords(records): 공개 리졸버로 반영 여부를 확인합니다(common.mjs lookup).
|
||||
*
|
||||
* 레코드 모양: { name(FQDN, 끝 점 없음), type, ttl, values[], priority?, purpose(화면 설명), source('haraka'|'resend'), optional?(권장 사항 — 준비 완료 판정에서 제외, 자동 적용도 하지 않음) }
|
||||
*/
|
||||
import * as google from './google.mjs';
|
||||
import * as route53 from './route53.mjs';
|
||||
import { lookup, bare, dnsError } from './common.mjs';
|
||||
import { inboundMode, outboundMode } from '../providers/index.mjs';
|
||||
import { mailDomain, harakaHostname } from '../config.mjs';
|
||||
import { resendDnsRecords } from '../providers/resend.mjs';
|
||||
|
||||
export const DNS_PROVIDERS = ['none', 'google', 'route53'];
|
||||
const MODULES = { google, route53 };
|
||||
|
||||
/** 설정된 DNS 모듈 이름(모르는 값은 none). */
|
||||
export const dnsProviderName = (env) => (DNS_PROVIDERS.includes(env.DNS_PROVIDER) ? env.DNS_PROVIDER : 'none');
|
||||
/** 모듈 객체 또는 null(none). */
|
||||
export const dnsProvider = (env) => MODULES[dnsProviderName(env)] || null;
|
||||
/** 모듈이 쓸 인증 정보가 설정에 있는지(값의 유효성은 probe 로 확인). */
|
||||
export function dnsConfigured(env) {
|
||||
const p = dnsProviderName(env);
|
||||
if (p === 'google') return !!String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
|
||||
if (p === 'route53') return !!(String(env.DNS_AWS_ACCESS_KEY_ID || '').trim() && String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim());
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 SPF 레코드에 우리 메커니즘을 끼워 넣습니다. 예: "v=spf1 include:_spf.google.com ~all" + ["mx","a:mail.x.com"] → "v=spf1 include:_spf.google.com mx a:mail.x.com ~all".
|
||||
* 이미 있는 메커니즘은 다시 넣지 않고, 끝의 all 지시(~all/-all/?all/+all)는 그대로 둡니다. 기존 값이 없으면 새로 만듭니다(기본 ~all).
|
||||
*/
|
||||
export function mergeSpf(existing, mechanisms) {
|
||||
const cur = String(existing || '').trim();
|
||||
if (!/^v=spf1\b/i.test(cur)) return `v=spf1 ${mechanisms.join(' ')} ~all`;
|
||||
const parts = cur.split(/\s+/); const all = parts.findLast((p) => /^[-~?+]?all$/i.test(p));
|
||||
const body = parts.filter((p) => !/^v=spf1$/i.test(p) && !/^[-~?+]?all$/i.test(p));
|
||||
for (const m of mechanisms) if (!body.some((p) => p.toLowerCase() === m.toLowerCase())) body.push(m);
|
||||
return ['v=spf1', ...body, all || '~all'].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인에 있어야 하는 레코드 목록.
|
||||
* @param {object} [o]
|
||||
* @param {object|null} [o.resendDomain] Resend 도메인 상세(records 포함). 수신 방식이 resend 가 아니면 그 안의 MX 는 뺍니다(루트 MX 는 한 가지만).
|
||||
* @param {string[]} [o.existingSpf] 루트 TXT 중 기존 SPF 값(있으면 병합)
|
||||
*/
|
||||
export function requiredRecords(env, { resendDomain = null, existingSpf = null } = {}) {
|
||||
const domain = mailDomain(env), host = bare(harakaHostname(env)), inbound = inboundMode(env), outbound = outboundMode(env), out = [];
|
||||
if (inbound === 'haraka') out.push({ name: domain, type: 'MX', ttl: 300, values: [host], priority: 10, purpose: '받는 메일을 이 서버(Haraka)로', source: 'haraka' });
|
||||
if (outbound === 'haraka') {
|
||||
out.push({ name: domain, type: 'TXT', ttl: 300, values: [mergeSpf(existingSpf, ['mx', `a:${host}`])], purpose: 'SPF — 이 서버가 보낸 메일을 신뢰', source: 'haraka' });
|
||||
if (env.HARAKA_DKIM_PUBLIC) out.push({ name: `${env.HARAKA_DKIM_SELECTOR || 'arimail'}._domainkey.${domain}`, type: 'TXT', ttl: 300, values: [`v=DKIM1; k=rsa; p=${env.HARAKA_DKIM_PUBLIC}`], purpose: 'DKIM 서명 공개키', source: 'haraka' });
|
||||
out.push({ name: `_dmarc.${domain}`, type: 'TXT', ttl: 300, values: ['v=DMARC1; p=none; adkim=r; aspf=r'], purpose: 'DMARC 정책(권장, 자동 적용하지 않음)', source: 'haraka', optional: true });
|
||||
}
|
||||
if (resendDomain) for (const r of resendDnsRecords(resendDomain)) { if (r.type === 'MX' && inbound !== 'resend') continue; out.push(r); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든(선택 아닌) 레코드의 반영 여부. 각 레코드에 check { ok, found[], error?, byProvider?, problem? } 를 붙여 돌려줍니다.
|
||||
* 1.0.11: Resend 가 자기 레코드를 verified 라고 하면 우리 조회 값이 달라도 ok 로 봅니다(byProvider). Resend 가 받아들인 값(예: 예전 수신 MX
|
||||
* inbound-smtp.<지역>.amazonaws.com)을 "틀렸다" 고 표시해 사용자가 멀쩡한 레코드를 바꾸게 하는 일을 막습니다. 반대로 Resend 가 failed 라고 해도
|
||||
* 우리 조회가 맞으면 ok — Resend 의 레코드 상태는 마지막 검증 시점의 것이라 DNS 를 고친 뒤에는 오래된 값일 수 있습니다(재검증이 갱신).
|
||||
* problem: 'wrong'(다른 값이 보임) / 'missing'(없음) — 화면이 "값을 바꾸세요" 와 "반영을 기다리세요" 를 구분하는 근거.
|
||||
*/
|
||||
export async function checkRecords(records, { resolver = null } = {}) {
|
||||
const out = [];
|
||||
for (const r of records) {
|
||||
const check = await lookup(r, { resolver });
|
||||
const byProvider = !check.ok && String(r.status || '') === 'verified';
|
||||
out.push({ ...r, check: { ...check, ok: check.ok || byProvider, ...(byProvider ? { byProvider: true } : {}), problem: check.ok || byProvider ? null : check.found && check.found.length ? 'wrong' : 'missing' } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 DNS 모듈로 레코드를 적용합니다(optional 레코드는 뺌). 모듈이 없으면 dns_not_configured.
|
||||
* @returns {Promise<{provider:string, zone:string, changed:string[], unchanged:string[]}>}
|
||||
*/
|
||||
export async function applyRecords(env, records) {
|
||||
const mod = dnsProvider(env); if (!mod) throw dnsError('dns_not_configured', 'DNS 모듈이 설정되지 않았습니다(DNS_PROVIDER)');
|
||||
const zone = await mod.findZone(env, mailDomain(env));
|
||||
const result = await mod.upsert(env, zone, records.filter((r) => !r.optional));
|
||||
return { provider: dnsProviderName(env), ...result };
|
||||
}
|
||||
|
||||
/** 연결 확인(설정 화면 단추): 인증과 존 찾기. */
|
||||
export async function probeDns(env) {
|
||||
const mod = dnsProvider(env); if (!mod) return { ok: false, provider: 'none', error: 'dns_not_configured' };
|
||||
try { return await mod.probe(env, mailDomain(env)); } catch (e) { return { ok: false, provider: dnsProviderName(env), error: e.message, detail: e.detail }; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 }; }
|
||||
Reference in New Issue
Block a user