fix(spoolbuddy): sync SSH key over heartbeat to survive Bambuddy keypair rotation

Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
  the data dir is recreated (volume remount, container recreate, fresh deploy).
  The daemon previously only fetched the pubkey at registration, so any
  rotation after a successful boot left ~/.ssh/authorized_keys pointing at
  a stale public half — every Update click then failed with "Connection
  closed by authenticating user spoolbuddy [preauth]" until the daemon was
  restarted by hand. Each prior registration also appended a fresh entry
  without pruning, accumulating stale Bambuddy-tagged keys indefinitely.

  - HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
    it via the same try/except shape as the register route so a missing or
    unreadable backend key doesn't break telemetry.
  - _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
    the current key once. No-op when already in sync (no mtime churn on
    every heartbeat). User-managed entries are preserved.
  - Daemon heartbeat handler calls _deploy_ssh_key when the response
    carries a key, so rotations propagate within one heartbeat instead
    of requiring a service restart.

  Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
  idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
  the key; backend key-read failure leaves ssh_public_key None but the
  heartbeat still 200s).
This commit is contained in:
maziggy
2026-05-01 10:44:01 +02:00
parent 44e4b6b5d7
commit 2aabbe5d37
6 changed files with 179 additions and 9 deletions
@@ -203,6 +203,54 @@ class TestDeviceEndpoints:
assert msg["type"] == "spoolbuddy_online"
assert msg["device_id"] == "sb-hb"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_heartbeat_returns_ssh_public_key(self, async_client: AsyncClient, device_factory):
"""Heartbeat response carries the current SSH public key so the daemon
can re-deploy it whenever Bambuddy's keypair rotates without waiting
for a service restart."""
await device_factory(device_id="sb-ssh-hb")
fake_key = "ssh-ed25519 AAAATESTKEY bambuddy-spoolbuddy"
with (
patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
patch(
"backend.app.services.spoolbuddy_ssh.get_public_key",
AsyncMock(return_value=fake_key),
),
):
mock_ws.broadcast = AsyncMock()
resp = await async_client.post(
f"{API}/devices/sb-ssh-hb/heartbeat",
json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
)
assert resp.status_code == 200
assert resp.json()["ssh_public_key"] == fake_key
@pytest.mark.asyncio
@pytest.mark.integration
async def test_heartbeat_ssh_key_failure_does_not_break_heartbeat(self, async_client: AsyncClient, device_factory):
"""If the backend can't read its own SSH key, the heartbeat must still
succeed — telemetry/commands are far more critical than key sync."""
await device_factory(device_id="sb-ssh-fail")
with (
patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
patch(
"backend.app.services.spoolbuddy_ssh.get_public_key",
AsyncMock(side_effect=OSError("disk full")),
),
):
mock_ws.broadcast = AsyncMock()
resp = await async_client.post(
f"{API}/devices/sb-ssh-fail/heartbeat",
json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
)
assert resp.status_code == 200
assert resp.json()["ssh_public_key"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):