CC2 ↔ Spoolman bridge with NFC tag generator
- Reads /opt/usr/canvas_record_info.json over SSH (pinned host key, restricted key), decodes Spoolman spool ids from the tag weight - Mirrors spools into CC2 Control's spool library and assigns slots, keeps its own slot mapping, sets Spoolman locations - Books the drop of CC2 Control's measured remaining weight to Spoolman after each print, idempotent across restarts and interrupted bookings - Dry-run by default; web UI for settings, SSH key, status and log - Tag generator for NFC Tools command chains (NTAG213/215/216) - Tests, mock environment, Dockerfile, Gitea Actions CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
commit
bf32667f77
32 files changed
+4216
No files matched your search
@@ -0,0 +1,245 @@
|
||||
"""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_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
|
||||
Reference in new issue
Block a user