This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 22:22:54 +02:00
1 parent e84df445c6
commit e87a382a7e
109 files changed
+1092 -2925

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""LOOPBACK-ONLY fake Tinyauth protocol peer. NEVER use this as real authentication.
A cookie named mock_auth with value allow approves a request. Any client can mint it.
This exists to test RustProxy auth_request, identity forwarding and fail-closed paths.
"""
from __future__ import annotations
import argparse
from collections import deque
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
import threading
import time
from urllib.parse import urlsplit
class State:
def __init__(self):
self.lock = threading.Lock()
self.status = None
self.delay_ms = 0
self.location = None
self.calls = 0
self.recent = deque(maxlen=16)
class Server(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address, state=None):
if not ipaddress.ip_address(address[0]).is_loopback:
raise ValueError("The fake auth server may bind only a numeric loopback address")
self.state = state or State()
super().__init__(address, Handler)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "RustProxy-Mock-Auth/0.4.0"
sys_version = ""
def log_message(self, *_):
pass
def setup(self):
super().setup()
self.connection.settimeout(5)
def reply(self, status, value=None, headers=()):
data = b"" if value is None else json.dumps(value, ensure_ascii=True).encode()
self.send_response(status)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json")
for key, value in headers:
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD":
try:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self):
path = urlsplit(self.path).path
if self.headers.get("Transfer-Encoding") or self.headers.get("Content-Length", "0") != "0":
self.close_connection = True
return self.reply(400, {"error":"GET verification requests must have no body"})
state = self.server.state
if path == "/api/auth/nginx":
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
pass
allowed = "mock_auth" in cookie and cookie["mock_auth"].value == "allow"
with state.lock:
state.calls += 1
# Do not store Cookie, Authorization, API keys, or query strings.
state.recent.append({"method":self.headers.get("X-Original-Method"),
"client":self.headers.get("X-Real-IP"), "host":self.headers.get("X-Forwarded-Host"),
"proto":self.headers.get("X-Forwarded-Proto"),
"forwarded":self.headers.get("Forwarded"),"xff":self.headers.get("X-Forwarded-For")})
status = state.status if state.status is not None else (204 if allowed else 401)
delay_ms, location = state.delay_ms, state.location
time.sleep(delay_ms / 1000)
headers = []
if 200 <= status < 300:
headers = [("Remote-User", "mock-user"), ("Remote-Email", "mock@example.test"), ("Remote-Groups", "testers")]
if location:
headers.append(("X-Tinyauth-Location", location))
return self.reply(status, headers=headers)
if path == "/state":
with state.lock:
return self.reply(200, {"calls":state.calls,"status":state.status,"delay_ms":state.delay_ms,"recent":list(state.recent)})
if path == "/login":
return self.reply(200, {"warning":"Fake login fixture, not authentication", "test_cookie":"mock_auth=allow", "instructions":"Send this cookie to the protected local test vhost."})
self.reply(404, {"error":"not found"})
def do_POST(self):
if urlsplit(self.path).path != "/control":
self.close_connection = True
return self.reply(405, {"error":"only POST /control is supported"})
try:
if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1:
raise ValueError("one Content-Length is required")
size = int(self.headers["Content-Length"])
if not 0 <= size <= 4096:
raise ValueError("body limit is 4096 bytes")
data = self.rfile.read(size)
if len(data) != size:
raise ValueError("incomplete request")
payload = json.loads(data)
if not isinstance(payload, dict) or set(payload) - {"status", "delay_ms", "location", "reset"}:
raise ValueError("invalid fields")
status = payload.get("status")
if status is not None and (type(status) is not int or status not in (200, 204, 401, 403, 500, 503)):
raise ValueError("unsupported verification status")
delay = payload.get("delay_ms", 0)
if type(delay) is not int or not 0 <= delay <= 15000:
raise ValueError("delay must be 0..15000 ms")
location = payload.get("location")
if location is not None and (not isinstance(location, str) or len(location) > 2048
or any(ord(c) < 32 or ord(c) > 126 for c in location)):
raise ValueError("invalid redirect header")
with self.server.state.lock:
state = self.server.state
if payload.get("reset"):
state.calls = 0
state.recent.clear()
state.status, state.delay_ms, state.location = status, delay, location
except (ValueError, KeyError, TimeoutError, OSError) as error:
self.close_connection = True
return self.reply(400, {"error":str(error)})
self.reply(200, {"updated":True})
def do_HEAD(self):
self.do_GET()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=9300)
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("port must be 1..65535")
server = Server(("127.0.0.1", args.port))
print(f"FAKE AUTH - LOCAL TESTING ONLY: http://127.0.0.1:{args.port}/api/auth/nginx", flush=True)
print("Approve with Cookie: mock_auth=allow. /control configures failures/delays. Ctrl+C stops.", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Local HTTP/1.1 origin for manual cache, health, compression and pool tests.
Only Python's standard library is used. This is not a production application.
"""
from __future__ import annotations
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
import secrets
import threading
import time
from urllib.parse import parse_qs, urlsplit
MAX_BODY = 2 * 1024 * 1024
MAX_WORKERS = 32
class State:
def __init__(self, name="origin-a", ttl=30):
self.name, self.ttl = name, ttl
self.healthy, self.delay_ms, self.sequence = True, 0, 0
self.lock = threading.Lock()
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, state):
self.state = state
self.slots = threading.BoundedSemaphore(MAX_WORKERS)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "RustProxy-Mock/0.3.0"
def setup(self):
super().setup()
self.connection.settimeout(15)
def log_message(self, *_):
pass
def send(self, status, body=b"", content_type="application/json; charset=utf-8", control="no-store", extra=None):
if isinstance(body, dict):
body = (json.dumps(body, ensure_ascii=False) + "\n").encode()
if isinstance(body, str):
body = body.encode()
if status in (204, 304):
body = b""
self.send_response(status)
self.send_header("Content-Type", content_type)
if status != 204:
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", control)
self.send_header("X-Mock-Origin", self.server.state.name)
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
if self.command != "HEAD":
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
self.close_connection = True
def integer(self, query, key, default, maximum):
value = int(query.get(key, [str(default)])[0])
if not 0 <= value <= maximum:
raise ValueError(key)
return value
def do_HEAD(self):
self.do_GET()
def do_GET(self):
target = urlsplit(self.path)
q = parse_qs(target.query)
state = self.server.state
try:
delay = self.integer(q, "ms", 800 if target.path == "/slow" else 0, 10000)
size = self.integer(q, "size", 65536, MAX_BODY)
ttl = self.integer(q, "ttl", state.ttl, 604800)
except ValueError:
return self.send(400, {"error":"invalid query parameter"})
with state.lock:
state.sequence += 1
sequence, healthy = state.sequence, state.healthy
delay += state.delay_ms
if delay:
time.sleep(min(delay, 10000) / 1000)
payload = {"origin":state.name, "sequence":sequence, "generated_at_ns":time.time_ns(),
"nonce":secrets.token_hex(16), "path":target.path,
"accept_language":self.headers.get("Accept-Language", ""), "healthy":healthy}
if target.path == "/health":
return self.send(200 if healthy else 503, payload)
if target.path == "/state":
return self.send(200, {**payload, "delay_ms":state.delay_ms, "ttl":state.ttl})
if target.path == "/":
return self.send(200, """<!doctype html><meta charset="utf-8"><title>RustProxy mock origin</title>
<h1>RustProxy test origin</h1><p>Each uncached request generates a new sequence and nonce.</p>
<ul><li><a href="/cache">/cache</a> - public JSON, 30-second freshness by default</li>
<li><a href="/nocache">/nocache</a> - no-store JSON</li><li><a href="/private">/private</a> - private JSON</li>
<li><a href="/cookie">/cookie</a> - Set-Cookie, bypass shared cache</li><li><a href="/large">/large?size=65536</a> - compressible bytes</li>
<li><a href="/slow">/slow?ms=800</a> - controlled delay</li><li><a href="/health">/health</a> - active probe target</li>
<li><a href="/status/503">/status/503</a> - custom error page test</li></ul>
<p>POST /control with JSON {"healthy":false,"delay_ms":0} to test failover/recovery.</p>""", "text/html; charset=utf-8")
if target.path == "/large":
prefix = (json.dumps(payload) + "\n").encode()
body = (prefix + b"RustProxy compression and cache fixture.\n" * (size // 30 + 1))[:size]
return self.send(200, body, "text/plain; charset=utf-8", f"public, max-age={ttl}")
if target.path.startswith("/status/"):
try:
status = int(target.path.rsplit("/", 1)[1])
if status not in [200, 204, 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]:
raise ValueError()
except ValueError:
return self.send(400, {"error":"unsupported status"})
return self.send(status, payload, extra={"WWW-Authenticate":'Basic realm="mock"'} if status == 401 else None)
if target.path == "/cache":
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Vary":"Accept-Language"})
if target.path == "/private":
return self.send(200, payload, control="private, max-age=60")
if target.path == "/cookie":
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Set-Cookie":"mock-session=demo; HttpOnly; SameSite=Lax; Path=/"})
self.send(200 if target.path in ("/nocache", "/slow") else 404, payload)
def do_POST(self):
# Always consume or close a body before reusing an HTTP/1.1 connection.
if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1:
self.close_connection = True
return self.send(411, {"error":"one Content-Length is required"})
try:
length = int(self.headers["Content-Length"])
if not 0 <= length <= 4096:
raise ValueError()
raw = self.rfile.read(length)
if len(raw) != length:
raise ValueError()
data = json.loads(raw)
if self.path != "/control" or not isinstance(data, dict) or set(data) - {"healthy", "delay_ms"}:
raise ValueError()
if "healthy" in data and not isinstance(data["healthy"], bool):
raise ValueError()
if "delay_ms" in data and (type(data["delay_ms"]) is not int or not 0 <= data["delay_ms"] <= 10000):
raise ValueError()
except (ValueError, TimeoutError):
self.close_connection = True
return self.send(400, {"error":"expected healthy: boolean and/or delay_ms: 0..10000"})
with self.server.state.lock:
for key, value in data.items():
setattr(self.server.state, key, value)
self.send(200, {"updated":True})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9001)
parser.add_argument("--name", default="origin-a")
parser.add_argument("--ttl", type=int, default=30)
parser.add_argument("--allow-remote", action="store_true", help="Explicitly expose this development-only server on a non-loopback IPv4 address")
args = parser.parse_args()
try:
ip = ipaddress.ip_address(args.host)
except ValueError:
parser.error("--host must be an IPv4 address")
if ip.version != 4:
parser.error("this development helper accepts IPv4 bindings only")
if not ip.is_loopback and not args.allow_remote:
parser.error("non-loopback binding requires --allow-remote; /control is unauthenticated")
if not 0 <= args.port <= 65535 or not 0 <= args.ttl <= 604800:
parser.error("invalid port or TTL")
if not args.name or len(args.name) > 64 or any(ord(c) < 32 or ord(c) > 126 for c in args.name):
parser.error("--name must contain 1..64 printable ASCII characters")
server = Server((args.host, args.port), State(args.name, args.ttl))
print(f"Mock origin {args.name}: http://{args.host}:{server.server_port} (development only)", flush=True)
print("Endpoints: /cache /nocache /private /cookie /large /slow /health /status/503; POST /control", flush=True)
try:
server.serve_forever(poll_interval=0.2)
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
import argparse
import gzip
import hashlib
import io
import time
import zlib
import brotli
import requests
import zstandard
TESTS = {
"identity": "identity",
"gzip": "gzip",
"deflate": "deflate",
"brotli": "br",
"zstd": "zstd",
"all": "zstd, br, gzip, deflate",
}
def decompress(data, content_encoding):
encodings = [
e.strip().lower()
for e in content_encoding.split(",")
if e.strip() and e.strip().lower() != "identity"
]
# Content-Encoding usuwa się w odwrotnej kolejności.
for encoding in reversed(encodings):
if encoding == "gzip":
data = gzip.decompress(data)
elif encoding == "deflate":
try:
data = zlib.decompress(data)
except zlib.error:
# Niektóre serwery wysyłają raw DEFLATE.
data = zlib.decompress(data, -zlib.MAX_WBITS)
elif encoding == "br":
data = brotli.decompress(data)
elif encoding == "zstd":
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(io.BytesIO(data)) as reader:
data = reader.read()
else:
raise RuntimeError(f"Nieznany Content-Encoding: {encoding}")
return data
def human_size(size):
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
return f"{size / 1024 / 1024:.2f} MB"
def request_test(url, accept_encoding, verify_ssl=True, timeout=15, revalidate=False):
headers = {
"Accept-Encoding": accept_encoding,
"User-Agent": "compression-tester/1.0",
}
if revalidate:
headers["Cache-Control"] = "no-cache"
start = time.perf_counter()
r = requests.get(
url,
headers=headers,
timeout=timeout,
verify=verify_ssl,
stream=True,
allow_redirects=True,
)
r.raw.decode_content = False
raw = r.raw.read()
elapsed = time.perf_counter() - start
content_encoding = r.headers.get("Content-Encoding", "identity")
try:
decoded = decompress(raw, content_encoding)
error = None
except Exception as exc:
decoded = b""
error = str(exc)
return {
"status": r.status_code,
"requested": accept_encoding,
"received": content_encoding,
"raw_size": len(raw),
"decoded_size": len(decoded),
"time": elapsed,
"decoded": decoded,
"error": error,
"headers": r.headers,
"final_url": r.url,
}
def main():
parser = argparse.ArgumentParser(
description="Tester HTTP Content-Encoding: zstd/br/gzip/deflate"
)
parser.add_argument("url", help="URL do przetestowania")
parser.add_argument(
"--insecure",
action="store_true",
help="Wyłącz weryfikację certyfikatu SSL",
)
parser.add_argument(
"--timeout",
type=int,
default=15,
help="Timeout HTTP w sekundach",
)
parser.add_argument(
"--revalidate",
action="store_true",
help="Wyslij Cache-Control: no-cache (wymusza rewalidacje i moze resetowac Age do 0)",
)
args = parser.parse_args()
if args.insecure:
requests.packages.urllib3.disable_warnings()
print()
print(f"URL: {args.url}")
print("=" * 100)
results = {}
for name, accept_encoding in TESTS.items():
try:
result = request_test(
args.url,
accept_encoding,
verify_ssl=not args.insecure,
timeout=args.timeout,
revalidate=args.revalidate,
)
results[name] = result
except Exception as exc:
results[name] = {
"error": f"REQUEST ERROR: {exc}"
}
identity_body = None
if "identity" in results:
identity_body = results["identity"].get("decoded")
print(
f"{'TEST':<10}"
f"{'HTTP':<7}"
f"{'REQUEST':<27}"
f"{'RESPONSE':<12}"
f"{'RAW':<12}"
f"{'DECODED':<12}"
f"{'RATIO':<10}"
f"{'TIME':<10}"
f"{'BODY'}"
)
print("-" * 120)
failed = False
for name, result in results.items():
if result.get("error"):
print(f"{name:<10} ERROR: {result['error']}")
failed = True
continue
raw_size = result["raw_size"]
decoded_size = result["decoded_size"]
if decoded_size:
ratio = raw_size / decoded_size
ratio_text = f"{ratio * 100:.1f}%"
else:
ratio_text = "-"
if identity_body is not None and result["decoded"]:
if result["decoded"] == identity_body:
body_status = "OK"
else:
body_status = "DIFF"
else:
body_status = "-"
print(
f"{name:<10}"
f"{result['status']:<7}"
f"{result['requested']:<27}"
f"{result['received']:<12}"
f"{human_size(raw_size):<12}"
f"{human_size(decoded_size):<12}"
f"{ratio_text:<10}"
f"{result['time']:.3f}s "
f"{body_status}"
)
print()
print("=" * 100)
print("Szczegóły nagłówków:")
print()
for name, result in results.items():
if result.get("error"):
continue
headers = result["headers"]
print(f"[{name}]")
print(f" URL finalny: {result['final_url']}")
print(f" HTTP status: {result['status']}")
print(f" Content-Encoding: {headers.get('Content-Encoding', '-')}")
print(f" Content-Type: {headers.get('Content-Type', '-')}")
print(f" Content-Length: {headers.get('Content-Length', '-')}")
print(f" Vary: {headers.get('Vary', '-')}")
print(f" Cache-Control: {headers.get('Cache-Control', '-')}")
print(f" Age: {headers.get('Age', '-')}")
print(f" X-Cache: {headers.get('X-Cache', '-')}")
print(f" X-Cache-Hits: {headers.get('X-Cache-Hits', '-')}")
print(f" Cache-Status: {headers.get('Cache-Status', '-')}")
print(f" ETag: {headers.get('ETag', '-')}")
print(f" Server: {headers.get('Server', '-')}")
print()
print("SHA256 po dekompresji:")
print()
for name, result in results.items():
body = result.get("decoded")
if body:
sha = hashlib.sha256(body).hexdigest()
print(f"{name:<10} {sha}")
if failed:
raise SystemExit(1)
if __name__ == "__main__":
main()