feat(services): HTTP/2 + HTTP/3 support via Caddy reverse-proxy

Swap Werkzeug for Caddy as the protocol layer for http and https decoy
services. Flask keeps owning app logic (fake_app, custom_body, headers,
syslog) on 127.0.0.1:8080; Caddy terminates h1/h2/h2c/h3 on the wire
with real-world TLS/QUIC fingerprints.

- Add `multi_enum` FieldType to ServiceConfigField + _coerce
- Add `http_versions` field to HTTPService (h1/h2c) and HTTPSService
  (h1/h2/h3); selecting h3 emits UDP/443 port mapping in compose
- Rewrite both Dockerfiles with multi-stage Caddy binary copy +
  setcap for port binding as the logrelay user
- Entrypoints parse HTTP_VERSIONS JSON, render a Caddyfile, start
  Flask in background, wait for it, then exec Caddy
- https/server.py drops direct TLS handling; Caddy owns the cert
- Add ProxyFix to both server.py so Flask sees real attacker IPs
- Frontend: multi_enum checkbox-group renderer in ServiceConfigFields;
  FormValue union extended to string[]; compactPayload skips []
- Fix stale test_smtp_relay_schema_matches_smtp: relay schema is a
  superset of smtp, not equal; update assertions accordingly
This commit is contained in:
2026-05-10 00:04:37 -04:00
parent ec5b49144e
commit 0653e500b5
14 changed files with 435 additions and 31 deletions

View File

@@ -1,6 +1,10 @@
FROM caddy:2 AS caddy-bin
ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
FROM ${BASE_IMAGE}
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
@@ -14,12 +18,18 @@ COPY server.py /opt/server.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80 443
EXPOSE 80
RUN useradd -r -s /bin/false -d /opt logrelay \
&& mkdir -p /etc/caddy /opt/.local/share/caddy /opt/.config/caddy \
&& chown -R logrelay:logrelay /etc/caddy /opt/.local /opt/.config \
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
&& rm -rf /var/lib/apt/lists/* \
&& setcap 'cap_net_bind_service+eip' /usr/bin/caddy \
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
ENV XDG_DATA_HOME=/opt/.local/share XDG_CONFIG_HOME=/opt/.config
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1

View File

@@ -1,3 +1,43 @@
#!/bin/bash
set -e
exec python3 /opt/server.py
# Parse HTTP_VERSIONS JSON → Caddy protocol tokens (h1 / h2c)
CADDY_PROTOCOLS=$(python3 -c "
import json, os
versions = json.loads(os.environ.get('HTTP_VERSIONS', '[\"http/1.1\"]'))
tokens = []
if 'http/1.1' in versions:
tokens.append('h1')
if 'http/2' in versions:
tokens.append('h2c')
print(' '.join(tokens) if tokens else 'h1')
")
cat > /etc/caddy/Caddyfile <<EOF
{
admin off
servers :80 {
protocols ${CADDY_PROTOCOLS}
}
}
:80 {
reverse_proxy 127.0.0.1:8080
}
EOF
python3 /opt/server.py &
# Wait for Flask to be ready before handing off to Caddy
python3 -c "
import socket, time
for _ in range(40):
try:
s = socket.create_connection(('127.0.0.1', 8080), timeout=0.25)
s.close()
break
except OSError:
time.sleep(0.1)
"
exec caddy run --config /etc/caddy/Caddyfile

View File

@@ -11,6 +11,7 @@ import os
from pathlib import Path
from flask import Flask, request, send_from_directory
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.serving import make_server, WSGIRequestHandler
import instance_seed as _seed
@@ -27,7 +28,7 @@ logging.getLogger("werkzeug").setLevel(logging.ERROR)
NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "http"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "80"))
PORT = int(os.environ.get("PORT", "8080"))
# Per-instance Server header. Every decky running one identical Apache
# version string is a one-query fleet discovery for any scanner.
@@ -85,6 +86,7 @@ _FAKE_APP_BODIES: dict[str, str] = {
}
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
@app.after_request
def _fix_server_header(response):
@@ -161,5 +163,5 @@ class _SilentHandler(WSGIRequestHandler):
if __name__ == "__main__":
_log("startup", msg=f"HTTP server starting as {NODE_NAME}")
srv = make_server("0.0.0.0", PORT, app, request_handler=_SilentHandler) # nosec B104
srv = make_server("127.0.0.1", PORT, app, request_handler=_SilentHandler)
srv.serve_forever()