2026-04-13 16:42:01 +00:00

293 lines
8.3 KiB
JavaScript
Executable File

// 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;