Files
AriWeb-Suite/modules/webmaker/lib/openprovider.js
T

117 lines
9.9 KiB
JavaScript
Raw Normal View History

'use strict';
// ============================================================================
// Openprovider API 클라이언트 (v1.4.0) — 도메인 검색·가격·등록·갱신, DNS 영역(레코드) 관리
//
// 문서: https://docs.openprovider.com/doc/all (swagger: https://docs.openprovider.com/swagger.json, v1beta)
// - 인증: POST /auth/login {username, password, ip} → data.token (Bearer). 토큰은 오래 유지되지만 만료·폐기될 수 있어
// 401 이 오면 한 번 다시 로그인한다. 토큰은 메모리에만 둔다.
// - 접속 정보는 관리자 콘솔 › 연동 키 의 "Openprovider" 항목(lib/vault.js, 종류 openprovider)에 암호화 보관한다:
// 주소(기본 https://api.openprovider.eu/v1beta), 설정 USERNAME=…, 키(secret)=비밀번호. 한 번 넣으면 화면에 다시 보이지 않는다.
// - 시험용 대체 주소: 환경 변수 OPENPROVIDER_API_BASE (모의 서버)
// 응답 형식: { code: 0, desc, data } — code 가 0 이 아니면 오류(desc). 여기서는 오류를 한국어 안내와 함께 던진다.
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const DEFAULT_BASE = 'https://api.openprovider.eu/v1beta';
let tokenCache = { key: '', token: '', at: 0 }; // 계정(사용자 이름+주소)별 토큰 1개, 6시간마다 새로 받음
/** 보관소에서 접속 정보 읽기. 없으면 null (화면에서 "관리자 설정 필요" 안내) */
function creds() {
let x = null; try { x = require('./vault').resolve('openprovider'); } catch (_) {}
const user = x && x.settings && (x.settings.USERNAME || x.settings.OPENPROVIDER_USERNAME);
if (!x || !x.secret || !user) return null;
return { base: (process.env.OPENPROVIDER_API_BASE || x.url || DEFAULT_BASE).replace(/\/+$/, ''), username: user, password: x.secret, nsGroup: x.settings.NS_GROUP || 'dns-openprovider' };
}
const ready = () => !!creds();
async function login(c, force) {
const key = `${c.base}|${c.username}`;
if (!force && tokenCache.key === key && tokenCache.token && Date.now() - tokenCache.at < 6 * 3600000) return tokenCache.token;
let r; try { r = await fetch(`${c.base}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: c.username, password: c.password, ip: '0.0.0.0' }), signal: AbortSignal.timeout(20000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok || j.code || !j.data || !j.data.token) throw httpError(`Openprovider 로그인 실패 (${j.desc || r.status}) — 관리자 콘솔 › 연동 키 의 Openprovider 사용자 이름·비밀번호를 확인하세요`, 502);
tokenCache = { key, token: j.data.token, at: Date.now() }; return j.data.token;
}
/**
* API 호출. 401 이면 다시 로그인해 한 번 더 시도한다.
* @param {string} method GET|POST|PUT|DELETE
* @param {string} path '/domains/check' 처럼 버전 뒤 경로
* @param {object} [body] JSON 본문
* @param {object} [query] 쿼리 문자열 값
*/
async function call(method, path, body, query) {
const c = creds(); if (!c) throw httpError('도메인 서비스가 아직 준비되지 않았습니다. 관리자에게 문의하세요 (관리자: 연동 키에 Openprovider 접속 정보를 넣어야 합니다)', 503);
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
for (let attempt = 0; attempt < 2; attempt++) {
const token = await login(c, attempt > 0);
let r; try { r = await fetch(`${c.base}${path}${qs}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(60000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
if (r.status === 401 && attempt === 0) continue;
const j = await r.json().catch(() => ({}));
if (!r.ok || (j.code && j.code !== 0)) throw Object.assign(httpError(`도메인 서비스 오류: ${j.desc || `HTTP ${r.status}`}`, r.status >= 500 ? 502 : 400), { opCode: j.code, opData: j.data });
return j.data;
}
throw httpError('Openprovider 인증이 거부되었습니다', 502);
}
/** "example.com" → { name: 'example', extension: 'com' } (co.kr 같은 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] };
}
/**
* 등록 가능 여부와 가격 (여러 개 한 번에).
* @returns {Array<{domain, status: 'free'|'active'|…, premium: boolean, price: {currency, amount}|null, reason}>}
* price 는 우리(리셀러)가 내는 가격(reseller) — 사용자 청구는 lib/domains.js 가 마진을 붙인다
*/
async function check(fqdns) {
const data = await call('POST', '/domains/check', { domains: fqdns.map(split), with_price: true });
return ((data && data.results) || []).map((x) => {
const pr = (x.price && (x.price.reseller || x.price.product)) || null; const prem = x.premium && x.premium.price && (x.premium.price.reseller || x.premium.price.product);
const use = x.is_premium && prem ? prem : pr;
return { domain: x.domain, status: x.status, premium: !!x.is_premium, price: use && Number.isFinite(Number(use.price)) ? { currency: use.currency || 'EUR', amount: Number(use.price) } : null, reason: x.reason || '' };
});
}
/** 작업별 가격 (create|renew|transfer, 기간 년) — 리셀러 가격 */
async function price(fqdn, operation = 'create', period = 1) {
const { name, extension } = split(fqdn);
const data = await call('GET', '/domains/prices', null, { 'domain.name': name, 'domain.extension': extension, operation, period });
const g = data && data.price; const p = g && (g.reseller || g.product);
if (!p || !Number.isFinite(Number(p.price))) throw httpError('도메인 가격을 받지 못했습니다', 502);
return { currency: p.currency || 'EUR', amount: Number(p.price), premium: !!(data && data.is_premium) };
}
/**
* 등록자(소유자) 연락처 만들기 → handle (XX123456-XX). 도메인 등록에 필요하다.
* @param {object} c { firstName, lastName, company, email, phoneCountry('+82'), phoneArea, phoneNumber, street, number, zipcode, city, state, country('KR') }
*/
async function createCustomer(c) {
const body = { name: { first_name: c.firstName, last_name: c.lastName, full_name: `${c.firstName} ${c.lastName}`.trim() }, company_name: c.company || undefined, email: c.email,
phone: { country_code: c.phoneCountry || '+82', area_code: c.phoneArea || '', subscriber_number: c.phoneNumber },
address: { street: c.street, number: c.number || '', zipcode: c.zipcode, city: c.city, state: c.state || '', country: (c.country || 'KR').toUpperCase() } };
const data = await call('POST', '/customers', body);
if (!data || !data.handle) throw httpError('등록자 정보를 만들지 못했습니다', 502); return data.handle;
}
/**
* 도메인 등록 (Openprovider 네임서버 그룹으로 — DNS 를 이 서비스가 관리할 수 있게).
* @returns {{ id: number, status: string, expiresAt: string|null }}
*/
async function createDomain(fqdn, { handle, period = 1, autorenew = 'off' } = {}) {
const c = creds(); const data = await call('POST', '/domains', { domain: split(fqdn), period, unit: 'y', owner_handle: handle, admin_handle: handle, tech_handle: handle, billing_handle: handle, ns_group: (c && c.nsGroup) || 'dns-openprovider', autorenew, is_private_whois_enabled: true });
return { id: data.id, status: data.status || 'REQ', expiresAt: data.expiration_date || null };
}
async function renewDomain(id, fqdn, period = 1) { const data = await call('POST', `/domains/${id}/renew`, { id, domain: split(fqdn), period }); return { status: (data && data.status) || 'ACT' }; }
async function getDomain(id) { return call('GET', `/domains/${id}`); }
// ---------- DNS 영역 ----------
/** 영역 만들기 (없을 때). records: [{name:'', type:'A', value, ttl}] — name '' = 도메인 자신(@) */
async function createZone(fqdn, records) { return call('POST', '/dns/zones', { domain: split(fqdn), type: 'master', records: records.map(rec) }); }
async function getZone(fqdn) { try { return await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}`); } catch (e) { if (e.status === 400 || e.status === 404) return null; throw e; } }
async function listRecords(fqdn) { const data = await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}/records`, null, { limit: 500 }); return ((data && data.results) || []).map((r) => ({ name: r.name, type: r.type, value: r.value, ttl: r.ttl, prio: r.prio })); }
/** 레코드 더하기·빼기 (영역의 NS·SOA 는 Openprovider 가 관리하므로 건드리지 않는다) */
async function updateRecords(fqdn, { add = [], remove = [] }) { return call('PUT', `/dns/zones/${encodeURIComponent(fqdn)}`, { domain: split(fqdn), name: fqdn, type: 'master', records: { add: add.map(rec), remove: remove.map(rec) } }); }
/** 레코드 이름을 영역 기준(상대 이름)으로: 'www.example.com' → 'www', 'example.com' → '' */
const rec = (r) => ({ name: r.name || '', type: r.type, value: String(r.value), ttl: Number(r.ttl) || 900, ...(r.prio !== undefined && r.prio !== null && r.prio !== '' ? { prio: Number(r.prio) } : {}) });
module.exports = { ready, creds, call, split, check, price, createCustomer, createDomain, renewDomain, getDomain, createZone, getZone, listRecords, updateRecords, DEFAULT_BASE };