Files
ok-server/ok-server.mjs

325 lines
9.3 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.
* - --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
* - 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.
*/
import http from "node:http";
import { URL } from "node:url";
const VALID_LOOKS = new Set(["basic", "nice", "bootstrap"]);
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 <strong>{ip}</strong>.</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 <strong>{ip}</strong>.</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 <strong>{ip}</strong>.</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>
`;
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
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, ip, proxyHeadersHtml) {
const template =
look === "basic" ? HTML_BASIC : look === "nice" ? HTML_NICE : HTML_BOOTSTRAP;
return template
.replaceAll("{ip}", ip)
.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);
if (isCliAgent(userAgent)) {
const body = PLAIN_TEMPLATE
.replace("{ip}", clientIp)
.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, clientIp, 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 = 8000;
let look = "nice";
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 === "--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)`);
}
look = value;
continue;
}
if (arg === "-h" || arg === "--help") {
const help = [
"Usage:",
" node ok-server.mjs [-b|--bind ADDRESS] [-p|--port PORT] [--look basic|nice|bootstrap]",
"",
"Defaults:",
" --bind 0.0.0.0",
" --port 8000",
" --look nice",
].join("\n");
console.log(help);
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return { bind, port, look };
}
function run(bind, port, look) {
const server = http.createServer((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);
});
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: ${err.message}\n`);
process.exitCode = 1;
});
server.listen(port, bind, () => {
console.log(`Serving on ${bind}:${port} (default look=${look}) (Ctrl-C to stop)`);
});
process.on("SIGINT", () => {
console.log("\nShutting down server");
server.close(() => process.exit(0));
});
}
try {
const { bind, port, look } = parseArgs(process.argv.slice(2));
run(bind, port, look);
} catch (err) {
process.stderr.write(`${err.message}\n`);
process.exit(2);
}