"""Read /opt/usr/canvas_record_info.json from the printer over SSH. The printer's authorized_keys entry forces `cat /opt/usr/canvas_record_info.json` for this key, so every exec returns that file whatever command is sent. One transport is kept open and a new channel is opened per read; nothing keeps running on the printer between reads. """ from __future__ import annotations import base64 import hashlib import io import json import os import socket import threading from pathlib import Path import paramiko from .storage import atomic_write, atomic_write_json, read_json REMOTE_COMMAND = "cat /opt/usr/canvas_record_info.json" MAX_RECORD_BYTES = 256 * 1024 class SSHError(RuntimeError): pass class HostKeyUnknown(SSHError): def __init__(self, key: paramiko.PKey): self.key = key super().__init__(f"Host-Key des Druckers noch nicht bestätigt ({fingerprint(key)})") class HostKeyMismatch(SSHError): def __init__(self, key: paramiko.PKey, expected: str): self.key = key super().__init__( f"Host-Key des Druckers hat sich geändert: {fingerprint(key)}, erwartet {expected}. " "Verbindung abgelehnt." ) def fingerprint(key: paramiko.PKey) -> str: digest = hashlib.sha256(key.asbytes()).digest() return "SHA256:" + base64.b64encode(digest).decode().rstrip("=") def load_private_key(text: str) -> paramiko.PKey: """Parse an OpenSSH/PEM private key (no passphrase).""" text = text.replace("\r\n", "\n").strip() + "\n" errors = [] for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey): try: return cls.from_private_key(io.StringIO(text)) except paramiko.PasswordRequiredException: raise SSHError("Der Schlüssel ist passwortgeschützt; bitte einen Schlüssel ohne Passphrase verwenden") from None except (paramiko.SSHException, ValueError, IndexError, TypeError) as e: errors.append(type(e).__name__) raise SSHError("Kein gültiger privater SSH-Schlüssel (ed25519, ecdsa oder rsa)") class KeyStore: """Private key and pinned printer host key in /ssh.""" def __init__(self, data_dir: Path): self.dir = data_dir / "ssh" self.key_path = self.dir / "id_key" self.host_path = self.dir / "host_key.json" def has_key(self) -> bool: return self.key_path.exists() def save_key(self, text: str) -> paramiko.PKey: text = text.replace("\r\n", "\n").strip() + "\n" key = load_private_key(text) self.dir.mkdir(parents=True, exist_ok=True) os.chmod(self.dir, 0o700) atomic_write(self.key_path, text.encode(), mode=0o600) return key def delete_key(self) -> None: try: self.key_path.unlink() except FileNotFoundError: pass def load_key(self) -> paramiko.PKey: try: os.chmod(self.key_path, 0o600) return load_private_key(self.key_path.read_text()) except FileNotFoundError: raise SSHError("Kein SSH-Schlüssel hinterlegt") from None def key_info(self) -> dict | None: if not self.has_key(): return None try: key = self.load_key() except SSHError as e: return {"error": str(e)} return { "type": key.get_name(), "fingerprint": fingerprint(key), "public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge", } def host_key(self) -> dict | None: return read_json(self.host_path, None) def pin_host_key(self, host: str, key: paramiko.PKey) -> None: self.dir.mkdir(parents=True, exist_ok=True) atomic_write_json(self.host_path, { "host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key), }, mode=0o600) def forget_host_key(self) -> None: try: self.host_path.unlink() except FileNotFoundError: pass class CanvasRecordReader: def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0): self.keys = keys self.host, self.port, self.user, self.timeout = host, port, user, timeout self._transport: paramiko.Transport | None = None self._lock = threading.Lock() def close(self) -> None: with self._lock: self._close() def _close(self) -> None: if self._transport is not None: try: self._transport.close() except Exception: pass self._transport = None def _connect(self, accept_unknown: bool = False) -> paramiko.PKey: """Open and authenticate a transport; returns the server's host key.""" key = self.keys.load_key() try: sock = socket.create_connection((self.host, self.port), timeout=self.timeout) except OSError as e: raise SSHError(f"Drucker {self.host}:{self.port} nicht erreichbar ({e.strerror or type(e).__name__})") from None t = paramiko.Transport(sock) t.banner_timeout = t.auth_timeout = self.timeout try: t.start_client(timeout=self.timeout) server_key = t.get_remote_server_key() pinned = self.keys.host_key() if pinned is None: if not accept_unknown: raise HostKeyUnknown(server_key) elif pinned.get("type") != server_key.get_name() or pinned.get("key") != server_key.get_base64(): raise HostKeyMismatch(server_key, pinned.get("fingerprint", "?")) t.auth_publickey(self.user, key) if not t.is_authenticated(): raise SSHError("SSH-Anmeldung fehlgeschlagen") t.set_keepalive(30) except SSHError: t.close() raise except paramiko.AuthenticationException: t.close() raise SSHError("SSH-Anmeldung mit dem hinterlegten Schlüssel abgelehnt") from None except (paramiko.SSHException, OSError, EOFError) as e: t.close() raise SSHError(f"SSH-Fehler ({type(e).__name__})") from None self._transport = t return server_key def _exec(self) -> bytes: assert self._transport is not None chan = self._transport.open_session(timeout=self.timeout) try: chan.settimeout(self.timeout) chan.exec_command(REMOTE_COMMAND) chunks, size = [], 0 while True: data = chan.recv(32768) if not data: break size += len(data) if size > MAX_RECORD_BYTES: raise SSHError("Antwort des Druckers ist zu groß") chunks.append(data) status = chan.recv_exit_status() if status not in (0, -1): raise SSHError(f"Befehl auf dem Drucker endete mit Status {status}") return b"".join(chunks) finally: chan.close() def read(self) -> dict: """Read and parse the canvas record; reconnects once on a dead transport.""" with self._lock: for attempt in (0, 1): if self._transport is None or not self._transport.is_active(): self._close() self._connect() try: raw = self._exec() break except (paramiko.SSHException, OSError, EOFError, socket.timeout) as e: self._close() if attempt: raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None return parse_record_json(raw) def test(self) -> dict: """Fresh connection; an unknown host key is reported, not trusted.""" with self._lock: self._close() try: server_key = self._connect(accept_unknown=True) raw = self._exec() except HostKeyMismatch: raise finally: self._close() pinned = self.keys.host_key() return { "host_key": {"type": server_key.get_name(), "key": server_key.get_base64(), "fingerprint": fingerprint(server_key)}, "host_key_pinned": pinned is not None, "record": parse_record_json(raw), } def parse_record_json(raw: bytes) -> dict: try: data = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, ValueError): raise SSHError("canvas_record_info.json ist kein gültiges JSON") from None if not isinstance(data, dict): raise SSHError("canvas_record_info.json hat ein unerwartetes Format") return data