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,
)