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

117 lines
9.9 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';
// ============================================================================
// 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 };