spoolbuddy_ssh.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. from pathlib import Path
  16. import asyncssh
  17. from cryptography.hazmat.primitives import serialization
  18. from cryptography.hazmat.primitives.asymmetric import ed25519
  19. from backend.app.core.config import settings
  20. logger = logging.getLogger(__name__)
  21. SSH_USER = "spoolbuddy"
  22. DEFAULT_INSTALL_PATH = "/opt/bambuddy"
  23. def _get_ssh_key_dir() -> Path:
  24. """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
  25. key_dir = settings.base_dir / "spoolbuddy" / "ssh"
  26. if not key_dir.exists():
  27. key_dir.mkdir(mode=0o700, parents=True)
  28. return key_dir
  29. async def get_or_create_keypair() -> tuple[Path, Path]:
  30. """Return (private_key_path, public_key_path), generating if missing.
  31. Uses the in-process `cryptography` library instead of shelling out to
  32. `ssh-keygen`. The subprocess approach fails inside Docker containers when
  33. the image runs under an arbitrary UID (e.g. PUID=1001) that is not listed
  34. in /etc/passwd — `ssh-keygen` calls `getpwuid()` for the current user's
  35. home directory and aborts with "no user exists for uid <N>".
  36. """
  37. key_dir = _get_ssh_key_dir()
  38. private_key = key_dir / "id_ed25519"
  39. public_key = key_dir / "id_ed25519.pub"
  40. if private_key.exists() and public_key.exists():
  41. return private_key, public_key
  42. logger.info("Generating SSH keypair for SpoolBuddy updates")
  43. priv_obj = ed25519.Ed25519PrivateKey.generate()
  44. pub_obj = priv_obj.public_key()
  45. private_bytes = priv_obj.private_bytes(
  46. encoding=serialization.Encoding.PEM,
  47. format=serialization.PrivateFormat.OpenSSH,
  48. encryption_algorithm=serialization.NoEncryption(),
  49. )
  50. public_bytes = pub_obj.public_bytes(
  51. encoding=serialization.Encoding.OpenSSH,
  52. format=serialization.PublicFormat.OpenSSH,
  53. )
  54. # OpenSSH public format has no comment field by default; append one to match
  55. # the previous ssh-keygen output so the authorized_keys line is identifiable.
  56. public_line = public_bytes + b" bambuddy-spoolbuddy\n"
  57. private_key.write_bytes(private_bytes)
  58. private_key.chmod(0o600)
  59. public_key.write_bytes(public_line)
  60. logger.info("SSH keypair generated at %s", key_dir)
  61. return private_key, public_key
  62. async def get_public_key() -> str:
  63. """Return the SSH public key content for pairing."""
  64. _, public_key = await get_or_create_keypair()
  65. return public_key.read_text().strip()
  66. def detect_current_branch() -> str:
  67. """Detect the git branch Bambuddy is running on.
  68. Reads `.git/HEAD` directly rather than shelling out to `git`. This keeps
  69. the behaviour identical for native installs, bare Docker containers
  70. (no .git — fall through to the env var), and Docker containers that
  71. bind-mount the repo (.git is present, no `git` binary required, and no
  72. `getpwuid()` call that could fail under an arbitrary PUID).
  73. Fallback order: `.git/HEAD` → `GIT_BRANCH` env var → `"main"`.
  74. """
  75. git_path = settings.base_dir / ".git"
  76. try:
  77. if git_path.exists():
  78. # Git worktrees use a file containing `gitdir: <path>` instead of a dir.
  79. if git_path.is_file():
  80. content = git_path.read_text(encoding="utf-8").strip()
  81. if content.startswith("gitdir:"):
  82. git_path = (settings.base_dir / content.removeprefix("gitdir:").strip()).resolve()
  83. head_file = git_path / "HEAD"
  84. if head_file.is_file():
  85. head = head_file.read_text(encoding="utf-8").strip()
  86. # Normal case: `ref: refs/heads/<branch>`.
  87. # Detached HEAD stores a raw commit hash — fall through to env var.
  88. if head.startswith("ref: refs/heads/"):
  89. return head.removeprefix("ref: refs/heads/").strip()
  90. except OSError as exc:
  91. logger.debug("Could not read .git/HEAD, falling back: %s", exc)
  92. return os.environ.get("GIT_BRANCH", "main")
  93. async def _run_ssh_command(
  94. ip: str,
  95. command: str,
  96. private_key: Path,
  97. timeout: int = 60,
  98. ) -> tuple[int, str, str]:
  99. """Execute a command on a SpoolBuddy device via SSH.
  100. Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
  101. for the Docker/PUID rationale.
  102. Returns (returncode, stdout, stderr). On connection failure the return
  103. code is 255 (matching `ssh`'s own convention) and stderr carries the
  104. asyncssh error message. On timeout the return code is -1.
  105. """
  106. try:
  107. async with asyncio.timeout(timeout):
  108. async with asyncssh.connect(
  109. host=ip,
  110. username=SSH_USER,
  111. client_keys=[str(private_key)],
  112. known_hosts=None, # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null
  113. connect_timeout=10,
  114. ) as conn:
  115. result = await conn.run(command, check=False)
  116. except TimeoutError:
  117. return -1, "", "SSH command timed out"
  118. except (asyncssh.Error, OSError) as exc:
  119. return 255, "", str(exc)
  120. stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace")
  121. stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace")
  122. # asyncssh's exit_status is None when the remote closed without setting one
  123. returncode = result.exit_status if result.exit_status is not None else 0
  124. return returncode, stdout, stderr
  125. async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
  126. """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
  127. Updates device.update_status/update_message in the DB and broadcasts
  128. progress via WebSocket at each step.
  129. """
  130. from sqlalchemy import select
  131. from backend.app.api.routes.spoolbuddy import ws_manager
  132. from backend.app.core.database import async_session
  133. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  134. install_path = install_path or DEFAULT_INSTALL_PATH
  135. branch = detect_current_branch()
  136. async def _update_progress(status: str, message: str) -> None:
  137. """Update device status in DB and broadcast via WebSocket."""
  138. async with async_session() as db:
  139. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  140. device = result.scalar_one_or_none()
  141. if device:
  142. device.update_status = status
  143. device.update_message = message[:255] if message else None
  144. if status in ("complete", "error"):
  145. device.pending_command = None
  146. await db.commit()
  147. await ws_manager.broadcast(
  148. {
  149. "type": "spoolbuddy_update",
  150. "device_id": device_id,
  151. "update_status": status,
  152. "update_message": message[:255] if message else None,
  153. }
  154. )
  155. try:
  156. private_key, _ = await get_or_create_keypair()
  157. # Step 1: Test SSH connectivity
  158. await _update_progress("updating", "Connecting via SSH...")
  159. rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
  160. if rc != 0:
  161. await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
  162. return
  163. # Step 2: Git fetch
  164. await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
  165. rc, _, stderr = await _run_ssh_command(
  166. ip_address,
  167. f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
  168. private_key,
  169. timeout=120,
  170. )
  171. if rc != 0:
  172. await _update_progress("error", f"git fetch failed: {stderr[:200]}")
  173. return
  174. # Step 3: Git checkout + reset
  175. await _update_progress("updating", "Applying update...")
  176. rc, _, stderr = await _run_ssh_command(
  177. ip_address,
  178. f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
  179. f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
  180. private_key,
  181. )
  182. if rc != 0:
  183. await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
  184. return
  185. # Step 4: Install dependencies
  186. await _update_progress("updating", "Installing dependencies...")
  187. venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
  188. rc, _, stderr = await _run_ssh_command(
  189. ip_address,
  190. f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
  191. private_key,
  192. timeout=120,
  193. )
  194. if rc != 0:
  195. logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
  196. # Step 5: Restart daemon
  197. await _update_progress("updating", "Restarting daemon...")
  198. rc, _, stderr = await _run_ssh_command(
  199. ip_address,
  200. "sudo /usr/bin/systemctl restart spoolbuddy.service",
  201. private_key,
  202. )
  203. if rc != 0:
  204. await _update_progress("error", f"Service restart failed: {stderr[:200]}")
  205. return
  206. # Step 6: Clear browser cache and restart kiosk
  207. # Remove Chromium's Service Worker + cache storage to prevent stale frontend
  208. await _run_ssh_command(
  209. ip_address,
  210. "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
  211. private_key,
  212. )
  213. rc, _, stderr = await _run_ssh_command(
  214. ip_address,
  215. "sudo /usr/bin/systemctl restart getty@tty1.service",
  216. private_key,
  217. )
  218. if rc != 0:
  219. logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
  220. logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
  221. except Exception as e:
  222. logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
  223. await _update_progress("error", f"Update failed: {str(e)[:200]}")