293 lines
8.3 KiB
JavaScript
Executable File
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;
|