feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
from k1link.fleet.trust import SCHEMA, PairingError, parse_invitation, pem, public_id
|
||||
from k1link.web.fleet_api import router
|
||||
|
||||
|
||||
def code(value):
|
||||
return "MCN1." + base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path, monkeypatch):
|
||||
fleet = FleetRegistry(tmp_path)
|
||||
key = Ed25519PrivateKey.generate()
|
||||
node_id = public_id("node_", key.public_key())
|
||||
invite = dict(
|
||||
schema=SCHEMA,
|
||||
node_id=node_id,
|
||||
id=secrets.token_urlsafe(32),
|
||||
secret=secrets.token_urlsafe(32),
|
||||
expires_at=int(time.time()) + 600,
|
||||
endpoint="https://192.168.20.4:8781",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def request(invitation, path, payload):
|
||||
calls.append((path, payload))
|
||||
if path.endswith("/inspect"):
|
||||
return (
|
||||
dict(schema=SCHEMA, node_id=node_id, name="Test board", host={"os": "Linux"}),
|
||||
key.public_key(),
|
||||
"192.168.20.5",
|
||||
)
|
||||
if path.endswith("/offer"):
|
||||
return (
|
||||
dict(node_id=node_id, receipt=secrets.token_urlsafe(32)),
|
||||
key.public_key(),
|
||||
"192.168.20.5",
|
||||
)
|
||||
return dict(node_id=node_id), key.public_key(), "192.168.20.5"
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", request)
|
||||
monkeypatch.setattr(fleet, "listen", lambda address: None)
|
||||
yield fleet, invite, key, calls
|
||||
fleet.close()
|
||||
|
||||
|
||||
def create(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
preview = fleet.preview(code(invite))
|
||||
row = fleet.add(preview["preview_id"], "Test vehicle", "ugv")
|
||||
return row, preview
|
||||
|
||||
|
||||
def heartbeat(row):
|
||||
return dict(
|
||||
schema=SCHEMA,
|
||||
node_id=row["node_id"],
|
||||
binding_id=row["binding"]["binding_id"],
|
||||
execution_binding={
|
||||
"node_id": row["node_id"],
|
||||
"agent_instance_id": "agent_test",
|
||||
"platform": "linux",
|
||||
},
|
||||
host={"hostname": "synthetic", "usb": [], "networks": []},
|
||||
devices=[],
|
||||
)
|
||||
|
||||
|
||||
def cert(row):
|
||||
return x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()).public_bytes(
|
||||
serialization.Encoding.DER
|
||||
)
|
||||
|
||||
|
||||
def test_preview_add_is_durable_and_idempotent(setup):
|
||||
fleet, invite, _, calls = setup
|
||||
public, preview = create(setup)
|
||||
assert public["enrollment"] == "pending"
|
||||
assert public["connectivity"] == "offline"
|
||||
assert fleet.add(preview["preview_id"], "Test vehicle", "ugv")["id"] == public["id"]
|
||||
fleet.advance(public["id"])
|
||||
assert fleet.listing()["items"][0]["enrollment"] == "paired"
|
||||
assert fleet.listing()["items"][0]["connectivity"] == "offline"
|
||||
row = fleet.find(public["id"])
|
||||
assert row["invitation"] is None and row["receipt"] is None
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
listed = fleet.listing()
|
||||
assert listed["items"][0]["connectivity"] == "online"
|
||||
assert invite["secret"] not in json.dumps(listed)
|
||||
assert "client_pem" not in json.dumps(listed)
|
||||
reopened = FleetRegistry(fleet.root)
|
||||
try:
|
||||
assert reopened.trust.core_id == fleet.trust.core_id
|
||||
assert reopened.listing()["items"][0]["id"] == public["id"]
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_lost_commit_ack_recovers_from_authenticated_heartbeat(setup, monkeypatch):
|
||||
fleet, _, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
|
||||
|
||||
def drop_ack(invitation, path, body):
|
||||
if path.endswith("/commit"):
|
||||
raise PairingError("Lost acknowledgement")
|
||||
return original(invitation, path, body)
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", drop_ack)
|
||||
with pytest.raises(PairingError):
|
||||
fleet.advance(public["id"])
|
||||
row = fleet.find(public["id"])
|
||||
assert row["receipt"] and row["enrollment"] == "pending"
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
assert fleet.find(public["id"])["enrollment"] == "paired"
|
||||
|
||||
|
||||
def test_revoke_during_network_completion_cannot_resurrect(setup, monkeypatch):
|
||||
fleet, _, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
|
||||
|
||||
def revoke_first(invitation, path, body):
|
||||
value = original(invitation, path, body)
|
||||
fleet.revoke(public["id"])
|
||||
return value
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", revoke_first)
|
||||
fleet.advance(public["id"])
|
||||
row = fleet.find(public["id"])
|
||||
assert row["enrollment"] == "revoked"
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 410
|
||||
|
||||
|
||||
def test_repair_retains_vehicle_and_rejects_old_binding(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
old = fleet.find(public["id"])
|
||||
fleet.revoke(public["id"])
|
||||
invite["id"] = secrets.token_urlsafe(32)
|
||||
updated, _ = create(setup)
|
||||
assert updated["id"] == public["id"] and updated["revision"] == 3
|
||||
assert fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old))[0] == 410
|
||||
|
||||
|
||||
def test_rotation_has_bounded_old_certificate_grace(setup):
|
||||
fleet, _, key, _ = setup
|
||||
public, _ = create(setup)
|
||||
row = fleet.find(public["id"])
|
||||
original = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode())
|
||||
soon = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(original.subject)
|
||||
.issuer_name(original.issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC) - timedelta(hours=1))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(fleet.trust.key, None)
|
||||
)
|
||||
row["binding"]["client_pem"] = pem(soon)
|
||||
fleet.save(row)
|
||||
status, reply = fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))
|
||||
assert status == 200 and reply["client_pem"] != pem(soon)
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
current = fleet.find(public["id"])
|
||||
current["certificate_previous"]["until"] = time.time() - 1
|
||||
fleet.save(current)
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"https://8.8.8.8:8781",
|
||||
"https://127.0.0.1:8781",
|
||||
"https://192.168.1.2:22",
|
||||
"https://user@192.168.1.2:8781",
|
||||
"https://192.168.1.2:8781/api",
|
||||
"http://192.168.1.2:8781",
|
||||
"https://local.test:8781",
|
||||
],
|
||||
)
|
||||
def test_invitation_never_targets_public_loopback_dns_or_other_ports(setup, address):
|
||||
_, invite, _, _ = setup
|
||||
invite["endpoint"] = address
|
||||
with pytest.raises(PairingError):
|
||||
parse_invitation(code(invite))
|
||||
|
||||
|
||||
def test_expiry_and_node_inventory_admission(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
row = fleet.find(public["id"])
|
||||
row["invitation"]["expires_at"] = 0
|
||||
fleet.save(row)
|
||||
fleet.advance(public["id"])
|
||||
assert fleet.find(public["id"])["enrollment"] == "failed"
|
||||
invite["expires_at"] = 0
|
||||
with pytest.raises(PairingError):
|
||||
parse_invitation(code(invite))
|
||||
|
||||
|
||||
def test_operator_api_rejects_cross_origin_and_remote_peer(setup):
|
||||
fleet, _, _, _ = setup
|
||||
app = FastAPI()
|
||||
app.state.fleet = fleet
|
||||
app.include_router(router)
|
||||
with TestClient(app, base_url="http://127.0.0.1:8000", client=("127.0.0.1", 55555)) as client:
|
||||
assert client.get("/api/v1/fleet").status_code == 200
|
||||
assert (
|
||||
client.get("/api/v1/fleet", headers={"Origin": "https://attacker.test"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.get("/api/v1/fleet", headers={"Host": "attacker.test:8000"}).status_code == 403
|
||||
)
|
||||
assert client.post("/api/v1/fleet/preview", json={"code": "bad"}).status_code == 409
|
||||
with TestClient(
|
||||
app, base_url="http://127.0.0.1:8000", client=("192.168.20.6", 55555)
|
||||
) as client:
|
||||
assert client.get("/api/v1/fleet").status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_identity", [False, True])
|
||||
def test_bootstrap_pin_is_verified_before_secret_is_sent(setup, monkeypatch, wrong_identity):
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from k1link.fleet.trust import node_request
|
||||
|
||||
_, invite, key, _ = setup
|
||||
if wrong_identity:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic Node")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC) - timedelta(minutes=1))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(hours=1))
|
||||
.sign(key, None)
|
||||
.public_bytes(serialization.Encoding.DER)
|
||||
)
|
||||
sent = []
|
||||
|
||||
class Socket:
|
||||
def getpeercert(self, **_kwargs):
|
||||
return certificate
|
||||
|
||||
def getsockname(self):
|
||||
return ("192.168.20.5", 41000)
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def read(self, _limit):
|
||||
return b'{"ok":true}'
|
||||
|
||||
class Connection:
|
||||
sock = Socket()
|
||||
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def request(self, *args):
|
||||
sent.append(args)
|
||||
|
||||
def getresponse(self):
|
||||
return Response()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.trust.http.client.HTTPSConnection", Connection)
|
||||
if wrong_identity:
|
||||
with pytest.raises(PairingError):
|
||||
node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})
|
||||
assert not sent
|
||||
else:
|
||||
assert node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})[0] == {
|
||||
"ok": True
|
||||
}
|
||||
assert len(sent) == 1
|
||||
Reference in New Issue
Block a user