spoolbuddy_ssh.py 9.1 KB

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