Files

133 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };