// routes/nginx.js import { Router } from "express"; import { promises as fs } from "fs"; import path from "node:path"; import { execFile } from "node:child_process"; import { STATIC_IP, PARENT_PATH, PUBLIC_WEB_ROOT, ERROR_LOG_ROOT, } from "../server.js"; const router = Router(); // ---- absolute binaries (systemd PATH is tiny) ---- const SUDO_BIN = process.env.SUDO_BIN || "/usr/bin/sudo"; const NGINX_BIN = process.env.NGINX_BIN || "/usr/sbin/nginx"; const SYSTEMCTL_BIN = process.env.SYSTEMCTL_BIN || "/bin/systemctl"; // ---------- helpers ---------- const isValidIPv4 = (ip) => /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split(".").every((n) => { const v = Number(n); return v >= 0 && v <= 255 && String(v) === n; }); const isValidDomain = (d) => /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/.test(d); const isValidPort = (p) => Number.isInteger(Number(p)) && Number(p) >= 1 && Number(p) <= 65535; // very small sanitizer to avoid path traversal via domain function safeDomain(d) { if (!isValidDomain(d)) throw new Error("Invalid domain"); return d.toLowerCase(); } function buildSSL({ ip, domain, port }) { const basePath = path.posix.join(PARENT_PATH, domain); const publicPath = path.posix.join(PUBLIC_WEB_ROOT, domain); return `server { listen ${ip}:443 ssl; server_name ${domain}; error_log ${path.posix.join(ERROR_LOG_ROOT, `${domain}.error.log`)} error; ssl_certificate ${path.posix.join(basePath, "ssl", `${domain}.pem`)}; ssl_certificate_key ${path.posix.join(basePath, "ssl", `${domain}.key`)}; location / { proxy_pass http://127.0.0.1:${port}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; 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; } location ~ /\\.(?!well-known\\/|file) { deny all; return 404; } location /error/ { alias ${path.posix.join(publicPath, "document_errors")}/; } disable_symlinks if_not_owner from=${path.posix.join(publicPath, "public_html")}; include ${path.posix.join(basePath, "nginx.ssl.conf_*")}; }`; } function buildNonSSL({ ip, domain, port }) { const basePath = path.posix.join(PARENT_PATH, domain); const publicPath = path.posix.join(PUBLIC_WEB_ROOT, domain); return `server { listen ${ip}:80; server_name ${domain}; error_log ${path.posix.join(ERROR_LOG_ROOT, `${domain}.error.log`)} error; include ${path.posix.join(basePath, "nginx.forcessl.conf*")}; location / { proxy_pass http://127.0.0.1:${port}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; 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; } location ~ /\\.(?!well-known\\/|file) { deny all; return 404; } location /error/ { alias ${path.posix.join(publicPath, "document_errors")}/; } disable_symlinks if_not_owner from=${path.posix.join(publicPath, "public_html")}; include ${path.posix.join(basePath, "nginx.conf_*")}; }`; } function sh(cmd, args = [], opts = {}) { return new Promise((resolve, reject) => { execFile(cmd, args, { timeout: 30_000, ...opts }, (err, stdout, stderr) => { if (err) return reject({ err, stdout, stderr }); resolve({ stdout, stderr }); }); }); } async function exists(p) { try { await fs.stat(p); return true; } catch { return false; } } // ---------- routes ---------- /** * POST /api/nginx/app * body: { domain: string, port: number|string, ip?: string, dryRun?: boolean, skipReload?: boolean } */ router.post("/nginx/app", async (req, res) => { try { const { domain, port, ip: ipIn, dryRun = false, skipReload = false } = req.body || {}; if (!domain || !port) { return res.status(400).json({ ok: false, error: "domain and port are required" }); } const ip = ipIn && String(ipIn).trim() ? String(ipIn).trim() : STATIC_IP; if (!isValidDomain(domain)) { return res.status(400).json({ ok: false, error: "Invalid domain" }); } if (!isValidPort(port)) { return res.status(400).json({ ok: false, error: "Invalid port" }); } if (!isValidIPv4(ip)) { return res.status(400).json({ ok: false, error: "Invalid ip" }); } const dom = safeDomain(domain); const context = { ip, domain: dom, port: Number(port) }; const sslConf = buildSSL(context); const nonsslConf = buildNonSSL(context); const domainDir = path.join(PARENT_PATH, dom); const sslFile = path.join(domainDir, "nginx.ssl.conf"); const nonsslFile = path.join(domainDir, "nginx.conf"); if (dryRun) { return res.json({ ok: true, dryRun: true, files: { [nonsslFile]: nonsslConf, [sslFile]: sslConf, }, }); } // ensure directory exists await fs.mkdir(domainDir, { recursive: true }); // preflight: can we write to the dir? try { await fs.access(domainDir, fs.constants.W_OK | fs.constants.X_OK); } catch { return res.status(403).json({ ok: false, error: `No write access to ${domainDir} for this process. Fix permissions or run with sudo.`, }); } // backup previous if exist (returns backup path or null) async function backupIfExists(fpath) { try { const stat = await fs.stat(fpath); if (stat.isFile()) { const bkp = `${fpath}.bak-${Date.now()}`; await fs.copyFile(fpath, bkp); return bkp; } } catch {} return null; } const nonsslBkp = await backupIfExists(nonsslFile); const sslBkp = await backupIfExists(sslFile); // write new files await fs.writeFile(nonsslFile, nonsslConf, "utf8"); await fs.writeFile(sslFile, sslConf, "utf8"); // nginx -t (as root, via sudo, with absolute paths) let testOut; try { //testOut = await sh(SUDO_BIN, [NGINX_BIN, "-t"]); } catch (e) { // restore backups if test fails try { if (nonsslBkp && (await exists(nonsslBkp))) await fs.copyFile(nonsslBkp, nonsslFile); if (sslBkp && (await exists(sslBkp))) await fs.copyFile(sslBkp, sslFile); } catch {} return res.status(500).json({ ok: false, error: "nginx -t failed", details: e.stderr || e.stdout || String(e.err), }); } // // reload (always via sudo/systemctl) // let reloadOut = { stdout: "", stderr: "" }; // if (!skipReload) { // try { // reloadOut = await sh(SUDO_BIN, [SYSTEMCTL_BIN, "reload", "nginx"]); // } catch (e) { // return res.status(500).json({ // ok: false, // error: "Failed to reload nginx", // details: e.stderr || e.stdout || String(e.err), // test: testOut, // }); // } // } // reload (always via sudo) let reloadOut = { stdout: "", stderr: "" }; if (!skipReload) { try { // 1) Test nginx configuration const testOut = await sh("sudo", ["nginx", "-t"]); if (testOut.stderr?.length) { // nginx -t writes to stderr even on success, so check exit code if sh() gives it console.log("nginx -t:", testOut.stderr); } // 2) Reload nginx if the test passed reloadOut = await sh("sudo", ["systemctl", "reload", "nginx"]); return res.json({ ok: true, test: testOut, reload: reloadOut, }); } catch (e) { return res.status(500).json({ ok: false, error: "Failed to validate or reload nginx", details: e.stderr || e.stdout || String(e.err), }); } } return res.json({ ok: true, written: [nonsslFile, sslFile], tested: testOut, reloaded: skipReload ? "skipped" : reloadOut, render: { nonssl: nonsslConf, ssl: sslConf }, }); } catch (err) { return res.status(500).json({ ok: false, error: String(err && err.message ? err.message : err) }); } }); export default router;