'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(/]*>([\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 설정에서 블록을 뽑는다 */ function parseApache(text, file) { const out = []; const re = /]*)>([\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 };