Files
cc2-spoolman-bridge/tests/test_ssh.py
T
Jonas MenzelandClaude Opus 5.5 bf32667f77
CI / test (push) Successful in 1m4s
CI / image (push) Successful in 1m1s
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
2026-10-25 03:01:17 +01:00

160 lines
5.4 KiB
Python

"""SSH reader against a local paramiko server that behaves like the restricted printer key."""
import io
import json
import os
import socket
import stat
import threading
import paramiko
import pytest
from app.sshsource import CanvasRecordReader, HostKeyMismatch, HostKeyUnknown, KeyStore, SSHError
from .fakes import record
RECORD = record(10001, "", 1000, 10008)
class Server(paramiko.ServerInterface):
def __init__(self, allowed: paramiko.PKey):
self.allowed = allowed
self.commands = []
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def get_allowed_auths(self, username):
return "publickey"
def check_auth_publickey(self, username, key):
ok = username == "root" and key.get_base64() == self.allowed.get_base64()
return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED
def check_channel_exec_request(self, channel, command):
self.commands.append(command)
def reply(): # forced command: always the canvas record
channel.sendall(json.dumps(RECORD).encode())
channel.send_exit_status(0)
channel.close()
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
return True
@pytest.fixture
def printer():
host_key = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
client_key_text = _new_key_text()
client_key = paramiko.Ed25519Key.from_private_key(io.StringIO(client_key_text))
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(5)
servers = []
stop = threading.Event()
def accept():
while not stop.is_set():
try:
conn, _ = sock.accept()
except OSError:
return
t = paramiko.Transport(conn)
t.add_server_key(host_key)
srv = Server(client_key)
servers.append((t, srv))
try:
t.start_server(server=srv)
except Exception:
pass
threading.Thread(target=accept, daemon=True).start()
yield {"port": sock.getsockname()[1], "host_key": host_key, "client_key_text": client_key_text,
"servers": servers}
stop.set()
sock.close()
for t, _ in servers:
t.close()
def _new_key_text():
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
return Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
def test_key_store_permissions_and_no_secret_in_info(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
mode = stat.S_IMODE(os.stat(keys.key_path).st_mode)
assert mode == 0o600
info = keys.key_info()
assert info["fingerprint"].startswith("SHA256:") and info["type"] == "ssh-ed25519"
assert "PRIVATE" not in json.dumps(info)
def test_rejects_garbage_key(tmp_path):
with pytest.raises(SSHError):
KeyStore(tmp_path).save_key("not a key")
def test_first_contact_requires_confirmation(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
with pytest.raises(HostKeyUnknown):
reader.read()
result = reader.test()
assert result["host_key_pinned"] is False
assert result["record"]["channels0"]["weight"] == "10001"
keys.pin_host_key("127.0.0.1", printer["host_key"])
rec = reader.read()
assert rec["channels3"]["weight"] == "10008"
# transport is reused between reads
reader.read()
assert len(printer["servers"]) == 3 # refused read, test(), then one transport for both reads
reader.close()
def test_changed_host_key_is_rejected(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
keys.pin_host_key("127.0.0.1", other)
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
with pytest.raises(HostKeyMismatch):
reader.read()
with pytest.raises(HostKeyMismatch):
reader.test()
def test_wrong_client_key_fails_cleanly(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(_new_key_text())
keys.pin_host_key("127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
CanvasRecordReader(keys, "127.0.0.1", printer["port"]).read()
def test_reconnects_after_drop(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
keys.pin_host_key("127.0.0.1", printer["host_key"])
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
reader.read()
for t, _ in printer["servers"]:
t.close()
assert reader.read()["channels0"]["weight"] == "10001"
def test_unreachable_printer(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
with pytest.raises(SSHError, match="nicht erreichbar"):
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read()