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,159 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user