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
+30 -9
View File
@@ -66,8 +66,18 @@ def _get_ip() -> str:
return "unknown"
SSH_KEY_TAG = "bambuddy-spoolbuddy"
def _deploy_ssh_key(public_key: str) -> None:
"""Write Bambuddy's SSH public key to authorized_keys if not already present."""
"""Sync Bambuddy's SSH public key into authorized_keys.
Replaces any prior key tagged ``bambuddy-spoolbuddy`` so the file always
reflects Bambuddy's *current* keypair. Without this, every Bambuddy key
rotation (data dir wipe, container recreate, etc.) leaves a stale entry
behind and the file grows unbounded.
"""
target = public_key.strip()
home = Path.home()
ssh_dir = home / ".ssh"
auth_keys = ssh_dir / "authorized_keys"
@@ -75,17 +85,24 @@ def _deploy_ssh_key(public_key: str) -> None:
try:
ssh_dir.mkdir(mode=0o700, exist_ok=True)
# Check if key already deployed
existing_lines: list[str] = []
if auth_keys.exists():
existing = auth_keys.read_text()
if public_key.strip() in existing:
return
existing_lines = auth_keys.read_text().splitlines()
# Append key
with auth_keys.open("a") as f:
f.write(public_key.strip() + "\n")
kept = [line for line in existing_lines if SSH_KEY_TAG not in line]
new_lines = kept + [target]
# Already in sync — current key present and no stale Bambuddy entries.
if existing_lines == new_lines:
return
auth_keys.write_text("\n".join(new_lines) + "\n")
auth_keys.chmod(0o600)
logger.info("SSH public key deployed to %s", auth_keys)
removed = len(existing_lines) - len(kept)
if removed:
logger.info("SSH public key updated in %s (replaced %d stale entries)", auth_keys, removed)
else:
logger.info("SSH public key deployed to %s", auth_keys)
except Exception as e:
logger.warning("Failed to deploy SSH key: %s", e)
@@ -233,6 +250,10 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
)
if result:
ssh_key = result.get("ssh_public_key")
if ssh_key:
_deploy_ssh_key(ssh_key)
cmd = result.get("pending_command")
if cmd == "tare":
scale = shared.get("scale")