Files
cc2-spoolman-bridge/app/sshsource.py
T
Jonas MenzelandClaude Opus 5.5 a9041eb538
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s
Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 03:53:01 +01:00

257 lines
9.4 KiB
Python

"""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 <data>/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_path(self, pid: str) -> Path:
if not pid.replace("-", "").replace("_", "").isalnum():
raise ValueError("invalid printer id")
return self.dir / f"host_key_{pid}.json"
def migrate_host_key(self, pid: str) -> None:
"""v1 kept one host key in host_key.json; it belongs to the first printer."""
if self.host_path.exists() and not self._host_path(pid).exists():
os.replace(self.host_path, self._host_path(pid))
def host_key(self, pid: str) -> dict | None:
return read_json(self._host_path(pid), None)
def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
atomic_write_json(self._host_path(pid), {
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
}, mode=0o600)
def forget_host_key(self, pid: str) -> None:
try:
self._host_path(pid).unlink()
except FileNotFoundError:
pass
class CanvasRecordReader:
def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
self.keys = keys
self.pid = pid
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(self.pid)
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 (paramiko.SSHException, OSError, EOFError) as e:
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
finally:
self._close()
pinned = self.keys.host_key(self.pid)
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