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
5 changes: 5 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ run-homelab-access:
argo submit --from workflowtemplate/homelab-access-probe \
-n {{ argo_ns }} --wait --log

# Run in-cluster auth-gated access probe (unauthenticated rejection + authenticated success)
run-homelab-auth:
argo submit --from workflowtemplate/homelab-access-probe \
-n {{ argo_ns }} -p auth-mode=true --wait --log

# Run first PVC/local-path restore drill (#60 #74 #84)
run-homelab-restore:
argo submit --from workflowtemplate/homelab-restore-drill \
Expand Down
5 changes: 5 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ Golden disks can be patched by workflow after key rotation; titan disk key refre
| VM stuck `Terminating` | KubeVirt controller race with launcher cleanup | Delete the `virt-launcher-*` pod and let reconciliation finish |
| `run-gnome-tests` pod fails at startup | Workflow template structure error, often misplaced `volumes:` | Fix the template in git and let ArgoCD reconcile it |
| WorkflowTemplate change appears ignored | Workflow was submitted before the new template was reconciled | Verify ArgoCD revision, wait or sync, then submit a new workflow |
| Auth probe returns 200 without credentials | `REQUIRE_AUTH` env var not injected into the fixture pod | Confirm `auth-mode=true` was passed to the workflow; check `kubectl describe pod` env section |
| `test_authenticated_returns_200` fails with connection error | TLS cert generation step failed or secret not mounted | Inspect `create-secrets` step logs; confirm `homelab-access-tls` Secret exists in the run namespace |
| Auth tests get 421 instead of 401 | Host header mismatch — runner is using the pod IP instead of `homelab-access.local` | The fixture validates `Host:`; ensure `TEST_HOSTNAME` is unset so the default `homelab-access.local` is used |
| `www-authenticate` header missing from 401 response | Fixture server code path not reached (connection refused or wrong port) | Check that `homelab-access` Service exposes port 8443 and the pod is Ready before tests run |
| Auth artifact files absent after run | Runner pod did not reach the test step (deploy-fixture failure) | Inspect `deploy-fixture` step logs for rollout timeout; check pod events in the run namespace |

## Historical notes

Expand Down
42 changes: 42 additions & 0 deletions WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,48 @@ hostDisk clone. Invoked as `onExit` from the pipeline templates.

---

## Homelab in-cluster probe lanes

Stateless probe workflows that deploy a fixture workload, run pytest checks
against the live Kubernetes objects, and clean up on exit. Delegated runner
pod uses `run-incluster-tests / run-pytest`.

### `homelab-access-probe` (issues #82 / #83)

Validates hostname resolution, HTTPS reachability, and (optionally) auth gating
for k8s-hosted homelab workloads. A self-contained Python HTTPS server is
deployed inline as the probe target; TLS cert and auth credentials are
generated fresh each run.

| Parameter | Default | Notes |
|---|---|---|
| `auth-mode` | `false` | `false` runs `test_access_probe.py`; `true` runs `test_auth_probe.py` |
| `branch` | `main` | testing-lab git ref cloned by the runner |

**Non-auth mode** (`auth-mode=false`) — suite `tests/homelab_access/test_access_probe.py`:
- Cluster DNS resolves the service FQDN
- HTTPS handshake exposes a valid certificate
- Expected Host header reaches the fixture and returns `access-ok`

**Auth mode** (`auth-mode=true`) — suite `tests/homelab_access/test_auth_probe.py` (15 tests; 1 explicit skip for SSO — issue #61):
- Unauthenticated request returns HTTP 401
- 401 carries `WWW-Authenticate: Basic realm="homelab"` header
- Wrong credentials are rejected
- Authenticated request returns HTTP 200 with `access-ok` body
- TLS handshake completes independently of auth status
- Failure artifacts captured: response status, headers, body, TLS evidence

Artifacts are written to `/tmp/results/` inside the runner pod and collected by the workflow log tail.

```
just run-homelab-access # non-auth probe, ~4–6 min
just run-homelab-auth # auth-gated probe, ~4–6 min
```

Out of scope: SSO / identity-provider integration (see issue #61).

---

## Dakota BST builds

### `dakota-bst`
Expand Down
38 changes: 38 additions & 0 deletions manifests/homelab-access-auth.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
# Auth-gated homelab access fixture manifest.
#
# Defines the GitOps-owned auth secret for the homelab-access-probe
# workflow when it runs in auth-mode=true. The workflow's create-secrets
# step generates a fresh ephemeral TLS cert and auth secret per run; this
# manifest documents the canonical shape for operators who need to inspect
# or pre-apply the auth configuration outside of a workflow run.
#
# Out of scope:
# - SSO / identity-provider integration → follow-up issue #61
# - cert-manager or external CA integration → future work
#
# The homelab-auth-config ConfigMap below is mounted into the probe workflow
# as documentation only; actual credentials are injected at runtime by the
# create-secrets template step in homelab-access-probe.yaml.
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-auth-config
namespace: argo
labels:
app.kubernetes.io/component: homelab-auth
app.kubernetes.io/part-of: bluefin-test-suite
bluefin.io/lane: homelab-auth
data:
# Expected authentication scheme for the first auth lane.
auth-scheme: "Basic"
# Realm advertised in WWW-Authenticate challenge.
auth-realm: "homelab"
# Expected HTTP status for unauthenticated requests.
expected-unauth-status: "401"
# Header key that must be present on 401 responses.
challenge-header: "WWW-Authenticate"
# Docs: actual credentials are stored in the per-run
# homelab-access-auth Secret created by the workflow's create-secrets step.
# Username: homelab Password: controlnode (ephemeral per workflow run)
artifact-dir: "/tmp/results"
260 changes: 260 additions & 0 deletions tests/homelab_access/test_auth_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""
Auth-gated homelab HTTPS access probe tests.

Validates the first auth-gating contract for k8s-hosted homelab workloads:

1. Unauthenticated request is rejected with HTTP 401.
2. 401 response carries the expected WWW-Authenticate challenge header.
3. Authenticated request succeeds with HTTP 200.
4. HTTPS remains intact while auth is enforced (TLS handshake passes).
5. Wrong credentials are rejected (not silently accepted).
6. Failure artifacts (headers, status, body) are written to RESULTS_DIR
for operator inspection.

The fixture is the Python HTTPS server deployed by the homelab-access-probe
WorkflowTemplate in auth-mode=true. Credentials are injected from the
per-run homelab-access-auth Secret (username: homelab, password: controlnode).

Out of scope:
- SSO / identity-provider integration (see issue #61)
- Token / OAuth flows (future work)
"""

from __future__ import annotations

import base64
import os
import ssl
import subprocess
import urllib.error
import urllib.request

import pytest

from tests.service_catalog.shared.kube import (
NAMESPACE,
SERVICE_NAME,
TEST_LANE,
RESULTS_DIR,
write_artifact,
)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

HOSTNAME = os.environ.get("TEST_HOSTNAME", "homelab-access.local")
_PORT = int(os.environ.get("TEST_PORT", "8443"))
_AUTH_USER = os.environ.get("TEST_AUTH_USER", "homelab")
_AUTH_PASS = os.environ.get("TEST_AUTH_PASS", "controlnode")
_SERVICE_FQDN = f"{SERVICE_NAME}.{NAMESPACE}.svc.cluster.local"
_BASE_URL = f"https://{_SERVICE_FQDN}:{_PORT}"

# Insecure SSL context — the fixture uses a self-signed cert generated
# per workflow run; we test the auth layer, not the PKI chain.
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _basic_header(user: str, password: str) -> str:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {raw}"


def _curl(*extra: str) -> subprocess.CompletedProcess[str]:
"""Run curl with TLS verification disabled and a fixed Host header."""
return subprocess.run(
[
"curl",
"-sk",
"--max-time", "20",
"-H", f"Host: {HOSTNAME}",
*extra,
],
capture_output=True,
text=True,
timeout=30,
)


def _dump_response(label: str, status: int, headers: dict, body: str) -> None:
header_text = "\n".join(f"{k}: {v}" for k, v in headers.items())
artifact = (
f"HTTP Status: {status}\n\n"
f"Headers:\n{header_text}\n\n"
f"Body:\n{body}\n"
)
write_artifact(f"{TEST_LANE}-{label}.txt", artifact)


# ---------------------------------------------------------------------------
# Test cases
# ---------------------------------------------------------------------------


class TestUnauthenticatedRejection:
"""Unauthenticated requests must be rejected before any content is served."""

def test_unauthenticated_returns_401(self):
"""GET without credentials must return HTTP 401 Unauthorized."""
result = _curl("-o", "/dev/null", "-w", "%{http_code}", f"{_BASE_URL}/healthz")
status = result.stdout.strip()
write_artifact(f"{TEST_LANE}-unauth-status.txt", f"HTTP {status}\n{result.stderr}")
assert status == "401", (
f"Expected 401 for unauthenticated request, got: {status!r}"
)

def test_unauthenticated_response_includes_www_authenticate(self):
"""401 response must include WWW-Authenticate challenge header."""
result = _curl("-D", "-", "-o", "/dev/null", f"{_BASE_URL}/healthz")
write_artifact(f"{TEST_LANE}-unauth-headers.txt", result.stdout + result.stderr)
headers_lower = result.stdout.lower()
assert "www-authenticate" in headers_lower, (
f"WWW-Authenticate header missing from 401 response.\n"
f"Full headers:\n{result.stdout}"
)

def test_unauthenticated_challenge_advertises_basic_realm(self):
"""WWW-Authenticate header must specify Basic scheme and homelab realm."""
result = _curl("-D", "-", "-o", "/dev/null", f"{_BASE_URL}/healthz")
combined = result.stdout + result.stderr
assert "basic" in combined.lower(), (
f"Expected 'Basic' scheme in WWW-Authenticate.\nGot: {combined}"
)
assert "homelab" in combined.lower(), (
f"Expected 'homelab' realm in WWW-Authenticate.\nGot: {combined}"
)

def test_unauthenticated_body_is_not_protected_content(self):
"""Body of 401 response must not contain the protected 'access-ok' token."""
result = _curl(f"{_BASE_URL}/healthz")
write_artifact(f"{TEST_LANE}-unauth-body.txt", result.stdout)
assert "access-ok" not in result.stdout, (
"Protected content was returned without authentication"
)


class TestAuthenticatedAccess:
"""Authenticated requests must pass the gate and receive expected content."""

def test_authenticated_returns_200(self):
"""GET with correct Basic credentials must return HTTP 200."""
token = _basic_header(_AUTH_USER, _AUTH_PASS)
result = _curl(
"-o", "/dev/null", "-w", "%{http_code}",
"-H", f"Authorization: {token}",
f"{_BASE_URL}/healthz",
)
status = result.stdout.strip()
write_artifact(f"{TEST_LANE}-auth-status.txt", f"HTTP {status}\n{result.stderr}")
assert status == "200", (
f"Expected 200 for authenticated request, got: {status!r}"
)

def test_authenticated_response_body_contains_success_token(self):
"""Authenticated response body must contain the 'access-ok' sentinel."""
token = _basic_header(_AUTH_USER, _AUTH_PASS)
result = _curl(
"-H", f"Authorization: {token}",
f"{_BASE_URL}/healthz",
)
write_artifact(f"{TEST_LANE}-auth-body.txt", result.stdout)
assert result.stdout.strip() == "access-ok", (
f"Expected body 'access-ok', got: {result.stdout.strip()!r}"
)

def test_wrong_password_returns_401(self):
"""GET with incorrect password must still return HTTP 401."""
token = _basic_header(_AUTH_USER, "wrong-password")
result = _curl(
"-o", "/dev/null", "-w", "%{http_code}",
"-H", f"Authorization: {token}",
f"{_BASE_URL}/healthz",
)
status = result.stdout.strip()
write_artifact(f"{TEST_LANE}-wrongpass-status.txt", f"HTTP {status}\n")
assert status == "401", (
f"Expected 401 for wrong credentials, got: {status!r}"
)

def test_wrong_user_returns_401(self):
"""GET with incorrect username must still return HTTP 401."""
token = _basic_header("intruder", _AUTH_PASS)
result = _curl(
"-o", "/dev/null", "-w", "%{http_code}",
"-H", f"Authorization: {token}",
f"{_BASE_URL}/healthz",
)
status = result.stdout.strip()
write_artifact(f"{TEST_LANE}-wronguser-status.txt", f"HTTP {status}\n")
assert status == "401", (
f"Expected 401 for wrong username, got: {status!r}"
)


class TestHttpsIntegrityUnderAuth:
"""TLS must remain intact while the auth gate is active."""

def test_tls_handshake_succeeds_without_credentials(self):
"""openssl s_client must complete the TLS handshake even for unauth requests."""
result = subprocess.run(
[
"openssl", "s_client",
"-connect", f"{_SERVICE_FQDN}:{_PORT}",
"-servername", HOSTNAME,
"-brief",
],
capture_output=True,
text=True,
timeout=30,
)
write_artifact(f"{TEST_LANE}-tls-handshake.txt", result.stdout + result.stderr)
combined = result.stdout + result.stderr
# openssl exits 1 when the server closes the connection after the HTTP
# layer, but the TLS layer itself must have completed.
assert "Protocol version" in combined or "Verification error" in combined or \
"Connection established" in combined, (
f"TLS handshake did not complete:\n{combined}"
)

def test_https_scheme_required_for_auth(self):
"""Confirming the fixture uses HTTPS — plaintext HTTP must not be reachable."""
result = subprocess.run(
["curl", "-s", "--max-time", "5",
f"http://{_SERVICE_FQDN}:{_PORT}/healthz"],
capture_output=True, text=True, timeout=15,
)
write_artifact(f"{TEST_LANE}-http-attempt.txt", result.stdout + result.stderr)
# Connection should fail or return non-200 — plain HTTP to TLS port
# either gets a connection error or a protocol error, never "access-ok"
assert "access-ok" not in result.stdout, (
"Fixture served protected content over plain HTTP — auth gate is missing TLS"
)

def test_authenticated_success_artifact_written(self):
"""At least one auth-success artifact must exist for operator inspection."""
# Drive an authenticated request to ensure the artifact is present
token = _basic_header(_AUTH_USER, _AUTH_PASS)
result = _curl(
"-D", "-",
"-H", f"Authorization: {token}",
f"{_BASE_URL}/healthz",
)
write_artifact(f"{TEST_LANE}-auth-evidence.txt", result.stdout + result.stderr)
artifacts = list(RESULTS_DIR.glob(f"{TEST_LANE}-*.txt"))
assert artifacts, (
f"No auth-lane artifacts found in {RESULTS_DIR}; "
"lane produced no inspectable evidence"
)

@pytest.mark.skip(
reason="SSO / identity-provider integration is explicitly out of scope — see issue #61"
)
def test_sso_token_accepted(self):
"""OIDC/SSO token acceptance: deferred to issue #61."""