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
+147 -6
View File
@@ -14,6 +14,7 @@ from urllib.parse import urlsplit
SCHEMA_VERSION = 1
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
DEFAULT_INGRESS_IMAGE = "caddy:2.10.2-alpine"
INSTALLATION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
@@ -113,6 +114,16 @@ class ReplicaConfig:
worker: int
@dataclass(frozen=True, slots=True)
class IngressConfig:
mode: str
image: str
trusted_proxy_cidrs: tuple[str, ...]
http_port: int
https_port: int
acme_email: str
@dataclass(frozen=True, slots=True)
class InstallationSpec:
schema_version: int
@@ -124,11 +135,13 @@ class InstallationSpec:
release: ReleaseConfig
components: ComponentConfig
replicas: ReplicaConfig
ingress: IngressConfig
enabled_modules: tuple[str, ...]
def to_dict(self) -> dict[str, Any]:
value = asdict(self)
value["enabled_modules"] = list(self.enabled_modules)
value["ingress"]["trusted_proxy_cidrs"] = list(self.ingress.trusted_proxy_cidrs)
return value
@@ -145,6 +158,12 @@ def default_spec(
storage_mode: str = "local",
garage_image: str = DEFAULT_GARAGE_IMAGE,
load_balancer_image: str = DEFAULT_LOAD_BALANCER_IMAGE,
ingress_mode: str | None = None,
ingress_image: str = DEFAULT_INGRESS_IMAGE,
trusted_proxy_cidrs: tuple[str, ...] = (),
ingress_http_port: int = 80,
ingress_https_port: int = 443,
acme_email: str = "",
api_replicas: int = 1,
web_replicas: int = 1,
worker_replicas: int | None = None,
@@ -218,6 +237,15 @@ def default_spec(
"web": web_replicas,
"worker": effective_worker_replicas,
},
"ingress": {
"mode": ingress_mode
or ("unconfigured" if profile == "self-hosted" else "local"),
"image": ingress_image if ingress_mode == "managed" else "",
"trusted_proxy_cidrs": list(trusted_proxy_cidrs),
"http_port": ingress_http_port,
"https_port": ingress_https_port,
"acme_email": acme_email,
},
"enabled_modules": list(modules),
}
return parse_spec(raw)
@@ -247,6 +275,7 @@ def parse_spec(raw: object) -> InstallationSpec:
"release",
"components",
"replicas",
"ingress",
"enabled_modules",
},
"installation",
@@ -281,6 +310,17 @@ def parse_spec(raw: object) -> InstallationSpec:
release = _release(root.get("release"))
components = _components(root.get("components"), profile=profile)
replicas = _replicas(root.get("replicas"), components=components)
ingress = _ingress(root.get("ingress"), profile=profile)
if ingress.mode == "managed":
try:
ipaddress.ip_address(public_parts.hostname or "")
except ValueError:
pass
else:
raise SpecError("managed ingress requires a DNS hostname in public_url")
public_port = public_parts.port or 443
if public_port != ingress.https_port:
raise SpecError("managed ingress HTTPS port must match the public_url port")
enabled_raw = root.get("enabled_modules")
if not isinstance(enabled_raw, list):
@@ -307,6 +347,7 @@ def parse_spec(raw: object) -> InstallationSpec:
release=release,
components=components,
replicas=replicas,
ingress=ingress,
enabled_modules=tuple(enabled_modules),
)
@@ -344,18 +385,14 @@ def _release(raw: object) -> ReleaseConfig:
raise SpecError(
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_keyring_sha256 = _optional_string(
value, "manifest_keyring_sha256"
).lower()
manifest_keyring_sha256 = _optional_string(value, "manifest_keyring_sha256").lower()
if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch(
manifest_keyring_sha256
):
raise SpecError(
"release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_signature_key_id = _optional_string(
value, "manifest_signature_key_id"
)
manifest_signature_key_id = _optional_string(value, "manifest_signature_key_id")
if manifest_signature_key_id and not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id
):
@@ -483,6 +520,84 @@ def _replicas(raw: object, *, components: ComponentConfig) -> ReplicaConfig:
return replicas
def _ingress(raw: object, *, profile: str) -> IngressConfig:
if raw is None:
return IngressConfig(
mode="unconfigured" if profile == "self-hosted" else "local",
image="",
trusted_proxy_cidrs=(),
http_port=80,
https_port=443,
acme_email="",
)
value = _mapping(raw, "ingress")
_only_keys(
value,
{
"mode",
"image",
"trusted_proxy_cidrs",
"http_port",
"https_port",
"acme_email",
},
"ingress",
)
mode = _choice(
value,
"mode",
{"local", "existing-proxy", "managed", "unconfigured"},
)
image = _optional_string(value, "image")
raw_cidrs = value.get("trusted_proxy_cidrs", [])
if not isinstance(raw_cidrs, list) or len(raw_cidrs) > 16:
raise SpecError(
"ingress.trusted_proxy_cidrs must be an array of at most 16 networks"
)
cidrs: list[str] = []
for item in raw_cidrs:
if not isinstance(item, str):
raise SpecError("ingress.trusted_proxy_cidrs must contain strings")
cidrs.append(_trusted_proxy_network(item))
if len(set(cidrs)) != len(cidrs):
raise SpecError("ingress.trusted_proxy_cidrs contains duplicates")
http_port = _port(_integer(value, "http_port"), "ingress.http_port")
https_port = _port(_integer(value, "https_port"), "ingress.https_port")
if http_port == https_port:
raise SpecError("ingress HTTP and HTTPS ports must differ")
acme_email = _optional_string(value, "acme_email")
if acme_email and (
len(acme_email) > 254 or re.fullmatch(r"[^@\s]+@[^@\s]+", acme_email) is None
):
raise SpecError("ingress.acme_email must be a valid email address")
if profile == "self-hosted" and mode == "local":
raise SpecError(
"self-hosted installations require existing-proxy or managed ingress"
)
if profile == "evaluation" and mode == "unconfigured":
raise SpecError("evaluation installations cannot use unconfigured ingress")
if mode == "managed":
image = _image(image or DEFAULT_INGRESS_IMAGE, "ingress.image")
if not acme_email:
raise SpecError("managed ingress requires ingress.acme_email")
elif image:
raise SpecError("ingress.image is only valid for managed ingress")
if mode == "existing-proxy" and not cidrs:
raise SpecError("existing-proxy ingress requires a trusted proxy CIDR")
if mode != "existing-proxy" and cidrs:
raise SpecError("trusted proxy CIDRs are only valid for existing-proxy ingress")
if mode != "managed" and acme_email:
raise SpecError("ingress.acme_email is only valid for managed ingress")
return IngressConfig(
mode=mode,
image=image,
trusted_proxy_cidrs=tuple(cidrs),
http_port=http_port,
https_port=https_port,
acme_email=acme_email,
)
def _service(
raw: object,
label: str,
@@ -614,6 +729,32 @@ def _network(value: str, label: str) -> str:
return str(network)
def _trusted_proxy_network(value: str) -> str:
try:
network = ipaddress.ip_network(value.strip(), strict=True)
except ValueError as exc:
raise SpecError(
"ingress.trusted_proxy_cidrs must contain canonical IP networks"
) from exc
if network.is_unspecified or network.is_multicast:
raise SpecError("trusted proxy CIDR cannot be unspecified or multicast")
if network.version == 4:
if network.is_global and network.prefixlen != 32:
raise SpecError("a public trusted proxy must be an exact IPv4 address")
if not network.is_global and network.prefixlen < 24:
raise SpecError(
"a private trusted IPv4 proxy network must be /24 or narrower"
)
else:
if network.is_global and network.prefixlen != 128:
raise SpecError("a public trusted proxy must be an exact IPv6 address")
if not network.is_global and network.prefixlen < 64:
raise SpecError(
"a private trusted IPv6 proxy network must be /64 or narrower"
)
return str(network)
def _port(value: int, label: str) -> int:
if value < 1 or value > 65535:
raise SpecError(f"{label} must be between 1 and 65535")