- 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
257 lines
9.4 KiB
Python
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
|