- 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
176 lines
6.0 KiB
Python
176 lines
6.0 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):
|
|
reject_exec = False
|
|
|
|
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)
|
|
if Server.reject_exec:
|
|
return False
|
|
|
|
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, "p1", "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("p1", "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("p1", "127.0.0.1", other)
|
|
reader = CanvasRecordReader(keys, "p1", "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("p1", "127.0.0.1", printer["host_key"])
|
|
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
|
|
CanvasRecordReader(keys, "p1", "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("p1", "127.0.0.1", printer["host_key"])
|
|
reader = CanvasRecordReader(keys, "p1", "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, "p1", "127.0.0.1", port, timeout=2).read()
|
|
|
|
|
|
def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch):
|
|
monkeypatch.setattr(Server, "reject_exec", True)
|
|
keys = KeyStore(tmp_path)
|
|
keys.save_key(printer["client_key_text"])
|
|
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
|
with pytest.raises(SSHError, match="Lesefehler"):
|
|
reader.test()
|
|
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
|
with pytest.raises(SSHError, match="Lesefehler"):
|
|
reader.read()
|