Implement supported ingress and TLS profiles
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m2s

This commit is contained in:
2026-08-03 01:15:06 +02:00
parent b40f1428fd
commit 2c515f73c2
12 changed files with 1174 additions and 37 deletions
+328 -15
View File
@@ -9,17 +9,24 @@ from pathlib import Path
import platform
import shutil
import socket
import ssl
import stat
import subprocess
import time
from typing import Callable, Mapping, Sequence
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from .bundle import (
BundlePaths,
canonical_json,
digest_json,
environment_fingerprint,
read_env,
render_caddy_config,
render_compose,
render_existing_proxy_contract,
service_names,
)
from .distribution import (
@@ -178,6 +185,7 @@ def build_plan(
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
checks: list[Check] = []
checks.extend(_ingress_configuration_checks(spec, paths))
images = {
"release.api_image": spec.release.api_image,
"release.web_image": spec.release.web_image,
@@ -191,6 +199,8 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
if spec.components.storage.mode == "garage":
images["components.storage.image"] = spec.components.storage.image
images["components.load_balancer.image"] = spec.components.load_balancer.image
if spec.ingress.mode == "managed":
images["ingress.image"] = spec.ingress.image
for label, image in images.items():
if image_is_unpublished(image):
@@ -340,6 +350,88 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
return tuple(checks)
def _ingress_configuration_checks(
spec: InstallationSpec,
paths: BundlePaths,
) -> tuple[Check, ...]:
if spec.ingress.mode == "unconfigured":
return (
Check(
"ingress.configuration",
"error" if spec.profile == "self-hosted" else "warning",
"No supported public ingress boundary is configured.",
"Select managed ingress or an existing reverse proxy before apply.",
),
)
checks = [
Check(
"ingress.configuration",
"ok",
f"Ingress mode {spec.ingress.mode!r} has a bounded configuration.",
)
]
if spec.ingress.mode == "managed":
checks.append(
_artifact_check(
"ingress.managed_config",
paths.caddy_config,
render_caddy_config(spec).encode("utf-8"),
"Managed ingress configuration",
)
)
ingress = render_compose(spec)["services"].get("ingress", {})
volumes = ingress.get("volumes", []) if isinstance(ingress, dict) else []
persistent = "caddy-data:/data" in volumes and "caddy-config:/config" in volumes
checks.append(
Check(
"ingress.certificate_state",
"ok" if persistent else "error",
(
"Managed certificate and renewal state uses persistent private volumes."
if persistent
else "Managed certificate state is not persistent."
),
"Restore the caddy-data and caddy-config volume bindings."
if not persistent
else "",
)
)
elif spec.ingress.mode == "existing-proxy":
checks.append(
_artifact_check(
"ingress.existing_proxy_contract",
paths.existing_proxy,
canonical_json(render_existing_proxy_contract(spec)),
"Existing reverse-proxy contract",
)
)
return tuple(checks)
def _artifact_check(
check_id: str,
path: Path,
expected: bytes,
label: str,
) -> Check:
try:
actual = path.read_bytes()
except OSError as exc:
return Check(
check_id,
"error",
f"{label} is unavailable: {exc}",
"Re-render the installation bundle.",
)
matches = actual == expected
return Check(
check_id,
"ok" if matches else "error",
f"{label} {'matches' if matches else 'does not match'} the installation specification.",
"Re-render the installation bundle before apply." if not matches else "",
)
def _distribution_checks(
spec: InstallationSpec,
paths: BundlePaths,
@@ -379,7 +471,9 @@ def _distribution_checks(
maximum_bytes=MAX_MANIFEST_BYTES,
)
if manifest_digest != spec.release.manifest_sha256:
raise DistributionError("stored manifest digest does not match installation")
raise DistributionError(
"stored manifest digest does not match installation"
)
keyring_digest = file_sha256(
paths.keyring,
maximum_bytes=MAX_KEYRING_BYTES,
@@ -400,7 +494,9 @@ def _distribution_checks(
expected_channel=spec.release.channel,
)
if key_id != spec.release.manifest_signature_key_id:
raise DistributionError("verified signature key does not match installation")
raise DistributionError(
"verified signature key does not match installation"
)
verify_manifest_binding(
manifest,
channel=spec.release.channel,
@@ -461,6 +557,8 @@ def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]:
values["test_mail"] = spec.components.mail.image
if spec.components.storage.mode == "garage":
values["garage"] = spec.components.storage.image
if spec.ingress.mode == "managed":
values["managed_ingress"] = spec.ingress.image
return values
@@ -471,6 +569,7 @@ def host_checks(
command_runner: CommandRunner | None = None,
) -> tuple[Check, ...]:
checks: list[Check] = []
runner = command_runner or _run_command
machine = platform.machine().lower()
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
checks.append(
@@ -543,7 +642,6 @@ def host_checks(
)
)
else:
runner = command_runner or _run_command
result = runner((docker, "compose", "version", "--short"), paths.root)
checks.append(
Check(
@@ -608,30 +706,245 @@ def host_checks(
)
receipt = _read_receipt(paths.receipt)
checks.extend(
_ingress_host_checks(
spec,
paths,
receipt=receipt,
docker=docker,
command_runner=runner,
)
)
return tuple(checks)
def _ingress_host_checks(
spec: InstallationSpec,
paths: BundlePaths,
*,
receipt: Mapping[str, object],
docker: str | None,
command_runner: CommandRunner,
) -> tuple[Check, ...]:
checks: list[Check] = []
applied = bool(receipt)
if spec.ingress.mode in {"managed", "existing-proxy"}:
public = urlsplit(spec.public_url)
host = public.hostname or ""
port = public.port or 443
checks.append(_dns_resolution_check(host, port))
if spec.ingress.mode == "managed" and not applied:
checks.append(
Check(
"ingress.tls",
"warning",
"TLS issuance will be verified after managed ingress starts.",
"Ensure public DNS resolves to this host and ports 80/443 are reachable.",
)
)
checks.append(
Check(
"ingress.public_route",
"warning",
"Public-route health will be verified after managed ingress starts.",
"Permit inbound HTTP and HTTPS through the host firewall and upstream NAT.",
)
)
else:
checks.append(_tls_validity_check(host, port))
checks.append(
_public_route_check(
spec.public_url,
require_ready=applied,
)
)
previous_ingress = receipt.get("ingress")
desired_ingress = {
"mode": spec.ingress.mode,
"http_port": spec.ingress.http_port,
"https_port": spec.ingress.https_port,
}
previous_listen = receipt.get("listen")
desired_listen = {
"address": spec.listen.address,
"port": spec.listen.port,
}
if not receipt or previous_listen != desired_listen:
available = _port_available(spec.listen.address, spec.listen.port)
if spec.ingress.mode == "managed":
if not applied or previous_ingress != desired_ingress:
for label, port in (
("http", spec.ingress.http_port),
("https", spec.ingress.https_port),
):
checks.append(
_available_port_check(
f"host.ingress_{label}_port",
"0.0.0.0",
port,
)
)
elif not applied or previous_listen != desired_listen:
checks.append(
Check(
_available_port_check(
"host.listen_port",
"ok" if available else "error",
(
f"Listen endpoint {spec.listen.address}:{spec.listen.port} is available."
if available
else f"Listen endpoint {spec.listen.address}:{spec.listen.port} is already in use."
),
"Choose another listen port or stop the conflicting service."
if not available
else "",
spec.listen.address,
spec.listen.port,
)
)
if applied:
checks.append(
_local_upstream_check(
spec,
paths,
docker=docker,
command_runner=command_runner,
)
)
return tuple(checks)
def _available_port_check(check_id: str, address: str, port: int) -> Check:
available = _port_available(address, port)
return Check(
check_id,
"ok" if available else "error",
(
f"Listen endpoint {address}:{port} is available."
if available
else f"Listen endpoint {address}:{port} is already in use."
),
"Choose another port or stop the conflicting service." if not available else "",
)
def _dns_resolution_check(host: str, port: int) -> Check:
try:
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
addresses = sorted({str(item[4][0]) for item in results})
except OSError as exc:
return Check(
"ingress.dns",
"error",
f"Public hostname {host!r} does not resolve: {exc}",
"Publish public A/AAAA records before apply.",
)
return Check(
"ingress.dns",
"ok",
f"Public hostname {host!r} resolves to {', '.join(addresses[:8])}.",
)
def _tls_validity_check(host: str, port: int) -> Check:
try:
context = ssl.create_default_context()
with socket.create_connection((host, port), timeout=3.0) as connection:
with context.wrap_socket(connection, server_hostname=host) as secured:
certificate = secured.getpeercert()
expires = str(certificate.get("notAfter") or "")
remaining_seconds = ssl.cert_time_to_seconds(expires) - time.time()
except (OSError, ValueError, ssl.SSLError) as exc:
return Check(
"ingress.tls",
"error",
f"Public TLS validation failed for {host}:{port}: {exc}",
"Correct certificate issuance, trust chain, hostname, and public routing.",
)
remaining_days = int(remaining_seconds // 86400)
level = "ok" if remaining_days >= 21 else "warning"
return Check(
"ingress.tls",
level,
f"Public TLS certificate is valid for approximately {remaining_days} more day(s).",
"Verify automated certificate renewal immediately."
if level == "warning"
else "",
)
def _public_route_check(public_url: str, *, require_ready: bool) -> Check:
target = public_url.rstrip("/") + "/health/ready"
status: int | None = None
try:
request = Request(target, headers={"User-Agent": "govoplan-deploy/doctor"})
with urlopen(request, timeout=4.0) as response:
status = response.status
except HTTPError as exc:
status = exc.code
except (OSError, URLError, ValueError) as exc:
return Check(
"ingress.public_route",
"error",
f"Public route is unreachable: {exc}",
"Check external DNS, firewall/NAT, reverse-proxy routing, and TLS.",
)
acceptable = status == 200 if require_ready else status not in {400, 404, 421}
return Check(
"ingress.public_route",
"ok" if acceptable else "error",
f"Public readiness route returned HTTP {status}.",
(
"Route the configured hostname and /health/ready path to the generated upstream."
if not acceptable
else ""
),
)
def _local_upstream_check(
spec: InstallationSpec,
paths: BundlePaths,
*,
docker: str | None,
command_runner: CommandRunner,
) -> Check:
if docker is None:
return Check(
"ingress.local_upstream",
"error",
"Local upstream health cannot be checked without Docker.",
"Restore Docker access and rerun doctor.",
)
argv = (
docker,
"compose",
"--env-file",
str(paths.env),
"--project-name",
spec.installation_id,
"--file",
str(paths.compose),
"exec",
"--no-TTY",
"load-balancer",
"wget",
"-qO-",
"http://127.0.0.1:8080/health",
)
try:
result = command_runner(argv, paths.root)
except (OSError, subprocess.SubprocessError) as exc:
return Check(
"ingress.local_upstream",
"error",
f"Local upstream health probe failed: {exc}",
"Inspect the load-balancer and WebUI service health.",
)
return Check(
"ingress.local_upstream",
"ok" if result.returncode == 0 else "error",
(
"The generated local upstream is healthy."
if result.returncode == 0
else "The generated local upstream health probe failed."
),
"Inspect load-balancer and WebUI health before exposing the route."
if result.returncode != 0
else "",
)
def _read_receipt(path: Path) -> Mapping[str, object]:
if not path.exists():
return {}