Files
DECNET/tests/services/test_https_compose_h3_app.py
anti 6a6f5807aa fix(pr3): adapt to quic-go v0.59.0 API — drop H3App, capture h3 SETTINGS via http3.Settingser
quic-go v0.59.0 (shipped with Caddy v2.11.2) removed quic.Connection as
a public interface and quic-go/logging as a public package, breaking
H3App's connection-wrapping approach.

Resolution:
- Remove H3App (h3app.go) entirely; Caddy handles h3 natively when h3
  is in the protocols list.
- Rewrite h3conn.go to keep only tryParseH3ControlStream + varint/name
  utilities (tested, useful for future stream-level tapping if the API
  ever re-exposes it).
- FPHandler.ServeHTTP: for h3 requests, type-assert ResponseWriter to
  http3.Settingser (the public interface exposed by quic-go/http3 v0.59),
  read the peer's Settings after ReceivedSettings channel closes, emit
  h3_settings fp record.
- https/entrypoint.sh: include h3 in CADDY_PROTOCOLS (Caddy now owns
  UDP/443); remove DECNET_H3_GLOBAL block.
- Update go.mod/go.sum to caddy v2.11.2 + quic-go v0.59.0.
- Update test_https_compose_h3_app.py to expect h3 in protocols when
  http/3 is selected, and assert decnet_h3 block is absent.
- All Go tests (9) and Python tests (15) remain green.
2026-05-10 03:43:34 -04:00

160 lines
6.2 KiB
Python

"""
Verify that the HTTPS Caddyfile template (generated by entrypoint.sh):
- Includes `decnet_h3` in the global block when http/3 is selected.
- Does NOT include `h3` in the Caddy `protocols` line when http/3 is selected
(H3App owns UDP/443 directly).
- Does NOT include `decnet_h3` when http/3 is not selected.
"""
from __future__ import annotations
import subprocess
import tempfile
import os
from pathlib import Path
import pytest
ENTRYPOINT = Path(__file__).parent.parent.parent / "decnet" / "templates" / "https" / "entrypoint.sh"
def run_entrypoint_to_caddyfile(http_versions: list[str], env_extra: dict | None = None) -> str:
"""
Run entrypoint.sh with a stub TLS cert and extract the generated Caddyfile.
Returns the Caddyfile content as a string, or raises on failure.
"""
if not ENTRYPOINT.exists():
pytest.skip("entrypoint.sh not found")
with tempfile.TemporaryDirectory() as tmpdir:
# Generate a throwaway self-signed cert so entrypoint doesn't fail.
cert_path = os.path.join(tmpdir, "cert.pem")
key_path = os.path.join(tmpdir, "key.pem")
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", key_path, "-out", cert_path,
"-days", "1", "-subj", "/CN=test"],
capture_output=True, check=True,
)
caddy_dir = os.path.join(tmpdir, "caddy")
os.makedirs(caddy_dir, exist_ok=True)
caddyfile_path = os.path.join(caddy_dir, "Caddyfile")
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HTTP_VERSIONS": str(http_versions).replace("'", '"'),
"TLS_DIR": tmpdir,
"TLS_CERT": cert_path,
"TLS_KEY": key_path,
"NODE_NAME": "test",
"LOG_TARGET": "",
"DECNET_FP_SOCK": os.path.join(tmpdir, "fp.sock"),
}
if env_extra:
env.update(env_extra)
# Patch entrypoint to stop after writing the Caddyfile (before Flask).
script = f"""
set -e
export TLS_DIR="{tmpdir}"
export TLS_CERT="{cert_path}"
export TLS_KEY="{key_path}"
"""
# Source just the variable-computation part of entrypoint.sh then write
# the Caddyfile. We replace `exec caddy` with `exit 0`.
src = ENTRYPOINT.read_text()
# Replace the Flask+Caddy run portion with early exit.
src = src.replace("python3 /opt/server.py &", "exit 0")
patched = script + src
import json
http_versions_json = json.dumps(http_versions)
env["HTTP_VERSIONS"] = http_versions_json
result = subprocess.run(
["bash", "-c", patched],
env=env,
capture_output=True,
text=True,
timeout=10,
)
caddyfile_content = ""
# The Caddyfile is written to /etc/caddy/Caddyfile.
# Since we're not root in tests, override it via env trick — just
# extract the content from the script output/error instead.
# Better: redirect output of the heredoc.
# Actually: just parse the script to extract the heredoc.
import re
m = re.search(r"cat > /etc/caddy/Caddyfile <<EOF\n(.*?)\nEOF", src, re.DOTALL)
if m:
template = m.group(1)
else:
pytest.skip("Could not extract Caddyfile template from entrypoint.sh")
# Evaluate the template variables the script computed.
# Run a simpler extraction script.
extract = f"""
import json, os, sys
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")
caddy_protocols = " ".join(tokens) if tokens else "h1"
print("CADDY_PROTOCOLS=" + caddy_protocols)
"""
r = subprocess.run(
["python3", "-c", extract],
env={"HTTP_VERSIONS": http_versions_json, "PATH": os.environ.get("PATH", "/usr/bin:/bin")},
capture_output=True, text=True,
)
vars_ = {}
for line in r.stdout.splitlines():
k, _, v = line.partition("=")
vars_[k.strip()] = v.strip()
caddyfile_content = template
caddyfile_content = caddyfile_content.replace("${CADDY_PROTOCOLS}", vars_.get("CADDY_PROTOCOLS", "h1"))
caddyfile_content = caddyfile_content.replace("${CERT}", cert_path)
caddyfile_content = caddyfile_content.replace("${KEY}", key_path)
return caddyfile_content
class TestHTTPSCaddyfileH3:
def test_h3_selected_adds_h3_to_protocols(self):
"""With h3 selected, Caddy's protocols line must include h3."""
caddyfile = run_entrypoint_to_caddyfile(["http/1.1", "http/2", "http/3"])
import re
proto_match = re.search(r"protocols\s+(.*)", caddyfile)
assert proto_match is not None, "no protocols line found"
assert "h3" in proto_match.group(1), f"h3 missing from protocols: {proto_match.group(1)!r}"
def test_h3_no_separate_decnet_h3_block(self):
"""decnet_h3 app is removed; the Caddyfile must never contain that token."""
caddyfile = run_entrypoint_to_caddyfile(["http/1.1", "http/2", "http/3"])
assert "decnet_h3" not in caddyfile, f"unexpected decnet_h3 in:\n{caddyfile}"
def test_h1_h2_only_no_h3_in_protocols(self):
"""Without h3 in HTTP_VERSIONS, h3 must not appear in the protocols line."""
caddyfile = run_entrypoint_to_caddyfile(["http/1.1", "http/2"])
import re
proto_match = re.search(r"protocols\s+(.*)", caddyfile)
if proto_match:
assert "h3" not in proto_match.group(1), f"unexpected h3 in protocols: {proto_match.group(1)!r}"
def test_h1_only_protocols_line(self):
caddyfile = run_entrypoint_to_caddyfile(["http/1.1"])
import re
proto_match = re.search(r"protocols\s+(.*)", caddyfile)
assert proto_match is not None
assert "h1" in proto_match.group(1)
assert "h2" not in proto_match.group(1)
def test_listener_wrapper_is_decnet_fp(self):
caddyfile = run_entrypoint_to_caddyfile(["http/1.1", "http/2"])
assert "decnet_fp" in caddyfile
# The old name must not appear.
assert "decnet_h2fp" not in caddyfile