'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 };