const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const NGINX_DIR = process.env.NGINX_CONF_D || '/etc/nginx/conf.d'; /** * Validates filename to prevent path traversal. * @param {string} filename */ const validateFilename = (filename) => { if (!/^[a-zA-Z0-9._-]+$/.test(filename)) { throw new Error('Invalid filename'); } if (filename.includes('..') || filename.includes('/')) { throw new Error('Invalid filename'); } }; /** * Validates filesystem path characters. * @param {string} pathStr */ const validatePath = (pathStr) => { if (!/^[a-zA-Z0-9/_.-]+$/.test(pathStr)) { throw new Error('Invalid path characters'); } }; /** * Lists all virtual hosts in the Nginx config directory. * Looks for .conf and .conf.disabled files. * @returns {Array} [{ filename, serverName, root, enabled }] */ const listVHosts = () => { try { if (!fs.existsSync(NGINX_DIR)) { console.warn(`Nginx config directory not found: ${NGINX_DIR}`); return []; } const files = fs.readdirSync(NGINX_DIR); const vhosts = []; files.forEach(file => { const isEnabled = file.endsWith('.conf'); const isDisabled = file.endsWith('.conf.disabled'); if (isEnabled || isDisabled) { const fullPath = path.join(NGINX_DIR, file); const content = fs.readFileSync(fullPath, 'utf8'); // Extract server_name and root using regex const serverNameMatch = content.match(/server_name\s+([^;]+);/); const rootMatch = content.match(/root\s+([^;]+);/); vhosts.push({ filename: file, serverName: serverNameMatch ? serverNameMatch[1].trim() : 'unknown', root: rootMatch ? rootMatch[1].trim() : 'unknown', enabled: isEnabled }); } }); return vhosts; } catch (err) { console.error('Error listing vhosts:', err.message); throw err; } }; /** * Renames a vhost file to enable or disable it. * @param {string} filename * @param {boolean} enable */ const toggleVHost = (filename, enable) => { validateFilename(filename); const currentPath = path.join(NGINX_DIR, filename); let newFilename = filename; if (enable && filename.endsWith('.conf.disabled')) { newFilename = filename.replace('.conf.disabled', '.conf'); } else if (!enable && filename.endsWith('.conf')) { newFilename = filename + '.disabled'; } else { // Already in desired state or unexpected filename return filename; } const newPath = path.join(NGINX_DIR, newFilename); // Using sudo mv via execFileSync might be safer if permissions are tight, // but the app should have permission if setup correctly. // We'll try fs.renameSync first. fs.renameSync(currentPath, newPath); return newFilename; }; /** * Creates a new virtual host configuration. * @param {string} domain * @param {string} documentRoot * @param {number} port * @param {string} type - 'static' | 'proxy' */ const createVHost = (domain, documentRoot, port, type) => { validateFilename(domain); validatePath(documentRoot); const filename = `${domain}.conf.disabled`; const fullPath = path.join(NGINX_DIR, filename); let config = ''; if (type === 'static') { config = `server { listen ${port}; server_name ${domain}; root ${documentRoot}; index index.html; location / { try_files $uri $uri/ /index.html; } } `; } else if (type === 'proxy') { config = `server { listen 80; server_name ${domain}; location / { proxy_pass http://127.0.0.1:${port}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } `; } fs.writeFileSync(fullPath, config); return filename; }; /** * Reloads Nginx using rc-service. */ const reloadNginx = () => { try { // First test config execFileSync('sudo', ['nginx', '-t']); // Then reload execFileSync('sudo', ['rc-service', 'nginx', 'reload']); return true; } catch (err) { console.error('Nginx reload failed:', err.message); throw new Error(`Nginx reload failed: ${err.stderr || err.message}`); } }; module.exports = { listVHosts, toggleVHost, createVHost, reloadNginx };