561 lines
70 KiB
JavaScript
561 lines
70 KiB
JavaScript
'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 };
|