Files

476 lines
38 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// 기존 운영 사이트 연결: 파일 가져오기(import) 와 수정본 되돌려 배포(push).
// 접근 방식은 두 가지만 지원한다.
// - ssh : SSH 키 (서비스가 생성한 키의 공개키를 서버에 등록하거나, 개인키를 붙여넣음)
// - password : ID/비밀번호 — SSH/SFTP(기본, 22) 또는 FTP(21)
// 구현: 셸이 있는 SSH 접속은 ssh+tar, 그 외(FTP, 셸 없는 SFTP 계정)는 curl(ftp://, sftp://) 로 파일 단위 전송.
// 비밀번호는 명령행에 올리지 않는다 (ssh: SSH_ASKPASS 헬퍼, curl: 표준입력 설정 파일).
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const { spawn, execFile } = require('child_process');
const providers = require('./providers');
const ROOT = path.resolve(__dirname, '..');
const KEYS = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'site-keys');
fs.mkdirSync(KEYS, { recursive: true, mode: 0o700 });
const ASKPASS = path.join(ROOT, 'hooks', 'askpass.sh');
// v1.2.0: 고정 크기 제한(300MB·4,000개)을 없애고, 이 서비스 서버의 사이트 저장 디스크 용량의 10% 를 넘으면 중지한다.
const CAPACITY_RATIO = 0.10;
/**
* 가져오기 허용 한도: 사이트 저장 경로(SITES_ROOT)가 있는 디스크의 전체 용량 × 10%, 그리고 남은 공간의 90% 중 작은 값.
* @returns {{limitBytes, totalBytes, freeBytes, byFree: boolean}}
*/
function capacity() {
const root = process.env.SITES_ROOT || '/var/www/sites';
try { const st = fs.statfsSync(root); const total = st.bsize * st.blocks; const free = st.bsize * st.bavail; const byTotal = total * CAPACITY_RATIO; const byFree = free * 0.9; return { limitBytes: Math.min(byTotal, byFree), totalBytes: total, freeBytes: free, byFree: byFree < byTotal }; }
catch (_) { return { limitBytes: Infinity, totalBytes: 0, freeBytes: 0, byFree: false }; }
}
const gb = (n) => (n / 1073741824).toFixed(n < 10737418240 ? 2 : 1) + 'GB';
/** 한도를 넘으면 사용자에게 보여 줄 오류 */
function capacityError(sizeBytes) {
const c = capacity(); if (sizeBytes <= c.limitBytes) return null;
return httpError(c.byFree ? `서버 저장 공간이 부족해 가져올 수 없습니다 (사이트 ${gb(sizeBytes)}, 서버 남은 공간 ${gb(c.freeBytes)}). 관리자에게 문의하세요.` : `서버 한계를 초과하는 사이트라 가져오기를 중지했습니다 (사이트 ${gb(sizeBytes)}, 한 사이트 허용 한도 ${gb(c.limitBytes)} = 서버 용량 ${gb(c.totalBytes)} 의 10%). 사이트 경로를 더 좁게 지정하거나 관리자에게 문의하세요.`, 413);
}
const EXCLUDE_DIRS = ['.git', '.svn', '.hg', 'node_modules', '.cache', '.claude', '.refs', '.webmaker-backups'];
const INDEX_NAMES = ['index.html', 'index.htm', 'index.php', 'default.html', 'default.htm'];
const CANDIDATE_DIRS = ['~/public_html', '~/www', '~/html', '~/htdocs', '~/httpdocs', '~/web', '/var/www/html', '/var/www/HOST', '/var/www/HOST/html', '/var/www/HOST/public_html', '/var/www', '/usr/share/nginx/html', '/srv/www', '/srv/www/HOST', '/srv/http'];
const FTP_CANDIDATES = ['', 'public_html', 'www', 'html', 'htdocs', 'httpdocs', 'web', 'public'];
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
function keyPath(id) { return path.join(KEYS, id); }
// ---------- 접속 정보 ----------
/** 입력값을 검증·정규화한다 (비밀번호/키는 포함하지 않음) */
function normalize(input = {}) {
const url = String(input.url || '').trim();
let host = String(input.host || '').trim();
if (url && !/^https?:\/\//i.test(url)) throw httpError('사이트 주소는 http:// 또는 https:// 로 시작해야 합니다');
if (!host && url) { try { host = new URL(url).hostname; } catch (_) {} }
const type = input.type === 'ssh' ? 'ssh' : 'password';
const protocol = type === 'ssh' ? 'ssh' : (input.protocol === 'ftp' ? 'ftp' : 'sftp');
const port = parseInt(input.port, 10) || (protocol === 'ftp' ? 21 : 22);
const user = String(input.user || '').trim();
const docroot = String(input.docroot || '').trim().replace(/\/+$/, '');
if (!host) throw httpError('접속할 서버 주소(호스트)를 입력하세요');
if (!/^[a-z0-9.-]+$/i.test(host)) throw httpError('서버 주소 형식이 올바르지 않습니다');
if (!user) throw httpError('접속 계정(ID)을 입력하세요');
if (!/^[a-z0-9._@-]+$/i.test(user)) throw httpError('접속 계정 형식이 올바르지 않습니다');
if (port < 1 || port > 65535) throw httpError('포트 번호가 올바르지 않습니다');
if (docroot && /(^|\/)\.\.(\/|$)/.test(docroot)) throw httpError('사이트 경로에 ".." 은 쓸 수 없습니다');
return { type, protocol, host, port, user, docroot, url };
}
/** 정규화된 정보 + 비밀번호/키를 합쳐 저장용 source 객체를 만든다 (비밀번호는 암호화) */
function buildSource(input, prev) {
const s = normalize(input);
if (s.type === 'password') {
const pw = typeof input.password === 'string' ? input.password : '';
if (pw) s.secret = providers.enc(pw);
else if (prev && prev.secret && prev.type === 'password') s.secret = prev.secret;
else throw httpError('비밀번호를 입력하세요');
} else {
if (input.privateKey && String(input.privateKey).trim()) s.keyId = storeKey(String(input.privateKey));
else if (input.keyId && fs.existsSync(keyPath(String(input.keyId).replace(/[^a-f0-9]/g, '')))) s.keyId = String(input.keyId).replace(/[^a-f0-9]/g, '');
else if (prev && prev.keyId && fs.existsSync(keyPath(prev.keyId))) s.keyId = prev.keyId;
else throw httpError('SSH 키가 없습니다. 키를 생성해 서버에 등록하거나 개인키를 붙여넣으세요');
}
if (typeof input.shell === 'boolean') s.shell = input.shell; else if (prev && typeof prev.shell === 'boolean') s.shell = prev.shell;
return s;
}
/** 사용자에게 보여줄 형태 (비밀 제외) */
function publicSource(s) {
if (!s) return null;
const pubFile = s.keyId ? keyPath(s.keyId) + '.pub' : null;
return { type: s.type, protocol: s.protocol, host: s.host, port: s.port, user: s.user, docroot: s.docroot, url: s.url, hasSecret: !!s.secret, hasKey: !!(s.keyId && fs.existsSync(keyPath(s.keyId))), publicKey: pubFile && fs.existsSync(pubFile) ? fs.readFileSync(pubFile, 'utf8').trim() : null, shell: s.shell !== false, importedAt: s.importedAt || null, fileCount: s.manifest ? Object.keys(s.manifest).length : 0, lastPushAt: s.lastPushAt || null, backupNote: s.backupNote || null };
}
// ---------- SSH 키 ----------
function newKey(comment) {
return new Promise((resolve, reject) => {
const id = crypto.randomBytes(12).toString('hex'); const f = keyPath(id);
execFile('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `webmaker-import-${(comment || 'site').replace(/[^a-z0-9.-]/gi, '')}`, '-f', f], (err) => {
if (err) return reject(new Error('키 생성 실패: ' + err.message));
try { fs.chmodSync(f, 0o600); } catch (_) {}
resolve({ keyId: id, publicKey: fs.readFileSync(f + '.pub', 'utf8').trim() });
});
});
}
function storeKey(text) {
const body = text.replace(/\r/g, '').trim() + '\n';
if (!/^-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(body)) throw httpError('개인키 형식이 아닙니다 (-----BEGIN ... PRIVATE KEY----- 로 시작해야 합니다)');
const id = crypto.randomBytes(12).toString('hex'); const f = keyPath(id);
fs.writeFileSync(f, body, { mode: 0o600 });
try {
const pub = require('child_process').execFileSync('ssh-keygen', ['-y', '-f', f], { timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
fs.writeFileSync(f + '.pub', pub + '\n', { mode: 0o600 });
} catch (e) { try { fs.unlinkSync(f); } catch (_) {} throw httpError('개인키를 읽을 수 없습니다 (암호가 걸린 키는 지원하지 않습니다): ' + String(e.stderr || e.message).split('\n')[0]); }
return id;
}
function deleteKey(id) { if (!id) return; for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} } }
// ---------- 프로세스 실행 ----------
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, { env: opts.env || process.env, stdio: [opts.stdin || opts.input !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: true });
let out = Buffer.alloc(0); let err = '';
child.stdout.on('data', (c) => { if (out.length < (opts.maxOut || 8 * 1024 * 1024)) out = Buffer.concat([out, c]); });
child.stderr.on('data', (c) => { err += c; if (err.length > 20000) err = err.slice(-20000); });
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { child.kill('SIGKILL'); } }, opts.timeout || 120000);
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: opts.binary ? out : out.toString().trim(), err: err.trim() }); });
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: opts.binary ? Buffer.alloc(0) : '', err: e.message }); });
if (opts.stdin) { opts.stdin.on('error', () => {}); opts.stdin.pipe(child.stdin); }
else if (opts.input !== undefined) { child.stdin.on('error', () => {}); child.stdin.end(opts.input); }
});
}
// ---------- SSH (키 / 비밀번호) ----------
function sshEnvArgs(src) {
const args = ['-p', String(src.port), '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=15', '-o', 'NumberOfPasswordPrompts=1', '-o', 'ServerAliveInterval=15', '-o', 'LogLevel=ERROR'];
const env = { ...process.env };
if (src.type === 'ssh') args.push('-i', keyPath(src.keyId), '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes');
else {
args.push('-o', 'PubkeyAuthentication=no', '-o', 'PreferredAuthentications=password,keyboard-interactive');
env.SSH_ASKPASS = ASKPASS; env.SSH_ASKPASS_REQUIRE = 'force'; env.DISPLAY = env.DISPLAY || ':0'; env.WM_SSH_PASSWORD = providers.dec(src.secret);
}
return { args, env };
}
function sshExec(src, script, opts = {}) {
const { args, env } = sshEnvArgs(src);
return run('ssh', [...args, `${src.user}@${src.host}`, script], { ...opts, env });
}
function sshHint(err) {
if (/Permission denied/.test(err)) return ' → 계정/비밀번호(또는 등록한 공개키)를 확인하세요.';
if (/Connection (refused|timed out)|No route|Could not resolve|Network is unreachable/.test(err)) return ' → 주소·포트와 방화벽을 확인하세요.';
if (/Host key verification failed|REMOTE HOST IDENTIFICATION/.test(err)) return ' → 서버 키가 바뀌었습니다. 관리자에게 known_hosts 정리를 요청하세요.';
return '';
}
// ---------- curl (FTP / 셸 없는 SFTP) ----------
function curlUrl(src, remotePath, isDir) {
const p = String(remotePath || '').replace(/\/+$/, '');
const enc = (s) => s.split('/').map((seg) => (seg === '~' ? seg : encodeURIComponent(seg))).join('/');
const tail = isDir ? '/' : '';
if (src.protocol === 'ftp') return `ftp://${src.host}:${src.port}/${enc(p.replace(/^\/+/, ''))}${p.replace(/^\/+/, '') ? tail : ''}`; // FTP 는 계정 루트 기준
if (p.startsWith('/')) return `sftp://${src.host}:${src.port}${enc(p)}${tail}`;
return `sftp://${src.host}:${src.port}/~/${enc(p)}${p ? tail : ''}`;
}
function curlConfig(src, url) {
const q = (s) => '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
if (src.type === 'ssh') return `user = ${q(`${src.user}:`)}\nkey = ${q(keyPath(src.keyId))}\npubkey = ${q(keyPath(src.keyId) + '.pub')}\nurl = ${q(url)}\n`; // SFTP 전용(셸 없는) 키 계정
return `user = ${q(`${src.user}:${providers.dec(src.secret)}`)}\nurl = ${q(url)}\n`;
}
async function curl(src, url, extra = [], opts = {}) {
const args = ['-sS', '--connect-timeout', '15', '-m', String(Math.floor((opts.timeout || 120000) / 1000)), '-K', '-', '--insecure'];
if (src.protocol === 'ftp') args.push('--ftp-pasv');
if (!extra.includes('-o') && !extra.includes('-T')) args.push('-o', '/dev/null'); // 출력이 필요 없는 호출(-Q 등)
args.push(...extra);
return run('curl', args, { ...opts, input: curlConfig(src, url) });
}
/** ls -l 형식 목록 파싱 (unix 계열 FTP LIST, libssh sftp 목록 공통) */
function parseListing(text) {
const out = [];
for (const line of String(text).split('\n')) {
const m = line.match(/^([-dl])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\S+\s+\d+\s+[\d:]+\s+(.+)$/);
if (!m) continue;
let name = m[3]; if (m[1] === 'l') continue; // 심볼릭 링크는 건너뜀
if (name === '.' || name === '..') continue;
// v1.3.17 (S10): 서버가 돌려준 이름에 경로 구분자·NUL·".." 이 있으면 버린다 (사이트 폴더 밖에 쓰는 것 방지)
if (/[\/\\\0]/.test(name) || name === '..' || name.includes('..' + '/')) continue;
out.push({ type: m[1] === 'd' ? 'dir' : 'file', size: parseInt(m[2], 10), name });
}
return out;
}
async function curlList(src, dir) {
const r = await curl(src, curlUrl(src, dir || '', true), ['-o', '-'], { timeout: 60000 });
if (r.code !== 0) throw new Error(`목록 조회 실패(${dir || '/'}): ${r.err.split('\n')[0] || 'exit ' + r.code}`);
return parseListing(r.out);
}
async function curlWalk(src, root, limits) {
const files = []; let totalKb = 0;
const walk = async (rel) => {
const items = await curlList(src, path.posix.join(root, rel));
for (const it of items) {
const r = rel ? `${rel}/${it.name}` : it.name;
if (it.type === 'dir') { if (EXCLUDE_DIRS.includes(it.name)) continue; await walk(r); }
else { files.push({ rel: r, size: it.size }); totalKb += Math.ceil(it.size / 1024); }
const ce = capacityError(totalKb * 1024); if (ce) throw ce; // 목록을 받는 도중에 한도를 넘으면 바로 중지
}
};
await walk('');
return files;
}
async function pool(items, n, fn) { let i = 0; const errs = []; await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (i < items.length) { const it = items[i++]; try { await fn(it); } catch (e) { errs.push(e); if (errs.length > 5) throw e; } } })); if (errs.length) throw errs[0]; }
// ---------- 접속 테스트 / 경로 탐색 ----------
async function test(src) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
const started = Date.now();
if (src.protocol === 'ftp') {
try { const items = await curlList(src, src.docroot || ''); return { ok: true, shell: false, detail: `FTP 접속 성공 — ${src.user}@${src.host}:${src.port}, 항목 ${items.length}개`, ms: Date.now() - started }; }
catch (e) { return { ok: false, detail: `FTP 접속 실패: ${e.message}`, ms: Date.now() - started }; }
}
const r = await sshExec(src, 'echo "USER=$(id -un)"; echo "HOST=$(uname -n 2>/dev/null)"; command -v tar >/dev/null 2>&1 && echo TAR=ok || echo TAR=missing; echo END', { timeout: 40000 });
if (/END/.test(r.out)) {
const kv = {}; for (const l of r.out.split('\n')) { const m = l.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (kv.TAR === 'missing') return { ok: false, detail: '접속은 되지만 서버에 tar 가 없습니다', ms: Date.now() - started };
return { ok: true, shell: true, detail: `SSH 접속 성공 — ${kv.USER}@${kv.HOST || src.host}`, ms: Date.now() - started };
}
const err = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' ');
// 셸이 없는 SFTP 전용 계정일 수 있음 → curl sftp 로 재시도
if (!/Permission denied|Could not resolve|Connection (refused|timed out)|No route/.test(err)) {
try { const items = await curlList(src, src.docroot || ''); return { ok: true, shell: false, detail: `SFTP 접속 성공 (셸 없음, 파일 전송 방식) — 항목 ${items.length}개`, ms: Date.now() - started }; } catch (_) {}
}
return { ok: false, detail: `접속 실패: ${err}${sshHint(err)}`, ms: Date.now() - started };
}
function fetchUrl(url, redirects = 0) {
return new Promise((resolve) => {
let u; try { u = new URL(url); } catch (_) { return resolve({ ok: false, error: 'URL 형식 오류' }); }
const mod = u.protocol === 'https:' ? https : http;
const req = mod.get(u, { timeout: 15000, rejectUnauthorized: false, headers: { 'User-Agent': 'webmaker-import/1.0', Accept: 'text/html,*/*' } }, (res) => {
// v1.3.17 (S12): 잘못된 Location 값이 응답 콜백 밖으로 예외를 던져 서버 프로세스가 죽지 않게 한다
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 5) { res.resume(); let next; try { next = new URL(res.headers.location, u).href; } catch (_) { return resolve({ ok: false, error: '잘못된 리다이렉트 주소' }); } return resolve(fetchUrl(next, redirects + 1)); }
const chunks = []; let n = 0;
res.on('data', (c) => { n += c.length; if (n <= 2 * 1024 * 1024) chunks.push(c); });
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 400, status: res.statusCode, body: Buffer.concat(chunks), finalUrl: u.href }));
});
req.on('timeout', () => { req.destroy(new Error('timeout')); });
req.on('error', (e) => resolve({ ok: false, error: e.message }));
});
}
const md5 = (b) => crypto.createHash('md5').update(b).digest('hex');
const titleOf = (html) => { const m = String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i); return m ? m[1].replace(/\s+/g, ' ').trim() : ''; };
/** 사이트 파일이 있을 만한 경로 후보를 찾고 실제 사이트 첫 화면과 대조한다 */
/**
* nginx 설정 텍스트에서 server 블록을 뽑는다 (중괄호 짝 맞춤, 주석 제거).
* @returns {Array<{server:'nginx', names:string[], root:string|null, listen:string[], file:string}>}
*/
function parseNginx(text, file) {
const out = []; const src = text.replace(/#[^\n]*/g, '');
const re = /\bserver\s*\{/g; let m;
while ((m = re.exec(src))) {
let depth = 1; let i = re.lastIndex; while (i < src.length && depth) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; }
const body = src.slice(re.lastIndex, i - 1); re.lastIndex = i;
// 하위 location 블록의 root 보다 서버 수준 root 를 우선: 중첩 블록을 지운 텍스트에서 찾는다
let flat = body; for (let k = 0; k < 5; k++) flat = flat.replace(/\{[^{}]*\}/g, '');
const names = ((flat.match(/\bserver_name\s+([^;]+);/) || [])[1] || '').split(/\s+/).filter((n) => n && n !== '_' && n !== '""');
const root = ((flat.match(/\broot\s+([^;]+);/) || [])[1] || (body.match(/location\s+\/\s*\{[^}]*\broot\s+([^;]+);/) || [])[1] || '').trim().replace(/^["']|["']$/g, '') || null;
const listen = [...flat.matchAll(/\blisten\s+([^;]+);/g)].map((x) => x[1].trim());
const proxy = /\bproxy_pass\s/.test(body) && !root;
out.push({ server: 'nginx', names, root, listen, file, proxy });
}
return out;
}
/** Apache 설정에서 <VirtualHost> 블록을 뽑는다 */
function parseApache(text, file) {
const out = []; const re = /<VirtualHost\s+([^>]*)>([\s\S]*?)<\/VirtualHost>/gi; let m;
while ((m = re.exec(text))) {
const body = m[2].replace(/^\s*#.*$/gm, '');
const names = [((body.match(/^\s*ServerName\s+(\S+)/mi) || [])[1] || ''), ...((body.match(/^\s*ServerAlias\s+(.+)$/mi) || [])[1] || '').split(/\s+/)].filter(Boolean).map((n) => n.replace(/:\d+$/, ''));
const root = ((body.match(/^\s*DocumentRoot\s+(.+)$/mi) || [])[1] || '').trim().replace(/^["']|["']$/g, '') || null;
out.push({ server: 'apache', names, root, listen: [m[1].trim()], file, proxy: /ProxyPass\s/i.test(body) && !root });
}
return out;
}
/**
* 접속한 서버의 웹서버 구성을 읽어 서비스 중인 사이트 목록을 만든다 (v1.2.0).
* 접속 계정으로 읽을 수 있는 설정 파일만 대상으로 한다. 고객 서버에서 sudo 는 쓰지 않는다(관리자 승인 없이 권한 상승을 시도하지 않음).
* 같은 문서 루트를 쓰는 블록(80/443 등)은 하나로 합친다.
* @returns {{servers: string[], sites: Array<{names, root, server, file, listen}>}}
*/
async function discoverSites(src) {
const files = '/etc/nginx/nginx.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf /usr/local/nginx/conf/nginx.conf /etc/apache2/sites-enabled/* /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf /usr/local/apache2/conf/httpd.conf /usr/local/apache2/conf/extra/httpd-vhosts.conf';
const script = `echo "PS=$(ps -eo comm= 2>/dev/null | grep -E '^(nginx|apache2|httpd|caddy|lighttpd|litespeed|lshttpd)$' | sort -u | tr '\n' ' ')";
for f in ${files}; do [ -r "$f" ] && [ -f "$f" ] || continue; echo "@@FILE $f"; head -c 400000 "$f"; echo; done; echo "@@END"`;
const r = await sshExec(src, script, { timeout: 60000, maxOut: 8 * 1024 * 1024 });
if (!/@@END/.test(r.out)) return { servers: [], sites: [], error: (r.err || '').split('\n').slice(-1)[0] || `exit ${r.code}` };
const servers = ((r.out.match(/^PS=(.*)$/m) || [])[1] || '').trim().split(/\s+/).filter(Boolean).map((x) => (x === 'httpd' || x === 'apache2' ? 'apache' : x));
const chunks = r.out.split(/^@@FILE /m).slice(1); const all = []; const viaT = chunks.some((c) => c.startsWith('nginx -T'));
for (const c of chunks) { const nl = c.indexOf('\n'); const file = c.slice(0, nl).trim(); const text = c.slice(nl + 1).replace(/@@END[\s\S]*$/, ''); if (viaT && file.startsWith('/etc/nginx')) continue; if (/nginx/.test(file)) all.push(...parseNginx(text, file === 'nginx -T' ? 'nginx -T' : file)); else all.push(...parseApache(text, file)); }
// 문서 루트 기준으로 합치기 (프록시 전용 블록은 파일 사이트가 아니므로 표시만)
const byRoot = new Map();
for (const x of all) { const key = x.root || `proxy:${x.names.join(',')}`; const prev = byRoot.get(key); if (prev) { prev.names = [...new Set([...prev.names, ...x.names])]; prev.listen = [...new Set([...prev.listen, ...x.listen])]; } else byRoot.set(key, { ...x }); }
const sites = [...byRoot.values()].filter((x) => x.root || x.names.length).slice(0, 60);
return { servers: [...new Set(servers)], sites };
}
async function detectDocroots(src) {
let webInfo = null;
const live = src.url ? await fetchUrl(src.url) : null;
const liveHash = live && live.ok ? md5(live.body) : null; const liveTitle = live && live.ok ? titleOf(live.body.toString('utf8')) : '';
const cands = [];
if (src.shell === false || src.protocol === 'ftp') {
const roots = src.docroot ? [src.docroot] : FTP_CANDIDATES;
for (const d of roots) {
let items; try { items = await curlList(src, d); } catch (_) { continue; }
const idx = INDEX_NAMES.map((n) => items.find((i) => i.type === 'file' && i.name === n)).find(Boolean);
const dirs = items.filter((i) => i.type === 'dir').map((i) => i.name);
cands.push({ path: d || '/', index: idx ? idx.name : null, size: idx ? idx.size : 0, files: items.filter((i) => i.type === 'file').length, dirs: dirs.slice(0, 12), match: null });
if (!src.docroot) for (const sub of dirs.filter((n) => FTP_CANDIDATES.includes(n) && n)) { if (!roots.includes(sub) && !cands.some((c) => c.path === (d ? d + '/' + sub : sub))) roots.push(d ? d + '/' + sub : sub); }
}
// 첫 화면 대조: index 파일 내려받아 비교
for (const c of cands.filter((x) => x.index && x.size < 2 * 1024 * 1024).slice(0, 6)) {
const tmp = path.join(os.tmpdir(), 'wm-idx-' + crypto.randomBytes(4).toString('hex'));
try { const r = await curl(src, curlUrl(src, path.posix.join(c.path === '/' ? '' : c.path, c.index)), ['-o', tmp], { timeout: 60000 }); if (r.code === 0) { const buf = fs.readFileSync(tmp); c.title = titleOf(buf.toString('utf8')); c.match = liveHash ? (md5(buf) === liveHash ? 'exact' : (c.title && c.title === liveTitle ? 'title' : 'none')) : null; } } catch (_) {} finally { try { fs.unlinkSync(tmp); } catch (_) {} }
}
} else {
const H = src.host.replace(/^www\./, '').toLowerCase();
// v1.2.0: 웹서버 구성(nginx/Apache)에서 서비스 중인 사이트를 모두 읽어 후보로 쓴다
const disc = src.docroot ? { servers: [], sites: [] } : await discoverSites(src).catch((e) => ({ servers: [], sites: [], error: e.message }));
webInfo = disc;
const dirs = src.docroot ? [src.docroot] : CANDIDATE_DIRS.flatMap((d) => (d.includes('HOST') ? [d.replace('HOST', src.host), d.replace('HOST', H)] : [d]));
const script = `for d in ${dirs.map(shq).join(' ')}; do d=$(eval echo "$d"); [ -d "$d" ] && echo "DIR=$d"; done 2>/dev/null; echo END`;
const r = await sshExec(src, script, { timeout: 60000 });
if (!/END/.test(r.out)) throw new Error(`경로 탐색 실패: ${(r.err || r.out).split('\n').slice(-1)[0] || 'exit ' + r.code}`);
const found = [];
for (const site of disc.sites.filter((x) => x.root)) { const p = site.root.replace(/\/+$/, '') || '/'; if (!found.some((f) => f.path === p)) found.push({ path: p, fromConf: true, names: site.names, server: site.server, conf: site.file, hostMatch: site.names.some((n) => n.toLowerCase().replace(/^www\./, '') === H) }); }
for (const l of r.out.split('\n')) { const m = l.match(/^DIR=(.+)$/); if (m) { const p = m[1].replace(/\/+$/, '') || '/'; if (!found.some((f) => f.path === p)) found.push({ path: p, fromConf: false }); } }
const uniq = found.filter((f, i, a) => a.findIndex((x) => x.path === f.path) === i).slice(0, 40);
if (uniq.length) {
const s2 = uniq.map((f, i) => `d=${shq(f.path)}; if [ -d "$d" ]; then n=$(find "$d" -maxdepth 2 -type f 2>/dev/null | head -500 | wc -l); idx=""; for i in ${INDEX_NAMES.join(' ')}; do [ -f "$d/$i" ] && { idx=$i; break; }; done; sz=0; t=""; h=""; if [ -n "$idx" ]; then sz=$(wc -c < "$d/$idx" 2>/dev/null); h=$(md5sum < "$d/$idx" 2>/dev/null | cut -d" " -f1); t=$(head -c 65536 "$d/$idx" | tr -d '\\n\\r' | sed -n 's/.*<[tT][iI][tT][lL][eE][^>]*>\\(.*\\)<\\/[tT][iI][tT][lL][eE]>.*/\\1/p' | head -c 200); fi; echo "C${i}=$n|$idx|$sz|$h|$t"; fi`).join('; ');
const r2 = await sshExec(src, s2 + '; echo END', { timeout: 90000 });
uniq.forEach((f, i) => { const m = r2.out.match(new RegExp(`^C${i}=(\\d+)\\|([^|]*)\\|(\\d*)\\|([^|]*)\\|(.*)$`, 'm')); if (!m) return; const title = m[5].replace(/\s+/g, ' ').trim(); cands.push({ path: f.path, fromConf: f.fromConf, names: f.names || [], server: f.server || null, conf: f.conf || null, hostMatch: !!f.hostMatch, files: parseInt(m[1], 10), index: m[2] || null, size: parseInt(m[3] || '0', 10), title, match: liveHash && m[2] ? (m[4] === liveHash ? 'exact' : (title && title === liveTitle ? 'title' : 'none')) : null }); });
}
}
const rank = { exact: 0, title: 1, none: 3 };
cands.sort((a, b) => ((rank[a.match] ?? 2) - (rank[b.match] ?? 2)) || ((b.hostMatch ? 1 : 0) - (a.hostMatch ? 1 : 0)) || ((b.fromConf ? 1 : 0) - (a.fromConf ? 1 : 0)) || ((b.index ? 1 : 0) - (a.index ? 1 : 0)));
// 웹서버 구성에서 찾은 사이트가 하나뿐이면 그대로, 여럿이면 목록에서 고르게 한다(화면 처리)
const confSites = cands.filter((c) => c.fromConf && c.index);
return { candidates: cands, multiSite: confSites.length > 1, webServers: webInfo ? webInfo.servers : [], proxies: webInfo ? webInfo.sites.filter((x) => !x.root).map((x) => ({ names: x.names, server: x.server })) : [], live: live ? { ok: live.ok, status: live.status || null, title: liveTitle, error: live.error || null } : null };
}
// ---------- 가져오기 ----------
/** p 가 dir 안(같은 경로 제외)인지 — 글자 경로 기준 */
function insideDir(dir, p) { const a = path.resolve(dir) + path.sep; return path.resolve(p).startsWith(a); }
/**
* v1.3.17 (S11): 내려받은 사이트 폴더에서 심볼릭 링크·FIFO·소켓·장치 파일을 지운다.
* tar 는 링크를 그대로 풀기 때문에, 사이트 밖을 가리키는 링크가 남으면 나중에 파일 API 로 사이트 밖을 읽고 쓸 수 있었다.
* 링크는 따라가지 않는다(lstat). 반환: 지운 상대 경로
*/
function stripSpecial(dir, base = '') {
const out = []; let ents = []; try { ents = fs.readdirSync(path.join(dir, base), { withFileTypes: true }); } catch (_) { return out; }
for (const e of ents) {
const rel = base ? `${base}/${e.name}` : e.name; const fp = path.join(dir, rel);
if (e.isDirectory() && !e.isSymbolicLink()) { out.push(...stripSpecial(dir, rel)); continue; }
if (!e.isFile()) { try { fs.rmSync(fp, { force: true }); out.push(rel); } catch (_) {} }
}
return out;
}
function localFiles(dir, base = '') {
const out = {};
for (const ent of fs.readdirSync(path.join(dir, base), { withFileTypes: true })) {
const rel = base ? `${base}/${ent.name}` : ent.name;
if (ent.isDirectory()) { if (EXCLUDE_DIRS.includes(ent.name)) continue; Object.assign(out, localFiles(dir, rel)); }
else if (ent.isFile()) { if (ent.name === '.unpublished') continue; const st = fs.statSync(path.join(dir, rel)); out[rel] = { size: st.size, hash: crypto.createHash('sha1').update(fs.readFileSync(path.join(dir, rel))).digest('hex') }; }
}
return out;
}
function tarExcludes() { return EXCLUDE_DIRS.flatMap((d) => ['--exclude', `./${d}`, '--exclude', `*/${d}`]); }
/** 원격 사이트 파일을 siteDir 로 내려받는다. 반환: { fileCount, bytes, index, title } */
async function pull(src, siteDir, onProgress = () => {}) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
if (!src.docroot) throw httpError('사이트 경로(문서 루트)를 지정하세요');
const useCurl = src.protocol === 'ftp' || src.shell === false;
if (useCurl) {
onProgress('파일 목록을 조회하고 있습니다');
const files = await curlWalk(src, src.docroot, {});
let done = 0; let bytes = 0;
await pool(files, 4, async (f) => {
const local = path.join(siteDir, f.rel);
if (!insideDir(siteDir, local)) throw new Error(`사이트 폴더 밖 경로는 내려받지 않습니다: ${f.rel}`); // v1.3.17 (S10) 쓰기 전에 다시 확인
fs.mkdirSync(path.dirname(local), { recursive: true, mode: 0o755 });
const r = await curl(src, curlUrl(src, path.posix.join(src.docroot, f.rel)), ['-o', local], { timeout: 300000 });
if (r.code !== 0) throw new Error(`내려받기 실패(${f.rel}): ${r.err.split('\n')[0]}`);
bytes += f.size; done += 1; if (done % 10 === 0 || done === files.length) onProgress(`파일을 내려받고 있습니다 (${done}/${files.length})`);
});
return finishPull(src, siteDir, files.length, bytes);
}
onProgress('사이트 용량을 확인하고 있습니다');
const pre = await sshExec(src, `D=${shq(src.docroot)}; [ -d "$D" ] || { echo NODIR; exit 0; }; echo "KB=$(du -sk "$D" 2>/dev/null | cut -f1)"; echo "N=$(find "$D" -type f ${EXCLUDE_DIRS.map((d) => `-not -path '*/${d}/*'`).join(' ')} 2>/dev/null | wc -l)"; echo END`, { timeout: 120000 });
if (/NODIR/.test(pre.out)) throw new Error(`서버에 경로가 없습니다: ${src.docroot}`);
const kv = {}; for (const l of pre.out.split('\n')) { const m = l.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (!/END/.test(pre.out)) throw new Error(`용량 확인 실패: ${(pre.err || pre.out).split('\n').slice(-1)[0] || 'exit ' + pre.code}`);
{ const ce = capacityError(parseInt(kv.KB || '0', 10) * 1024); if (ce) throw ce; }
onProgress(`파일 ${kv.N}개(${(parseInt(kv.KB || '0', 10) / 1024).toFixed(1)}MB)를 내려받고 있습니다`);
const { args, env } = sshEnvArgs(src);
const ssh = spawn('ssh', [...args, `${src.user}@${src.host}`, `tar -C ${shq(src.docroot)} ${EXCLUDE_DIRS.map((d) => `--exclude=./${d} --exclude='*/${d}'`).join(' ')} -cf - .`], { env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
let sshErr = ''; ssh.stderr.on('data', (c) => { sshErr += c; });
const r = await run('tar', ['-C', siteDir, '--no-same-owner', '--no-same-permissions', '-xf', '-'], { stdin: ssh.stdout, timeout: 900000 });
await new Promise((res) => { if (ssh.exitCode !== null) return res(); ssh.on('close', res); setTimeout(res, 5000); });
if (r.code !== 0) throw new Error(`내려받기 실패: ${(r.err || sshErr).split('\n').slice(-1)[0] || 'exit ' + r.code}`);
const removed = stripSpecial(siteDir); if (removed.length) onProgress(`심볼릭 링크·특수 파일 ${removed.length}개는 가져오지 않았습니다 (${removed.slice(0, 3).join(', ')}${removed.length > 3 ? ' 외' : ''})`);
const files = localFiles(siteDir);
return finishPull(src, siteDir, Object.keys(files).length, Object.values(files).reduce((a, f) => a + f.size, 0), files);
}
function finishPull(src, siteDir, count, bytes, files) {
files = files || localFiles(siteDir);
// 사이트 폴더는 nginx 가 읽을 수 있어야 한다
for (const rel of Object.keys(files)) { try { fs.chmodSync(path.join(siteDir, rel), 0o644); } catch (_) {} }
const index = INDEX_NAMES.find((n) => files[n]); let title = '';
if (index) { try { title = titleOf(fs.readFileSync(path.join(siteDir, index), 'utf8').slice(0, 200000)); } catch (_) {} }
src.manifest = Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.hash])); src.importedAt = new Date().toISOString();
const dynamic = Object.keys(files).some((f) => /\.(php|asp|aspx|jsp|cgi|py|rb)$/i.test(f));
return { fileCount: Object.keys(files).length, bytes, index, title, dynamic };
}
// ---------- 되돌려 배포 ----------
/** siteDir 의 현재 파일을 원격 문서 루트에 반영한다. 가져온 뒤 사라진 파일은 삭제하고, 서버에만 있는(가져오지 않은) 파일은 건드리지 않는다. */
async function push(src, siteDir, onProgress = () => {}) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
if (!src.docroot) throw httpError('사이트 경로(문서 루트)가 없습니다');
const started = Date.now();
const files = localFiles(siteDir);
const prev = src.manifest || {};
const removed = Object.keys(prev).filter((rel) => !files[rel] && !rel.startsWith('uploads/'));
const changed = Object.keys(files).filter((rel) => prev[rel] !== files[rel].hash);
const useCurl = src.protocol === 'ftp' || src.shell === false;
let detail;
if (useCurl) {
let done = 0;
onProgress(`변경된 파일 ${changed.length}개를 올리고 있습니다`);
await pool(changed, 3, async (rel) => {
const r = await curl(src, curlUrl(src, path.posix.join(src.docroot, rel)), ['-T', path.join(siteDir, rel), '--ftp-create-dirs'], { timeout: 300000 });
if (r.code !== 0) throw new Error(`올리기 실패(${rel}): ${r.err.split('\n')[0]}`);
done += 1; if (done % 10 === 0) onProgress(`파일을 올리고 있습니다 (${done}/${changed.length})`);
});
let delFail = 0;
for (const rel of removed) {
const remote = path.posix.join(src.docroot, rel);
const cmd = src.protocol === 'ftp' ? `DELE ${remote.replace(/^\/+/, '')}` : `rm "${(remote.startsWith('/') ? remote : '/~/' + remote).replace(/"/g, '\\"')}"`;
const r = await curl(src, curlUrl(src, '', true), ['-Q', cmd], { timeout: 60000 });
if (r.code !== 0) delFail += 1;
}
detail = `파일 ${changed.length}개 반영, ${removed.length - delFail}개 삭제${delFail ? ` (${delFail}개 삭제 실패)` : ''} → ${src.protocol.toUpperCase()} ${src.user}@${src.host}:${src.docroot} (서버 백업은 이 방식에서 만들지 않으며, 가져온 원본은 이 서비스의 버전 기록에 남아 있습니다)`;
} else {
onProgress('서버에 백업을 만들고 파일을 반영하고 있습니다');
const tar = spawn('tar', ['-C', siteDir, ...tarExcludes(), '-cf', '-', '.']);
const remote = `set -e; D=${shq(src.docroot)}; [ -d "$D" ] || { echo NODIR; exit 1; }; B="$HOME/.webmaker-backups"; BK=""; if mkdir -p "$B" 2>/dev/null && tar -C "$D" -czf "$B/$(date +%Y%m%d-%H%M%S).tgz" . 2>/dev/null; then BK=$(ls -1t "$B"/*.tgz 2>/dev/null | head -1); ls -1t "$B"/*.tgz 2>/dev/null | tail -n +4 | xargs -r rm -f; fi; echo "BACKUP=$BK";
tar -C "$D" --no-same-owner -xf -; ${removed.length ? `for f in ${removed.map(shq).join(' ')}; do rm -f "$D/$f"; done;` : ''} echo PUSHED`;
const r = await sshExec(src, remote, { stdin: tar.stdout, timeout: 600000 });
if (!/PUSHED/.test(r.out)) { const err = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' '); throw new Error(`반영 실패: ${/NODIR/.test(r.out) ? '서버에 경로가 없습니다: ' + src.docroot : err}${/Permission denied/.test(err) ? ' → 접속 계정에 사이트 폴더 쓰기 권한이 있는지 확인하세요' : ''}`); }
const bk = (r.out.match(/^BACKUP=(.*)$/m) || [])[1] || '';
src.backupNote = bk ? `서버 백업: ${bk}` : '서버 백업을 만들지 못했습니다(권한/용량). 가져온 원본은 이 서비스의 버전 기록에 남아 있습니다.';
detail = `파일 ${changed.length}개 반영, ${removed.length}개 삭제 → ${src.user}@${src.host}:${src.docroot}${bk ? ` · 서버 백업 ${path.posix.basename(bk)}` : ' · 서버 백업 생략(권한 없음)'}`;
}
src.manifest = Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.hash])); src.lastPushAt = new Date().toISOString();
return { ok: true, detail, changed: changed.length, removed: removed.length, ms: Date.now() - started };
}
/** 배포 후 실제 사이트 주소가 열리고 우리가 보낸 첫 화면과 같은지 확인 */
async function verifyLive(src, siteDir) {
if (!src.url) return { items: [] };
const live = await fetchUrl(src.url);
if (!live.ok) return { items: [{ kind: 'url', url: src.url, ok: false, note: live.error || `HTTP ${live.status}` }] };
const index = INDEX_NAMES.find((n) => fs.existsSync(path.join(siteDir, n)));
let same = null; if (index && /\.html?$/i.test(index)) { try { same = md5(fs.readFileSync(path.join(siteDir, index))) === md5(live.body); } catch (_) {} }
return { items: [{ kind: 'url', url: live.finalUrl || src.url, ok: true, note: same === null ? `HTTP ${live.status} 응답` : (same ? `HTTP ${live.status} · 첫 화면이 반영된 내용과 일치` : `HTTP ${live.status} · 첫 화면 내용이 아직 다릅니다 (캐시/CDN 지연일 수 있음)`), same }] };
}
module.exports = { stripSpecial, insideDir, capacity, capacityError, discoverSites, parseNginx, parseApache, normalize, buildSource, publicSource, newKey, storeKey, deleteKey, test, detectDocroots, pull, push, verifyLive, parseListing, fetchUrl };