158 lines
15 KiB
JavaScript
158 lines
15 KiB
JavaScript
'use strict';
|
|||
|
|
// ============================================================================
|
||
|
|
// 플랫폼 도메인 와일드카드 인증서 관리 (v1.4.14, 요구 9)
|
||
|
|
//
|
||
|
|
// 배경: 서브 도메인 계정의 사이트(mycompany.ibit.kr …)는 모두 플랫폼 도메인(ibit.kr) 아래에 있다. 사이트마다 인증서를 따로
|
||
|
|
// 받으면 Let's Encrypt 의 "등록 도메인당 주간 발급 한도" 에 걸리고, 인증서 수도 끝없이 늘어난다. 그래서 플랫폼 도메인에는
|
||
|
|
// 와일드카드 인증서(*.ibit.kr) 하나를 발급해 그 아래 모든 사이트의 HTTPS 를 함께 처리한다.
|
||
|
|
// 고객이 가진 도메인(example.com)은 사이트별 인증서(wh-<사이트ID>, webroot 방식)를 그대로 쓴다 — 두 종류는 분리해 관리한다.
|
||
|
|
//
|
||
|
|
// 와일드카드는 DNS-01 인증만 가능하다(_acme-challenge.<도메인> TXT 레코드). TXT 를 넣는 방법(dnsMode):
|
||
|
|
// openprovider : 플랫폼 도메인 DNS 가 Openprovider 에 있을 때(설정 › 도메인 서비스 › DNS 방식 '자동') — 훅이 API 로 TXT 를 넣고 지운다. 갱신도 자동.
|
||
|
|
// manual : 그 밖(ibit.kr 은 hosting.co.kr DNS) — 훅이 필요한 TXT 값을 대기 파일에 적고 공용 DNS 에 보일 때까지 기다린다.
|
||
|
|
// 운영자는 관리자 콘솔 › 설정 › SSL 에 뜨는 TXT 를 DNS 관리 화면에 넣는다. 갱신(약 60일마다)도 같은 절차가 필요하다.
|
||
|
|
//
|
||
|
|
// 동작: 배포 서버(type=local, 서비스 서버 자신)에서 certbot 을 --manual + 훅(scripts/acme-dns-hook.js)으로 실행한다.
|
||
|
|
// 인증서 이름 wh-wild-<플랫폼 도메인>, SAN 은 *.<플랫폼 도메인> 하나. 발급은 오래 걸릴 수 있어(수동 DNS 대기 최대 WAIT_MIN 분) 비동기로
|
||
|
|
// 돌리고 상태(진행 중·대기 TXT·마지막 결과)를 data/ssl-wildcard.json 과 /var/tmp/webmaker-acme/ 에서 읽어 화면에 보인다.
|
||
|
|
// 원격(ssh) 배포 서버는 지원하지 않는다 — 훅이 그 서버에서 실행되어 이 서비스의 DNS 접속 정보를 쓸 수 없기 때문.
|
||
|
|
//
|
||
|
|
// v1.5.17 (요구 1): 서버를 설치할 때 이미 *.<플랫폼 도메인> 인증서를 받아 둔 경우(이름이 wh-wild-… 가 아님)도 인지한다.
|
||
|
|
// deploy.findWildcardCert 가 SAN 으로 찾고(detect), 서비스 기동 때 autoApply 가 HTTP 로 배포돼 있던 서브 도메인 사이트의 nginx 설정을
|
||
|
|
// 그 인증서로 다시 써 재배포 없이 HTTPS 가 된다. 설치 점검표(lib/setupstate)와 scripts/ssl-wildcard.js 도 같은 detect 를 쓴다.
|
||
|
|
// ============================================================================
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const { spawn } = require('child_process');
|
||
|
|
const deploy = require('./deploy');
|
||
|
|
|
||
|
|
const ROOT = path.resolve(__dirname, '..');
|
||
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
|
||
|
|
const STATE_FILE = path.join(DATA_ROOT, 'ssl-wildcard.json');
|
||
|
|
/** 훅(root 로 실행)과 서비스(일반 사용자)가 함께 쓰는 대기 디렉터리 — 훅이 TXT 챌린지를 적고, 서비스가 읽어 화면에 보인다 */
|
||
|
|
const PENDING_DIR = process.env.WM_ACME_DIR || '/var/tmp/webmaker-acme';
|
||
|
|
const HOOK = path.join(ROOT, 'scripts', 'acme-dns-hook.js');
|
||
|
|
const WAIT_MIN = Math.max(5, parseInt(process.env.WM_ACME_WAIT_MIN || '40', 10)); // 수동 DNS 에서 TXT 를 기다리는 최대 시간(분)
|
||
|
|
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
||
|
|
|
||
|
|
let job = null; // 진행 중인 발급 { startedAt, targetId, domain, mode, child }
|
||
|
|
|
||
|
|
function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) || {}; } catch (_) { return {}; } }
|
||
|
|
function saveState(d) { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(d, null, 1), { mode: 0o600 }); } catch (_) {} }
|
||
|
|
|
||
|
|
/** 플랫폼 도메인 DNS 방식 → 챌린지 방식 */
|
||
|
|
// v1.5.36 (요구 5): 플랫폼 도메인의 DNS 공급자 id(openprovider·porkbun·route53·gcloud) — 훅이 그 공급자로 TXT 를 넣는다. 없으면 'manual'
|
||
|
|
function dnsMode() { try { const z = require('./domains').platformZone(); return z ? z.id : 'manual'; } catch (_) { return 'manual'; } }
|
||
|
|
/** 와일드카드 발급 대상 배포 서버: 지정 → 기본 배포 서버 */
|
||
|
|
function targetFor(targetId) { const dl = deploy.list(); const tid = targetId || dl.defaultId; return tid ? deploy.get(tid) : null; }
|
||
|
|
|
||
|
|
/**
|
||
|
|
* v1.5.17 와일드카드 인증서 감지 — 플랫폼 도메인과 기본(또는 지정) 배포 서버에서 *.<도메인> 인증서를 찾는다. 이름은 무엇이든 된다.
|
||
|
|
* @returns {Promise<{platformDomain:string, target:object|null, cert:object|null, reason:string}>} cert: deploy.findWildcardCert 결과(hasCert·certName·external·만료)
|
||
|
|
*/
|
||
|
|
async function detect(targetId) {
|
||
|
|
const pd = deploy.platformDomain(); const t = targetFor(targetId);
|
||
|
|
if (!pd) return { platformDomain: '', target: null, cert: null, reason: '플랫폼 도메인이 없습니다' };
|
||
|
|
if (!t) return { platformDomain: pd, target: null, cert: null, reason: '배포 서버가 없습니다' };
|
||
|
|
if (t.allowSudo !== true) return { platformDomain: pd, target: t, cert: null, reason: `배포 서버 "${t.name}" 에서 sudo 사용이 허용되지 않아 인증서 디렉터리를 읽을 수 없습니다` };
|
||
|
|
try { return { platformDomain: pd, target: t, cert: await deploy.findWildcardCert(t, pd), reason: '' }; } catch (e) { return { platformDomain: pd, target: t, cert: null, reason: e.message }; }
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* v1.5.17 기동 때 자동 적용: 와일드카드 인증서가 있는데(설치 때 미리 받아 둔 것 포함) 서브 도메인 주소가 HTTP 로 배포돼 있는 사이트의 vhost 를 다시 쓴다.
|
||
|
|
* 판단 근거는 마지막 성공 배포 기록의 domainUrls — 플랫폼 서브 도메인이 http:// 로 남아 있으면 그 사이트. 결과는 상태 파일 autoApply 에 남겨 관리자 콘솔 › 설정 › SSL 에 보인다.
|
||
|
|
* @returns {Promise<{checked:boolean, certName?:string, reconfigured:number, sites:string[], reason?:string}>}
|
||
|
|
*/
|
||
|
|
async function autoApply(targetId) {
|
||
|
|
const d = await detect(targetId);
|
||
|
|
if (!d.cert || !d.cert.hasCert) return { checked: !!d.cert, reconfigured: 0, sites: [], reason: d.reason || '와일드카드 인증서 없음' };
|
||
|
|
const store = require('./store'); const pd = d.platformDomain; const sites = [];
|
||
|
|
for (const s0 of store.list()) {
|
||
|
|
const site = store.load(s0.id); if (!site) continue;
|
||
|
|
const subs = (site.domains || []).filter((x) => deploy.isPlatformSub(x, pd)); if (!subs.length) continue;
|
||
|
|
const dep = [...(site.deployments || [])].reverse().find((x) => x.ok && x.kind !== 'unpublish'); if (!dep || dep.targetId !== d.target.id) continue;
|
||
|
|
if (!subs.some((x) => (dep.domainUrls || []).includes(`http://${x}/`))) continue; // 이미 HTTPS 로 배포된 사이트는 건너뜀
|
||
|
|
try { const r = await deploy.configureVhost(site.id, d.target.id, site.domains); if (r.ok) { sites.push(site.id); dep.domainUrls = (dep.domainUrls || []).map((u) => (subs.some((x) => u === `http://${x}/`) ? u.replace('http://', 'https://') : u)); dep.sslDetail = (dep.sslDetail ? dep.sslDetail + ' / ' : '') + `v1.5.17 기동 때 와일드카드 인증서(${d.cert.certName}) 자동 적용`; store.save(site); } } catch (_) {}
|
||
|
|
}
|
||
|
|
const st = loadState(); st.autoApply = { at: new Date().toISOString(), certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites }; saveState(st);
|
||
|
|
return { checked: true, certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites };
|
||
|
|
}
|
||
|
|
/** 훅이 남긴 대기 챌린지 (수동 DNS 모드) — { domain, name, value, at } 또는 null */
|
||
|
|
function pendingChallenge(domain) {
|
||
|
|
try {
|
||
|
|
const f = path.join(PENDING_DIR, `${domain}.json`); const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||
|
|
if (Date.now() - Date.parse(j.at) > (WAIT_MIN + 5) * 60000) return null; // 끝난 지 오래된 파일은 무시
|
||
|
|
return j;
|
||
|
|
} catch (_) { return null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 현재 상태 (관리자 콘솔 › 설정 › SSL).
|
||
|
|
* @returns {{ platformDomain, dnsMode, target, supported, reason, cert, pending, running, last, waitMinutes }}
|
||
|
|
*/
|
||
|
|
async function status(targetId) {
|
||
|
|
const pd = deploy.platformDomain(); const t = targetFor(targetId); const mode = dnsMode(); const st = loadState();
|
||
|
|
const out = { platformDomain: pd, dnsMode: mode, target: t ? { id: t.id, name: t.name, type: t.type } : null, supported: false, reason: '', certName: pd ? deploy.wildcardCertName(pd) : null, cert: null, pending: pd ? pendingChallenge(pd) : null, running: !!job, job: job ? { startedAt: job.startedAt, domain: job.domain, mode: job.mode } : null, last: st.last || null, waitMinutes: WAIT_MIN, email: !!(require('./settings').load().letsEncryptEmail) };
|
||
|
|
if (!pd) { out.reason = '플랫폼 도메인이 없습니다 — 관리자 콘솔 › 설정 › 도메인 서비스 에 플랫폼 도메인(예: ibit.kr)을 넣으세요'; return out; }
|
||
|
|
if (!t) { out.reason = '배포 서버가 없습니다 (관리자 콘솔 › 배포 서버)'; return out; }
|
||
|
|
if (t.type !== 'local') { out.reason = `와일드카드 인증서는 서비스 서버가 배포 서버인 경우(종류 local)에만 발급합니다. "${t.name}" 은(는) 원격(ssh) 서버라 DNS 인증 훅을 실행할 수 없습니다 — 그 서버의 서브 도메인은 사이트별 인증서로 발급됩니다`; }
|
||
|
|
else if (t.allowSudo !== true) out.reason = '배포 서버 설정에서 sudo 사용 허용이 꺼져 있어 certbot 을 실행할 수 없습니다';
|
||
|
|
else out.supported = true;
|
||
|
|
// v1.5.17: 서비스가 발급한 이름뿐 아니라 서버에 미리 받아 둔 *.<도메인> 인증서도 찾는다 — 있으면 그 이름이 certName 이 되고 external 로 표시
|
||
|
|
try { out.cert = await deploy.findWildcardCert(t, pd); out.certName = out.cert.certName; out.external = !!out.cert.external; } catch (e) { out.cert = { hasCert: false, error: e.message }; }
|
||
|
|
out.autoApply = st.autoApply || null;
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 와일드카드 인증서 발급·갱신 시작 (비동기). 이미 진행 중이면 409.
|
||
|
|
* certbot 은 --keep-until-expiring 이라 만료가 멀면 그대로 두고 성공으로 끝난다(갱신 강제는 force=true).
|
||
|
|
*/
|
||
|
|
async function issue(targetId, { force = false } = {}) {
|
||
|
|
if (job) throw httpError('와일드카드 인증서 발급이 이미 진행 중입니다', 409);
|
||
|
|
const s = await status(targetId); if (!s.supported) throw httpError(s.reason || '발급할 수 없습니다', 400);
|
||
|
|
// v1.5.17: 다른 절차로 받아 둔 와일드카드 인증서가 이미 유효하면 새로 발급하지 않는다(서브 도메인에 자동 적용 중). 갱신은 그 인증서를 받은 방법(certbot renew 타이머)대로.
|
||
|
|
if (!force && s.cert && s.cert.hasCert && s.external && !(s.cert.daysLeft != null && s.cert.daysLeft < 20)) throw httpError(`서버에 이미 와일드카드 인증서가 있습니다 (이름 ${s.cert.certName}, ${(s.cert.sans || []).join(', ')}, 만료 ${s.cert.expires}) — 서브 도메인 사이트에 자동 적용됩니다. 이 서비스 이름(${deploy.wildcardCertName(s.platformDomain)})으로 따로 발급하려면 강제 갱신(force)을 쓰세요`, 409);
|
||
|
|
s.certName = deploy.wildcardCertName(s.platformDomain); // 발급은 늘 서비스 이름으로
|
||
|
|
const t = targetFor(targetId); const pd = s.platformDomain; const mode = s.dnsMode;
|
||
|
|
const email = require('./settings').load().letsEncryptEmail || '';
|
||
|
|
try { fs.mkdirSync(PENDING_DIR, { recursive: true, mode: 0o1777 }); fs.chmodSync(PENDING_DIR, 0o1777); } catch (_) {}
|
||
|
|
try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {}
|
||
|
|
const node = process.execPath;
|
||
|
|
// 훅에 넘길 환경: DATA_ROOT(접속 정보·설정), 방식, 대기 시간. sudo 는 환경을 지우므로 env 로 명시한다
|
||
|
|
const envArgs = [`DATA_ROOT=${DATA_ROOT}`, `WM_ACME_MODE=${mode}`, `WM_ACME_DIR=${PENDING_DIR}`, `WM_ACME_WAIT_MIN=${WAIT_MIN}`, `WM_ACME_ZONE=${pd}`, `PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`];
|
||
|
|
const args = ['-n', 'env', ...envArgs, 'certbot', 'certonly', '--manual', '--preferred-challenges', 'dns', '--manual-auth-hook', `${node} ${HOOK} auth`, '--manual-cleanup-hook', `${node} ${HOOK} cleanup`, '--non-interactive', '--agree-tos', ...(email ? ['-m', email] : ['--register-unsafely-without-email']), '--cert-name', s.certName, '--expand', force ? '--force-renewal' : '--keep-until-expiring', '-d', `*.${pd}`, '--deploy-hook', 'systemctl reload nginx || nginx -s reload'];
|
||
|
|
const startedAt = new Date().toISOString();
|
||
|
|
const child = spawn('sudo', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
|
job = { startedAt, targetId: t.id, domain: pd, mode, child };
|
||
|
|
let out = '', err = '';
|
||
|
|
child.stdout.on('data', (d) => { out += d; if (out.length > 20000) out = out.slice(-20000); });
|
||
|
|
child.stderr.on('data', (d) => { err += d; if (err.length > 20000) err = err.slice(-20000); });
|
||
|
|
const timer = setTimeout(() => { try { child.kill('SIGTERM'); } catch (_) {} }, (WAIT_MIN + 10) * 60000);
|
||
|
|
child.on('close', async (code) => {
|
||
|
|
clearTimeout(timer); job = null;
|
||
|
|
const all = `${out}\n${err}`; const ok = code === 0 && !/error|failed|problem/i.test(err.split('\n').filter((l) => !/^Saving debug log/.test(l)).join('\n')) || /Successfully received certificate|Certificate not yet due for renewal|not yet due/.test(all);
|
||
|
|
const summary = ok ? (/not yet due/.test(all) ? '기존 인증서가 아직 유효해 그대로 둠' : '발급·갱신 완료') : (all.match(/(?:Error|Problem|Detail|Timeout|too many|rate|unauthorized|Hook)[^\n]{0,300}/gi) || [all.trim().split('\n').slice(-3).join(' ')]).slice(0, 3).join(' / ').slice(0, 600);
|
||
|
|
const st = loadState(); st.last = { at: new Date().toISOString(), ok, detail: summary, domain: pd, mode, code }; saveState(st);
|
||
|
|
try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {}
|
||
|
|
// 발급이 끝났으면 이 플랫폼의 서브 도메인을 쓰는 사이트들의 nginx 설정을 와일드카드 인증서로 다시 쓴다
|
||
|
|
if (ok) { try { await reconfigureSites(t.id, pd); } catch (_) {} }
|
||
|
|
});
|
||
|
|
return { started: true, startedAt, mode, certName: s.certName, waitMinutes: WAIT_MIN };
|
||
|
|
}
|
||
|
|
/** 발급 뒤: 플랫폼 서브 도메인이 연결된 사이트들의 vhost 를 다시 구성 (HTTP → 와일드카드 HTTPS) */
|
||
|
|
async function reconfigureSites(targetId, pd) {
|
||
|
|
const store = require('./store'); let n = 0;
|
||
|
|
for (const s0 of store.list()) {
|
||
|
|
const site = store.load(s0.id); if (!site || !(site.domains || []).some((d) => deploy.isPlatformSub(d, pd))) continue;
|
||
|
|
const dep = (site.deployments || []).filter((d) => d.ok).slice(-1)[0]; if (!dep || dep.targetId !== targetId) continue;
|
||
|
|
try { await deploy.configureVhost(site.id, targetId, site.domains); n += 1; } catch (_) {}
|
||
|
|
}
|
||
|
|
const st = loadState(); if (st.last) { st.last.reconfigured = n; saveState(st); }
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
/** 진행 중 발급 취소 */
|
||
|
|
function cancel() { if (!job) return false; try { job.child.kill('SIGTERM'); } catch (_) {} return true; }
|
||
|
|
|
||
|
|
function lastState() { return loadState(); } // v1.6.31 (요구 20) 운영 복구 목록용 — 마지막 발급 결과
|
||
|
|
module.exports = { lastState, status, issue, cancel, detect, autoApply, pendingChallenge, dnsMode, PENDING_DIR, WAIT_MIN };
|