diff --git a/decnet/services/base.py b/decnet/services/base.py index 942813d2..65030b4b 100644 --- a/decnet/services/base.py +++ b/decnet/services/base.py @@ -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}") diff --git a/decnet/services/http.py b/decnet/services/http.py index 7639ed42..e608c740 100644 --- a/decnet/services/http.py +++ b/decnet/services/http.py @@ -43,6 +43,14 @@ class HTTPService(BaseService): label="Custom response body", type="textarea", ), + ServiceConfigField( + key="http_versions", + label="Supported HTTP versions", + type="multi_enum", + enum=["http/1.1", "http/2"], + default=["http/1.1"], + help="Protocol versions Caddy advertises. HTTP/3 requires TLS — use the https service.", + ), ] def compose_fragment( @@ -77,6 +85,8 @@ class HTTPService(BaseService): ) if "custom_body" in cfg: fragment["environment"]["CUSTOM_BODY"] = cfg["custom_body"] + if "http_versions" in cfg: + fragment["environment"]["HTTP_VERSIONS"] = json.dumps(cfg["http_versions"]) if "files" in cfg: files_path = str(Path(cfg["files"]).resolve()) fragment["environment"]["FILES_DIR"] = "/opt/html_files" diff --git a/decnet/services/https.py b/decnet/services/https.py index 8faefbb4..6d64048b 100644 --- a/decnet/services/https.py +++ b/decnet/services/https.py @@ -59,6 +59,14 @@ class HTTPSService(BaseService): type="textarea", secret=True, ), + ServiceConfigField( + key="http_versions", + label="Supported HTTP versions", + type="multi_enum", + enum=["http/1.1", "http/2", "http/3"], + default=["http/1.1"], + help="Protocol versions Caddy advertises. HTTP/3 uses QUIC over UDP/443.", + ), ] def compose_fragment( @@ -103,6 +111,10 @@ class HTTPSService(BaseService): fragment["environment"]["TLS_KEY"] = cfg["tls_key"] if "tls_cn" in cfg: fragment["environment"]["TLS_CN"] = cfg["tls_cn"] + if "http_versions" in cfg: + fragment["environment"]["HTTP_VERSIONS"] = json.dumps(cfg["http_versions"]) + if "http/3" in cfg["http_versions"]: + fragment.setdefault("ports", []).append("443:443/udp") return fragment diff --git a/decnet/templates/http/Dockerfile b/decnet/templates/http/Dockerfile index 0ac3ca50..8cae6172 100644 --- a/decnet/templates/http/Dockerfile +++ b/decnet/templates/http/Dockerfile @@ -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 diff --git a/decnet/templates/http/entrypoint.sh b/decnet/templates/http/entrypoint.sh index c830b733..d8194069 100644 --- a/decnet/templates/http/entrypoint.sh +++ b/decnet/templates/http/entrypoint.sh @@ -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 </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 diff --git a/decnet/templates/https/entrypoint.sh b/decnet/templates/https/entrypoint.sh index f88a889e..3d82cc14 100644 --- a/decnet/templates/https/entrypoint.sh +++ b/decnet/templates/https/entrypoint.sh @@ -4,10 +4,8 @@ set -e TLS_DIR="/opt/tls" mkdir -p "$TLS_DIR" -# TLS_CERT/TLS_KEY may arrive as either a host-side path OR raw PEM -# content (the wizard ships PEM textareas as decoded strings). Detect by -# looking for a PEM header; if present, write to disk and rebind the var -# to the on-disk path. +# TLS_CERT/TLS_KEY may arrive as either a host-side path OR raw PEM content. +# Detect by looking for a PEM header; if present, write to disk. if [ -n "$TLS_CERT" ] && printf '%s' "$TLS_CERT" | grep -q 'BEGIN '; then printf '%s' "$TLS_CERT" > "$TLS_DIR/cert.pem" CERT="$TLS_DIR/cert.pem" @@ -31,8 +29,46 @@ if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then 2>/dev/null fi -# server.py reads TLS_CERT/TLS_KEY as filesystem paths. -export TLS_CERT="$CERT" -export TLS_KEY="$KEY" +# Parse HTTP_VERSIONS JSON → Caddy protocol tokens (h1 / h2 / h3) +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('h2') +if 'http/3' in versions: + tokens.append('h3') +print(' '.join(tokens) if tokens else 'h1') +") -exec python3 /opt/server.py +cat > /etc/caddy/Caddyfile <; export function toFormValue(field: ServiceConfigFieldDTO, raw: unknown): FormValue { if (raw === undefined || raw === null) { if (field.type === 'bool') return Boolean(field.default); if (field.type === 'int') return field.default == null ? ('' as unknown as number) : Number(field.default); + if (field.type === 'multi_enum') return Array.isArray(field.default) ? (field.default as string[]) : []; return (field.default as string | undefined) ?? ''; } if (field.type === 'bool') return Boolean(raw); if (field.type === 'int') return Number(raw); + if (field.type === 'multi_enum') return Array.isArray(raw) ? (raw as string[]) : []; return String(raw); } @@ -51,6 +53,7 @@ export function compactPayload( for (const f of fields) { const v = state[f.key]; if (v === '' || v === undefined || v === null) continue; + if (Array.isArray(v) && v.length === 0) continue; out[f.key] = v; } return out; @@ -129,7 +132,31 @@ const ServiceConfigFields: React.FC = ({ {f.label} {f.secret && · secret} - {f.type === 'bool' ? ( + {f.type === 'multi_enum' ? ( +
+ {(f.enum ?? []).map((opt) => { + const optId = `${id}-${opt}`; + const selected = Array.isArray(v) ? (v as string[]) : []; + const checked = selected.includes(opt); + return ( + + ); + })} +
+ ) : f.type === 'bool' ? ( 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" diff --git a/tests/services/test_config_schema.py b/tests/services/test_config_schema.py index 656e93c8..4c2e1c62 100644 --- a/tests/services/test_config_schema.py +++ b/tests/services/test_config_schema.py @@ -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"}) diff --git a/tests/services/test_http_https_versions.py b/tests/services/test_http_https_versions.py new file mode 100644 index 00000000..747c94d0 --- /dev/null +++ b/tests/services/test_http_https_versions.py @@ -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", [])