Files
AriWeb-Suite/modules/arimail/server/dns/google.mjs

100 lines
7.9 KiB
JavaScript

/**
* Google Cloud DNS 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 서비스 계정(JSON 키)으로 인증해 Cloud DNS REST API(v1)를 부릅니다.
*
* 인증: 서비스 계정 JSON 의 client_email / private_key 로 RS256 JWT 를 만들어 OAuth2 토큰(https://oauth2.googleapis.com/token)과 교환합니다.
* 범위는 ndev.clouddns.readwrite. 토큰은 만료 1분 전까지 재사용합니다. 서비스 계정에는 프로젝트의 "DNS 관리자" 역할이 필요합니다.
* 존 찾기: 프로젝트의 managedZones 목록에서 dnsName 이 도메인(또는 그 상위 도메인)과 같은 공개 존을 고릅니다(DNS_GOOGLE_ZONE 으로 고정 가능).
* 적용: changes API 에 additions/deletions 를 한 번에 넘깁니다(같은 이름·형식의 기존 rrset 은 deletions 로 빼고 새 값으로 넣음 = UPSERT).
* TXT 값은 따옴표로 감싸고 255자 단위로 나눕니다(DKIM 공개키처럼 긴 값). MX 는 "우선순위 호스트." 형식, CNAME/MX 의 호스트는 끝에 점을 붙입니다.
*
* 설정(config.json): DNS_GOOGLE_CREDENTIALS(서비스 계정 JSON 전체, 비밀), DNS_GOOGLE_PROJECT(비우면 JSON 의 project_id), DNS_GOOGLE_ZONE(비우면 자동).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createSign } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
const tokenCache = new WeakMap(); // env.DB → { token, exp }
/** 서비스 계정 JSON 해석. 비어 있거나 모양이 틀리면 dns_not_configured. */
export function credentials(env) {
const raw = String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (!raw) throw dnsError('dns_not_configured', 'Google 서비스 계정 JSON 이 없습니다');
let json; try { json = JSON.parse(raw); } catch { throw dnsError('dns_bad_credentials', '서비스 계정 JSON 을 해석할 수 없습니다'); }
if (!json.client_email || !json.private_key) throw dnsError('dns_bad_credentials', '서비스 계정 JSON 에 client_email / private_key 가 없습니다');
const project = String(env.DNS_GOOGLE_PROJECT || json.project_id || '').trim();
if (!project) throw dnsError('dns_not_configured', '프로젝트 id 가 없습니다(DNS_GOOGLE_PROJECT)');
return { email: json.client_email, key: json.private_key, tokenUri: json.token_uri || 'https://oauth2.googleapis.com/token', project };
}
const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');
/** RS256 JWT 를 만들어 액세스 토큰을 받습니다(캐시). */
async function accessToken(env) {
const cached = tokenCache.get(env.DB || env); if (cached && cached.exp > Date.now() + 60000) return cached.token;
const c = credentials(env), now = Math.floor(Date.now() / 1000);
const unsigned = b64url({ alg: 'RS256', typ: 'JWT' }) + '.' + b64url({ iss: c.email, scope: SCOPE, aud: c.tokenUri, iat: now, exp: now + 3600 });
let signature; try { signature = createSign('RSA-SHA256').update(unsigned).sign(c.key, 'base64url'); } catch (e) { throw dnsError('dns_bad_credentials', '개인키로 서명하지 못했습니다: ' + e.message); }
const r = await (env.DNS_FETCH || fetch)(c.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: unsigned + '.' + signature }), signal: AbortSignal.timeout(20000) }).catch((e) => { throw dnsError('dns_unavailable', 'Google 인증 서버 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.access_token) throw dnsError(r.status === 400 || r.status === 401 ? 'dns_auth_failed' : 'dns_unavailable', `토큰 발급 실패 ${r.status} ${data.error_description || data.error || ''}`.trim());
tokenCache.set(env.DB || env, { token: data.access_token, exp: Date.now() + Number(data.expires_in || 3600) * 1000 });
return data.access_token;
}
/** Cloud DNS API 호출. 오류는 dns_* 코드로. */
async function api(env, path, method = 'GET', body) {
const token = await accessToken(env), c = credentials(env);
const r = await (env.DNS_FETCH || fetch)(`https://dns.googleapis.com/dns/v1/projects/${encodeURIComponent(c.project)}${path}`, { method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Cloud DNS 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw dnsError(r.status === 401 ? 'dns_auth_failed' : r.status === 403 ? 'dns_forbidden' : r.status === 404 ? 'dns_zone_not_found' : 'dns_unavailable', `Cloud DNS ${r.status} ${data.error?.message || ''}`.trim());
return data;
}
/**
* 도메인을 담당하는 존을 찾습니다. DNS_GOOGLE_ZONE 이 있으면 그 존을, 없으면 dnsName 이 도메인과 같거나 상위인 공개 존 중 가장 긴 것.
* @returns {Promise<{id:string, name:string, dnsName:string}>}
*/
export async function findZone(env, domain) {
const want = fqdn(domain);
if (env.DNS_GOOGLE_ZONE) { const z = await api(env, '/managedZones/' + encodeURIComponent(env.DNS_GOOGLE_ZONE)); return { id: z.name, name: z.name, dnsName: z.dnsName }; }
const list = (await api(env, '/managedZones')).managedZones || [];
const candidates = list.filter((z) => z.visibility !== 'private' && (want === z.dnsName || want.endsWith('.' + z.dnsName))).sort((a, b) => b.dnsName.length - a.dnsName.length);
if (!candidates.length) throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 존이 프로젝트에 없습니다`);
return { id: candidates[0].name, name: candidates[0].name, dnsName: candidates[0].dnsName };
}
/** 레코드 값 → Cloud DNS rrdatas 문자열. */
export function rrdata(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 특정 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const data = await api(env, `/managedZones/${encodeURIComponent(zone.id)}/rrsets?name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
return (data.rrsets || [])[0] || null;
}
/**
* 레코드들을 UPSERT 합니다(같은 이름·형식은 새 값으로 교체). 바뀐 것이 없으면 변경을 보내지 않습니다.
* @param {object[]} records { name, type, ttl, values[], priority? }
* @returns {Promise<{zone:string, changed:string[], unchanged:string[]}>}
*/
export async function upsert(env, zone, records) {
const additions = [], deletions = [], changed = [], unchanged = [];
for (const r of records) {
const want = { name: fqdn(r.name), type: r.type, ttl: Number(r.ttl || 300), rrdatas: rrdata(r) };
const cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.rrdatas].sort()) === JSON.stringify([...want.rrdatas].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
if (cur) deletions.push({ name: cur.name, type: cur.type, ttl: cur.ttl, rrdatas: cur.rrdatas });
additions.push(want); changed.push(`${r.type} ${r.name}`);
}
if (additions.length) await api(env, `/managedZones/${encodeURIComponent(zone.id)}/changes`, 'POST', { additions, deletions });
return { zone: zone.name, changed, unchanged };
}
/** 연결 확인: 토큰을 받고 존을 찾아 봅니다. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'google', zone: zone.name, dnsName: zone.dnsName, project: credentials(env).project }; }