The swarm controller (port 8770) exposed 9 routes with zero app-layer auth, and swarmctl --tls defaulted off — anyone able to reach the port could enroll workers (minting CA-signed certs + private keys), deploy, or tear down the fleet. Two fail-closed layers: - require_operator_cert gates every operator route (enroll/deploy/ teardown/hosts/check/deckies). When mTLS is on, the peer cert's CN must be an operator identity (decnet-master/swarmctl); worker and updater@* certs are rejected. Plaintext loopback (single-host master) is accepted as the local operator — the docker.sock boundary. - swarmctl refuses to bind a routable interface without --tls, so a network-exposed plaintext control plane can never start. /heartbeat keeps its worker fingerprint pinning. Closes the two ASVS criticals (control-plane no-auth, unauthenticated cert minting).
141 lines
4.7 KiB
Python
141 lines
4.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Tests for the shared swarm mTLS peer-identity helper (``_mtls``).
|
|
|
|
No live TLS: peer certs are minted via the real PKI and fed in through a
|
|
fabricated ASGI scope, exactly the way uvicorn's TLS-scope shim would.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from decnet.swarm import pki
|
|
from decnet.web.router.swarm import _mtls
|
|
|
|
|
|
# ------------------------- cert fixtures ------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def ca(tmp_path, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(pki, "DEFAULT_CA_DIR", tmp_path / "ca")
|
|
return pki.ensure_ca()
|
|
|
|
|
|
def _der_for(ca, cn: str) -> bytes:
|
|
"""Issue a cert with the given CN and return its DER bytes."""
|
|
from cryptography import x509
|
|
|
|
issued = pki.issue_worker_cert(ca, cn, [])
|
|
cert = x509.load_pem_x509_certificate(issued.cert_pem)
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
return cert.public_bytes(serialization.Encoding.DER)
|
|
|
|
|
|
def _scope_with(der: bytes) -> dict:
|
|
return {"extensions": {"tls": {"client_cert_chain": [der]}}}
|
|
|
|
|
|
# ------------------------- extraction --------------------------------------
|
|
|
|
|
|
def test_extract_peer_cert_parses_fingerprint_and_cn(ca) -> None:
|
|
der = _der_for(ca, "decnet-master")
|
|
peer = _mtls.extract_peer_cert(_scope_with(der))
|
|
assert peer is not None
|
|
assert peer.sha256 == hashlib.sha256(der).hexdigest().lower()
|
|
assert peer.cn == "decnet-master"
|
|
|
|
|
|
def test_extract_peer_cert_fallback_transport_path(ca) -> None:
|
|
der = _der_for(ca, "swarmctl")
|
|
ssl_obj = MagicMock()
|
|
ssl_obj.getpeercert.return_value = der
|
|
transport = MagicMock()
|
|
transport.get_extra_info.return_value = ssl_obj
|
|
|
|
peer = _mtls.extract_peer_cert({"transport": transport})
|
|
assert peer is not None and peer.cn == "swarmctl"
|
|
ssl_obj.getpeercert.assert_called_with(binary_form=True)
|
|
|
|
|
|
def test_extract_peer_cert_none_when_no_cert() -> None:
|
|
assert _mtls.extract_peer_cert({}) is None
|
|
|
|
|
|
def test_extract_fingerprint_works_on_non_cert_der() -> None:
|
|
# Fingerprint must be computed even when the bytes aren't a parseable
|
|
# cert (CN parse fails → None), matching the heartbeat unit tests.
|
|
der = b"\x30\x82not-a-real-cert"
|
|
scope = _scope_with(der)
|
|
assert _mtls.extract_peer_fingerprint(scope) == hashlib.sha256(der).hexdigest()
|
|
peer = _mtls.extract_peer_cert(scope)
|
|
assert peer is not None and peer.cn is None
|
|
|
|
|
|
# ------------------------- require_operator_cert ---------------------------
|
|
|
|
|
|
def _request_with(scope: dict, client_host: str | None = None) -> MagicMock:
|
|
req = MagicMock()
|
|
req.scope = scope
|
|
req.client = None if client_host is None else MagicMock(host=client_host)
|
|
return req
|
|
|
|
|
|
def test_require_operator_accepts_master(ca) -> None:
|
|
peer = _mtls.require_operator_cert(_request_with(_scope_with(_der_for(ca, "decnet-master"))))
|
|
assert peer.cn == "decnet-master"
|
|
|
|
|
|
def test_require_operator_accepts_swarmctl(ca) -> None:
|
|
peer = _mtls.require_operator_cert(_request_with(_scope_with(_der_for(ca, "swarmctl"))))
|
|
assert peer.cn == "swarmctl"
|
|
|
|
|
|
def test_require_operator_rejects_worker_cn(ca) -> None:
|
|
# A worker cert is CA-signed but must not drive the control plane, even
|
|
# from loopback — the CN gate fires before the loopback fallback.
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as ei:
|
|
_mtls.require_operator_cert(
|
|
_request_with(_scope_with(_der_for(ca, "worker-1")), client_host="127.0.0.1")
|
|
)
|
|
assert ei.value.status_code == 403
|
|
|
|
|
|
def test_require_operator_rejects_updater_cn(ca) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as ei:
|
|
_mtls.require_operator_cert(_request_with(_scope_with(_der_for(ca, "updater@worker-1"))))
|
|
assert ei.value.status_code == 403
|
|
|
|
|
|
def test_require_operator_allows_certless_loopback() -> None:
|
|
# Shipping default: plaintext loopback, no client cert → local operator.
|
|
peer = _mtls.require_operator_cert(_request_with({}, client_host="127.0.0.1"))
|
|
assert peer.cn is None and peer.sha256 == ""
|
|
|
|
|
|
def test_require_operator_rejects_certless_non_loopback() -> None:
|
|
# No cert from off-box → fail closed (the startup guard makes this
|
|
# unreachable in practice, but defense in depth).
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as ei:
|
|
_mtls.require_operator_cert(_request_with({}, client_host="10.0.0.9"))
|
|
assert ei.value.status_code == 403
|
|
|
|
|
|
def test_require_operator_rejects_certless_unknown_client() -> None:
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as ei:
|
|
_mtls.require_operator_cert(_request_with({}, client_host=None))
|
|
assert ei.value.status_code == 403
|