#!/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); });