mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user