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:
@@ -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}")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
75
tests/services/test_caddyfile_render.py
Normal file
75
tests/services/test_caddyfile_render.py
Normal 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"
|
||||||
@@ -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"})
|
||||||
|
|||||||
137
tests/services/test_http_https_versions.py
Normal file
137
tests/services/test_http_https_versions.py
Normal 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", [])
|
||||||
Reference in New Issue
Block a user