Files
AriWeb-Suite/modules/webmaker/lib/deploy.js
T

561 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 배포 서버(호스팅 서버) 관리와 사이트 배포.
// - 종류: local(이 서버의 디렉터리) | ssh(원격 서버, tar-over-ssh 로 전송 후 원자적 교체)
// - 설정: data/deploy-targets.json, 개인키: data/deploy-keys/<id> (0600)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn, execFile, execFileSync } = require('child_process');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'deploy-targets.json');
const KEYS = path.join(DATA_ROOT, 'deploy-keys');
fs.mkdirSync(KEYS, { recursive: true, mode: 0o700 });
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { defaultId: null, targets: [] }; } }
function save(d) { const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); fs.renameSync(tmp, FILE); try { fs.chmodSync(FILE, 0o600); } catch (_) {} }
function keyPath(id) { return path.join(KEYS, id); }
function pub(t, d) { return { ...t, hasKey: fs.existsSync(keyPath(t.id)), publicKey: fs.existsSync(keyPath(t.id) + '.pub') ? fs.readFileSync(keyPath(t.id) + '.pub', 'utf8').trim() : null, isDefault: d.defaultId === t.id }; }
function list() { const d = load(); return { defaultId: d.defaultId, targets: d.targets.map((t) => pub(t, d)) }; }
function get(id) { const d = load(); const t = d.targets.find((x) => x.id === id); return t ? pub(t, d) : null; }
function siteUrlOn(t, siteId) { return t.publicBaseUrl ? `${String(t.publicBaseUrl).replace(/\/$/, '')}/${siteId}/` : null; }
function upsert(input) {
const d = load(); const id = input.id || crypto.randomBytes(6).toString('hex');
const t = d.targets.find((x) => x.id === id) || { id, createdAt: new Date().toISOString() };
const type = input.type === 'local' ? 'local' : 'ssh';
const name = String(input.name || '').trim(); if (!name) throw httpError('이름을 입력하세요');
const baseDir = String(input.baseDir || '').trim(); if (!baseDir.startsWith('/')) throw httpError('사이트 저장 경로는 / 로 시작하는 절대 경로여야 합니다');
const publicBaseUrl = String(input.publicBaseUrl || '').trim(); if (publicBaseUrl && !/^https?:\/\//.test(publicBaseUrl)) throw httpError('공개 URL 을 입력할 경우 http:// 또는 https:// 로 시작해야 합니다');
Object.assign(t, { name, type, baseDir, publicBaseUrl, note: String(input.note || ''), manageVhost: input.manageVhost === undefined ? (t.manageVhost !== undefined ? t.manageVhost : true) : !!(input.manageVhost === true || input.manageVhost === 'true' || input.manageVhost === 'on'), enableSsl: input.enableSsl === undefined ? (t.enableSsl !== undefined ? t.enableSsl : true) : !!(input.enableSsl === true || input.enableSsl === 'true' || input.enableSsl === 'on'), allowSudo: input.allowSudo === undefined ? (t.allowSudo !== undefined ? t.allowSudo : null) : !!(input.allowSudo === true || input.allowSudo === 'true' || input.allowSudo === 'on'), updatedAt: new Date().toISOString() });
if (type === 'ssh') {
t.host = String(input.host || '').trim(); if (!t.host) throw httpError('서버 주소(host)를 입력하세요');
t.port = parseInt(input.port || 22, 10) || 22; t.user = String(input.user || 'root').trim() || 'root';
if (typeof input.privateKey === 'string' && input.privateKey.trim()) {
const k = input.privateKey.replace(/\r/g, '').trim() + '\n'; if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(k)) throw httpError('개인키 형식이 아닙니다 (-----BEGIN ... PRIVATE KEY-----)');
fs.writeFileSync(keyPath(id), k, { mode: 0o600 }); try { fs.chmodSync(keyPath(id), 0o600); } catch (_) {}
try { fs.unlinkSync(keyPath(id) + '.pub'); } catch (_) {}
try { const out = execFileSync('ssh-keygen', ['-y', '-f', keyPath(id)], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); fs.writeFileSync(keyPath(id) + '.pub', out + '\n'); } catch (_) {}
}
}
if (!d.targets.some((x) => x.id === id)) d.targets.push(t);
if (!d.defaultId) d.defaultId = id;
save(d); return pub(t, d);
}
function remove(id) { const d = load(); const i = d.targets.findIndex((x) => x.id === id); if (i < 0) throw httpError('배포 서버가 없습니다', 404); d.targets.splice(i, 1); if (d.defaultId === id) d.defaultId = d.targets[0] ? d.targets[0].id : null; save(d); for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} } return list(); }
function setDefault(id) { const d = load(); if (!d.targets.some((x) => x.id === id)) throw httpError('배포 서버가 없습니다', 404); d.defaultId = id; save(d); return list(); }
function generateKey(id) {
return new Promise((resolve, reject) => {
const d = load(); const t = d.targets.find((x) => x.id === id); if (!t) return reject(httpError('배포 서버가 없습니다', 404));
for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} }
execFile('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `webhosting-deploy-${t.name}`, '-f', keyPath(id)], (err) => { if (err) return reject(new Error('키 생성 실패: ' + err.message)); try { fs.chmodSync(keyPath(id), 0o600); } catch (_) {} resolve(pub(t, d)); });
});
}
function sshArgs(t) { return ['-i', keyPath(t.id), '-p', String(t.port || 22), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=12', '-o', 'IdentitiesOnly=yes', `${t.user}@${t.host}`]; }
/**
* 외부 명령 실행 (ssh·tar·bash). opts.stdin 으로 넘긴 스트림을 자식의 표준 입력에 흘려 넣는다.
* v1.5.13: 자식이 입력을 다 읽기 전에 끝나면(ssh 접속 실패, 스크립트가 바로 exit 등) 자식 stdin 소켓에 EPIPE 'error' 가 난다.
* 이 소켓에 처리기가 없어 프로세스 전체가 죽었다(2026-10-14 11:27 UTC webmaker.ibit.kr, 발행 중 `write EPIPE` 로 서비스 재시작).
* 이제 stdin 양쪽의 오류를 받아 두었다가 자식의 종료 코드·stderr 와 함께 결과로 돌려준다 — 실패 원인은 호출한 쪽의 안내 문구로 나간다.
* @param {string} cmd @param {string[]} args
* @param {{stdin?:import('stream').Readable, timeout?:number, raw?:boolean}} opts
* @returns {Promise<{code:number, out:string, err:string}>}
*/
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: [opts.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '', err = '', stdinErr = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { err += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), opts.timeout || 120000);
// opts.raw: 출력을 다듬지 않는다 — 접속 로그 읽기는 마지막 줄바꿈이 "완전한 줄" 의 표시라 지우면 마지막 기록을 버리게 된다 (v1.3.17 B10)
child.on('close', (code) => { clearTimeout(timer); const e = err.trim() || (stdinErr ? `입력 전송 중단 (${stdinErr}) — 상대가 입력을 받기 전에 끝났습니다` : ''); resolve({ code, out: opts.raw ? out : out.trim(), err: e }); });
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: '', err: e.message }); });
if (opts.stdin) {
child.stdin.on('error', (e) => { stdinErr = e.code || e.message; }); // EPIPE 등 — 프로세스를 죽이지 않고 결과에 남긴다
opts.stdin.on('error', (e) => { stdinErr = stdinErr || (e.code || e.message); try { child.stdin.end(); } catch (_) {} });
opts.stdin.pipe(child.stdin);
}
});
}
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
/** 연결/쓰기 권한 테스트 */
async function test(id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const started = Date.now();
if (t.type === 'local') {
try { fs.mkdirSync(t.baseDir, { recursive: true }); fs.accessSync(t.baseDir, fs.constants.W_OK); const cap = await vhostCapability(t); return record(id, { ok: true, detail: `로컬 경로 ${t.baseDir} 쓰기 가능 · ${capText(cap)}`, ms: Date.now() - started }); }
catch (e) { return record(id, { ok: false, detail: `로컬 경로 접근 실패: ${e.message}`, ms: Date.now() - started }); }
}
if (!t.hasKey) return record(id, { ok: false, detail: 'SSH 키가 없습니다. 키를 생성하거나 붙여넣으세요.', ms: 0 });
// 단계별 진단: 접속 → 사용자 → 경로 존재/생성 → 쓰기 권한 → tar
const script = `D=${shq(t.baseDir)}; echo "USER=$(id -un)"; echo "HOST=$(uname -n)";
if [ -d "$D" ]; then echo "DIR=exists"; else if mkdir -p "$D" 2>/tmp/.wh_err; then echo "DIR=created"; else echo "DIR=mkdir_failed $(cat /tmp/.wh_err 2>/dev/null)"; fi; fi;
if [ -d "$D" ]; then echo "OWNER=$(stat -c %U:%G "$D" 2>/dev/null || ls -ld "$D" | awk '{print $3":"$4}')"; echo "PERM=$(stat -c %a "$D" 2>/dev/null)"; if [ -w "$D" ]; then echo "WRITE=ok"; else echo "WRITE=denied"; fi; fi;
command -v tar >/dev/null 2>&1 && echo "TAR=ok" || echo "TAR=missing"; echo "END"`;
const r = await run('ssh', [...sshArgs(t), script], { timeout: 30000 });
const kv = {}; for (const line of r.out.split('\n')) { const m = line.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (!/END/.test(r.out)) {
const err = (r.err || r.out || '').split('\n').filter(Boolean).slice(-2).join(' ');
const hint = /Permission denied \(publickey/.test(err) ? ' → 배포 서버의 ~/.ssh/authorized_keys 에 공개키가 등록되어 있는지, 사용자 이름이 맞는지 확인하세요.' : /Connection (refused|timed out)|No route|Could not resolve/.test(err) ? ' → 주소·포트와 방화벽을 확인하세요.' : /Host key verification failed|REMOTE HOST IDENTIFICATION/.test(err) ? ' → 서버 키가 바뀌었습니다. 서비스 서버의 ~/.ssh/known_hosts 에서 해당 호스트 항목을 지우세요.' : '';
return record(id, { ok: false, detail: `접속 실패: ${err || `exit ${r.code}`}${hint}`, ms: Date.now() - started });
}
const problems = [];
if (kv.DIR && kv.DIR.startsWith('mkdir_failed')) problems.push(`저장 경로 ${t.baseDir} 를 만들 수 없습니다 (${kv.DIR.replace('mkdir_failed', '').trim() || '권한 없음'}). 서버에서 미리 만들고 접속 사용자에게 쓰기 권한을 주세요: sudo mkdir -p ${t.baseDir} && sudo chown ${kv.USER || t.user} ${t.baseDir}`);
else if (kv.WRITE === 'denied') problems.push(`접속은 되지만 저장 경로 ${t.baseDir} 에 쓰기 권한이 없습니다 (소유자 ${kv.OWNER || '?'}, 권한 ${kv.PERM || '?'}, 접속 사용자 ${kv.USER || t.user}). 서버에서: sudo chown -R ${kv.USER || t.user} ${t.baseDir} (또는 chmod 로 쓰기 허용)`);
if (kv.TAR === 'missing') problems.push('배포 서버에 tar 가 없습니다. 설치하세요 (apt install tar).');
const ok = !problems.length;
let capLine = '';
if (ok && t.manageVhost !== false) { try { const cap = await vhostCapability(t); capLine = ` · ${capText(cap)}`; } catch (_) {} }
return record(id, { ok, detail: ok ? `연결 성공 — ${kv.USER}@${kv.HOST}, 저장 경로 ${t.baseDir} (${kv.DIR === 'created' ? '새로 생성' : '존재'}, 소유자 ${kv.OWNER}, 쓰기 가능), tar 있음${capLine}` : problems.join(' / '), ms: Date.now() - started });
}
function record(id, result) { const d = load(); const t = d.targets.find((x) => x.id === id); if (t) { t.lastTest = { ...result, at: new Date().toISOString() }; save(d); } return { ...result, at: new Date().toISOString() }; }
/** 사이트 디렉터리를 대상 서버의 baseDir/<siteId>/ 로 배포 (원자적 교체) */
async function deploySite(siteId, siteDir, id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const started = Date.now(); const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') {
if (path.resolve(dest) === path.resolve(siteDir)) return { ok: true, url: siteUrlOn(t, siteId), detail: '서비스 서버 자체(같은 경로) — 이미 배포된 상태', ms: 0, targetId: id, samePath: true };
// 배포 대상이 빌더의 작업 폴더 자체인데 다른 내용(예: 안내 페이지)을 올리려는 경우: 작업 파일이 지워지므로 거부한다 (v1.1.4)
const own = path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId));
if (path.resolve(dest) === own) return { ok: false, url: null, detail: '서비스 서버 자체 경로에는 작업 폴더 외의 내용을 올릴 수 없습니다 (작업 파일 보호)', ms: 0, targetId: id, samePath: true };
// v1.6.31 (요구 15, 검토 B): 임시 폴더에 복사 → 기존 폴더를 .prev 로 옮기고 새 폴더를 제자리에 → 실패하면 .prev 를 되돌린다. .prev 는 "이전 배포본으로 되돌리기" 에 쓴다
const tmp = dest + '.tmp-' + Date.now(); const prev = dest + '.prev';
try { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.cpSync(siteDir, tmp, { recursive: true, filter: (src) => !/[\\/]\.(git|claude|refs)(\b|$)/.test(src) }); fs.rmSync(prev, { recursive: true, force: true }); if (fs.existsSync(dest)) fs.renameSync(dest, prev); try { fs.renameSync(tmp, dest); } catch (e) { if (fs.existsSync(prev) && !fs.existsSync(dest)) fs.renameSync(prev, dest); throw e; } return { ok: true, url: siteUrlOn(t, siteId), detail: `로컬 복사 완료 → ${dest}${fs.existsSync(prev) ? ' (이전 배포본 보관)' : ''}`, ms: Date.now() - started, targetId: id, hasPrev: fs.existsSync(prev) }; }
catch (e) { fs.rmSync(tmp, { recursive: true, force: true }); return { ok: false, url: null, detail: `로컬 복사 실패: ${e.message}`, ms: Date.now() - started, targetId: id }; }
}
if (!t.hasKey) return { ok: false, url: null, detail: 'SSH 키가 없습니다', ms: 0, targetId: id };
const tar = spawn('tar', ['-C', siteDir, '--exclude=./.git', '--exclude=./.claude', '--exclude=./.refs', '-cf', '-', '.']);
// v1.6.31 (요구 15, 검토 B): 릴리스 디렉터리(<dest>.releases/<시각>)에 풀고 심볼릭 링크 <dest> 를 바꾼다(원자적 rename). 실패해도 현재 링크는 그대로라 사이트가 끊기지 않는다.
// 예전 방식(rm -rf 뒤 mv)으로 만들어진 실제 디렉터리는 첫 배포 때 릴리스로 옮겨 링크로 바꾼다. 이전 릴리스 2개를 남겨 "되돌리기" 에 쓴다.
const rel = new Date().toISOString().replace(/[:.]/g, '-');
const remote = `set -e; D=${shq(dest)}; R="$D.releases"; N="$R/${rel}"; mkdir -p "$N"; tar -C "$N" -xf -; chmod -R a+rX "$N"; if [ -d "$D" ] && [ ! -L "$D" ]; then mv "$D" "$R/legacy-${rel}"; fi; ln -sfn "$N" "$D.new" && mv -Tf "$D.new" "$D"; ls -1dt "$R"/*/ 2>/dev/null | tail -n +4 | xargs -r rm -rf; echo DEPLOYED`;
const r = await run('ssh', [...sshArgs(t), remote], { stdin: tar.stdout, timeout: 300000 });
const ok = r.code === 0 && /DEPLOYED/.test(r.out);
const errText = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' ');
return { ok, url: ok ? siteUrlOn(t, siteId) : null, detail: ok ? `전송 완료 → ${t.user}@${t.host}:${dest}` : `전송 실패: ${errText}${/Permission denied/.test(errText) ? ' → 저장 경로의 쓰기 권한을 확인하세요 (연결 테스트 참고)' : ''}`, ms: Date.now() - started, targetId: id };
}
/**
* v1.6.31 (요구 15): 이전 배포본으로 되돌리기. 원격은 릴리스 디렉터리 중 현재가 아닌 가장 최근 것으로 링크를 바꾸고, 로컬(다른 경로)은 .prev 와 자리를 맞바꾼다. 같은 경로(서비스 서버 자체)는 되돌릴 배포본이 따로 없다(버전 되돌리기 뒤 재배포).
* @returns {Promise<{ok:boolean, detail:string, release?:string}>}
*/
async function rollback(siteId, id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404); const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') {
if (isSamePathTarget(t, siteId)) return { ok: false, detail: '서비스 서버 자체(같은 경로) 배포는 보관된 이전 배포본이 없습니다 — 수정 탭의 버전 되돌리기로 내용을 되돌린 뒤 다시 발행하세요' };
const prev = dest + '.prev'; if (!fs.existsSync(prev)) return { ok: false, detail: '보관된 이전 배포본(.prev)이 없습니다' };
const tmp = dest + '.swap-' + Date.now(); try { fs.renameSync(dest, tmp); fs.renameSync(prev, dest); fs.renameSync(tmp, prev); } catch (e) { return { ok: false, detail: '되돌리기 실패: ' + e.message }; }
return { ok: true, detail: '이전 배포본으로 되돌렸습니다 (지금 것은 .prev 로 보관)' };
}
const script = `set -e; D=${shq(dest)}; R="$D.releases"; [ -L "$D" ] || { echo NO_LINK; exit 0; }; CUR=$(readlink -f "$D"); P=$(ls -1dt "$R"/*/ 2>/dev/null | sed 's#/$##' | grep -vx "$CUR" | head -1); [ -n "$P" ] || { echo NO_PREV; exit 0; }; ln -sfn "$P" "$D.new" && mv -Tf "$D.new" "$D"; echo "ROLLED=$P"`;
const r = await execOn(t, script, { timeout: 60000 }); const o = r.out || '';
if (/NO_LINK/.test(o)) return { ok: false, detail: '이 서버의 배포본은 아직 릴리스 방식(1.6.31)으로 전환되지 않았습니다 — 한 번 재배포한 뒤부터 되돌릴 수 있습니다' };
if (/NO_PREV/.test(o)) return { ok: false, detail: '보관된 이전 릴리스가 없습니다' };
const m = o.match(/ROLLED=(\S+)/); return m ? { ok: true, detail: `이전 릴리스로 되돌렸습니다: ${path.posix.basename(m[1])}`, release: m[1] } : { ok: false, detail: '되돌리기 실패: ' + (r.err || o).split('\n').slice(-2).join(' ') };
}
/** 같은 경로(서비스 서버 자체) 대상인지: 발행 취소를 파일 교체 대신 .unpublished 표식으로 처리해야 하는 경우 */
function isSamePathTarget(t, siteId) { return !!(t && t.type === 'local' && path.resolve(path.posix.join(t.baseDir, siteId)) === path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId))); }
/** 대상 서버에서 사이트 제거 */
async function removeSite(siteId, id) {
const t = get(id); if (!t) return { ok: false, detail: '배포 서버가 없습니다' };
const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') { if (path.resolve(dest) !== path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId))) fs.rmSync(dest, { recursive: true, force: true }); return { ok: true }; }
const r = await run('ssh', [...sshArgs(t), `rm -rf ${shq(dest)} && echo REMOVED`], { timeout: 60000 });
return { ok: r.code === 0, detail: r.err };
}
// ---------- 도메인 / vhost ----------
const DOMAIN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.[a-z0-9-]{1,63})+$/i;
function normalizeDomains(list) {
const out = []; for (const raw of Array.isArray(list) ? list : String(list || '').split(/[\s,]+/)) { const d = String(raw || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); if (!d) continue; if (!DOMAIN_RE.test(d)) throw httpError(`도메인 형식이 올바르지 않습니다: ${raw}`); if (!out.includes(d)) out.push(d); }
return out.slice(0, 20);
}
// 접속 통계용 로그 형식(http 컨텍스트). vhost 를 쓸 때 conf.d 에 함께 설치한다 (lib/stats.js 와 동일 형식)
const WMSTATS_FORMAT = "log_format wmstats '$msec|$remote_addr|$host|$request_method|$request_uri|$status|$request_length|$bytes_sent|$http_referer|$http_user_agent';";
/** 사이트별 접속 로그 경로 (원격 배포 서버). 수집기가 sudo -n 으로 읽는다 */
function statsLogPath(siteId) { return `/var/log/nginx/wh-${siteId}.access.log`; }
/**
* nginx 설정 본문.
* v1.4.14 (요구 9): 도메인 묶음(groups) 마다 server 블록을 만든다. 묶음은 configureVhost 가 나눈다 —
* platform: 플랫폼 도메인의 서브 도메인(mycompany.ibit.kr) → 와일드카드 인증서(wh-wild-<플랫폼 도메인>) 하나로 HTTPS
* own : 고객이 가진 도메인(example.com, www.example.com) → 그 사이트만의 인증서(wh-<사이트ID>, webroot 발급)
* 두 인증서를 분리하는 이유: 와일드카드는 운영자가 한 번 발급하면 모든 서브 도메인 사이트가 바로 HTTPS 가 되고(발급 한도 걱정 없음),
* 고객 도메인은 사이트 폐기 때 그 인증서만 지울 수 있다.
* @param {Array<{domains:string[], ssl:{certName,hasOptions,hasDhparam}|null}>|string[]} groups 묶음 목록 (옛 호출 호환: 도메인 배열 + ssl)
*/
function vhostConf(groups, root, ssl, siteId) {
if (Array.isArray(groups) && (!groups.length || typeof groups[0] === 'string')) groups = [{ domains: groups, ssl }]; // 1.4.5 이전 호출 형태
return `# generated by webhosting-agent (v1.4.14: ${groups.map((g) => `${g.domains.join(' ')} → ${g.ssl ? g.ssl.certName : 'http'}`).join(' | ')})\n` + groups.filter((g) => g.domains && g.domains.length).map((g) => vhostBlock(g.domains, root, g.ssl, siteId)).join('\n');
}
function vhostBlock(domains, root, ssl, siteId) {
const acme = ` location ^~ /.well-known/acme-challenge/ { root ${root}; default_type "text/plain"; }\n`;
const statsLog = siteId ? ` access_log ${statsLogPath(siteId)} wmstats;\n` : '';
const errPages = ` # 오류 페이지: 서버 종류·버전이나 다른 서비스의 내용이 드러나지 않도록 일반 안내로 대체\n server_tokens off;\n error_page 404 /__wh_404;\n error_page 403 =404 /__wh_404;\n error_page 500 502 503 504 /__wh_50x;\n location = /__wh_404 { internal; default_type text/html; return 404 '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>페이지를 찾을 수 없습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></main></body></html>'; }\n location = /__wh_50x { internal; default_type text/html; return 503 '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>일시적인 오류가 발생했습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>일시적인 오류가 발생했습니다</h1><p>잠시 후 다시 시도해 주세요.</p></main></body></html>'; }\n`;
// v1.5.27 (요구 4): 발행 취소 표식(.unpublished)이 작업 폴더에 있으면 어떤 경로든(/, /ko/ …) 안내 페이지(503). 1.5.19 까지는 서비스 공개 주소(/<ID>/)의 nginx 규칙에만
// 있어, 같은 경로 배포 서버의 도메인 주소(sample.ariweb.net/ko/)는 발행 취소 뒤에도 그대로 열렸다. 원격 서버는 파일 자체가 안내 페이지로 바뀌므로 표식이 없어 영향 없음.
const maint = ` location = /__wh_maint { internal; default_type text/html; add_header Cache-Control "no-store"; return 503 '${require('./maintpage').nginxString()}'; }\n`;
const common = ` root ${root};\n index index.html;\n charset utf-8;\n${statsLog}${errPages}${maint}${acme} location / { if (-f $document_root/.unpublished) { rewrite ^ /__wh_maint last; } try_files $uri $uri/ =404; }\n location ~ /\\. { return 404; }\n`;
if (!ssl) return `server {\n listen 80;\n listen [::]:80;\n server_name ${domains.join(' ')};\n${common}}\n`;
// v1.5.27 (요구 3): 인증서가 letsencrypt 가 아닌 곳(예: /etc/ssl/cloudflare/x.pem, Cloudflare 원본 인증서)에 있으면 ssl.certFile·keyFile 로 그 경로를 쓴다
const live = `/etc/letsencrypt/live/${ssl.certName}`; const certFile = ssl.certFile || `${live}/fullchain.pem`; const keyFile = ssl.keyFile || `${live}/privkey.pem`;
return `# ssl: ${ssl.certName}\nserver {\n listen 80;\n listen [::]:80;\n server_name ${domains.join(' ')};\n server_tokens off;\n${acme} location / { return 301 https://$host$request_uri; }\n}\nserver {\n listen 443 ssl http2;\n listen [::]:443 ssl http2;\n server_name ${domains.join(' ')};\n ssl_certificate ${certFile};\n ssl_certificate_key ${keyFile};\n${ssl.hasOptions ? ' include /etc/letsencrypt/options-ssl-nginx.conf;\n' : ' ssl_protocols TLSv1.2 TLSv1.3;\n ssl_prefer_server_ciphers off;\n'}${ssl.hasDhparam ? ' ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;\n' : ''}${common}}\n`;
}
/**
* v1.5.19 (요구 2): sudo 점검 셸 조각 — 안 되면 NO_SUDO 와 함께 sudo 의 오류 문장(SUDO_ERR=…)을 남기고, 되더라도 /etc/nginx 가 읽기 전용이면 RO_ETC 를 남긴다.
* 배경: webmaker.ariweb.net 의 서비스 유닛(다른 절차로 설치)에 NoNewPrivileges=yes 와 ProtectSystem=strict 가 있어 서비스 안에서는 sudo 가 "no new privileges" 로
* 막히고, 설령 되더라도 /etc/nginx 가 읽기 전용이다. 1.5.17 까지는 둘 다 "비밀번호 없는 sudo 권한이 없습니다 — sudoers 를 고치세요" 로만 안내해 원인을 찾을 수 없었다
* (2026-10-15 07:27 UTC sample.ariweb.net 첫 배포 실패). exitWord: 실패 때 실행할 명령(예: 'rm -f "$T"; exit 0').
*/
function sudoProbe(exitWord) { return `if ! sudo -n true 2>/tmp/wh-sudo.$$; then echo "NO_SUDO"; echo "SUDO_ERR=$(tr '\\n' ' ' </tmp/wh-sudo.$$ | cut -c1-300)"; rm -f /tmp/wh-sudo.$$; ${exitWord}; fi; rm -f /tmp/wh-sudo.$$; if [ -d /etc/nginx ] && ! sudo -n test -w /etc/nginx; then echo "RO_ETC"; fi`; }
/**
* sudo 실패·읽기 전용의 원인별 안내 문구 (vhost·인증서·기본 서버·연결 테스트·설치 점검표가 같이 쓴다).
* @param {string} out 스크립트 출력 (NO_SUDO / SUDO_ERR=… / RO_ETC)
* @returns {string} 원인과 고치는 방법. sudo 가 되고 쓰기도 되면 ''
*/
function sudoHint(out) {
const o = String(out || ''); const err = (o.match(/SUDO_ERR=(.*)/) || [, ''])[1].trim();
if (/NO_SUDO/.test(o)) {
if (/no new privileges/i.test(err)) return '서비스가 systemd 유닛의 NoNewPrivileges=yes 아래에서 실행되어 sudo 를 쓸 수 없습니다 (sudo: "no new privileges" flag is set). 서버에서 sudo node scripts/unit-check.js --fix 를 실행해 드롭인(NoNewPrivileges=no, ReadWritePaths 추가)을 만든 뒤 sudo systemctl daemon-reload && systemctl restart <유닛> — 또는 유닛 파일에서 NoNewPrivileges 를 지우고 ProtectSystem=strict 면 ReadWritePaths 에 /etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp 를 더한다 (v1.5.31)';
return `배포 서버에서 접속 사용자에게 비밀번호 없는 sudo 권한이 없어 nginx 설정을 쓸 수 없습니다${err ? ` (sudo: ${err})` : ''}. /etc/sudoers 에 "<user> ALL=(ALL) NOPASSWD: ALL" 또는 nginx 관련 명령을 허용하세요.`;
}
if (/RO_ETC/.test(o)) return '/etc/nginx 가 읽기 전용입니다 — 서비스 유닛의 ProtectSystem=strict(또는 full) 때문입니다. 서버에서 sudo node scripts/unit-check.js --fix 로 드롭인을 만들거나, 유닛의 ReadWritePaths 에 /etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp 를 더한(또는 ProtectSystem=yes) 뒤 daemon-reload·재시작하세요 (v1.5.31)';
return '';
}
/**
* v1.7.3 (요구 1): nginx 설정 검사가 "duplicate log_format wmstats" 로 실패한 서버의 자가 복구 조각 — 우리가 만든 conf.d/wmstats.conf 를 지우고 다시 검사한다.
* (2026-10-24 webmaker.ibit.kr: 운영자가 서버 이전 때 넣은 webmaker-log-format.conf 와 겹쳐 nginx -t 가 계속 실패 → 기본 오류 서버·vhost 가 모두 적용되지 않았다.)
* $ERRF 에 nginx -t 의 stderr 가 있어야 한다. 성공하면 WMSTATS_DUP_REMOVED 를 찍고 0 으로 끝난다.
*/
const WMSTATS_DEDUP = `if grep -q 'duplicate "log_format" name "wmstats"' "$ERRF" && [ -f /etc/nginx/conf.d/wmstats.conf ]; then sudo -n rm -f /etc/nginx/conf.d/wmstats.conf; echo "WMSTATS_DUP_REMOVED"; sudo -n nginx -t 2>"$ERRF"; else false; fi`;
/**
* v1.7.3: 설정을 바꾸지 않을 때("이미 같음")도 nginx 가 지금 깨진 상태면 고쳐 둔다 — vhost 파일은 있지만 reload 가 안 된 서버(중복 wmstats)를 재배포로 살리기 위함.
* sudo 가 안 되면 아무것도 하지 않는다(SKIP). 출력: NGINX_OK / NGINX_FIXED / NGINX_BROKEN <사유>
*/
function nginxRepairScript() {
return `if ! sudo -n true 2>/dev/null; then echo SKIP; exit 0; fi; ERRF=/tmp/wh-nginx-err.$$; if sudo -n nginx -t 2>"$ERRF"; then echo NGINX_OK; elif ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); echo NGINX_FIXED; else echo "NGINX_BROKEN $(tail -2 "$ERRF" | tr '\\n' ' ')"; fi; rm -f "$ERRF"`;
}
/** vhost 설정 스크립트: stdin 으로 설정 내용을 받는다. sudo -n 필요 */
function vhostScript(siteId) {
const name = `wh-${siteId}.conf`;
return `set -e; T=/tmp/${name}.$; cat > "$T";
${sudoProbe('rm -f "$T"; exit 0')}
if ! command -v nginx >/dev/null 2>&1 && [ ! -x /usr/sbin/nginx ]; then echo "NO_NGINX"; rm -f "$T"; exit 0; fi
# v1.7.3 (요구 1): wmstats 로그 형식이 다른 파일(운영자가 서버 이전 때 만든 conf.d/webmaker-log-format.conf 등)에 이미 있으면 우리 파일을 만들지 않는다 — 같은 이름을 두 번 정의하면 nginx -t 가 실패해 vhost 가 적용되지 않는다 (2026-10-24 webmaker.ibit.kr guide.ariweb.xyz)
if [ -d /etc/nginx/conf.d ] && [ ! -f /etc/nginx/conf.d/wmstats.conf ]; then if sudo -n nginx -T 2>/dev/null | grep -Eq '^\\s*log_format\\s+wmstats\\b'; then echo "WMSTATS_EXISTS"; else printf '%s\\n' ${shq(WMSTATS_FORMAT)} | sudo -n tee /etc/nginx/conf.d/wmstats.conf >/dev/null; echo "WMSTATS_WRITTEN"; fi; fi
if [ -d /etc/nginx/sites-available ]; then C=/etc/nginx/sites-available/${name}; sudo -n install -m 644 "$T" "$C"; sudo -n ln -sf "$C" /etc/nginx/sites-enabled/${name};
elif [ -d /etc/nginx/conf.d ]; then C=/etc/nginx/conf.d/${name}; sudo -n install -m 644 "$T" "$C";
else echo "NO_CONF_DIR"; rm -f "$T"; exit 0; fi
rm -f "$T"; echo "CONF=$C";
ERRF=/tmp/wh-nginx-err.$$
# v1.7.3: 검사 실패가 wmstats 중복(이전 버전이 만든 우리 파일 + 다른 파일의 같은 정의)이면 우리 파일을 지우고 다시 검사한다 — 다른 쪽 정의도 같은 형식(lib/stats.js 와 맞춤)이라 통계 수집은 그대로다
if sudo -n nginx -t 2>"$ERRF" || ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload) && echo "VHOST_OK"; else echo "NGINX_TEST_FAIL $(tail -2 "$ERRF" | tr '\n' ' ')"; fi; rm -f "$ERRF"`;
}
function removeVhostScript(siteId) { const name = `wh-${siteId}.conf`; return `if sudo -n true 2>/dev/null; then sudo -n rm -f /etc/nginx/sites-enabled/${name} /etc/nginx/sites-available/${name} /etc/nginx/conf.d/${name}; (sudo -n nginx -t >/dev/null 2>&1 && (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload)) || true; command -v certbot >/dev/null 2>&1 && sudo -n certbot delete --cert-name wh-${siteId} --non-interactive >/dev/null 2>&1; echo VHOST_REMOVED; else echo NO_SUDO; fi`; }
function vhostResult(out, err, domains) {
if (/VHOST_OK/.test(out)) return { ok: true, detail: `도메인 ${domains.join(', ')} 을(를) nginx 에 연결했습니다 (${(out.match(/CONF=(\S+)/) || [])[1] || ''})${/WMSTATS_DUP_REMOVED/.test(out) ? ' · 중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf)을 정리했습니다' : ''}` };
if (/NO_SUDO/.test(out)) return { ok: false, detail: sudoHint(out) };
if (/NO_NGINX/.test(out)) return { ok: false, detail: '배포 서버에 nginx 가 없습니다.' };
if (/NO_CONF_DIR/.test(out)) return { ok: false, detail: 'nginx 설정 디렉터리(/etc/nginx/sites-available 또는 conf.d)를 찾지 못했습니다.' };
if (/NGINX_TEST_FAIL/.test(out)) {
const msg = (out.match(/NGINX_TEST_FAIL (.*)/) || [, ''])[1];
// v1.7.3 (요구 1): 원인별 안내 — 같은 log_format 이름이 두 파일에 있으면 어느 파일인지 찾는 방법까지 적는다 (2026-10-24 webmaker.ibit.kr: 운영자가 만든 webmaker-log-format.conf 와 우리 wmstats.conf)
const hint = /duplicate "log_format" name "wmstats"/.test(msg) ? ' — 접속 통계 로그 형식(wmstats)이 nginx 설정의 두 파일에 정의되어 있습니다. 배포 서버에서 sudo grep -rn "log_format wmstats" /etc/nginx 로 중복 파일을 찾아 하나만 남기세요(webmaker 가 만든 파일은 /etc/nginx/conf.d/wmstats.conf). 다음 배포부터는 자동으로 정리합니다' : (/unknown directive/.test(msg) ? ' — 배포 서버의 nginx 가 이 지시어를 모릅니다(버전이 낮음). nginx -v 로 버전을 확인하세요' : '');
return { ok: false, detail: 'nginx 설정 검사 실패: ' + msg + hint };
}
if (/RO_ETC/.test(out) || /Read-only file system/i.test(err || '')) return { ok: false, detail: 'vhost 설정 실패: ' + (sudoHint('RO_ETC')) + (err ? ` (${err.split('\n').slice(-1)[0]})` : '') };
return { ok: false, detail: 'vhost 설정 실패: ' + (err || out || '알 수 없는 오류').split('\n').slice(-2).join(' ') };
}
const { Readable } = require('stream');
/** 와일드카드 인증서 이름 (플랫폼 도메인별) */
function wildcardCertName(platformDomain) { return `wh-wild-${String(platformDomain || '').toLowerCase()}`; }
/** 이름이 플랫폼 도메인의 "한 단계 아래" 서브 도메인인지 (mycompany.ibit.kr ○, a.b.ibit.kr ×, ibit.kr ×) — 와일드카드 *.ibit.kr 가 덮는 범위 */
function isPlatformSub(name, platformDomain) { if (!platformDomain) return false; const n = String(name || '').toLowerCase(); const p = String(platformDomain).toLowerCase(); return n.endsWith('.' + p) && !n.slice(0, -(p.length + 1)).includes('.'); }
/** 플랫폼 도메인 (관리자 설정, 없으면 서비스 주소에서 유도) — lib/domains 는 설정만 읽으므로 순환 참조가 없다 */
function platformDomain() { try { return require('./domains').config().platformDomain || ''; } catch (_) { return ''; } }
/** 대상 서버의 사이트 인증서 상태: {hasCert, sans[], expires, hasOptions, hasDhparam} */
async function certStatus(t, siteId) { return certStatusFor(t, `wh-${siteId}`); }
/** 대상 서버의 인증서 상태 (이름으로). v1.4.14: 와일드카드 인증서도 같은 방식으로 본다 */
async function certStatusFor(t, certName) {
const live = `/etc/letsencrypt/live/${certName}`;
const script = `if sudo -n test -f ${shq(live + '/fullchain.pem')} 2>/dev/null; then echo HAS_CERT; echo "SANS=$(sudo -n openssl x509 -in ${shq(live + '/fullchain.pem')} -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' ')"; echo "EXP=$(sudo -n openssl x509 -in ${shq(live + '/fullchain.pem')} -noout -enddate 2>/dev/null | cut -d= -f2)"; fi; [ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 30000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 40000 }) : { out: '' });
const o = r.out || '';
const expires = (o.match(/EXP=(.*)/) || [, ''])[1].trim(); const expMs = Date.parse(expires);
return { certName, hasCert: /HAS_CERT/.test(o), sans: ((o.match(/SANS=(.*)/) || [, ''])[1]).trim().split(/\s+/).filter(Boolean), expires, daysLeft: Number.isFinite(expMs) ? Math.floor((expMs - Date.now()) / 86400000) : null, hasOptions: /HAS_OPTIONS/.test(o), hasDhparam: /HAS_DHPARAM/.test(o) };
}
/**
* v1.5.17 (요구 1): 대상 서버에 있는 플랫폼 도메인 와일드카드 인증서 찾기.
* 1.5.16 까지는 서비스가 발급한 이름(wh-wild-<도메인>)만 봤다. 서버를 설치할 때 운영자가 이미 certbot 으로 *.<도메인> 인증서를 받아 둔 경우
* (이름이 다름 — 예: certbot certonly -d '*.example.com' → live/example.com)에는 그것을 못 보고 서브 도메인 사이트가 HTTP 로 열렸다.
* 이제 /etc/letsencrypt/live/<모든 이름>/fullchain.pem 의 SAN 을 보고 *.<플랫폼 도메인> 을 포함한 인증서를 고른다.
* 우선순위: 서비스가 발급한 wh-wild-<도메인> → 그 밖의 인증서 중 만료가 가장 먼 것.
* live/ 는 root 만 읽을 수 있어 sudo -n 으로 훑는다(sudo 가 안 되면 빈 결과 → HTTP).
* @returns {Promise<{certName:string, hasCert:boolean, external:boolean, sans:string[], expires:string, daysLeft:number|null, hasOptions:boolean, hasDhparam:boolean, candidates:Array<{name:string, sans:string[], expires:string}>}>}
* external: 서비스가 아니라 다른 절차로 발급된 인증서(이름이 wh-wild-… 가 아님)를 쓰고 있음
*/
async function findWildcardCert(t, pd) {
const want = `*.${String(pd || '').toLowerCase()}`; const own = wildcardCertName(pd);
// v1.5.27 (요구 3): letsencrypt 외의 인증서도 후보 — nginx 설정(sites-enabled·conf.d)이 참조하는 ssl_certificate 파일과 /etc/ssl 아래 .pem/.crt (예: Cloudflare 원본 인증서 *.example.com).
// 키 파일은 같은 폴더의 같은 이름 .key / privkey.pem / 이름.key 중 있는 것. 줄 형식 CERT|이름|SAN…|만료|인증서경로|키경로 (letsencrypt 는 경로를 비워 live/<이름> 규칙을 쓴다)
const inner = `for d in /etc/letsencrypt/live/*/; do f="$d/fullchain.pem"; [ -f "$f" ] || continue; n=$(basename "$d"); s=$(openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' '); e=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | cut -d= -f2); echo "CERT|$n|$s|$e||"; done
for f in $( { grep -hoE '^\\s*ssl_certificate\\s+[^;]+' /etc/nginx/sites-enabled/* /etc/nginx/conf.d/* 2>/dev/null | awk '{print $2}'; ls /etc/ssl/*/*.pem /etc/ssl/*/*.crt /etc/nginx/ssl/*.pem /etc/nginx/ssl/*.crt 2>/dev/null; } | grep -v '^/etc/letsencrypt/' | sort -u ); do [ -f "$f" ] || continue; s=$(openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' '); [ -n "$s" ] || continue; e=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | cut -d= -f2); b="\${f%.*}"; k=""; for c in "$b.key" "$(dirname "$f")/privkey.pem" "$b-key.pem" "\${b}.pem.key"; do [ -f "$c" ] && { k="$c"; break; }; done; echo "CERT|$(basename "$f")|$s|$e|$f|$k"; done`;
const script = `sudo -n bash -c ${shq(inner)} 2>/dev/null; [ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 40000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 50000 }) : { out: '' });
const o = r.out || '';
const certs = o.split('\n').filter((l) => l.startsWith('CERT|')).map((l) => { const [, name, sans, expires, certFile, keyFile] = l.split('|'); return { name, sans: (sans || '').trim().split(/\s+/).filter(Boolean).map((x) => x.toLowerCase()), expires: (expires || '').trim(), certFile: (certFile || '').trim(), keyFile: (keyFile || '').trim() }; });
// 키 파일을 못 찾은 외부 인증서는 nginx 가 쓸 수 없으므로 뺀다. 같은 파일이 여러 번 나오면 하나만
const seen = new Set(); const candidates = certs.filter((c) => c.sans.includes(want) && (!c.certFile || c.keyFile)).filter((c) => { const k = c.certFile || c.name; if (seen.has(k)) return false; seen.add(k); return true; });
const pick = candidates.find((c) => c.name === own) || candidates.slice().sort((a, b) => (Date.parse(b.expires) || 0) - (Date.parse(a.expires) || 0))[0] || null;
const expMs = pick ? Date.parse(pick.expires) : NaN;
return { certName: pick ? pick.name : own, hasCert: !!pick, external: !!pick && pick.name !== own, certFile: pick ? pick.certFile || '' : '', keyFile: pick ? pick.keyFile || '' : '', sans: pick ? pick.sans : [], expires: pick ? pick.expires : '', daysLeft: Number.isFinite(expMs) ? Math.floor((expMs - Date.now()) / 86400000) : null, hasOptions: /HAS_OPTIONS/.test(o), hasDhparam: /HAS_DHPARAM/.test(o), candidates: candidates.map((c) => ({ name: c.name, sans: c.sans, expires: c.expires, certFile: c.certFile || '' })), scanned: certs.length };
}
/**
* 사이트 도메인을 인증서 묶음으로 나눈다 (v1.4.14, 요구 9).
* @returns {{ platform: string[], own: string[], platformDomain: string }} platform = 플랫폼 서브 도메인(와일드카드 대상), own = 고객 도메인(사이트 인증서 대상)
*/
function splitDomains(domains) {
const pd = platformDomain(); const platform = []; const own = [];
for (const d of domains) (isPlatformSub(d, pd) ? platform : own).push(d);
return { platform, own, platformDomain: pd };
}
/**
* 대상 서버에 도메인 vhost 를 구성한다. 기존 인증서가 있으면 HTTPS 설정을 유지한다(재배포 시 HTTPS 유실 방지).
* v1.4.14 (요구 9): 플랫폼 서브 도메인은 와일드카드 인증서(있을 때), 고객 도메인은 사이트 인증서(있을 때)로 각각 HTTPS.
* @returns {{ok, detail, groups, needCert: string[], sslKept, platformNoWild: boolean, cert, wildcard}}
* needCert: 아직 인증서가 없는 고객 도메인(webroot 발급 대상) · platformNoWild: 서브 도메인이 있는데 와일드카드 인증서가 없음
*/
async function configureVhost(siteId, id, domains) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: '이 배포 서버에서 sudo 사용이 허용되지 않아 nginx 설정을 쓰지 못했습니다. 관리자 콘솔 › 배포 서버에서 sudo 사용 허용을 켜 주세요.' };
domains = normalizeDomains(domains); if (!domains.length) return { ok: true, detail: '도메인 없음(생략)', skipped: true, needCert: [], groups: [] };
const root = path.posix.join(t.baseDir, siteId);
const { platform, own, platformDomain: pd } = splitDomains(domains);
// v1.5.17: 이름과 무관하게 *.<플랫폼 도메인> SAN 을 가진 인증서를 찾는다(서버 설치 때 미리 받아 둔 인증서도 자동 적용)
const wild = platform.length ? await findWildcardCert(t, pd) : { hasCert: false, sans: [] };
const wildOk = wild.hasCert && wild.sans.includes(`*.${pd}`.toLowerCase());
const cs = own.length ? await certStatus(t, siteId) : { hasCert: false, sans: [] };
const ownOk = cs.hasCert && own.every((d) => cs.sans.includes(d));
const groups = [];
if (platform.length) groups.push({ kind: 'platform', domains: platform, ssl: wildOk ? { certName: wild.certName, certFile: wild.certFile || '', keyFile: wild.keyFile || '', hasOptions: wild.hasOptions, hasDhparam: wild.hasDhparam } : null });
if (own.length) groups.push({ kind: 'own', domains: own, ssl: ownOk ? { certName: 'wh-' + siteId, hasOptions: cs.hasOptions, hasDhparam: cs.hasDhparam } : null });
const conf = vhostConf(groups, root, null, siteId);
// v1.5.27 (요구 1·7): 지금 설정 파일이 이미 같은 내용이고 nginx 에 켜져 있으면 다시 쓰지 않는다(sudo 불필요). webmaker.ariweb.net 처럼 서비스 안에서 sudo 가 막힌 서버에서
// 한 번 구성된 도메인이 재배포 때마다 "도메인 연결 설정에 실패" 로 나오던 것을 막는다. 설정 파일(644)은 sudo 없이 읽을 수 있다.
if (t.type === 'local') { try { const name = `wh-${siteId}.conf`; const cur = fs.readFileSync(fs.existsSync(`/etc/nginx/sites-available/${name}`) ? `/etc/nginx/sites-available/${name}` : `/etc/nginx/conf.d/${name}`, 'utf8'); const enabled = fs.existsSync(`/etc/nginx/sites-enabled/${name}`) || fs.existsSync(`/etc/nginx/conf.d/${name}`); if (enabled && cur === conf) {
// v1.7.3 (요구 1): 설정은 같아도 nginx 가 지금 깨져 있으면(중복 wmstats 로 reload 가 안 된 채 남은 서버) 고쳐 둔다 — 2026-10-24 webmaker.ibit.kr 에서 재배포가 "이미 같음" 으로 끝나 복구되지 않던 것
let fixed = ''; try { const rr = await run('bash', ['-c', nginxRepairScript()], { timeout: 30000 }); const o = rr.out || ''; if (/NGINX_FIXED/.test(o)) fixed = ' · 중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf)을 정리하고 nginx 를 다시 읽었습니다'; else if (/NGINX_BROKEN/.test(o)) { const res = { ok: false, detail: `도메인 ${domains.join(', ')} 의 nginx 설정은 이미 있지만 nginx 설정 검사가 실패해 적용되지 않습니다: ${(o.match(/NGINX_BROKEN (.*)/) || [, ''])[1]}` }; return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains); } } catch (_) {}
const res = { ok: true, unchanged: true, detail: `도메인 ${domains.join(', ')} 의 nginx 설정이 이미 같아 그대로 둠${fixed}` }; return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains); } } catch (_) {} }
const r = t.type === 'local' ? await run('bash', ['-c', vhostScript(siteId)], { stdin: Readable.from([conf]), timeout: 60000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), vhostScript(siteId)], { stdin: Readable.from([conf]), timeout: 90000 }) : { out: '', err: 'SSH 키가 없습니다' });
const res = vhostResult(r.out, r.err, domains);
return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains);
}
/** configureVhost 결과 마무리: 묶음·인증서·안내 문구를 붙인다 (새로 쓴 경우와 "이미 같음" 경우가 함께 쓴다, v1.5.27) */
function finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains) {
res.groups = groups.map((g) => ({ kind: g.kind, domains: g.domains, ssl: !!g.ssl, certName: g.ssl ? g.ssl.certName : null }));
res.cert = cs; res.wildcard = platform.length ? { ...wild, ok: wildOk, platformDomain: pd } : null;
res.needCert = ownOk ? [] : own.slice(); res.platformNoWild = platform.length > 0 && !wildOk;
res.sslKept = groups.length > 0 && groups.every((g) => !!g.ssl);
if (res.ok) {
const notes = [];
if (platform.length) notes.push(wildOk ? `${platform.join(', ')}: 와일드카드 인증서(*.${pd}${wild.external ? `, 서버에 있던 인증서 ${wild.certName}` : ''})로 HTTPS` : `${platform.join(', ')}: 와일드카드 인증서(*.${pd})가 없어 HTTP — 관리자 콘솔 › 설정 › SSL 에서 발급`);
if (own.length) notes.push(ownOk ? `${own.join(', ')}: 기존 사이트 인증서로 HTTPS 유지` : (cs.hasCert ? `${own.join(', ')}: 기존 인증서가 일부 도메인을 포함하지 않아(${own.filter((d) => !cs.sans.includes(d)).join(', ')}) 재발급 필요` : `${own.join(', ')}: 사이트 인증서 발급 대상`));
if (notes.length) res.detail += ' · ' + notes.join(' · ');
}
return res;
}
async function removeVhost(siteId, id) {
const t = get(id); if (!t) return { ok: false };
const r = t.type === 'local' ? await run('bash', ['-c', removeVhostScript(siteId)], { timeout: 60000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), removeVhostScript(siteId)], { timeout: 60000 }) : { out: '', err: 'no key' });
return { ok: /VHOST_REMOVED/.test(r.out), detail: r.out || r.err };
}
/** 연결 테스트 보조: sudo/nginx 가능 여부 */
/**
* 대상 서버의 vhost 구성 능력: sudo -n · nginx · /etc/nginx 쓰기. v1.5.19: 안 되는 이유(hint)도 돌려준다 — NoNewPrivileges·ProtectSystem 같은 유닛 설정을 구분.
* @returns {Promise<{sudo:boolean, nginx:boolean, writable:boolean, ok:boolean, hint:string}>}
*/
async function vhostCapability(t) {
const script = `${sudoProbe('true')}; if sudo -n true 2>/dev/null; then echo SUDO=yes; else echo SUDO=no; fi; if command -v nginx >/dev/null 2>&1 || [ -x /usr/sbin/nginx ]; then echo NGINX=yes; else echo NGINX=no; fi`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 20000 }) : await run('ssh', [...sshArgs(t), script], { timeout: 30000 });
const o = r.out || ''; const sudo = /SUDO=yes/.test(o); const nginx = /NGINX=yes/.test(o); const writable = sudo && !/RO_ETC/.test(o);
return { sudo, nginx, writable, ok: sudo && nginx && writable, hint: !nginx ? 'nginx 없음' : sudoHint(o) };
}
/** 연결 테스트·점검표용 한 줄: "도메인 vhost 설정 가능" 또는 "불가(이유)" */
function capText(cap) { return `도메인 vhost 설정 ${cap.ok ? '가능' : '불가(' + (cap.hint || 'sudo 권한 없음') + ')'}`; }
// ---------- 배포 후 접속 확인 ----------
const dns = require('dns').promises;
/**
* v1.5.19: 종류 local 배포 서버(서비스 서버 자신)의 "도메인이 가리켜야 할 IP" 후보. 앞에 올수록 안내에 쓰인다.
* ① 설정 › 도메인 서비스 › 사이트 IP(domains.targetIp) ② 배포 서버 공개 URL 호스트의 A 레코드 ③ 서비스 공개 주소 호스트의 A 레코드
* ④ 이 서버 인터페이스의 공인 IPv4 ⑤ 사설 IPv4(사내망 운영) — 127.0.0.1 은 넣지 않는다.
*/
async function localServerIps(t) {
const out = []; const add = (ip) => { if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && ip !== '127.0.0.1' && !out.includes(ip)) out.push(ip); };
try { add(require('./domains').config().targetIp); } catch (_) {}
const hostOf = (u) => { try { return new URL(u).hostname; } catch (_) { return ''; } };
for (const u of [t.publicBaseUrl, process.env.PUBLIC_BASE_URL || (() => { try { return require('./config').get('service.publicBaseUrl'); } catch (_) { return ''; } })()]) { const h = hostOf(u); if (h) for (const ip of await resolveA(h)) add(ip); }
const priv = (ip) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(ip); const ifs = [];
for (const list of Object.values(require('os').networkInterfaces())) for (const i of list || []) if (i.family === 'IPv4' && !i.internal) ifs.push(i.address);
for (const ip of ifs.filter((x) => !priv(x))) add(ip); for (const ip of ifs.filter(priv)) add(ip);
return out;
}
const http = require('http'); const https = require('https');
function httpGet(url, hostHeader) {
return new Promise((resolve) => {
let u; try { u = new URL(url); } catch (_) { return resolve({ status: 0, error: '잘못된 URL' }); }
const mod = u.protocol === 'https:' ? https : http;
// v1.5.31: IP 로 접속하며 Host 만 바꿀 때 TLS SNI 도 그 호스트로 — 서버가 SNI 로 인증서·server 블록을 고르는 구성을 위해
const req = mod.get(u, { timeout: 8000, rejectUnauthorized: false, servername: u.protocol === 'https:' && hostHeader && !/^\d+\.\d+\.\d+\.\d+$/.test(hostHeader) ? hostHeader : undefined, headers: { Host: hostHeader || u.host, 'User-Agent': 'WebHostingAgent-check/1.0' } }, (res) => {
let body = ''; res.on('data', (c) => { if (body.length < 20000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || null, title: (body.match(/<title[^>]*>([^<]*)/i) || [])[1] || '' }));
});
req.on('timeout', () => req.destroy(new Error('시간 초과'))); req.on('error', (e) => resolve({ status: 0, error: e.message }));
});
}
async function resolveA(host) { if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return [host]; try { return await dns.resolve4(host); } catch (_) { return []; } }
/** 배포 결과 접속 확인: 도메인별 DNS·vhost 응답, 공개 URL 응답 */
const DEFAULT_PAGE_RE = /^(Welcome to nginx!?|Apache2 .* Default Page|Index of \/|It works!?)$/i; // 웹서버 기본 페이지 제목 — vhost 가 아직 활성화되지 않았다는 신호
async function checkAccess(id, siteId, domains, opts = {}) {
const t = get(id); if (!t) return { items: [] };
// v1.6.33 (요구 0): nginx reload 는 비동기라 vhost 적용 직후의 확인이 기본 페이지("Welcome to nginx!")·HTTPS 미적용으로 나와 cert-wait 로 오판했다
// (guide.ariweb.net 2026-10-18). 도메인 항목이 기본 페이지이거나 HTTPS 가 안 되면 3초 뒤 한 번 더 확인한다(최대 2회).
const first = await checkAccessOnce(t, siteId, domains);
const suspicious = (it) => it.kind === 'domain' && it.pointsToTarget && (DEFAULT_PAGE_RE.test(it.title || '') || (!it.https && it.ok));
if (opts.retry !== false && first.items.some(suspicious)) { await new Promise((r) => setTimeout(r, 3000)); const second = await checkAccessOnce(t, siteId, domains); if (second.items.filter(suspicious).length < first.items.filter(suspicious).length) { second.retried = true; return second; } first.retried = true; }
return first;
}
async function checkAccessOnce(t, siteId, domains) {
// v1.5.19 (요구 2): 종류 local 은 1.5.17 까지 127.0.0.1 을 "서버 IP" 로 써서, DNS 가 서버의 공인 IP 를 바르게 가리켜도 "DNS 가 다른 서버를 가리킴 — A 레코드를
// 127.0.0.1 로 바꾸세요" 라는 틀린 안내가 나왔다(webmaker.ariweb.net, sample.ariweb.net). 이제 이 서버가 가질 수 있는 공개 IP 들(localServerIps)과 비교하고,
// 응답 확인(vhost)만 127.0.0.1 로 한다. 안내에 적는 IP 는 공인 IP(또는 설정 › 도메인 서비스 › 사이트 IP)다.
const local = t.type === 'local'; const targetIps = local ? await localServerIps(t) : await resolveA(t.host);
const targetIp = targetIps[0] || null; const probeIp = local ? '127.0.0.1' : targetIp; const items = [];
for (const d of normalizeDomains(domains || [])) {
const ips = await resolveA(d); const viaIp = probeIp ? await httpGet(`http://${probeIp}/`, d) : { status: 0, error: '대상 서버 IP 확인 실패' };
const vhostOk = viaIp.status === 200; const dnsOk = ips.length > 0; const points = dnsOk && targetIps.length ? ips.some((ip) => targetIps.includes(ip)) : false;
let note = '';
if (!dnsOk) note = `DNS 레코드 없음 — 도메인 관리(DNS)에서 A 레코드를 ${targetIp || '배포 서버 IP'} 로 추가하세요`;
else if (!points) note = `DNS 가 다른 서버(${ips.join(', ')})를 가리킴 — A 레코드를 ${targetIp} 로 바꾸세요`;
const httpsR = probeIp ? await httpGet(`https://${probeIp}/`, d) : { status: 0 };
// v1.5.31 (요구 2): HTTP 가 301 로 HTTPS 로 보내는 정상 구성에서 nginx 의 301 본문 제목("301 Moved Permanently")과 HTTPS 응답 제목("Webmaker")을 비교해
// "접속 불가: 배포 서버 응답 301 → https://…" 로 오판했다(webmaker.ariweb.net, 와일드카드 인증서 적용 직후). 제목 비교는 HTTP 가 200 일 때(같은 사이트인지 확인용)만 한다
const httpsOk = httpsR.status === 200 && (viaIp.status !== 200 || !httpsR.title || !viaIp.title || httpsR.title === viaIp.title);
const httpRedirectsToHttps = viaIp.status === 301 && /^https:\/\//.test(viaIp.location || '') && (viaIp.location || '').includes(d);
const serveOk = vhostOk || (httpRedirectsToHttps && httpsOk);
if (!serveOk) note += (note ? ' · ' : '') + `배포 서버 응답 ${viaIp.status || viaIp.error}${viaIp.location ? ' → ' + viaIp.location : ''} (vhost 설정을 확인하세요)`;
items.push({ kind: 'domain', url: httpsOk ? `https://${d}/` : `http://${d}/`, ok: dnsOk && points && serveOk, https: httpsOk, dns: ips, pointsToTarget: points, serverStatus: viaIp.status, title: (httpsOk && httpsR.title ? httpsR.title : viaIp.title) || '', /* v1.5.31: HTTPS 가 적용됐으면 그 제목 */ vhostPending: DEFAULT_PAGE_RE.test((viaIp.title || '')) /* v1.6.33 */, note: note || `정상 (DNS → ${targetIp}, ${httpsOk ? 'HTTPS 적용됨' : 'HTTP 응답 200 · HTTPS 미적용'}${(httpsOk && httpsR.title ? httpsR.title : viaIp.title) ? ', ' + (httpsOk && httpsR.title ? httpsR.title : viaIp.title) : ''})` });
}
const pubUrl = siteUrlOn(t, siteId);
if (pubUrl) { const r = await httpGet(pubUrl); items.push({ kind: 'public', url: pubUrl, ok: r.status === 200, serverStatus: r.status, title: r.title || '', note: r.status === 200 ? `정상 (응답 200${r.title ? ', ' + r.title : ''})` : `응답 ${r.status || r.error}${r.location ? ' → ' + r.location : ''} — 이 주소는 배포 서버의 웹서버가 저장 경로를 직접 서비스할 때만 열립니다. 아니면 배포 서버 설정의 공개 URL 을 비우세요` }); }
return { items, targetIp, checkedAt: new Date().toISOString() };
}
// ---------- Let's Encrypt SSL (webroot 방식, nginx 플러그인 불필요) ----------
function certbotScript(siteId, root, domains, email) {
const dArgs = domains.map((d) => `-d ${shq(d)}`).join(' ');
return `${sudoProbe('exit 0')}
if ! command -v certbot >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then echo INSTALLING; sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y -q certbot >/tmp/wh-certbot-install.log 2>&1 || { echo "INSTALL_FAIL $(tail -2 /tmp/wh-certbot-install.log | tr '\\n' ' ')"; exit 0; }; else echo NO_CERTBOT; exit 0; fi
fi
sudo -n certbot certonly --webroot -w ${shq(root)} --non-interactive --agree-tos ${email ? `-m ${shq(email)}` : '--register-unsafely-without-email'} --cert-name ${shq('wh-' + siteId)} --expand --keep-until-expiring --deploy-hook 'systemctl reload nginx || nginx -s reload' ${dArgs} >/tmp/wh-certbot.log 2>&1 && echo CERT_OK || { echo "CERT_FAIL $(grep -iE 'error|failed|problem|detail|too many|rate|unauthorized|timeout' /tmp/wh-certbot.log | grep -v '^Saving' | head -4 | tr '\\n' ' ')"; }
[ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
}
/**
* 대상 서버에서 고객 도메인들에 대해 Let's Encrypt 인증서를 발급(webroot, 이름 wh-<사이트ID>)하고 HTTPS 설정을 적용한다.
* v1.4.14: 플랫폼 서브 도메인은 여기서 발급하지 않는다(와일드카드 인증서 담당, lib/ssl.js). 발급 뒤 설정은 configureVhost 가
* 두 인증서를 함께 반영한다.
* @param {string[]} domains 발급할 도메인 (플랫폼 서브 도메인이 섞여 있으면 뺀다)
*/
async function issueSsl(siteId, id, domains, email) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const all = normalizeDomains(domains); domains = splitDomains(all).own; if (!domains.length) return { ok: false, skipped: true, detail: all.length ? '플랫폼 서브 도메인은 와일드카드 인증서로 처리합니다 (사이트별 발급 없음)' : '도메인 없음' };
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: 'sudo 사용이 허용되지 않아 인증서를 발급하지 못했습니다' };
const root = path.posix.join(t.baseDir, siteId);
const exec = (script, stdin, timeout) => (t.type === 'local' ? run('bash', ['-c', script], { stdin, timeout }) : (t.hasKey ? run('ssh', [...sshArgs(t), script], { stdin, timeout }) : Promise.resolve({ out: '', err: 'no key', code: 1 })));
const r = await exec(certbotScript(siteId, root, domains, email), null, 300000);
const o = r.out || '';
if (/NO_SUDO/.test(o)) return { ok: false, detail: sudoHint(o) };
if (/NO_CERTBOT|INSTALL_FAIL/.test(o)) return { ok: false, detail: 'certbot 이 없고 자동 설치에 실패했습니다: ' + o.replace(/[\s\S]*INSTALL_FAIL/, '').trim() };
if (!/CERT_OK/.test(o)) return { ok: false, detail: '인증서 발급 실패: ' + ((o.match(/CERT_FAIL (.*)/) || [, ''])[1].trim() || (r.err || o).split('\n').slice(-2).join(' ')).slice(0, 500) };
// 발급된 인증서를 포함해 vhost 를 다시 구성 (플랫폼 서브 도메인의 와일드카드 묶음도 함께)
const vr = await configureVhost(siteId, id, all);
if (!vr.ok) return { ok: false, detail: '인증서는 발급되었지만 HTTPS 설정 적용 실패: ' + vr.detail };
return { ok: true, domains, detail: `Let's Encrypt 인증서 발급·HTTPS 설정 완료 (${domains.join(', ')}, HTTP→HTTPS 리디렉션)${/INSTALLING/.test(o) ? ' · certbot 설치함' : ''}`, vhost: vr };
}
// ---------- 기본 오류 서버(default_server): 미등록 호스트·경로가 다른 서비스로 흘러가지 않도록 ----------
function defaultServerScript() {
const page404 = "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>페이지를 찾을 수 없습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,\"Segoe UI\",\"Noto Sans KR\",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></main></body></html>";
const conf80 = `server {\n listen 80 default_server;\n listen [::]:80 default_server;\n server_name _;\n server_tokens off;\n location ^~ /.well-known/acme-challenge/ { root /var/www/html; default_type "text/plain"; }\n location / { default_type text/html; return 404 '${page404}'; }\n}\n`;
const conf443 = `server {\n listen 443 ssl default_server;\n listen [::]:443 ssl default_server;\n server_name _;\n server_tokens off;\n ssl_reject_handshake on;\n}\n`;
// v1.7.3 (요구 1): ssl_reject_handshake 는 nginx 1.19.4 부터다 — Ubuntu 20.04 의 1.18(2026-10-24 webmaker.ibit.kr) 에서는 "unknown directive" 로 nginx -t 가 실패해 기본 오류 서버가 되돌려졌다.
// 낮은 버전에서는 ssl-cert 패키지의 자체 서명 인증서(snakeoil)로 443 기본 서버를 열고 404 안내만 준다(브라우저에는 인증서 경고가 뜨지만 다른 사이트 내용은 새지 않는다). 그 인증서도 없으면 443 기본 서버는 만들지 않는다.
const conf443Legacy = `server {\n listen 443 ssl default_server;\n listen [::]:443 ssl default_server;\n server_name _;\n server_tokens off;\n ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;\n ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;\n location / { default_type text/html; return 404 '${page404}'; }\n}\n`;
return `${sudoProbe('exit 0')}
NGV=$( (command -v nginx >/dev/null 2>&1 && nginx -v || /usr/sbin/nginx -v) 2>&1 | sed -n 's#.*nginx/\\([0-9.][0-9.]*\\).*#\\1#p')
REJECT_OK=1; if [ -n "$NGV" ] && [ "$(printf '%s\\n%s\\n' 1.19.4 "$NGV" | sort -V | head -1)" != "1.19.4" ]; then REJECT_OK=0; if sudo -n test -f /etc/ssl/private/ssl-cert-snakeoil.key && [ -f /etc/ssl/certs/ssl-cert-snakeoil.pem ]; then echo "OLD_NGINX=$NGV SNAKEOIL"; else echo "OLD_NGINX=$NGV NO443"; fi; fi
if [ -d /etc/nginx/sites-available ]; then A=/etc/nginx/sites-available/wh-default.conf; E=/etc/nginx/sites-enabled/wh-default.conf; elif [ -d /etc/nginx/conf.d ]; then A=/etc/nginx/conf.d/00-wh-default.conf; E=$A; else echo NO_CONF_DIR; exit 0; fi
sudo -n rm -f "$E" 2>/dev/null; [ "$A" != "$E" ] || sudo -n rm -f "$A"
T=$(sudo -n nginx -T 2>/dev/null)
H80=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*default_server' | grep -E '(:80\\b|\\b80\\b)' | grep -vE '443' | head -1)
H443=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*default_server' | grep -E '443' | head -1)
L443=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*443' | head -1)
OUT=""; [ -z "$H80" ] && OUT="$OUT${'$'}(printf '%s' ${shq(conf80)})"
if [ -z "$H443" ] && [ -n "$L443" ]; then if [ "$REJECT_OK" = 1 ]; then OUT="$OUT
${'$'}(printf '%s' ${shq(conf443)})"; elif [ -f /etc/ssl/certs/ssl-cert-snakeoil.pem ] && sudo -n test -f /etc/ssl/private/ssl-cert-snakeoil.key; then OUT="$OUT
${'$'}(printf '%s' ${shq(conf443Legacy)})"; fi; fi
[ -n "$H80" ] && echo "EXISTING80=$(echo $H80 | tr -s ' ')"; [ -n "$H443" ] && echo "EXISTING443=$(echo $H443 | tr -s ' ')"
if [ -n "$OUT" ]; then printf '# generated by webhosting-agent: default error server\\n%s' "$OUT" > /tmp/wh-default.$$; sudo -n install -m 644 /tmp/wh-default.$$ "$A"; [ "$A" != "$E" ] && sudo -n ln -sf "$A" "$E"; rm -f /tmp/wh-default.$$; echo "WROTE=$A"; fi
ERRF=/tmp/wh-nginx-err.$$
if sudo -n nginx -t 2>"$ERRF" || ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); echo RELOADED; else echo "NGINX_TEST_FAIL $(tail -2 "$ERRF" | tr '\\n' ' ')"; sudo -n rm -f "$E"; [ "$A" != "$E" ] || true; sudo -n nginx -t >/dev/null 2>&1 && (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); fi; rm -f "$ERRF"
sleep 1
echo "CHECK80=$(curl -s -o /tmp/wh-c80.$$ -w '%{http_code}' -H 'Host: unknown-host.invalid' http://127.0.0.1/ 2>/dev/null)|$(grep -o '<title>[^<]*' /tmp/wh-c80.$$ 2>/dev/null | head -1)"; rm -f /tmp/wh-c80.$$
[ -n "$L443" ] && echo "CHECK443=$(curl -sk -o /dev/null -w '%{http_code}' --resolve unknown-host.invalid:443:127.0.0.1 https://unknown-host.invalid/ 2>/dev/null)"
echo END`;
}
/** 배포 서버의 기본 오류 서버를 점검·적용하고 미등록 호스트 요청이 안내 페이지/거부로 처리되는지 검증 */
async function ensureDefaultServer(id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
if (t.manageVhost === false) return { ok: true, skipped: true, detail: 'vhost 자동 설정 꺼짐 — 기본 오류 서버 점검 생략' };
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: 'sudo 가 허용되지 않아 기본 오류 서버를 설정하지 못했습니다' };
// v1.5.27: 같은 서버에 이미 우리 기본 오류 서버 설정이 켜져 있으면 sudo 없이 "유지" (sudo 가 막힌 유닛에서 매 배포마다 경고가 나지 않게)
if (t.type === 'local' && (fs.existsSync('/etc/nginx/sites-enabled/wh-default.conf') || fs.existsSync('/etc/nginx/conf.d/00-wh-default.conf'))) { try { const r0 = await run('bash', ['-c', 'sudo -n true 2>/dev/null && echo SUDO_OK || echo SUDO_NO'], { timeout: 10000 }); if (/SUDO_NO/.test(r0.out)) return { ok: true, kept: true, detail: '기존 기본 오류 서버 설정 유지 (sudo 를 쓸 수 없어 점검 생략)' }; } catch (_) {} }
const script = defaultServerScript();
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 90000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 120000 }) : { out: '', err: 'no key' });
const o = r.out || '';
if (/NO_SUDO/.test(o)) return { ok: false, detail: sudoHint(o) };
if (/NO_CONF_DIR/.test(o)) return { ok: false, detail: 'nginx 설정 디렉터리를 찾지 못했습니다' };
const c80 = (o.match(/CHECK80=(\d+)\|?(.*)/) || []); const c443 = (o.match(/CHECK443=(\d+)/) || [])[1];
const ok80 = c80[1] === '404' && /페이지를 찾을 수 없습니다/.test(c80[2] || '');
const ok443 = c443 === undefined || c443 === '000' || c443 === '404';
const parts = [];
parts.push(/WROTE=/.test(o) ? `기본 오류 서버 설정 적용(${(o.match(/WROTE=(\S+)/) || [])[1]})` : '기존 기본 서버 유지');
if (/EXISTING80=/.test(o)) parts.push(`80 포트에 기존 default_server 있음`);
if (/EXISTING443=/.test(o)) parts.push(`443 포트에 기존 default_server 있음`);
if (/NGINX_TEST_FAIL/.test(o)) parts.push('nginx 설정 검사 실패로 되돌림: ' + (o.match(/NGINX_TEST_FAIL (.*)/) || [, ''])[1]);
if (/WMSTATS_DUP_REMOVED/.test(o)) parts.push('중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf) 정리'); // v1.7.3
// v1.7.3: 낮은 nginx 버전 안내 — 자체 서명 인증서로 대신했는지, 443 기본 서버를 만들지 못했는지
if (/OLD_NGINX=/.test(o)) { const v = (o.match(/OLD_NGINX=(\S+)/) || [, '?'])[1]; parts.push(/SNAKEOIL/.test(o) ? `nginx ${v} 은 ssl_reject_handshake 를 지원하지 않아(1.19.4 부터) 자체 서명 인증서로 443 기본 서버를 열고 404 로 응답` : `nginx ${v} 은 ssl_reject_handshake 를 지원하지 않고(1.19.4 부터) 자체 서명 인증서(ssl-cert 패키지)도 없어 443 기본 서버를 만들지 못함 — sudo apt install ssl-cert 뒤 다시 배포하거나 nginx 를 올리세요`); }
parts.push(`미등록 호스트 HTTP 요청 → ${c80[1] || '?'}${ok80 ? ' (안내 페이지)' : ' (다른 내용이 노출될 수 있음!)'}`);
if (c443 !== undefined) parts.push(`미등록 호스트 HTTPS 요청 → ${c443 === '000' ? '핸드셰이크 거부' : c443}${ok443 ? '' : ' (다른 사이트가 노출될 수 있음!)'}`);
return { ok: ok80 && ok443 && !/NGINX_TEST_FAIL/.test(o), detail: parts.join(' · '), raw: o.slice(0, 600) };
}
/**
* 원격 배포 서버의 사이트 접속 로그에서 offset 이후 부분을 읽는다 (접속 통계 수집용).
* 파일이 교체되어 inode 가 바뀌거나 크기가 줄었으면 처음부터 읽는다. 읽기 권한이 없으면 sudo -n 으로 시도.
* @returns {{ok, inode, size, text, detail}}
*/
async function readSiteLog(id, siteId, offset, knownInode) {
const t = get(id); if (!t || t.type !== 'ssh' || !t.hasKey) return { ok: false, detail: '원격 서버 아님' };
const f = statsLogPath(siteId);
const script = `F=${shq(f)}; R=cat; [ -r "$F" ] || R="sudo -n cat"; T=tail; [ -r "$F" ] || T="sudo -n tail"; S=$($R "$F" >/dev/null 2>&1 && stat -c '%i %s' "$F" 2>/dev/null || sudo -n stat -c '%i %s' "$F" 2>/dev/null); [ -n "$S" ] || { echo "NOFILE"; exit 0; }; set -- $S; echo "INODE=$1 SIZE=$2"; START=${Number(offset) || 0}; if [ "$1" != "${knownInode || ''}" ] || [ "$2" -lt "$START" ]; then START=0; fi; echo "START=$START"; echo "---"; $T -c +$((START + 1)) "$F" 2>/dev/null | head -c 33554432`;
const r = await run('ssh', [...sshArgs(t), script], { timeout: 120000, maxOut: 40 * 1024 * 1024, raw: true });
if (r.code !== 0 && !/INODE=/.test(r.out)) return { ok: false, detail: (r.err || '').split('\n').slice(-1)[0] || `exit ${r.code}` };
if (/^NOFILE/m.test(r.out)) return { ok: false, detail: '로그 파일 없음', nofile: true };
const head = r.out.slice(0, r.out.indexOf('---')); const text = r.out.slice(r.out.indexOf('---') + 4);
const inode = (head.match(/INODE=(\d+)/) || [])[1]; const size = parseInt((head.match(/SIZE=(\d+)/) || [])[1] || '0', 10); const start = parseInt((head.match(/START=(\d+)/) || [])[1] || '0', 10);
return { ok: true, inode, size, start, text };
}
module.exports = { rollback, _run: run, /* v1.5.13 시험용 */ isSamePathTarget, readSiteLog, statsLogPath, list, get, upsert, remove, setDefault, generateKey, test, deploySite, removeSite, siteUrlOn, normalizeDomains, configureVhost, removeVhost, vhostCapability, sudoHint, localServerIps, checkAccess, issueSsl, ensureDefaultServer, certStatus, certStatusFor, findWildcardCert, wildcardCertName, isPlatformSub, splitDomains, platformDomain, vhostConf, run, sshArgs, shq };