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

@@ -0,0 +1,75 @@
"""Caddyfile protocol token generation for http and https entrypoints."""
import json
def _http_protocols(versions_json: str | None) -> str:
"""Mirrors the Python inline logic in templates/http/entrypoint.sh."""
versions = json.loads(versions_json or '["http/1.1"]')
tokens = []
if "http/1.1" in versions:
tokens.append("h1")
if "http/2" in versions:
tokens.append("h2c")
return " ".join(tokens) if tokens else "h1"
def _https_protocols(versions_json: str | None) -> str:
"""Mirrors the Python inline logic in templates/https/entrypoint.sh."""
versions = json.loads(versions_json or '["http/1.1"]')
tokens = []
if "http/1.1" in versions:
tokens.append("h1")
if "http/2" in versions:
tokens.append("h2")
if "http/3" in versions:
tokens.append("h3")
return " ".join(tokens) if tokens else "h1"
# ---------------------------------------------------------------------------
# HTTP (cleartext) protocol token tests
# ---------------------------------------------------------------------------
def test_http_h1_only():
assert _http_protocols('["http/1.1"]') == "h1"
def test_http_h1_and_h2c():
assert _http_protocols('["http/1.1", "http/2"]') == "h1 h2c"
def test_http_h2c_only():
assert _http_protocols('["http/2"]') == "h2c"
def test_http_default_fallback():
assert _http_protocols(None) == "h1"
def test_http_empty_versions_fallback():
# Should not happen (coercion rejects empty list) but guard the fallback.
assert _http_protocols("[]") == "h1"
# ---------------------------------------------------------------------------
# HTTPS (TLS) protocol token tests
# ---------------------------------------------------------------------------
def test_https_h1_only():
assert _https_protocols('["http/1.1"]') == "h1"
def test_https_h1_and_h2():
assert _https_protocols('["http/1.1", "http/2"]') == "h1 h2"
def test_https_all_three():
assert _https_protocols('["http/1.1", "http/2", "http/3"]') == "h1 h2 h3"
def test_https_h3_only():
assert _https_protocols('["http/3"]') == "h3"
def test_https_default_fallback():
assert _https_protocols(None) == "h1"

View File

@@ -236,11 +236,16 @@ def test_smtp_mta_enum_rejects_unknown():
SMTPService().validate_cfg({"mta": "qmail"})
def test_smtp_relay_schema_matches_smtp():
assert (
{f.key for f in SMTPRelayService.config_schema}
== {f.key for f in SMTPService.config_schema}
)
def test_smtp_relay_schema_is_superset_of_smtp():
base_keys = {f.key for f in SMTPService.config_schema}
relay_keys = {f.key for f in SMTPRelayService.config_schema}
assert base_keys <= relay_keys, f"Relay schema missing base keys: {base_keys - relay_keys}"
relay_only = relay_keys - base_keys
assert relay_only == {"upstream_host", "upstream_port", "upstream_user",
"upstream_pass", "upstream_sender", "probe_limit"}
def test_smtp_relay_compose_sets_open_relay_and_propagates_banner():
svc = SMTPRelayService()
frag = svc.compose_fragment(
"decky-test", service_cfg=svc.validate_cfg({"banner": "x", "mta": "postfix"})

View File

@@ -0,0 +1,137 @@
"""http_versions multi_enum field: coercion, compose env, UDP port gate."""
import json
import pytest
from decnet.services.base import ConfigValidationError, ServiceConfigField, _coerce
from decnet.services.http import HTTPService
from decnet.services.https import HTTPSService
# ---------------------------------------------------------------------------
# multi_enum coercion via base._coerce
# ---------------------------------------------------------------------------
_FIELD = ServiceConfigField(
key="http_versions",
label="HTTP versions",
type="multi_enum",
enum=["http/1.1", "http/2", "http/3"],
)
def test_multi_enum_accepts_valid_list():
assert _coerce(_FIELD, ["http/1.1", "http/2"]) == ["http/1.1", "http/2"]
def test_multi_enum_single_item():
assert _coerce(_FIELD, ["http/1.1"]) == ["http/1.1"]
def test_multi_enum_all_three():
assert _coerce(_FIELD, ["http/1.1", "http/2", "http/3"]) == [
"http/1.1", "http/2", "http/3"
]
def test_multi_enum_deduplicates():
result = _coerce(_FIELD, ["http/1.1", "http/2", "http/1.1"])
assert result == ["http/1.1", "http/2"]
def test_multi_enum_rejects_non_list():
with pytest.raises(ConfigValidationError, match="expected list"):
_coerce(_FIELD, "http/1.1")
def test_multi_enum_rejects_non_list_int():
with pytest.raises(ConfigValidationError, match="expected list"):
_coerce(_FIELD, 1)
def test_multi_enum_rejects_empty_list():
with pytest.raises(ConfigValidationError, match="must not be empty"):
_coerce(_FIELD, [])
def test_multi_enum_rejects_unknown_value():
with pytest.raises(ConfigValidationError, match="not in allowed values"):
_coerce(_FIELD, ["http/1.1", "http/4"])
def test_multi_enum_coerces_items_to_str():
# Submitters may send ints or mixed types; each item is str-coerced before lookup.
field_no_enum = ServiceConfigField(
key="tags", label="Tags", type="multi_enum", enum=None
)
assert _coerce(field_no_enum, [1, 2]) == ["1", "2"]
# ---------------------------------------------------------------------------
# HTTPService: http_versions in schema, env propagation, no h3 option
# ---------------------------------------------------------------------------
def test_http_schema_includes_http_versions():
keys = {f.key for f in HTTPService.config_schema}
assert "http_versions" in keys
def test_http_schema_no_h3_in_enum():
field = next(f for f in HTTPService.config_schema if f.key == "http_versions")
assert "http/3" not in (field.enum or [])
def test_http_compose_http_versions_env():
svc = HTTPService()
cfg = svc.validate_cfg({"http_versions": ["http/1.1", "http/2"]})
frag = svc.compose_fragment("decky-test", service_cfg=cfg)
versions = json.loads(frag["environment"]["HTTP_VERSIONS"])
assert versions == ["http/1.1", "http/2"]
def test_http_compose_no_versions_no_env_key():
frag = HTTPService().compose_fragment("decky-test", service_cfg={})
assert "HTTP_VERSIONS" not in frag["environment"]
# ---------------------------------------------------------------------------
# HTTPSService: http_versions in schema, env propagation, UDP port for h3
# ---------------------------------------------------------------------------
def test_https_schema_includes_http_versions():
keys = {f.key for f in HTTPSService.config_schema}
assert "http_versions" in keys
def test_https_schema_has_h3():
field = next(f for f in HTTPSService.config_schema if f.key == "http_versions")
assert "http/3" in (field.enum or [])
def test_https_compose_http_versions_env():
svc = HTTPSService()
cfg = svc.validate_cfg({"http_versions": ["http/1.1", "http/2"]})
frag = svc.compose_fragment("decky-test", service_cfg=cfg)
versions = json.loads(frag["environment"]["HTTP_VERSIONS"])
assert versions == ["http/1.1", "http/2"]
def test_https_compose_h3_adds_udp_port():
svc = HTTPSService()
cfg = svc.validate_cfg({"http_versions": ["http/1.1", "http/2", "http/3"]})
frag = svc.compose_fragment("decky-test", service_cfg=cfg)
assert "443:443/udp" in frag.get("ports", [])
def test_https_compose_no_h3_no_udp_port():
svc = HTTPSService()
cfg = svc.validate_cfg({"http_versions": ["http/1.1", "http/2"]})
frag = svc.compose_fragment("decky-test", service_cfg=cfg)
assert "443:443/udp" not in frag.get("ports", [])
def test_https_compose_h3_only_still_adds_udp_port():
svc = HTTPSService()
cfg = svc.validate_cfg({"http_versions": ["http/3"]})
frag = svc.compose_fragment("decky-test", service_cfg=cfg)
assert "443:443/udp" in frag.get("ports", [])