78 lines
5.5 KiB
JavaScript
78 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
'use strict';
|
||
|
|
// ============================================================================
|
||
|
|
// certbot DNS-01 훅 (v1.4.14, 요구 9) — 와일드카드 인증서(*.플랫폼 도메인) 발급에 쓴다.
|
||
|
|
//
|
||
|
|
// certbot 이 --manual-auth-hook / --manual-cleanup-hook 으로 이 스크립트를 부른다 (sudo 로 실행되므로 root).
|
||
|
|
// 인자: auth | cleanup
|
||
|
|
// 환경(certbot 이 넣음): CERTBOT_DOMAIN(인증할 도메인, 와일드카드는 '*' 없이), CERTBOT_VALIDATION(TXT 값), CERTBOT_REMAINING_CHALLENGES
|
||
|
|
// 환경(lib/ssl.js 가 넣음): WM_ACME_MODE(openprovider|manual), WM_ACME_DIR(대기 파일 폴더), WM_ACME_WAIT_MIN(수동 대기 분), WM_ACME_ZONE(플랫폼 도메인), DATA_ROOT
|
||
|
|
//
|
||
|
|
// 방식
|
||
|
|
// openprovider : lib/openprovider 로 영역(WM_ACME_ZONE)에 _acme-challenge TXT 를 넣고(cleanup 때 지움), 공용 DNS 에 보일 때까지 최대 5분 기다린다.
|
||
|
|
// manual : 운영자가 DNS 관리 화면에 넣어야 하므로, 필요한 TXT 를 WM_ACME_DIR/<도메인>.json 에 적어 두고(관리자 콘솔이 보여 줌)
|
||
|
|
// 공용 DNS 에 그 값이 보일 때까지 최대 WM_ACME_WAIT_MIN 분 기다린다. 시간 안에 안 보이면 1 로 끝나 certbot 이 실패한다.
|
||
|
|
// 출력은 certbot 로그(/var/log/letsencrypt)에 남는다. 이 파일은 서비스 코드(lib/*)를 그대로 쓰므로 서비스와 같은 폴더에 있어야 한다.
|
||
|
|
// ============================================================================
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const dns = require('dns');
|
||
|
|
|
||
|
|
const action = process.argv[2];
|
||
|
|
const domain = String(process.env.CERTBOT_DOMAIN || '').toLowerCase().replace(/^\*\./, '');
|
||
|
|
const value = process.env.CERTBOT_VALIDATION || '';
|
||
|
|
// v1.5.36 (요구 5): 방식은 DNS 공급자 id(openprovider·porkbun·route53·gcloud) 또는 manual — lib/dnszone 공통 창구로 TXT 를 넣고 지운다
|
||
|
|
const AUTO_MODES = ['openprovider', 'porkbun', 'route53', 'gcloud'];
|
||
|
|
const mode = AUTO_MODES.includes(process.env.WM_ACME_MODE) ? process.env.WM_ACME_MODE : 'manual';
|
||
|
|
const dir = process.env.WM_ACME_DIR || '/var/tmp/webmaker-acme';
|
||
|
|
const zone = (process.env.WM_ACME_ZONE || domain).toLowerCase();
|
||
|
|
const waitMin = Math.max(1, parseInt(process.env.WM_ACME_WAIT_MIN || '40', 10));
|
||
|
|
const name = `_acme-challenge.${domain}`;
|
||
|
|
const rel = name === zone ? '' : (name.endsWith('.' + zone) ? name.slice(0, -(zone.length + 1)) : name); // 영역 기준 상대 이름
|
||
|
|
const log = (m) => process.stdout.write(`[acme-hook ${action} ${mode}] ${m}\n`);
|
||
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
|
|
||
|
|
/** 공용 DNS(1.1.1.1·8.8.8.8)에 TXT 값이 보이는지 */
|
||
|
|
async function visible() {
|
||
|
|
const r = new dns.promises.Resolver({ timeout: 4000, tries: 1 }); r.setServers(['1.1.1.1', '8.8.8.8']);
|
||
|
|
try { return (await r.resolveTxt(name)).some((t) => t.join('') === value); } catch (_) { return false; }
|
||
|
|
}
|
||
|
|
async function waitVisible(maxMs, stepMs) {
|
||
|
|
const until = Date.now() + maxMs; let n = 0;
|
||
|
|
while (Date.now() < until) { if (await visible()) { log(`TXT 확인됨 (${n}회 조회)`); return true; } n += 1; await sleep(stepMs); if (n % 4 === 0) log(`TXT 대기 중… ${Math.round((until - Date.now()) / 60000)}분 남음`); }
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
async function main() {
|
||
|
|
if (!domain || (action === 'auth' && !value)) { log('CERTBOT_DOMAIN/CERTBOT_VALIDATION 이 없습니다'); process.exit(1); }
|
||
|
|
const pendingFile = path.join(dir, `${zone}.json`);
|
||
|
|
if (mode !== 'manual') {
|
||
|
|
const dz = require(path.join(__dirname, '..', 'lib', 'dnszone'));
|
||
|
|
if (action === 'auth') {
|
||
|
|
log(`${dz.LABELS[mode] || mode} 영역 ${zone} 에 TXT ${name} 추가`);
|
||
|
|
await dz.addRecords(zone, [{ name: rel, type: 'TXT', value, ttl: 300 }], 'certbot-hook', mode);
|
||
|
|
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(pendingFile, JSON.stringify({ domain: zone, name, value, mode, at: new Date().toISOString(), auto: true }), { mode: 0o644 }); } catch (_) {}
|
||
|
|
if (!(await waitVisible(5 * 60000, 10000))) log('5분 안에 공용 DNS 에 보이지 않았지만 계속 진행합니다 (Let\'s Encrypt 가 권한 서버에 직접 묻습니다)');
|
||
|
|
// Let's Encrypt 는 권한 네임서버에 직접 묻는다 — 레코드 전파를 조금 더 기다린다
|
||
|
|
await sleep(20000);
|
||
|
|
} else {
|
||
|
|
log(`TXT ${name} 삭제`);
|
||
|
|
try { await dz.removeRecords(zone, [{ name: rel, type: 'TXT', value, ttl: 300 }], 'certbot-hook', mode); } catch (e) { log('삭제 실패(무시): ' + e.message); }
|
||
|
|
try { fs.unlinkSync(pendingFile); } catch (_) {}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// 수동 DNS: 운영자가 넣을 TXT 를 적어 두고 보일 때까지 기다린다
|
||
|
|
if (action === 'auth') {
|
||
|
|
fs.mkdirSync(dir, { recursive: true });
|
||
|
|
fs.writeFileSync(pendingFile, JSON.stringify({ domain: zone, name, value, mode, at: new Date().toISOString(), waitMinutes: waitMin }), { mode: 0o644 });
|
||
|
|
log(`DNS 에 TXT 레코드를 넣어 주세요: ${name} = ${value} (최대 ${waitMin}분 대기, 관리자 콘솔 › 설정 › SSL 에도 표시됨)`);
|
||
|
|
const ok = await waitVisible(waitMin * 60000, 15000);
|
||
|
|
if (!ok) { log(`${waitMin}분 안에 TXT 가 보이지 않아 중단합니다`); process.exit(1); }
|
||
|
|
await sleep(15000); // 권한 서버 전파 여유
|
||
|
|
} else {
|
||
|
|
try { fs.unlinkSync(pendingFile); } catch (_) {}
|
||
|
|
log(`인증이 끝났습니다. DNS 의 TXT ${name} 는 지워도 됩니다 (다음 갱신 때 새 값이 필요합니다)`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
main().catch((e) => { log('오류: ' + (e && e.message || e)); process.exit(1); });
|