Files
AriWeb-Suite/modules/arimail/server/dns/index.mjs
T

100 lines
7.0 KiB
JavaScript

/**
* DNS 모듈 선택과 "필요한 레코드" 계산(1.0.5, 요구사항 1·2).
*
* 역할
* - dnsProvider(env): 설정 DNS_PROVIDER(none | google | route53)에 따라 Google Cloud DNS / AWS Route 53 모듈을 고릅니다. none 이면 자동 적용 없이
* 레코드를 사용자에게 보여 주기만 합니다(요구사항 1: 인증 정보가 없으면 DNS 설정 정보를 제공).
* - requiredRecords(env, { resendDomain, existing }): 지금 설정(수·발신 방식, 도메인, Haraka 호스트 이름, DKIM, Resend 도메인의 요구 레코드)으로
* 도메인에 있어야 하는 레코드 목록을 만듭니다. 기존 SPF 가 있으면 우리 메커니즘만 끼워 넣어(mergeSpf) 다른 발송 서비스 설정을 깨지 않습니다.
* - applyRecords(env, records): 고른 모듈로 존을 찾고 UPSERT 합니다.
* - checkRecords(records): 공개 리졸버로 반영 여부를 확인합니다(common.mjs lookup).
*
* 레코드 모양: { name(FQDN, 끝 점 없음), type, ttl, values[], priority?, purpose(화면 설명), source('haraka'|'resend'), optional?(권장 사항 — 준비 완료 판정에서 제외, 자동 적용도 하지 않음) }
*/
import * as google from './google.mjs';
import * as route53 from './route53.mjs';
import { lookup, bare, dnsError } from './common.mjs';
import { inboundMode, outboundMode } from '../providers/index.mjs';
import { mailDomain, harakaHostname } from '../config.mjs';
import { resendDnsRecords } from '../providers/resend.mjs';
export const DNS_PROVIDERS = ['none', 'google', 'route53'];
const MODULES = { google, route53 };
/** 설정된 DNS 모듈 이름(모르는 값은 none). */
export const dnsProviderName = (env) => (DNS_PROVIDERS.includes(env.DNS_PROVIDER) ? env.DNS_PROVIDER : 'none');
/** 모듈 객체 또는 null(none). */
export const dnsProvider = (env) => MODULES[dnsProviderName(env)] || null;
/** 모듈이 쓸 인증 정보가 설정에 있는지(값의 유효성은 probe 로 확인). */
export function dnsConfigured(env) {
const p = dnsProviderName(env);
if (p === 'google') return !!String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (p === 'route53') return !!(String(env.DNS_AWS_ACCESS_KEY_ID || '').trim() && String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim());
return false;
}
/**
* 기존 SPF 레코드에 우리 메커니즘을 끼워 넣습니다. 예: "v=spf1 include:_spf.google.com ~all" + ["mx","a:mail.x.com"] → "v=spf1 include:_spf.google.com mx a:mail.x.com ~all".
* 이미 있는 메커니즘은 다시 넣지 않고, 끝의 all 지시(~all/-all/?all/+all)는 그대로 둡니다. 기존 값이 없으면 새로 만듭니다(기본 ~all).
*/
export function mergeSpf(existing, mechanisms) {
const cur = String(existing || '').trim();
if (!/^v=spf1\b/i.test(cur)) return `v=spf1 ${mechanisms.join(' ')} ~all`;
const parts = cur.split(/\s+/); const all = parts.findLast((p) => /^[-~?+]?all$/i.test(p));
const body = parts.filter((p) => !/^v=spf1$/i.test(p) && !/^[-~?+]?all$/i.test(p));
for (const m of mechanisms) if (!body.some((p) => p.toLowerCase() === m.toLowerCase())) body.push(m);
return ['v=spf1', ...body, all || '~all'].join(' ');
}
/**
* 도메인에 있어야 하는 레코드 목록.
* @param {object} [o]
* @param {object|null} [o.resendDomain] Resend 도메인 상세(records 포함). 수신 방식이 resend 가 아니면 그 안의 MX 는 뺍니다(루트 MX 는 한 가지만).
* @param {string[]} [o.existingSpf] 루트 TXT 중 기존 SPF 값(있으면 병합)
*/
export function requiredRecords(env, { resendDomain = null, existingSpf = null } = {}) {
const domain = mailDomain(env), host = bare(harakaHostname(env)), inbound = inboundMode(env), outbound = outboundMode(env), out = [];
if (inbound === 'haraka') out.push({ name: domain, type: 'MX', ttl: 300, values: [host], priority: 10, purpose: '받는 메일을 이 서버(Haraka)로', source: 'haraka' });
if (outbound === 'haraka') {
out.push({ name: domain, type: 'TXT', ttl: 300, values: [mergeSpf(existingSpf, ['mx', `a:${host}`])], purpose: 'SPF — 이 서버가 보낸 메일을 신뢰', source: 'haraka' });
if (env.HARAKA_DKIM_PUBLIC) out.push({ name: `${env.HARAKA_DKIM_SELECTOR || 'arimail'}._domainkey.${domain}`, type: 'TXT', ttl: 300, values: [`v=DKIM1; k=rsa; p=${env.HARAKA_DKIM_PUBLIC}`], purpose: 'DKIM 서명 공개키', source: 'haraka' });
out.push({ name: `_dmarc.${domain}`, type: 'TXT', ttl: 300, values: ['v=DMARC1; p=none; adkim=r; aspf=r'], purpose: 'DMARC 정책(권장, 자동 적용하지 않음)', source: 'haraka', optional: true });
}
if (resendDomain) for (const r of resendDnsRecords(resendDomain)) { if (r.type === 'MX' && inbound !== 'resend') continue; out.push(r); }
return out;
}
/**
* 모든(선택 아닌) 레코드의 반영 여부. 각 레코드에 check { ok, found[], error?, byProvider?, problem? } 를 붙여 돌려줍니다.
* 1.0.11: Resend 가 자기 레코드를 verified 라고 하면 우리 조회 값이 달라도 ok 로 봅니다(byProvider). Resend 가 받아들인 값(예: 예전 수신 MX
* inbound-smtp.<지역>.amazonaws.com)을 "틀렸다" 고 표시해 사용자가 멀쩡한 레코드를 바꾸게 하는 일을 막습니다. 반대로 Resend 가 failed 라고 해도
* 우리 조회가 맞으면 ok — Resend 의 레코드 상태는 마지막 검증 시점의 것이라 DNS 를 고친 뒤에는 오래된 값일 수 있습니다(재검증이 갱신).
* problem: 'wrong'(다른 값이 보임) / 'missing'(없음) — 화면이 "값을 바꾸세요" 와 "반영을 기다리세요" 를 구분하는 근거.
*/
export async function checkRecords(records, { resolver = null } = {}) {
const out = [];
for (const r of records) {
const check = await lookup(r, { resolver });
const byProvider = !check.ok && String(r.status || '') === 'verified';
out.push({ ...r, check: { ...check, ok: check.ok || byProvider, ...(byProvider ? { byProvider: true } : {}), problem: check.ok || byProvider ? null : check.found && check.found.length ? 'wrong' : 'missing' } });
}
return out;
}
/**
* 고른 DNS 모듈로 레코드를 적용합니다(optional 레코드는 뺌). 모듈이 없으면 dns_not_configured.
* @returns {Promise<{provider:string, zone:string, changed:string[], unchanged:string[]}>}
*/
export async function applyRecords(env, records) {
const mod = dnsProvider(env); if (!mod) throw dnsError('dns_not_configured', 'DNS 모듈이 설정되지 않았습니다(DNS_PROVIDER)');
const zone = await mod.findZone(env, mailDomain(env));
const result = await mod.upsert(env, zone, records.filter((r) => !r.optional));
return { provider: dnsProviderName(env), ...result };
}
/** 연결 확인(설정 화면 단추): 인증과 존 찾기. */
export async function probeDns(env) {
const mod = dnsProvider(env); if (!mod) return { ok: false, provider: 'none', error: 'dns_not_configured' };
try { return await mod.probe(env, mailDomain(env)); } catch (e) { return { ok: false, provider: dnsProviderName(env), error: e.message, detail: e.detail }; }
}