Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
133 lines
14 KiB
JavaScript
133 lines
14 KiB
JavaScript
'use strict';
|
||
// ============================================================================
|
||
// Amazon Route 53 Domains 등록 업체 (v1.6.32, 요구 1) — 검색(등록 가능 여부·가격), 등록, 갱신, 네임서버, 소유 확인
|
||
//
|
||
// 접속 정보: 보관소 종류 'route53'(DNS 와 같은 키 — lib/dns/route53.js 의 creds() 재사용). IAM 권한은 route53domains:CheckDomainAvailability ·
|
||
// ListPrices · RegisterDomain · GetOperationDetail · GetDomainDetail · ListDomains · RenewDomain · UpdateDomainNameservers (docs/DOMAIN-DNS-PROVIDERS.md).
|
||
// API: Route 53 Domains 는 글로벌 서비스로 엔드포인트가 route53domains.us-east-1.amazonaws.com 하나뿐이다(리전 us-east-1 고정, DNS 쪽 REGION 설정과 무관).
|
||
// 프로토콜은 JSON 1.1 — POST / 에 헤더 X-Amz-Target: Route53Domains_v20140515.<작업이름>, Content-Type: application/x-amz-json-1.1. 서명은 lib/awsv4.js(SigV4).
|
||
// 시험용 대체 주소: 환경 변수 ROUTE53DOMAINS_API_BASE (모의 서버 검증용).
|
||
// 다른 업체와 다른 점:
|
||
// - 등록·갱신·네임서버 변경이 비동기다(OperationId 를 돌려주고 뒤에서 처리). createDomain 은 최대 CREATE_WAIT_MS 동안 GetOperationDetail 을 돌려 보고,
|
||
// 그 안에 끝나지 않으면 status 'PENDING' 으로 돌려준다 — 등록 요청은 AWS 에 접수된 상태이므로 구매 흐름(lib/domains.js)은 성공으로 기록·차감하고,
|
||
// getDomain() 으로 만료일이 채워질 때까지 "처리 중" 으로 보인다. (ERROR/FAILED 로 끝나면 예외를 던져 차감하지 않는다.)
|
||
// - 고객 핸들(createCustomer)이 없다: 등록자 정보는 RegisterDomain 호출에 그대로 넣는다(toContact). createCustomer 는 'inline' 이라는 표식만 돌려
|
||
// needsContact=true 흐름(먼저 등록자 정보를 저장)이 그대로 동작하게 한다.
|
||
// - 가격은 USD(ListPrices), 확장자별 1년 가격 × 기간. Route 53 가 다루지 않는 확장자(.kr 등)는 ListPrices 가 비어 돌아와 "지원하지 않는 확장자" 로 안내한다.
|
||
// - 전화번호는 "+국가번호.국내번호" 형식(예 +82.1012345678). 국내번호 맨 앞 0 은 뗀다.
|
||
// ============================================================================
|
||
const awsv4 = require('../awsv4');
|
||
const dnsp = require('../dns/route53'); // creds() 공유
|
||
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
||
const HOST = 'route53domains.us-east-1.amazonaws.com'; const REGION = 'us-east-1'; const TARGET = 'Route53Domains_v20140515';
|
||
const CREATE_WAIT_MS = 60000; // 등록 작업 완료를 기다리는 최대 시간(구매 흐름의 90초 제한보다 짧게)
|
||
const base = () => (process.env.ROUTE53DOMAINS_API_BASE || `https://${HOST}`).replace(/\/+$/, '');
|
||
const ready = () => !!dnsp.creds();
|
||
|
||
/** example.com → { name:'example', extension:'com' } (Porkbun 과 같은 규칙: 2단계 공용 확장자도 허용) */
|
||
function split(fqdn) {
|
||
const d = String(fqdn || '').trim().toLowerCase().replace(/\.$/, '');
|
||
const m = d.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.((?:co|or|ne|go|ac|re|pe)\.kr|com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,24})$/);
|
||
if (!m) throw httpError('도메인 이름은 영문 소문자·숫자·하이픈으로 된 example.com 형식이어야 합니다');
|
||
return { name: m[1], extension: m[2] };
|
||
}
|
||
|
||
/** 서명된 JSON 1.1 호출. 오류는 AWS 의 message(또는 __type)를 담아 던진다 */
|
||
async function call(op, body) {
|
||
const c = dnsp.creds(); if (!c) throw httpError(dnsp.missingMessage(), 503);
|
||
const u = new URL(base()); const payload = JSON.stringify(body || {});
|
||
const headers = awsv4.sign({ method: 'POST', host: u.host, path: '/', query: {}, headers: { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': `${TARGET}.${op}` }, body: payload, service: 'route53domains', region: REGION, accessKeyId: c.accessKeyId, secretAccessKey: c.secretAccessKey, sessionToken: c.sessionToken });
|
||
let r; try { r = await fetch(`${base()}/`, { method: 'POST', headers, body: payload, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Route 53 Domains 연결 실패: ${e.message}`, 502); }
|
||
const text = await r.text(); let j = {}; try { j = text ? JSON.parse(text) : {}; } catch (_) { j = { message: text.slice(0, 200) }; }
|
||
if (!r.ok) { const type = String(j.__type || j.code || '').split('#').pop(); const msg = j.message || j.Message || type || text.slice(0, 200); throw Object.assign(httpError(`Route 53 Domains 오류 (${type || 'HTTP ' + r.status}): ${msg}`, r.status === 403 ? 403 : r.status >= 500 ? 502 : 400), { awsType: type }); }
|
||
return j;
|
||
}
|
||
|
||
// ---------- 가격 ----------
|
||
let priceCache = { at: 0, map: {} }; // 확장자 → {registration, renewal, transfer} USD (1시간)
|
||
async function tldPrice(extension) {
|
||
const hit = priceCache.map[extension]; if (hit && Date.now() - priceCache.at < 3600000) return hit;
|
||
const j = await call('ListPrices', { Tld: extension, MaxItems: 1 }); const p = (j.Prices || [])[0]; if (!p) return null;
|
||
const n = (x) => (x && Number.isFinite(Number(x.Price)) ? Number(x.Price) : NaN);
|
||
const out = { registration: n(p.RegistrationPrice), renewal: n(p.RenewalPrice), transfer: n(p.TransferPrice), currency: (p.RegistrationPrice && p.RegistrationPrice.Currency) || 'USD' };
|
||
if (Date.now() - priceCache.at >= 3600000) priceCache = { at: Date.now(), map: {} }; priceCache.map[extension] = out; return out;
|
||
}
|
||
async function price(fqdn, operation = 'create', period = 1) {
|
||
const { extension } = split(fqdn); const pr = await tldPrice(extension); if (!pr) throw httpError(`Route 53 는 .${extension} 도메인을 등록하지 않습니다 (지원 확장자 목록: AWS 문서 "Domains that you can register with Route 53")`);
|
||
const one = operation === 'renew' ? pr.renewal : operation === 'transfer' ? pr.transfer : pr.registration; if (!Number.isFinite(one)) throw httpError(`Route 53 가격표에 .${extension} 의 ${operation} 가격이 없습니다`);
|
||
return { currency: pr.currency, amount: Math.round(one * period * 100) / 100, perYear: one };
|
||
}
|
||
|
||
// ---------- 검색 ----------
|
||
/** 등록 가능 여부와 가격. Availability: AVAILABLE(+_RESERVED/_PREORDER 는 일반 등록 불가로 봄) · UNAVAILABLE* · RESERVED · DONT_KNOW · INVALID_NAME_FOR_TLD · PENDING */
|
||
async function check(fqdns) {
|
||
const out = [];
|
||
for (const f of fqdns) {
|
||
let extension; try { ({ extension } = split(f)); } catch (e) { out.push({ domain: f, status: 'unknown', premium: false, price: null, reason: e.message }); continue; }
|
||
const pr = await tldPrice(extension).catch(() => null);
|
||
if (!pr) { out.push({ domain: f, status: 'unknown', premium: false, price: null, reason: `Route 53 가 다루지 않는 확장자(.${extension})` }); continue; }
|
||
try {
|
||
const j = await call('CheckDomainAvailability', { DomainName: f }); const a = String(j.Availability || 'DONT_KNOW');
|
||
const status = a === 'AVAILABLE' ? 'free' : (a === 'DONT_KNOW' || a === 'PENDING') ? 'unknown' : 'active';
|
||
const reason = a === 'AVAILABLE' ? '' : { UNAVAILABLE: '이미 등록된 도메인', UNAVAILABLE_PREMIUM: '프리미엄 도메인(자동 등록 불가)', UNAVAILABLE_RESTRICTED: '등록 제한 도메인', RESERVED: '예약된 이름', AVAILABLE_RESERVED: '예약된 이름(일반 등록 불가)', AVAILABLE_PREORDER: '사전 예약 전용', INVALID_NAME_FOR_TLD: '이 확장자에 쓸 수 없는 이름', DONT_KNOW: '등록 업체가 가능 여부를 알려 주지 않음(잠시 뒤 다시)', PENDING: '확인 중(잠시 뒤 다시)' }[a] || a;
|
||
out.push({ domain: f, status, premium: a === 'UNAVAILABLE_PREMIUM', price: { currency: pr.currency, amount: pr.registration }, reason });
|
||
} catch (e) { out.push({ domain: f, status: 'unknown', premium: false, price: { currency: pr.currency, amount: pr.registration }, reason: `가능 여부 확인 실패: ${e.message}` }); }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------- 등록자 정보 ----------
|
||
/** 우리 연락처(lib/domains.js contactFields) → Route 53 ContactDetail. 회사명이 있으면 COMPANY(OrganizationName 필수), 아니면 PERSON */
|
||
function toContact(c) {
|
||
if (!c) throw httpError('먼저 등록자 정보(이름·주소·전화·이메일)를 저장하세요', 409);
|
||
const cc = String(c.phoneCountry || '+82').replace(/[^0-9]/g, '') || '82'; const national = String((c.phoneArea || '') + (c.phoneNumber || '')).replace(/[^0-9]/g, '').replace(/^0+/, '');
|
||
const addr = [c.street, c.number].filter(Boolean).join(' ').slice(0, 255);
|
||
const o = { FirstName: c.firstName, LastName: c.lastName, ContactType: c.company ? 'COMPANY' : 'PERSON', AddressLine1: addr, City: c.city, CountryCode: String(c.country || 'KR').toUpperCase(), ZipCode: c.zipcode, PhoneNumber: `+${cc}.${national}`, Email: c.email };
|
||
if (c.company) o.OrganizationName = String(c.company).slice(0, 255);
|
||
if (c.state) o.State = String(c.state).slice(0, 255);
|
||
for (const k of ['FirstName', 'LastName', 'AddressLine1', 'City', 'ZipCode', 'Email']) if (!o[k]) throw httpError(`등록자 정보에 ${k} 가 비어 있습니다 — 도메인 탭 › 등록자 정보를 다시 저장하세요`);
|
||
if (!national) throw httpError('등록자 전화번호가 비어 있습니다');
|
||
return o;
|
||
}
|
||
async function createCustomer(contact) { toContact(contact); return 'inline'; } // 유효성만 확인하고 표식을 돌려준다(핸들 개념이 없음)
|
||
|
||
// ---------- 등록·갱신·조회 ----------
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
/** 작업(OperationId)이 끝날 때까지 최대 waitMs 기다린다. 끝나면 상세, 아니면 마지막 상태를 돌려준다 */
|
||
async function waitOperation(opId, waitMs) {
|
||
const until = Date.now() + waitMs; let last = { Status: 'SUBMITTED' };
|
||
while (Date.now() < until) { last = await call('GetOperationDetail', { OperationId: opId }); if (['SUCCESSFUL', 'ERROR', 'FAILED'].includes(last.Status)) break; await sleep(Math.min(5000, Math.max(1000, until - Date.now()))); }
|
||
return last;
|
||
}
|
||
const isoDate = (epoch) => (Number.isFinite(Number(epoch)) && Number(epoch) > 0 ? new Date(Number(epoch) * 1000).toISOString().slice(0, 10) : null);
|
||
async function createDomain(fqdn, { period = 1, contact } = {}) {
|
||
const ct = toContact(contact); const years = Math.min(10, Math.max(1, parseInt(period, 10) || 1));
|
||
const j = await call('RegisterDomain', { DomainName: fqdn, DurationInYears: years, AutoRenew: false, AdminContact: ct, RegistrantContact: ct, TechContact: ct, PrivacyProtectAdminContact: true, PrivacyProtectRegistrantContact: true, PrivacyProtectTechContact: true });
|
||
const opId = j.OperationId; const st = opId ? await waitOperation(opId, CREATE_WAIT_MS) : { Status: 'SUCCESSFUL' };
|
||
if (st.Status === 'ERROR' || st.Status === 'FAILED') throw httpError(`Route 53 등록 실패: ${st.Message || st.Status}`, 502);
|
||
let expiresAt = null; if (st.Status === 'SUCCESSFUL') { try { expiresAt = (await getDomain(fqdn)).expiresAt; } catch (_) {} }
|
||
if (!expiresAt) { const exp = new Date(); exp.setFullYear(exp.getFullYear() + years); expiresAt = exp.toISOString().slice(0, 10); }
|
||
// id 는 도메인 이름(이후 갱신·네임서버·조회가 이름으로 간다). 아직 처리 중이면 PENDING — 구매 흐름은 접수된 것으로 기록하고 상세 조회로 확정한다
|
||
return { id: fqdn, status: st.Status === 'SUCCESSFUL' ? 'ACT' : 'PENDING', expiresAt, operationId: opId || null, raw: st };
|
||
}
|
||
async function getDomain(ref) {
|
||
const j = await call('GetDomainDetail', { DomainName: String(ref).toLowerCase() });
|
||
return { expiresAt: isoDate(j.ExpirationDate), status: (j.StatusList || []).join(',') || 'ACT', nameServers: (j.Nameservers || []).map((n) => n.Name), autoRenew: !!j.AutoRenew, raw: undefined };
|
||
}
|
||
/** 갱신: 현재 만료 연도(CurrentExpiryYear)가 필요해 먼저 상세를 읽는다 */
|
||
async function renewDomain(ref, fqdn, period = 1) {
|
||
const name = String(fqdn || ref).toLowerCase(); const d = await getDomain(name); const year = d.expiresAt ? Number(d.expiresAt.slice(0, 4)) : new Date().getUTCFullYear();
|
||
const j = await call('RenewDomain', { DomainName: name, DurationInYears: Math.min(10, Math.max(1, parseInt(period, 10) || 1)), CurrentExpiryYear: year });
|
||
const st = j.OperationId ? await waitOperation(j.OperationId, 20000) : { Status: 'SUCCESSFUL' };
|
||
if (st.Status === 'ERROR' || st.Status === 'FAILED') throw httpError(`Route 53 갱신 실패: ${st.Message || st.Status}`, 502);
|
||
return { status: st.Status === 'SUCCESSFUL' ? 'ACT' : 'PENDING', operationId: j.OperationId || null };
|
||
}
|
||
async function setNameServers(fqdn, ref, ns) {
|
||
const j = await call('UpdateDomainNameservers', { DomainName: String(fqdn).toLowerCase(), Nameservers: ns.map((n) => ({ Name: String(n).replace(/\.$/, '') })) });
|
||
return { ok: true, operationId: j.OperationId || null };
|
||
}
|
||
/** 이 AWS 계정이 그 도메인을 갖고 있는지 (구매 재조정·접속 확인용). 없으면 null */
|
||
async function ownsDomain(name) { try { const d = await getDomain(name); return d; } catch (e) { if (e.awsType === 'InvalidInput' || /not found|does not exist|not associated/i.test(e.message)) return null; throw e; } }
|
||
async function listDomains() { const out = []; let marker; do { const j = await call('ListDomains', { MaxItems: 100, ...(marker ? { Marker: marker } : {}) }); for (const d of j.Domains || []) out.push({ name: d.DomainName, expiresAt: isoDate(d.Expiry), autoRenew: !!d.AutoRenew }); marker = j.NextPageMarker; } while (marker); return out; }
|
||
module.exports = { id: 'route53', label: 'Amazon Route 53', ready, split, check, price, createCustomer, createDomain, renewDomain, getDomain, setNameServers, ownsDomain, listDomains, toContact, call, needsContact: true };
|