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. # submitters (PUT /…/services/{svc}/config) keep working with raw strings.
TEXTAREA_B64_PREFIX = "b64:" 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) @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}" f"{spec.key}: {s!r} not in allowed values {spec.enum}"
) )
return s 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}") raise ConfigValidationError(f"{spec.key}: unknown field type {t!r}")

View File

@@ -43,6 +43,14 @@ class HTTPService(BaseService):
label="Custom response body", label="Custom response body",
type="textarea", 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( def compose_fragment(
@@ -77,6 +85,8 @@ class HTTPService(BaseService):
) )
if "custom_body" in cfg: if "custom_body" in cfg:
fragment["environment"]["CUSTOM_BODY"] = cfg["custom_body"] 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: if "files" in cfg:
files_path = str(Path(cfg["files"]).resolve()) files_path = str(Path(cfg["files"]).resolve())
fragment["environment"]["FILES_DIR"] = "/opt/html_files" fragment["environment"]["FILES_DIR"] = "/opt/html_files"

View File

@@ -59,6 +59,14 @@ class HTTPSService(BaseService):
type="textarea", type="textarea",
secret=True, 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( def compose_fragment(
@@ -103,6 +111,10 @@ class HTTPSService(BaseService):
fragment["environment"]["TLS_KEY"] = cfg["tls_key"] fragment["environment"]["TLS_KEY"] = cfg["tls_key"]
if "tls_cn" in cfg: if "tls_cn" in cfg:
fragment["environment"]["TLS_CN"] = cfg["tls_cn"] 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 return fragment

View File

@@ -1,6 +1,10 @@
FROM caddy:2 AS caddy-bin
ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252 ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \ python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -14,12 +18,18 @@ COPY server.py /opt/server.py
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 80 443 EXPOSE 80
RUN useradd -r -s /bin/false -d /opt logrelay \ 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 \ && apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
&& rm -rf /var/lib/apt/lists/* \ && 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) && (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 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1 CMD kill -0 1 || exit 1

View File

@@ -1,3 +1,43 @@
#!/bin/bash #!/bin/bash
set -e 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 pathlib import Path
from flask import Flask, request, send_from_directory from flask import Flask, request, send_from_directory
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.serving import make_server, WSGIRequestHandler from werkzeug.serving import make_server, WSGIRequestHandler
import instance_seed as _seed import instance_seed as _seed
@@ -27,7 +28,7 @@ logging.getLogger("werkzeug").setLevel(logging.ERROR)
NODE_NAME = os.environ.get("NODE_NAME", "webserver") NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "http" SERVICE_NAME = "http"
LOG_TARGET = os.environ.get("LOG_TARGET", "") 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 # Per-instance Server header. Every decky running one identical Apache
# version string is a one-query fleet discovery for any scanner. # 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 = 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 @app.after_request
def _fix_server_header(response): def _fix_server_header(response):
@@ -161,5 +163,5 @@ class _SilentHandler(WSGIRequestHandler):
if __name__ == "__main__": if __name__ == "__main__":
_log("startup", msg=f"HTTP server starting as {NODE_NAME}") _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() srv.serve_forever()

View File

@@ -1,6 +1,10 @@
FROM caddy:2 AS caddy-bin
ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252 ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip openssl \ python3 python3-pip openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -17,12 +21,18 @@ RUN chmod +x /entrypoint.sh
RUN mkdir -p /opt/tls RUN mkdir -p /opt/tls
EXPOSE 443 EXPOSE 443
RUN useradd -r -s /bin/false -d /opt logrelay \ RUN useradd -r -s /bin/false -d /opt logrelay \
&& chown -R logrelay:logrelay /opt/tls \ && 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 \ && apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
&& rm -rf /var/lib/apt/lists/* \ && 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) && (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 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1 CMD kill -0 1 || exit 1

View File

@@ -4,10 +4,8 @@ set -e
TLS_DIR="/opt/tls" TLS_DIR="/opt/tls"
mkdir -p "$TLS_DIR" mkdir -p "$TLS_DIR"
# TLS_CERT/TLS_KEY may arrive as either a host-side path OR raw PEM # TLS_CERT/TLS_KEY may arrive as either a host-side path OR raw PEM content.
# content (the wizard ships PEM textareas as decoded strings). Detect by # Detect by looking for a PEM header; if present, write to disk.
# looking for a PEM header; if present, write to disk and rebind the var
# to the on-disk path.
if [ -n "$TLS_CERT" ] && printf '%s' "$TLS_CERT" | grep -q 'BEGIN '; then if [ -n "$TLS_CERT" ] && printf '%s' "$TLS_CERT" | grep -q 'BEGIN '; then
printf '%s' "$TLS_CERT" > "$TLS_DIR/cert.pem" printf '%s' "$TLS_CERT" > "$TLS_DIR/cert.pem"
CERT="$TLS_DIR/cert.pem" CERT="$TLS_DIR/cert.pem"
@@ -31,8 +29,46 @@ if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
2>/dev/null 2>/dev/null
fi fi
# server.py reads TLS_CERT/TLS_KEY as filesystem paths. # Parse HTTP_VERSIONS JSON → Caddy protocol tokens (h1 / h2 / h3)
export TLS_CERT="$CERT" CADDY_PROTOCOLS=$(python3 -c "
export TLS_KEY="$KEY" 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 json
import logging import logging
import os import os
import ssl
from pathlib import Path from pathlib import Path
from flask import Flask, request, send_from_directory from flask import Flask, request, send_from_directory
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.serving import make_server, WSGIRequestHandler from werkzeug.serving import make_server, WSGIRequestHandler
import instance_seed as _seed import instance_seed as _seed
@@ -29,7 +29,7 @@ logging.getLogger("werkzeug").setLevel(logging.ERROR)
NODE_NAME = os.environ.get("NODE_NAME", "webserver") NODE_NAME = os.environ.get("NODE_NAME", "webserver")
SERVICE_NAME = "https" SERVICE_NAME = "https"
LOG_TARGET = os.environ.get("LOG_TARGET", "") LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "443")) PORT = int(os.environ.get("PORT", "8080"))
_SERVER_CHOICES = [ _SERVER_CHOICES = [
"Apache/2.4.41 (Ubuntu)", "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", "{}")) EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "") CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "")
FILES_DIR = os.environ.get("FILES_DIR", "") 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] = { _FAKE_APP_BODIES: dict[str, str] = {
"apache_default": ( "apache_default": (
@@ -86,6 +84,7 @@ _FAKE_APP_BODIES: dict[str, str] = {
} }
app = Flask(__name__) 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 @app.after_request
def _fix_server_header(response): def _fix_server_header(response):
@@ -155,10 +154,5 @@ class _SilentHandler(WSGIRequestHandler):
if __name__ == "__main__": if __name__ == "__main__":
_log("startup", msg=f"HTTPS server starting as {NODE_NAME}") _log("startup", msg=f"HTTPS server starting as {NODE_NAME}")
srv = make_server("127.0.0.1", PORT, app, request_handler=_SilentHandler)
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.serve_forever() srv.serve_forever()

View File

@@ -5,7 +5,7 @@ import './ServiceConfigForm.css';
export interface ServiceConfigFieldDTO { export interface ServiceConfigFieldDTO {
key: string; key: string;
label: string; label: string;
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum'; type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum' | 'multi_enum';
default?: unknown; default?: unknown;
secret?: boolean; secret?: boolean;
help?: string | null; help?: string | null;
@@ -20,17 +20,19 @@ export interface SchemaResponse {
fields: ServiceConfigFieldDTO[]; fields: ServiceConfigFieldDTO[];
} }
export type FormValue = string | number | boolean; export type FormValue = string | number | boolean | string[];
export type FormState = Record<string, FormValue>; export type FormState = Record<string, FormValue>;
export function toFormValue(field: ServiceConfigFieldDTO, raw: unknown): FormValue { export function toFormValue(field: ServiceConfigFieldDTO, raw: unknown): FormValue {
if (raw === undefined || raw === null) { if (raw === undefined || raw === null) {
if (field.type === 'bool') return Boolean(field.default); 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 === '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) ?? ''; return (field.default as string | undefined) ?? '';
} }
if (field.type === 'bool') return Boolean(raw); if (field.type === 'bool') return Boolean(raw);
if (field.type === 'int') return Number(raw); if (field.type === 'int') return Number(raw);
if (field.type === 'multi_enum') return Array.isArray(raw) ? (raw as string[]) : [];
return String(raw); return String(raw);
} }
@@ -51,6 +53,7 @@ export function compactPayload(
for (const f of fields) { for (const f of fields) {
const v = state[f.key]; const v = state[f.key];
if (v === '' || v === undefined || v === null) continue; if (v === '' || v === undefined || v === null) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[f.key] = v; out[f.key] = v;
} }
return out; return out;
@@ -129,7 +132,31 @@ const ServiceConfigFields: React.FC<Props> = ({
{f.label} {f.label}
{f.secret && <span className="svc-cfg-secret-tag">· secret</span>} {f.secret && <span className="svc-cfg-secret-tag">· secret</span>}
</label> </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 <input
id={id} id={id}
type="checkbox" type="checkbox"

View File

@@ -181,3 +181,30 @@ select.svc-cfg-input {
font-style: italic; font-style: italic;
} }
.svc-cfg-status.alert-text { font-style: normal; } .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"}) SMTPService().validate_cfg({"mta": "qmail"})
def test_smtp_relay_schema_matches_smtp(): def test_smtp_relay_schema_is_superset_of_smtp():
assert ( base_keys = {f.key for f in SMTPService.config_schema}
{f.key for f in SMTPRelayService.config_schema} relay_keys = {f.key for f in SMTPRelayService.config_schema}
== {f.key for f in SMTPService.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() svc = SMTPRelayService()
frag = svc.compose_fragment( frag = svc.compose_fragment(
"decky-test", service_cfg=svc.validate_cfg({"banner": "x", "mta": "postfix"}) "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", [])