diff --git a/Justfile b/Justfile index 37cb4e689..2c5845be4 100644 --- a/Justfile +++ b/Justfile @@ -282,7 +282,7 @@ run-homelab-storage: argo submit --from workflowtemplate/homelab-storage \ -n {{ argo_ns }} --wait --log -# Run in-cluster homelab access probe +# Run in-cluster homelab access probe (includes HTTPS exposure lane #58) run-homelab-access: argo submit --from workflowtemplate/homelab-access-probe \ -n {{ argo_ns }} --wait --log diff --git a/argo/workflow-templates/homelab-access-probe.yaml b/argo/workflow-templates/homelab-access-probe.yaml index d14b714b4..d619dfaad 100644 --- a/argo/workflow-templates/homelab-access-probe.yaml +++ b/argo/workflow-templates/homelab-access-probe.yaml @@ -62,6 +62,26 @@ spec: value: "homelab-access" - name: branch value: "{{workflow.parameters.branch}}" + - name: run-https-exposure-tests + depends: "deploy-fixture.Succeeded" + when: "'{{workflow.parameters.auth-mode}}' == 'false'" + templateRef: + name: run-incluster-tests + template: run-pytest + arguments: + parameters: + - name: namespace + value: "homelab-access-{{workflow.uid}}" + - name: suite-path + value: "homelab_access/test_https_exposure.py" + - name: lane + value: "homelab-https-exposure" + - name: app-label + value: "app=homelab-access" + - name: service-name + value: "homelab-access" + - name: branch + value: "{{workflow.parameters.branch}}" - name: run-auth-tests depends: "deploy-fixture.Succeeded" when: "'{{workflow.parameters.auth-mode}}' == 'true'" diff --git a/docs/homelab-contracts.md b/docs/homelab-contracts.md index 98ab4f45a..9e9ea5b14 100644 --- a/docs/homelab-contracts.md +++ b/docs/homelab-contracts.md @@ -2,8 +2,9 @@ This document defines the in-cluster workload validation contracts for the testing-lab QA factory. It covers the workload matrix (#57), shared-storage -and RWX limits (#62), storage observability surface (#70, #78), and the -fleet-client vs. cluster-node boundary (#72). +and RWX limits (#62), storage observability surface (#70, #78), the +fleet-client vs. cluster-node boundary (#72), and the HTTPS service-exposure +lane (#58). --- @@ -18,6 +19,7 @@ access guarantees it must prove. | **General-purpose** | `homelab-substrate` | `tests/homelab_substrate/` | k3s scheduling, pod lifecycle, in-cluster HTTP/TCP reachability, `local-path` PVC allocation | | **NAS / storage** | `homelab-storage` | `tests/homelab_storage/` | PVC bound on `local-path`, data survives `rollout restart`, `findmnt`/`df`/`lsblk`/ZFS artifacts captured | | **Service access** | `homelab-access-probe` | `tests/homelab_access/` | Cluster-DNS resolution, TLS handshake, expected-host routing via SNI | +| **HTTPS exposure** | `homelab-access-probe` | `tests/homelab_access/test_https_exposure.py` | Certificate subject match, TLS 1.2+ enforcement, HTTPS reachability, wrong-host rejection (§6) | ### Minimum persistence contract per class @@ -187,7 +189,91 @@ The lab validates the following hostname/routing pattern for exposed in-cluster --- -## 6. Known Blockers and Deferred Work +## 6. HTTPS Service-Exposure Lane (#58) + +This section defines the first HTTPS service-exposure validation lane under the +access/TLS epic (#53). The representative service endpoint is the +`homelab-access` fixture — a Python HTTPS server deployed in-cluster with a +self-signed TLS certificate, SNI-based host routing, and optional basic auth. + +### Representative endpoint + +| Property | Value | +|---|---| +| **Service** | `homelab-access..svc.cluster.local:8443` | +| **Expected hostname** | `homelab-access.local` | +| **TLS certificate** | Self-signed, CN=`homelab-access.local`, 1-day validity | +| **Protocol** | HTTPS (TLS 1.2+) | +| **Health response** | `access-ok` on `GET /healthz` with correct `Host` header | + +### Minimum evidence the lane must capture + +Every run of this lane must produce the following evidence artifacts: + +| Check | Evidence artifact | Pass criteria | +|---|---|---| +| **Cluster DNS resolution** | `https-dns.txt` | `getent hosts` resolves the service FQDN to a cluster IP | +| **TLS handshake completes** | `https-tls-handshake.txt` | `openssl s_client` output contains `Protocol version` and `Verify return code` | +| **Certificate subject matches** | `https-cert-subject.txt` | Certificate subject CN or SAN matches the expected hostname | +| **TLS version is 1.2 or higher** | `https-tls-version.txt` | Negotiated protocol is `TLSv1.2` or `TLSv1.3` | +| **HTTPS reachability** | `https-reachability.txt` | `curl --resolve` over HTTPS returns HTTP 200 with body `access-ok` | +| **Wrong-host rejection** | `https-wrong-host.txt` | Request with an incorrect `Host` header returns HTTP 421 | + +### Fixture deployment + +The lane reuses the `homelab-access-probe` WorkflowTemplate's fixture +(§1 Service access class). The fixture deploys: + +1. A TLS secret (`homelab-access-tls`) with a self-signed certificate. +2. An auth secret (`homelab-access-auth`) with basic credentials. +3. A Python HTTPS server that validates `Host` headers and optionally + enforces basic auth. +4. A ClusterIP Service on port 8443. + +The HTTPS exposure lane runs with `auth-mode=false` — auth-gating is a +separate concern validated by the auth lane (#61). + +### What this lane validates vs. what it defers + +| Concern | This lane (#58) | Deferred to | +|---|---|---| +| TLS handshake and certificate presence | ✅ | — | +| Certificate subject/SAN match | ✅ | — | +| TLS version enforcement (1.2+) | ✅ | — | +| HTTPS reachability from within the cluster | ✅ | — | +| Host-header routing correctness | ✅ | — | +| Wrong-host rejection (421) | ✅ | — | +| Basic auth / credential gating | ❌ | #61 (auth-gating lane) | +| ACME / Let's Encrypt certificate issuance | ❌ | Future cert-manager lane | +| External/LAN reachability (NodePort, Ingress) | ❌ | bluespeed / ingress lane | +| Firewall rules / NetworkPolicy enforcement | ❌ | Follow-up under #53 | +| mTLS between services | ❌ | Future service-mesh lane | + +### Follow-up work called out explicitly + +1. **Auth-gating lane (#61)**: Once this HTTPS lane proves transport security, + #61 layers credential validation on top. The `auth-mode=true` parameter + and `test_auth_probe.py` test module are already scaffolded in the + `homelab-access-probe` WorkflowTemplate but not yet covered by a lane + definition. + +2. **Firewall / NetworkPolicy**: The current lane validates reachability + within the cluster namespace but does not assert NetworkPolicy rules that + restrict cross-namespace access. A NetworkPolicy validation lane should be + filed under #53 once the HTTPS and auth lanes are stable. + +3. **Certificate lifecycle**: The fixture uses ephemeral 1-day self-signed + certs created per workflow run. Validating cert-manager integration, + renewal, and ACME issuance is out of scope and should be tracked as a + separate issue. + +4. **External exposure**: LAN-facing reverse proxy patterns + (`bluespeed.local`) and ingress controller validation belong to the + bluespeed project, not this lane. + +--- + +## 7. Known Blockers and Deferred Work | Issue | Status | Dependency | |---|---|---| diff --git a/tests/homelab_access/test_https_exposure.py b/tests/homelab_access/test_https_exposure.py new file mode 100644 index 000000000..6ac8d1b87 --- /dev/null +++ b/tests/homelab_access/test_https_exposure.py @@ -0,0 +1,112 @@ +""" +HTTPS service-exposure lane checks (#58). + +Validates certificate, TLS version, HTTPS reachability, and host-routing +for the homelab-access fixture. Auth-gating is out of scope — see #61. +""" + +from __future__ import annotations + +import os +import re +import subprocess + +from tests.service_catalog.shared.kube import RESULTS_DIR, write_artifact + + +NAMESPACE = os.environ["TEST_NAMESPACE"] +SERVICE_NAME = os.environ.get("TEST_SERVICE_NAME", "homelab-access") +HOSTNAME = "homelab-access.local" +SERVICE_FQDN = f"{SERVICE_NAME}.{NAMESPACE}.svc.cluster.local" +HTTPS_PORT = 8443 + +TIMEOUT_SECONDS = 30 +MIN_TLS_VERSION = "TLSv1.2" +VALID_TLS_VERSIONS = {"TLSv1.2", "TLSv1.3"} + + +def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT_SECONDS) + + +class TestHTTPSExposure: + """Evidence suite for the HTTPS service-exposure lane (#58).""" + + def test_cluster_dns_resolves(self): + result = run("getent", "hosts", SERVICE_FQDN) + write_artifact("https-dns.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"DNS resolution failed for {SERVICE_FQDN}: {result.stderr}" + + def test_tls_handshake_completes(self): + result = run( + "openssl", "s_client", + "-connect", f"{SERVICE_FQDN}:{HTTPS_PORT}", + "-servername", HOSTNAME, + "-brief", + ) + combined = result.stdout + result.stderr + write_artifact("https-tls-handshake.txt", combined) + assert result.returncode == 0, f"TLS handshake failed: {combined}" + assert "Protocol version" in combined, f"No protocol version in handshake output: {combined}" + + def test_certificate_subject_matches(self): + result = run( + "openssl", "s_client", + "-connect", f"{SERVICE_FQDN}:{HTTPS_PORT}", + "-servername", HOSTNAME, + ) + combined = result.stdout + result.stderr + cert_result = subprocess.run( + ["openssl", "x509", "-noout", "-subject", "-ext", "subjectAltName"], + input=result.stdout, + capture_output=True, + text=True, + timeout=TIMEOUT_SECONDS, + ) + evidence = cert_result.stdout + cert_result.stderr + write_artifact("https-cert-subject.txt", evidence) + assert HOSTNAME in evidence, ( + f"Certificate subject/SAN does not contain {HOSTNAME}: {evidence}" + ) + + def test_tls_version_is_acceptable(self): + result = run( + "openssl", "s_client", + "-connect", f"{SERVICE_FQDN}:{HTTPS_PORT}", + "-servername", HOSTNAME, + "-brief", + ) + combined = result.stdout + result.stderr + write_artifact("https-tls-version.txt", combined) + + match = re.search(r"Protocol version\s*:\s*(TLSv[\d.]+)", combined) + assert match, f"Could not parse TLS version from: {combined}" + negotiated = match.group(1) + assert negotiated in VALID_TLS_VERSIONS, ( + f"Negotiated {negotiated}, expected one of {VALID_TLS_VERSIONS}" + ) + + def test_https_reachability(self): + result = run( + "curl", "-sk", + "--resolve", f"{HOSTNAME}:{HTTPS_PORT}:{SERVICE_FQDN}", + f"https://{HOSTNAME}:{HTTPS_PORT}/healthz", + ) + write_artifact("https-reachability.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"HTTPS request failed: {result.stderr}" + assert result.stdout.strip() == "access-ok", ( + f"Expected 'access-ok', got: {result.stdout.strip()}" + ) + + def test_wrong_host_rejected(self): + result = run( + "curl", "-sk", + "-o", "/dev/null", "-w", "%{http_code}", + "-H", "Host: wrong-host.example.com", + f"https://{SERVICE_FQDN}:{HTTPS_PORT}/healthz", + ) + write_artifact("https-wrong-host.txt", result.stdout + result.stderr) + assert result.returncode == 0, f"curl failed: {result.stderr}" + assert result.stdout.strip() == "421", ( + f"Expected HTTP 421 for wrong host, got: {result.stdout.strip()}" + )