Files
turnstone/tests/test_tls_client.py
T
Patrick Buckley a3ff07a86d fix(tls): mTLS-aware container healthcheck + boot-time init retry
A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.

- TLSClient.init() grows attempts/base_delay retry (server passes 6
  attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
  failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
  presenting the node's own cert as the client cert with the cluster CA
  pinned; dials localhost because the internal CA issues DNS SANs only.
  Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
  default <tmpdir>/turnstone-tls) so the probe can find them; boot
  clears stale dirs and refuses a symlinked/foreign-owned root; renewal
  rewrites the PEM dir so the probe's client cert never outlives the
  served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
  disabled) so a silently downgraded node is observable.
2026-06-09 18:37:46 -07:00

270 lines
9.1 KiB
Python

"""Tests for TLSClient — service node certificate provisioning."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
lacme = pytest.importorskip("lacme")
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
# ── Console URL discovery ─────────────────────────────────────────────────────
def test_discover_console_url():
"""TLSClient discovers console URL from services table."""
from turnstone.core.tls import TLSClient
storage = get_storage()
storage.register_service("console", "console", "http://console:8080")
client = TLSClient(storage=storage, hostnames=["node-1"])
url = client._discover_console_url()
assert url == "http://console:8080"
def test_discover_console_url_missing():
"""TLSClient raises if no console registered."""
from turnstone.core.tls import TLSClient
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
with pytest.raises(RuntimeError, match="No console service found"):
client._discover_console_url()
def test_explicit_console_url_skips_discovery():
"""When console_url is provided, discovery is skipped."""
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://explicit:9090",
hostnames=["node-1"],
)
assert client._console_url == "http://explicit:9090"
# ── SSL context construction ─────────────────────────────────────────────────
@pytest.mark.anyio
async def test_ssl_contexts_none_before_init():
"""SSL contexts are None before init()."""
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://localhost:8080",
hostnames=["node-1"],
)
assert client.get_server_ssl_context() is None
assert client.get_client_ssl_context() is None
assert not client.initialized
# ── Backward compatibility ───────────────────────────────────────────────────
def test_collector_tls_defaults():
"""Collector with default TLS params works without changes."""
from turnstone.console.collector import ClusterCollector
storage_mock = MagicMock()
collector = ClusterCollector(storage=storage_mock)
# Should store TLS settings for async client creation
assert collector._tls_verify is True
# ── init() retry ─────────────────────────────────────────────────────────────
def _make_flaky_client(monkeypatch, failures: int):
"""TLSClient whose CA fetch fails ``failures`` times, then succeeds.
Returns (client, calls, sleeps) — mutable lists recording each CA-fetch
attempt and each backoff delay (asyncio.sleep is stubbed out).
"""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://console:9999",
hostnames=["node-1"],
)
calls: list[int] = []
sleeps: list[float] = []
async def flaky_fetch():
calls.append(len(calls) + 1)
if len(calls) <= failures:
raise ConnectionError("console not accepting connections yet")
async def ok_request():
pass
async def fake_sleep(delay):
sleeps.append(delay)
monkeypatch.setattr(client, "_fetch_ca_cert", flaky_fetch)
monkeypatch.setattr(client, "_request_cert", ok_request)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
return client, calls, sleeps
@pytest.mark.anyio
async def test_init_default_single_attempt(monkeypatch):
"""Default init() keeps the old behavior: one attempt, no sleep."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=1)
with pytest.raises(ConnectionError):
await client.init()
assert calls == [1]
assert sleeps == []
@pytest.mark.anyio
async def test_init_retries_transient_failure(monkeypatch):
"""A transient console outage is absorbed by retries with backoff."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=2)
await client.init(attempts=6)
assert calls == [1, 2, 3]
assert sleeps == [1.0, 2.0]
@pytest.mark.anyio
async def test_init_retries_exhausted_raises(monkeypatch):
"""When every attempt fails, the last error propagates."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=99)
with pytest.raises(ConnectionError):
await client.init(attempts=3)
assert calls == [1, 2, 3]
assert sleeps == [1.0, 2.0]
@pytest.mark.anyio
async def test_init_retries_discovery_failure(monkeypatch):
"""Console discovery (not-yet-registered console) is retried too."""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
attempts: list[int] = []
def flaky_discover():
attempts.append(len(attempts) + 1)
if len(attempts) == 1:
raise RuntimeError("No console service found in services table.")
return "http://console:9999"
async def ok():
pass
monkeypatch.setattr(client, "_discover_console_url", flaky_discover)
monkeypatch.setattr(client, "_fetch_ca_cert", ok)
monkeypatch.setattr(client, "_request_cert", ok)
monkeypatch.setattr(asyncio, "sleep", lambda _: ok())
await client.init(attempts=2)
assert attempts == [1, 2]
assert client._console_url == "http://console:9999"
# ── PEM runtime dir ──────────────────────────────────────────────────────────
def test_pem_runtime_dir_env_override(monkeypatch, tmp_path):
"""TURNSTONE_TLS_PEM_DIR overrides the default location."""
from turnstone.core.tls import tls_pem_runtime_dir
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(tmp_path / "custom"))
assert tls_pem_runtime_dir() == tmp_path / "custom"
def test_pem_runtime_dir_default(monkeypatch):
"""Default lives under the system tempdir."""
import tempfile
from turnstone.core.tls import tls_pem_runtime_dir
monkeypatch.delenv("TURNSTONE_TLS_PEM_DIR", raising=False)
assert tls_pem_runtime_dir() == Path(tempfile.gettempdir()) / "turnstone-tls"
def test_prepare_pem_runtime_dir_clears_stale(monkeypatch, tmp_path):
"""Boot prep creates the dir 0700 and removes stale lacme-pem-* dirs."""
from turnstone.core.tls import prepare_pem_runtime_dir
root = tmp_path / "tls"
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(root))
stale = root / "lacme-pem-stale"
stale.mkdir(parents=True)
(stale / "key.pem").write_text("old")
(root / "unrelated").mkdir()
result = prepare_pem_runtime_dir()
assert result == root
assert not stale.exists()
assert (root / "unrelated").exists() # only lacme-pem-* is cleared
assert (root.stat().st_mode & 0o777) == 0o700
def test_prepare_pem_runtime_dir_rejects_symlink(monkeypatch, tmp_path):
"""A pre-created symlink at the root must be refused, not followed.
On bare metal the default root sits in shared /tmp; following a
planted symlink would land key material under an attacker-chosen
path."""
from turnstone.core.tls import prepare_pem_runtime_dir
target = tmp_path / "elsewhere"
target.mkdir()
link = tmp_path / "tls-link"
link.symlink_to(target)
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(link))
with pytest.raises(RuntimeError, match="symlink or not owned"):
prepare_pem_runtime_dir()
def test_refresh_runtime_pems_rotates_dir(monkeypatch, tmp_path):
"""Renewal writes a fresh complete PEM dir, then drops the old one."""
from lacme import CertificateAuthority, MemoryStore
from turnstone.core.tls import prepare_pem_runtime_dir, refresh_runtime_pems
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(tmp_path / "tls"))
root = prepare_pem_runtime_dir()
ca = CertificateAuthority(store=MemoryStore())
ca.init()
boot_bundle = ca.issue(["node-1", "localhost"])
renewed_bundle = ca.issue(["node-1", "localhost"])
boot = refresh_runtime_pems(boot_bundle, ca_pem=ca.root_cert_pem, previous=None)
boot_dir = boot.cert.parent
assert boot_dir.parent == root
renewed = refresh_runtime_pems(renewed_bundle, ca_pem=ca.root_cert_pem, previous=boot_dir)
new_dir = renewed.cert.parent
assert new_dir.parent == root
assert not boot_dir.exists()
for name in ("fullchain.pem", "key.pem", "ca.pem"):
assert (new_dir / name).is_file()