445 lines
13 KiB
JavaScript
Executable File
445 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Simple HTTP server that responds 200 with a test message including client IP.
|
|
*
|
|
* Behavior:
|
|
* - GET returns HTTP/1.1 200 with a message including the requester IP
|
|
* - HEAD returns the same headers as GET but no body
|
|
* - Displays incoming X-* headers when present
|
|
* - If User-Agent contains "curl" or "wget" (case-insensitive), the server
|
|
* responds with Content-Type: text/plain; otherwise Content-Type: text/html.
|
|
* - --pem specifies a PEM bundle file (cert chain + private key). When provided,
|
|
* the server listens on both HTTP (--port) and HTTPS (--tls-port).
|
|
* Without --pem the server listens on plain HTTP only.
|
|
* - --look controls which HTML variant is returned for non-CLI agents:
|
|
* * basic - plain HTML with no external references
|
|
* * nice - includes Google Font "Noto Sans" (default)
|
|
* * bootstrap - Bootstrap 5 layout
|
|
* * tailwind - Tailwind CSS via @tailwindcss/browser@latest
|
|
* - The URL query parameter "look" (e.g. /?look=nice) overrides --look for
|
|
* that request only. Values are case-insensitive and must be basic,nice,bootstrap,tailwind.
|
|
*/
|
|
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import fs from "node:fs";
|
|
import { URL } from "node:url";
|
|
|
|
const VALID_LOOKS = new Set(["basic", "nice", "bootstrap", "tailwind"]);
|
|
|
|
const PLAIN_TEMPLATE =
|
|
"Hello, This is a test HTTP server.\n\n" +
|
|
"Your request came from {ip}.\n\n" +
|
|
"{proxy_headers_block}" +
|
|
"Have a nice day!\n";
|
|
|
|
const HTML_BASIC = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Test HTTP server</title>
|
|
</head>
|
|
<body>
|
|
<h1>Hello,</h1>
|
|
<p>This is a test HTTP server.</p>
|
|
<p>Your request came from {ip_html}.</p>
|
|
{proxy_headers_html}
|
|
<p>Have a nice day!</p>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const HTML_NICE = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Test HTTP server</title>
|
|
<!-- Google Font: Noto Sans -->
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
body { font-family: "Noto Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; padding: 2rem; background: #f6f7fb; }
|
|
.card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); max-width: 800px; margin: 2rem auto; }
|
|
h1 { margin-top: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Hello,</h1>
|
|
<p>This is a test HTTP server.</p>
|
|
<p>Your request came from {ip_html}.</p>
|
|
{proxy_headers_html}
|
|
<p>Have a nice day!</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const HTML_BOOTSTRAP = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Test HTTP server</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
|
|
</head>
|
|
<body class="bg-light">
|
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
|
|
<div class="container">
|
|
<a class="navbar-brand" href="#">Test HTTP server</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="container">
|
|
<div class="row justify-content-center">
|
|
<div class="col-md-8">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body">
|
|
<h1 class="card-title">Hello,</h1>
|
|
<p class="card-text">This is a test HTTP server.</p>
|
|
<p class="card-text">Your request came from {ip_html}.</p>
|
|
{proxy_headers_html}
|
|
<hr>
|
|
<p class="mb-0">Have a nice day!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="text-center mt-4 mb-4">
|
|
<small class="text-muted">Simple status page for firewall/testing</small>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const HTML_TAILWIND = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Test HTTP server</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@latest"></script>
|
|
</head>
|
|
<body class="min-h-screen bg-slate-100 text-slate-900">
|
|
<div class="mx-auto max-w-3xl px-4 py-10 sm:py-16">
|
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
|
|
<div class="bg-slate-900 px-6 py-4 text-slate-100">
|
|
<h1 class="text-xl font-semibold">Test HTTP server</h1>
|
|
<p class="mt-1 text-sm text-slate-300">Simple status page for firewall/testing</p>
|
|
</div>
|
|
<main class="space-y-4 px-6 py-6">
|
|
<p class="text-2xl font-bold">Hello,</p>
|
|
<p>This is a test HTTP server.</p>
|
|
<p>Your request came from {ip_html}.</p>
|
|
{proxy_headers_html}
|
|
<p class="pt-2">Have a nice day!</p>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function isCliAgent(userAgent) {
|
|
if (!userAgent) {
|
|
return false;
|
|
}
|
|
const ua = userAgent.toLowerCase();
|
|
return ua.includes("curl") || ua.includes("wget");
|
|
}
|
|
|
|
function getRequestLook(req) {
|
|
const url = new URL(req.url || "/", "http://localhost");
|
|
const look = (url.searchParams.get("look") || "").trim().toLowerCase();
|
|
if (VALID_LOOKS.has(look)) {
|
|
return look;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectXHeaders(req) {
|
|
const xHeaders = [];
|
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
if (!name.toLowerCase().startsWith("x-")) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
xHeaders.push([name, value.join(", ")]);
|
|
} else if (value !== undefined) {
|
|
xHeaders.push([name, value]);
|
|
}
|
|
}
|
|
xHeaders.sort((a, b) => a[0].localeCompare(b[0], "en", { sensitivity: "base" }));
|
|
return xHeaders;
|
|
}
|
|
|
|
function proxyMarkup(xHeaders) {
|
|
if (xHeaders.length === 0) {
|
|
return { proxyHeadersBlock: "", proxyHeadersHtml: "" };
|
|
}
|
|
|
|
const plainLines = xHeaders.map(([name, value]) => ` - ${name}: ${value}`);
|
|
const proxyHeadersBlock =
|
|
"Reverse proxy condition: detected via X-* headers.\n" +
|
|
"X-* headers:\n" +
|
|
plainLines.join("\n") +
|
|
"\n\n";
|
|
|
|
const htmlItems = xHeaders
|
|
.map(([name, value]) => `<li><code>${escapeHtml(name)}</code>: ${escapeHtml(value)}</li>`)
|
|
.join("");
|
|
const proxyHeadersHtml =
|
|
"<p>Reverse proxy condition: <strong>detected via X-* headers</strong>.</p>" +
|
|
`<p>X-* headers:</p><ul>${htmlItems}</ul>`;
|
|
|
|
return { proxyHeadersBlock, proxyHeadersHtml };
|
|
}
|
|
|
|
function htmlForLook(look, ipHtml, proxyHeadersHtml) {
|
|
const template =
|
|
look === "basic"
|
|
? HTML_BASIC
|
|
: look === "nice"
|
|
? HTML_NICE
|
|
: look === "bootstrap"
|
|
? HTML_BOOTSTRAP
|
|
: HTML_TAILWIND;
|
|
|
|
return template
|
|
.replace("{ip_html}", ipHtml)
|
|
.replace("{proxy_headers_html}", proxyHeadersHtml);
|
|
}
|
|
|
|
function makeBodyAndType(req, clientIp, defaultLook) {
|
|
const userAgent = req.headers["user-agent"] || "";
|
|
const xHeaders = collectXHeaders(req);
|
|
const { proxyHeadersBlock, proxyHeadersHtml } = proxyMarkup(xHeaders);
|
|
|
|
let xff = (req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
|
// strip port: [::1]:port -> ::1, 1.2.3.4:port -> 1.2.3.4
|
|
if (xff.startsWith("[")) {
|
|
xff = xff.slice(1, xff.includes("]") ? xff.indexOf("]") : xff.length);
|
|
} else if ((xff.match(/:/g) || []).length === 1) {
|
|
xff = xff.split(":")[0];
|
|
}
|
|
let ipText, ipHtml;
|
|
if (xff) {
|
|
ipText = `${xff} (forwarded by proxy ${clientIp})`;
|
|
ipHtml = `<strong>${escapeHtml(xff)}</strong> (forwarded by proxy <strong>${escapeHtml(clientIp)}</strong>)`;
|
|
} else {
|
|
ipText = clientIp;
|
|
ipHtml = `<strong>${escapeHtml(clientIp)}</strong>`;
|
|
}
|
|
|
|
if (isCliAgent(userAgent)) {
|
|
const body = PLAIN_TEMPLATE
|
|
.replace("{ip}", ipText)
|
|
.replace("{proxy_headers_block}", proxyHeadersBlock);
|
|
return { body: Buffer.from(body, "utf8"), contentType: "text/plain; charset=utf-8" };
|
|
}
|
|
|
|
const requestLook = getRequestLook(req);
|
|
const look = requestLook || defaultLook;
|
|
const html = htmlForLook(look, ipHtml, proxyHeadersHtml);
|
|
return { body: Buffer.from(html, "utf8"), contentType: "text/html; charset=utf-8" };
|
|
}
|
|
|
|
function getClientIp(req) {
|
|
return req.socket?.remoteAddress || "unknown";
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
let bind = "0.0.0.0";
|
|
let port = 8080;
|
|
let tlsPort = 8443;
|
|
let look = "nice";
|
|
let pem = null;
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
|
|
if (arg === "-b" || arg === "--bind") {
|
|
i += 1;
|
|
if (i >= argv.length) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
bind = argv[i];
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-p" || arg === "--port") {
|
|
i += 1;
|
|
if (i >= argv.length) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
const parsedPort = Number.parseInt(argv[i], 10);
|
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
|
throw new Error(`Invalid port: ${argv[i]}`);
|
|
}
|
|
port = parsedPort;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--pem") {
|
|
i += 1;
|
|
if (i >= argv.length) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
pem = argv[i];
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--tls-port") {
|
|
i += 1;
|
|
if (i >= argv.length) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
const parsedTlsPort = Number.parseInt(argv[i], 10);
|
|
if (!Number.isInteger(parsedTlsPort) || parsedTlsPort < 1 || parsedTlsPort > 65535) {
|
|
throw new Error(`Invalid tls-port: ${argv[i]}`);
|
|
}
|
|
tlsPort = parsedTlsPort;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--look") {
|
|
i += 1;
|
|
if (i >= argv.length) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
const value = String(argv[i]).trim().toLowerCase();
|
|
if (!VALID_LOOKS.has(value)) {
|
|
throw new Error(`Invalid look: ${argv[i]} (expected one of: basic, nice, bootstrap, tailwind)`);
|
|
}
|
|
look = value;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-h" || arg === "--help") {
|
|
const help = [
|
|
"Usage:",
|
|
" node ok-server.mjs [-b|--bind ADDRESS] [-p|--port PORT] [--tls-port PORT] [--pem PEMFILE] [--look basic|nice|bootstrap|tailwind]",
|
|
"",
|
|
"Defaults:",
|
|
" --bind 0.0.0.0",
|
|
" --port 8080",
|
|
" --tls-port 8443",
|
|
" --pem (none, plain HTTP only)",
|
|
" --look nice",
|
|
].join("\n");
|
|
console.log(help);
|
|
process.exit(0);
|
|
}
|
|
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
return { bind, port, tlsPort, look, pem };
|
|
}
|
|
|
|
function run(bind, port, tlsPort, look, pem) {
|
|
let isShuttingDown = false;
|
|
const servers = [];
|
|
|
|
function createRequestHandler(req, res) {
|
|
const method = req.method || "GET";
|
|
|
|
if (method !== "GET" && method !== "HEAD") {
|
|
res.writeHead(405, { "Content-Length": "0" });
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const clientIp = getClientIp(req);
|
|
const { body, contentType } = makeBodyAndType(req, clientIp, look);
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": contentType,
|
|
"Content-Length": String(body.length),
|
|
});
|
|
|
|
if (method === "HEAD") {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
res.end(body);
|
|
}
|
|
|
|
function setupServer(server, scheme, listenPort) {
|
|
server.on("request", (req) => {
|
|
const clientIp = getClientIp(req);
|
|
const now = new Date().toUTCString();
|
|
const method = req.method || "GET";
|
|
const path = req.url || "/";
|
|
process.stderr.write(`${clientIp} - - [${now}] "${method} ${path}"\n`);
|
|
});
|
|
|
|
server.on("error", (err) => {
|
|
process.stderr.write(`Server error (${scheme}): ${err.message}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
server.listen(listenPort, bind, () => {
|
|
console.log(`Serving on ${scheme}://${bind}:${listenPort} (default look=${look})`);
|
|
});
|
|
|
|
servers.push(server);
|
|
}
|
|
|
|
// Create HTTP server
|
|
const httpServer = http.createServer(createRequestHandler);
|
|
setupServer(httpServer, "http", port);
|
|
|
|
// Create HTTPS server if PEM is provided
|
|
if (pem) {
|
|
let pemContent;
|
|
try {
|
|
pemContent = fs.readFileSync(pem);
|
|
} catch (err) {
|
|
throw new Error(`Cannot read PEM file '${pem}': ${err.message}`);
|
|
}
|
|
const httpsServer = https.createServer({ cert: pemContent, key: pemContent }, createRequestHandler);
|
|
setupServer(httpsServer, "https", tlsPort);
|
|
}
|
|
|
|
function handleShutdownSignal() {
|
|
if (isShuttingDown) {
|
|
return;
|
|
}
|
|
isShuttingDown = true;
|
|
console.log("\nShutting down server");
|
|
let closed = 0;
|
|
servers.forEach((server) => {
|
|
server.close(() => {
|
|
closed += 1;
|
|
if (closed === servers.length) {
|
|
process.exit(0);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
process.on("SIGINT", handleShutdownSignal);
|
|
process.on("SIGTERM", handleShutdownSignal);
|
|
}
|
|
|
|
try {
|
|
const { bind, port, tlsPort, look, pem } = parseArgs(process.argv.slice(2));
|
|
run(bind, port, tlsPort, look, pem);
|
|
} catch (err) {
|
|
process.stderr.write(`${err.message}\n`);
|
|
process.exit(2);
|
|
}
|