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

@@ -11,7 +11,7 @@ from typing import Any, Literal
# submitters (PUT /…/services/{svc}/config) keep working with raw strings.
TEXTAREA_B64_PREFIX = "b64:"
FieldType = Literal["string", "password", "int", "bool", "textarea", "enum"]
FieldType = Literal["string", "password", "int", "bool", "textarea", "enum", "multi_enum"]
@dataclass(frozen=True)
@@ -146,4 +146,23 @@ def _coerce(spec: ServiceConfigField, raw: Any) -> Any:
f"{spec.key}: {s!r} not in allowed values {spec.enum}"
)
return s
if t == "multi_enum":
if not isinstance(raw, list):
raise ConfigValidationError(
f"{spec.key}: expected list, got {type(raw).__name__}"
)
if not raw:
raise ConfigValidationError(f"{spec.key}: list must not be empty")
seen: set[str] = set()
result: list[str] = []
for item in raw:
s = str(item)
if spec.enum and s not in spec.enum:
raise ConfigValidationError(
f"{spec.key}: {s!r} not in allowed values {spec.enum}"
)
if s not in seen:
seen.add(s)
result.append(s)
return result
raise ConfigValidationError(f"{spec.key}: unknown field type {t!r}")