Compare commits
8 Commits
22fbb896ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 286fcdbe5b | |||
| aa2d51a0dd | |||
| 83fd45bebd | |||
| ec0fa80dd5 | |||
| b0b8a8994d | |||
| 5c323d07b5 | |||
| 5ad1e04d0a | |||
| 02555ade28 |
3
.editorconfig
Normal file
3
.editorconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.14-slim
|
||||
WORKDIR /app
|
||||
COPY ./ok-server.py .
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
65
README.md
65
README.md
@@ -1,3 +1,64 @@
|
||||
# ok-server
|
||||
# Connectivity Test Server
|
||||
|
||||
A simple HTTP server designed for performing connectivity tests. May be run from command line or using a container.
|
||||
A simple HTTP server for connectivity testing. It can be run from the command line or in a container.
|
||||
|
||||
The Python version relies only on Python's standard library, so it should work in any environment with Python installed. The Node.js version also relies only on the standard library, so it should work in any environment with Node.js installed.
|
||||
|
||||
It displays a simple HTML or plain-text page with the client's IP address and any detected `X-*` headers.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
The **ok-server** scripts were generated using AI under human supervision. The owner of the repository is not responsible for any issues that may arise from using the AI-generated code.
|
||||
|
||||
## Usage
|
||||
|
||||
### Command Line
|
||||
|
||||
To run the server from the command line:
|
||||
|
||||
```bash
|
||||
python3 ok-server.py
|
||||
python3 ok-server.py --look basic
|
||||
python3 ok-server.py --bind 127.0.0.1 --port 8080 --look bootstrap
|
||||
```
|
||||
|
||||
`--look` accepts `basic`, `nice`, or `bootstrap`.
|
||||
You can override the look per request with the `look` query parameter, for example:
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
node ok-server.mjs
|
||||
node ok-server.mjs --look basic
|
||||
node ok-server.mjs --bind 127.0.0.1 --port 8080 --look bootstrap
|
||||
```
|
||||
|
||||
Connect to the server using a web browser or a tool like `curl`:
|
||||
|
||||
```bash
|
||||
curl -si "http://localhost:8000"
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Use the included `Dockerfile` to build and run the server in a Docker container:
|
||||
|
||||
```bash
|
||||
docker build -t ok-server .
|
||||
docker run --rm -d --name ok-server --cpus=1 --memory=256m -p 8000:8000 ok-server
|
||||
```
|
||||
|
||||
### Container CLI on a Mac
|
||||
|
||||
```shell
|
||||
container builder stop
|
||||
container builder start -c 4 -m 1G
|
||||
container build -t ok-server .
|
||||
container run --rm -d --name ok-server -c 1 -m 256m -p 8000:8000 ok-server
|
||||
```
|
||||
|
||||
This will start the server and expose it on port 8000.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
3
entrypoint.sh
Normal file
3
entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
exec python3 ok-server.py "$@"
|
||||
324
ok-server.mjs
Executable file
324
ok-server.mjs
Executable file
@@ -0,0 +1,324 @@
|
||||
#!/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, "&")
|
||||
.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, 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);
|
||||
}
|
||||
63
ok-server.py
63
ok-server.py
@@ -4,6 +4,7 @@
|
||||
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 whether any incoming X-* headers were detected and lists them
|
||||
- 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:
|
||||
@@ -25,8 +26,10 @@ Test:
|
||||
wget -S -O - http://localhost:8000/ # text/plain for wget
|
||||
open http://localhost:8000/ # browser gets HTML variant per look
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from html import escape
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
@@ -34,9 +37,11 @@ from urllib.parse import urlparse, parse_qs
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
HTML_BASIC = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -47,11 +52,13 @@ HTML_BASIC = """<!doctype html>
|
||||
<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>
|
||||
"""
|
||||
|
||||
|
||||
HTML_NICE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -71,12 +78,14 @@ HTML_NICE = """<!doctype html>
|
||||
<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>
|
||||
"""
|
||||
|
||||
|
||||
HTML_BOOTSTRAP = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -100,6 +109,7 @@ HTML_BOOTSTRAP = """<!doctype html>
|
||||
<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>
|
||||
@@ -122,6 +132,32 @@ VALID_LOOKS = ("basic", "nice", "bootstrap")
|
||||
class OkHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _collect_x_headers(self):
|
||||
x_headers = []
|
||||
for name, value in self.headers.items():
|
||||
if name.lower().startswith("x-"):
|
||||
x_headers.append((name, value))
|
||||
return sorted(x_headers, key=lambda item: item[0].lower())
|
||||
|
||||
def _proxy_markup(self, x_headers):
|
||||
if not x_headers:
|
||||
return "", ""
|
||||
|
||||
plain_lines = [f" - {name}: {value}" for name, value in x_headers]
|
||||
proxy_headers_block = (
|
||||
"Reverse proxy condition: detected via X-* headers.\n"
|
||||
"X-* headers:\n" + "\n".join(plain_lines) + "\n\n"
|
||||
)
|
||||
|
||||
html_items = "".join(
|
||||
[f"<li><code>{escape(name)}</code>: {escape(value)}</li>" for name, value in x_headers]
|
||||
)
|
||||
proxy_headers_html = (
|
||||
"<p>Reverse proxy condition: <strong>detected via X-* headers</strong>.</p>"
|
||||
"<p>X-* headers:</p><ul>" + html_items + "</ul>"
|
||||
)
|
||||
return proxy_headers_block, proxy_headers_html
|
||||
|
||||
def _is_cli_agent(self, ua: str) -> bool:
|
||||
if not ua:
|
||||
return False
|
||||
@@ -142,17 +178,32 @@ class OkHandler(BaseHTTPRequestHandler):
|
||||
return look
|
||||
return None
|
||||
|
||||
def _html_for_look(self, look: str, ip: str) -> str:
|
||||
def _html_for_look(self, look: str, ip: str, proxy_headers_html: str) -> str:
|
||||
if look == "basic":
|
||||
return HTML_BASIC.format(ip=ip)
|
||||
return HTML_BASIC.format(
|
||||
ip=ip,
|
||||
proxy_headers_html=proxy_headers_html,
|
||||
)
|
||||
if look == "nice":
|
||||
return HTML_NICE.format(ip=ip)
|
||||
return HTML_NICE.format(
|
||||
ip=ip,
|
||||
proxy_headers_html=proxy_headers_html,
|
||||
)
|
||||
# fallback to bootstrap
|
||||
return HTML_BOOTSTRAP.format(ip=ip)
|
||||
return HTML_BOOTSTRAP.format(
|
||||
ip=ip,
|
||||
proxy_headers_html=proxy_headers_html,
|
||||
)
|
||||
|
||||
def _make_body_and_type(self, client_ip: str, user_agent: str) -> Tuple[bytes, str]:
|
||||
x_headers = self._collect_x_headers()
|
||||
proxy_headers_block, proxy_headers_html = self._proxy_markup(x_headers)
|
||||
|
||||
if self._is_cli_agent(user_agent):
|
||||
body = PLAIN_TEMPLATE.format(ip=client_ip).encode("utf-8")
|
||||
body = PLAIN_TEMPLATE.format(
|
||||
ip=client_ip,
|
||||
proxy_headers_block=proxy_headers_block,
|
||||
).encode("utf-8")
|
||||
ctype = "text/plain; charset=utf-8"
|
||||
return body, ctype
|
||||
|
||||
@@ -164,7 +215,7 @@ class OkHandler(BaseHTTPRequestHandler):
|
||||
# fallback to server-level default (now: nice)
|
||||
look = getattr(self.server, "look", "nice")
|
||||
|
||||
html = self._html_for_look(look, client_ip)
|
||||
html = self._html_for_look(look, client_ip, proxy_headers_html)
|
||||
body = html.encode("utf-8")
|
||||
ctype = "text/html; charset=utf-8"
|
||||
return body, ctype
|
||||
|
||||
Reference in New Issue
Block a user