Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions argo/workflow-templates/homelab-access-probe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Expand Down
92 changes: 89 additions & 3 deletions docs/homelab-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand All @@ -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

Expand Down Expand Up @@ -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.<namespace>.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 |
|---|---|---|
Expand Down
112 changes: 112 additions & 0 deletions tests/homelab_access/test_https_exposure.py
Original file line number Diff line number Diff line change
@@ -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()}"
)