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}")

View File

@@ -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"

View File

@@ -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

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()

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 openssl \
&& rm -rf /var/lib/apt/lists/*
@@ -17,12 +21,18 @@ RUN chmod +x /entrypoint.sh
RUN mkdir -p /opt/tls
EXPOSE 443
RUN useradd -r -s /bin/false -d /opt logrelay \
&& chown -R logrelay:logrelay /opt/tls \
&& 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

@@ -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 <<EOF
{
admin off
servers :443 {
protocols ${CADDY_PROTOCOLS}
}
}
:443 {
tls ${CERT} ${KEY}
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

@@ -9,10 +9,10 @@ with configurable pages. Forwards events as JSON to LOG_TARGET if set.
import json
import logging
import os
import ssl
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
@@ -29,7 +29,7 @@ logging.getLogger("werkzeug").setLevel(logging.ERROR)
NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "https"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "443"))
PORT = int(os.environ.get("PORT", "8080"))
_SERVER_CHOICES = [
"Apache/2.4.41 (Ubuntu)",
@@ -50,8 +50,6 @@ FAKE_APP = os.environ.get("FAKE_APP", "")
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "")
FILES_DIR = os.environ.get("FILES_DIR", "")
TLS_CERT = os.environ.get("TLS_CERT", "/opt/tls/cert.pem")
TLS_KEY = os.environ.get("TLS_KEY", "/opt/tls/key.pem")
_FAKE_APP_BODIES: dict[str, str] = {
"apache_default": (
@@ -86,6 +84,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):
@@ -155,10 +154,5 @@ class _SilentHandler(WSGIRequestHandler):
if __name__ == "__main__":
_log("startup", msg=f"HTTPS server starting as {NODE_NAME}")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(TLS_CERT, TLS_KEY)
srv = make_server("0.0.0.0", PORT, app, request_handler=_SilentHandler) # nosec B104
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
srv = make_server("127.0.0.1", PORT, app, request_handler=_SilentHandler)
srv.serve_forever()

View File

@@ -5,7 +5,7 @@ import './ServiceConfigForm.css';
export interface ServiceConfigFieldDTO {
key: string;
label: string;
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum';
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum' | 'multi_enum';
default?: unknown;
secret?: boolean;
help?: string | null;
@@ -20,17 +20,19 @@ export interface SchemaResponse {
fields: ServiceConfigFieldDTO[];
}
export type FormValue = string | number | boolean;
export type FormValue = string | number | boolean | string[];
export type FormState = Record<string, FormValue>;
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<Props> = ({
{f.label}
{f.secret && <span className="svc-cfg-secret-tag">· secret</span>}
</label>
{f.type === 'bool' ? (
{f.type === 'multi_enum' ? (
<fieldset className="svc-cfg-multi-enum">
{(f.enum ?? []).map((opt) => {
const optId = `${id}-${opt}`;
const selected = Array.isArray(v) ? (v as string[]) : [];
const checked = selected.includes(opt);
return (
<label key={opt} htmlFor={optId} className="svc-cfg-multi-enum-option">
<input
id={optId}
type="checkbox"
checked={checked}
onChange={() => {
const next = checked
? selected.filter((x) => x !== opt)
: [...selected, opt];
setVal(f.key, next);
}}
/>
{opt}
</label>
);
})}
</fieldset>
) : f.type === 'bool' ? (
<input
id={id}
type="checkbox"

View File

@@ -181,3 +181,30 @@ select.svc-cfg-input {
font-style: italic;
}
.svc-cfg-status.alert-text { font-style: normal; }
/* multi_enum checkbox group — sits in the input column, stacks vertically. */
.svc-cfg-multi-enum {
grid-column: 2 / 3;
grid-row: 1 / 2;
display: flex;
flex-direction: column;
gap: 4px;
border: none;
padding: 0;
margin: 0;
}
.svc-cfg-multi-enum-option {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
cursor: pointer;
user-select: none;
}
.svc-cfg-multi-enum-option input[type="checkbox"] {
width: 13px;
height: 13px;
accent-color: var(--violet);
cursor: pointer;
flex-shrink: 0;
}

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", [])