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
+12
View File
@@ -279,6 +279,17 @@ async def device_heartbeat(
if was_offline:
logger.info("SpoolBuddy device back online: %s", device.device_id)
# Include current SSH public key so the daemon can re-deploy it whenever
# Bambuddy's keypair rotates (data dir wiped, container recreated, etc.) —
# otherwise SSH updates fail until the daemon restarts.
ssh_public_key: str | None = None
try:
from backend.app.services.spoolbuddy_ssh import get_public_key
ssh_public_key = await get_public_key()
except Exception:
pass
return HeartbeatResponse(
pending_command=pending,
pending_write_payload=pending_write,
@@ -287,6 +298,7 @@ async def device_heartbeat(
calibration_factor=device.calibration_factor,
display_brightness=device.display_brightness,
display_blank_timeout=device.display_blank_timeout,
ssh_public_key=ssh_public_key,
)
+1
View File
@@ -74,6 +74,7 @@ class HeartbeatResponse(BaseModel):
calibration_factor: float
display_brightness: int = 100
display_blank_timeout: int = 0
ssh_public_key: str | None = None
# --- NFC schemas ---
@@ -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):