spoolbuddy_ssh.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. """SSH-based update service for SpoolBuddy devices.
  2. Instead of the daemon updating itself (fragile: permission issues, self-modifying
  3. code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
  4. update remotely: git fetch/checkout, pip install, systemctl restart.
  5. Uses `asyncssh` (pure-Python async SSH client) rather than shelling out to the
  6. OpenSSH `ssh` binary. The subprocess approach fails in Docker: both `ssh` and
  7. `ssh-keygen` call `getpwuid(getuid())` during startup and abort with
  8. "No user exists for uid <N>" when the container runs under a UID that is not
  9. listed in /etc/passwd (e.g. PUID=1000 on python:3.13-slim, which only has
  10. entries for root). asyncssh does all of its work in-process.
  11. """
  12. import asyncio
  13. import logging
  14. import os
  15. import shlex
  16. from pathlib import Path
  17. import asyncssh
  18. from cryptography.hazmat.primitives import serialization
  19. from cryptography.hazmat.primitives.asymmetric import ed25519
  20. from backend.app.core.config import settings
  21. logger = logging.getLogger(__name__)
  22. PIP_INSTALL_TIMEOUT = 300
  23. SSH_USER = "spoolbuddy"
  24. DEFAULT_INSTALL_PATH = "/opt/bambuddy"
  25. # Project root — where the `.git` directory lives for native installs and for
  26. # Docker containers that bind-mount the repo. This is intentionally distinct
  27. # from `settings.base_dir`, which points at the persistent *data* directory
  28. # (e.g. `DATA_DIR=/app/data` in Docker) and therefore never contains `.git`.
  29. # `backend/app/services/spoolbuddy_ssh.py` → parents[3] = project root.
  30. _APP_DIR = Path(__file__).resolve().parents[3]
  31. # Note for Docker: asyncssh.connect() internally calls getpass.getuser() to
  32. # resolve the *local* username for ~/.ssh/config host matching. Under an
  33. # arbitrary PUID with no /etc/passwd entry this would raise OSError. The
  34. # Dockerfile sets LOGNAME/USER/HOME so getpass.getuser() succeeds via env-var
  35. # lookup before ever touching the passwd database.
  36. def _get_ssh_key_dir() -> Path:
  37. """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
  38. key_dir = settings.base_dir / "spoolbuddy" / "ssh"
  39. if not key_dir.exists():
  40. key_dir.mkdir(mode=0o700, parents=True)
  41. return key_dir
  42. async def get_or_create_keypair() -> tuple[Path, Path]:
  43. """Return (private_key_path, public_key_path), generating if missing.
  44. Uses the in-process `cryptography` library instead of shelling out to
  45. `ssh-keygen`. The subprocess approach fails inside Docker containers when
  46. the image runs under an arbitrary UID (e.g. PUID=1001) that is not listed
  47. in /etc/passwd — `ssh-keygen` calls `getpwuid()` for the current user's
  48. home directory and aborts with "no user exists for uid <N>".
  49. """
  50. key_dir = _get_ssh_key_dir()
  51. private_key = key_dir / "id_ed25519"
  52. public_key = key_dir / "id_ed25519.pub"
  53. if private_key.exists() and public_key.exists():
  54. return private_key, public_key
  55. logger.info("Generating SSH keypair for SpoolBuddy updates")
  56. priv_obj = ed25519.Ed25519PrivateKey.generate()
  57. pub_obj = priv_obj.public_key()
  58. private_bytes = priv_obj.private_bytes(
  59. encoding=serialization.Encoding.PEM,
  60. format=serialization.PrivateFormat.OpenSSH,
  61. encryption_algorithm=serialization.NoEncryption(),
  62. )
  63. public_bytes = pub_obj.public_bytes(
  64. encoding=serialization.Encoding.OpenSSH,
  65. format=serialization.PublicFormat.OpenSSH,
  66. )
  67. # OpenSSH public format has no comment field by default; append one to match
  68. # the previous ssh-keygen output so the authorized_keys line is identifiable.
  69. public_line = public_bytes + b" bambuddy-spoolbuddy\n"
  70. private_key.write_bytes(private_bytes)
  71. private_key.chmod(0o600)
  72. public_key.write_bytes(public_line)
  73. logger.info("SSH keypair generated at %s", key_dir)
  74. return private_key, public_key
  75. async def get_public_key() -> str:
  76. """Return the SSH public key content for pairing."""
  77. _, public_key = await get_or_create_keypair()
  78. return public_key.read_text().strip()
  79. def detect_current_branch() -> str:
  80. """Detect the git branch Bambuddy is running on.
  81. Reads `.git/HEAD` directly from the application root (``_APP_DIR``) rather
  82. than shelling out to `git`. The application root is deliberately distinct
  83. from ``settings.base_dir``: in Docker, ``base_dir`` points at the data
  84. volume (``/app/data``) which never contains ``.git``, while the repo is
  85. bind-mounted (or COPYd) to ``/app``. This works for native installs,
  86. bare Docker containers (no ``.git`` — fall through to the env var), and
  87. Docker containers that bind-mount the repo (``.git`` is present, no
  88. ``git`` binary required, and no ``getpwuid()`` call that could fail under
  89. an arbitrary PUID).
  90. Fallback order: ``.git/HEAD`` → ``GIT_BRANCH`` env var → ``"main"``.
  91. """
  92. git_path = _APP_DIR / ".git"
  93. try:
  94. if git_path.exists():
  95. # Git worktrees use a file containing `gitdir: <path>` instead of
  96. # a directory — follow the pointer.
  97. if git_path.is_file():
  98. content = git_path.read_text(encoding="utf-8").strip()
  99. if content.startswith("gitdir:"):
  100. git_path = (_APP_DIR / content.removeprefix("gitdir:").strip()).resolve()
  101. head_file = git_path / "HEAD"
  102. if head_file.is_file():
  103. head = head_file.read_text(encoding="utf-8").strip()
  104. # Normal case: `ref: refs/heads/<branch>`.
  105. # Detached HEAD stores a raw commit hash — fall through to env var.
  106. if head.startswith("ref: refs/heads/"):
  107. return head.removeprefix("ref: refs/heads/").strip()
  108. except OSError as exc:
  109. logger.debug("Could not read .git/HEAD, falling back: %s", exc)
  110. return os.environ.get("GIT_BRANCH", "main")
  111. async def _run_ssh_command(
  112. ip: str,
  113. command: str,
  114. private_key: Path,
  115. *,
  116. known_hosts: "asyncssh.SSHKnownHosts | None" = None,
  117. timeout: int = 60,
  118. ) -> tuple[int, str, str, str | None]:
  119. """Execute a command on a SpoolBuddy device via SSH.
  120. Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
  121. for the Docker/PUID rationale.
  122. Returns (returncode, stdout, stderr, observed_host_key).
  123. observed_host_key is non-None only on a successful connection when known_hosts=None
  124. was passed. Callers are responsible for also checking whether a stored key already
  125. exists before persisting — use `observed_key and not stored_host_key` not just
  126. `observed_key is not None`.
  127. On connection failure rc=255; on timeout rc=-1.
  128. """
  129. observed_host_key: str | None = None
  130. try:
  131. async with asyncio.timeout(timeout):
  132. async with asyncssh.connect(
  133. host=ip,
  134. username=SSH_USER,
  135. client_keys=[str(private_key)],
  136. known_hosts=known_hosts,
  137. config=[], # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs
  138. connect_timeout=10,
  139. ) as conn:
  140. if known_hosts is None:
  141. # TOFU first-use: capture the host key for storage
  142. server_key = conn.get_server_host_key()
  143. if server_key:
  144. observed_host_key = server_key.export_public_key("openssh").decode().strip()
  145. result = await conn.run(command, check=False)
  146. except asyncssh.HostKeyNotVerifiable:
  147. logger.error("SSH host key mismatch for %s — possible MITM attack", ip)
  148. return 255, "", "Host key mismatch — verify device identity before retrying", None
  149. except TimeoutError:
  150. return -1, "", "SSH command timed out", None
  151. except (asyncssh.Error, OSError) as exc:
  152. return 255, "", str(exc), None
  153. stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace")
  154. stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace")
  155. # asyncssh's exit_status is None when the remote closed without setting one
  156. returncode = result.exit_status if result.exit_status is not None else 0
  157. return returncode, stdout, stderr, observed_host_key
  158. async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
  159. """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
  160. Updates device.update_status/update_message in the DB and broadcasts
  161. progress via WebSocket at each step. Host key verification uses TOFU:
  162. the device's SSH public key is stored on first connect and verified on
  163. all subsequent connections.
  164. """
  165. from sqlalchemy import select
  166. from backend.app.api.routes.spoolbuddy import ws_manager
  167. from backend.app.core.database import async_session
  168. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  169. install_path = install_path or DEFAULT_INSTALL_PATH
  170. branch = detect_current_branch()
  171. safe_branch = shlex.quote(branch)
  172. safe_path = shlex.quote(install_path)
  173. # Load the stored SSH host key for TOFU verification
  174. stored_host_key: str | None = None
  175. async with async_session() as db:
  176. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  177. dev = result.scalar_one_or_none()
  178. if dev:
  179. stored_host_key = dev.ssh_host_key
  180. known_hosts: asyncssh.SSHKnownHosts | None = None
  181. if stored_host_key:
  182. try:
  183. # asyncssh.import_known_hosts() expects str — passing bytes crashes
  184. # inside its line-by-line parser with a TypeError.
  185. known_hosts = asyncssh.import_known_hosts(f"{ip_address} {stored_host_key}\n")
  186. except (ValueError, TypeError, asyncssh.Error) as exc:
  187. logger.warning(
  188. "Could not parse stored SSH host key for %s, falling back to TOFU: %s",
  189. device_id,
  190. exc,
  191. )
  192. async def _update_progress(status: str, message: str) -> None:
  193. """Update device status in DB and broadcast via WebSocket."""
  194. async with async_session() as db:
  195. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  196. device = result.scalar_one_or_none()
  197. if device:
  198. device.update_status = status
  199. device.update_message = message[:255] if message else None
  200. if status in ("complete", "error"):
  201. device.pending_command = None
  202. await db.commit()
  203. await ws_manager.broadcast(
  204. {
  205. "type": "spoolbuddy_update",
  206. "device_id": device_id,
  207. "update_status": status,
  208. "update_message": message[:255] if message else None,
  209. }
  210. )
  211. try:
  212. private_key, _ = await get_or_create_keypair()
  213. # Step 1: Test SSH connectivity
  214. await _update_progress("updating", "Connecting via SSH...")
  215. rc, _, stderr, observed_key = await _run_ssh_command(
  216. ip_address, "echo ok", private_key, known_hosts=known_hosts
  217. )
  218. if rc != 0:
  219. await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
  220. return
  221. # TOFU: persist host key on first successful connect
  222. if observed_key and not stored_host_key:
  223. async with async_session() as db:
  224. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  225. d = result.scalar_one_or_none()
  226. if d:
  227. d.ssh_host_key = observed_key
  228. await db.commit()
  229. logger.info("TOFU: stored SSH host key for SpoolBuddy %s", device_id)
  230. try:
  231. known_hosts = asyncssh.import_known_hosts(f"{ip_address} {observed_key}\n")
  232. except (ValueError, TypeError, asyncssh.Error) as exc:
  233. logger.error(
  234. "TOFU: could not parse just-stored host key for %s; "
  235. "remaining SSH steps in this run will not verify host key: %s",
  236. device_id,
  237. exc,
  238. )
  239. known_hosts = None
  240. # Step 2: Git fetch
  241. await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
  242. rc, _, stderr, _ = await _run_ssh_command(
  243. ip_address,
  244. f"cd {safe_path} && git -c safe.directory={safe_path} fetch origin {safe_branch}",
  245. private_key,
  246. known_hosts=known_hosts,
  247. timeout=120,
  248. )
  249. if rc != 0:
  250. await _update_progress("error", f"git fetch failed: {stderr[:200]}")
  251. return
  252. # Step 3: Git checkout + reset
  253. await _update_progress("updating", "Applying update...")
  254. rc, _, stderr, _ = await _run_ssh_command(
  255. ip_address,
  256. f"cd {safe_path} && git -c safe.directory={safe_path} checkout {safe_branch} "
  257. f"&& git -c safe.directory={safe_path} reset --hard origin/{safe_branch}",
  258. private_key,
  259. known_hosts=known_hosts,
  260. )
  261. if rc != 0:
  262. await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
  263. return
  264. # Step 4: Install dependencies
  265. await _update_progress("updating", "Installing dependencies...")
  266. venv_pip = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip")
  267. rc, pip_output, stderr, _ = await _run_ssh_command(
  268. ip_address,
  269. f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
  270. private_key,
  271. known_hosts=known_hosts,
  272. # A slow Pi building wheels needs more than two minutes.
  273. timeout=PIP_INSTALL_TIMEOUT,
  274. )
  275. deps_note = ""
  276. if rc == -1:
  277. # Timed out here, not necessarily on the Pi: pip may still be
  278. # installing, and restarting the daemon over a half-installed venv
  279. # (then reporting success) is how a broken update looked fine (TO-C12).
  280. await _update_progress(
  281. "error",
  282. "Installing dependencies timed out; the daemon was not restarted. "
  283. "Run the update again once the device has finished.",
  284. )
  285. return
  286. if rc != 0:
  287. # A pip error is not fatal -- the daemon may not need the upgrade --
  288. # but it is said, with pip's own output (2>&1 sends it to stdout).
  289. logger.warning(
  290. "SpoolBuddy %s: pip install exited %s (continuing): %s", device_id, rc, (pip_output or stderr)[-500:]
  291. )
  292. deps_note = f" (installing dependencies failed, exit {rc}: see the Bambuddy log)"
  293. # Step 5: Restart daemon
  294. await _update_progress("updating", "Restarting daemon...")
  295. rc, _, stderr, _ = await _run_ssh_command(
  296. ip_address,
  297. "sudo /usr/bin/systemctl restart spoolbuddy.service",
  298. private_key,
  299. known_hosts=known_hosts,
  300. )
  301. if rc != 0:
  302. await _update_progress("error", f"Service restart failed: {stderr[:200]}")
  303. return
  304. # Step 6: Clear browser cache and restart kiosk
  305. # Remove Chromium's Service Worker + cache storage to prevent stale frontend
  306. await _run_ssh_command(
  307. ip_address,
  308. "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
  309. private_key,
  310. known_hosts=known_hosts,
  311. )
  312. rc, _, stderr, _ = await _run_ssh_command(
  313. ip_address,
  314. "sudo /usr/bin/systemctl restart getty@tty1.service",
  315. private_key,
  316. known_hosts=known_hosts,
  317. )
  318. if rc != 0:
  319. logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
  320. logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
  321. await _update_progress("complete", f"Updated to {branch}{deps_note}")
  322. except Exception:
  323. logger.exception("SpoolBuddy %s: SSH update failed", device_id)
  324. await _update_progress("error", "Update failed due to an internal error")