Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1377 lines
49 KiB
Python
1377 lines
49 KiB
Python
"""Neustarts und SSH-Zugang zu Clients.
|
|
|
|
Zwei Luecken, die erst im Betrieb auffielen:
|
|
|
|
1. **Die Neustart-Historie zeigte nur die halbe Wahrheit.** Sie hiess
|
|
"PoE-Vorgaenge" und enthielt auch nur PoE-Vorgaenge. Ein Geraet, das die
|
|
Wartung per ``reboot`` neu gestartet hatte, fehlte dort vollstaendig --
|
|
obwohl genau dort nachsieht, wer wissen will, warum ein Geraet weg war.
|
|
|
|
2. **Fuer Clients gab es keine Host-Schluessel-Freigabe.** Die Wartung
|
|
verweigerte den Dienst ("Schluessel unbekannt"), und es gab keine Stelle in
|
|
der Oberflaeche, an der man ihn haette ansehen und freigeben koennen. Eine
|
|
Sackgasse, aus der nur ein Shell-Zugang auf den Server herausfuehrte.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def tesm_app(instance_env: Any):
|
|
from tesm import create_app
|
|
|
|
return create_app(TESTING=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def tclient(tesm_app: Any):
|
|
return tesm_app.test_client()
|
|
|
|
|
|
@pytest.fixture()
|
|
def admin(tesm_app: Any):
|
|
from tesm_core.extension import core
|
|
from tesm_core.security import passwords
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, "
|
|
"updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))",
|
|
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
)
|
|
return {"username": "root", "password": "Ein-gutes-Passwort-1"}
|
|
|
|
|
|
def _token(client: Any, path: str = "/login") -> str:
|
|
body = client.get(path).get_data(as_text=True)
|
|
for marker in ('name="csrf_token" value="', 'data-csrf="'):
|
|
if marker in body:
|
|
start = body.index(marker) + len(marker)
|
|
return body[start : body.index('"', start)]
|
|
raise AssertionError(f"Kein CSRF-Token in {path}")
|
|
|
|
|
|
@pytest.fixture()
|
|
def session(tclient: Any, admin: dict[str, str]):
|
|
tclient.post(
|
|
"/login",
|
|
data={
|
|
"username": admin["username"],
|
|
"password": admin["password"],
|
|
"csrf_token": _token(tclient),
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
return tclient
|
|
|
|
|
|
@pytest.fixture()
|
|
def device(tesm_app: Any):
|
|
"""Ein Client mit Zugangsdaten -- Voraussetzung fuer Wartung und Konsole."""
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO credentials (name, username, secret_encrypted, category, created_at, "
|
|
"updated_at) VALUES ('linux-login','wartung','', 'linux', datetime('now'), "
|
|
"datetime('now'))"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, "
|
|
"created_at, updated_at) VALUES ('Kamera-1','aa:bb:cc:dd:ee:01','10.9.9.9',22,1,1,"
|
|
"datetime('now'), datetime('now'))"
|
|
)
|
|
return {"id": 1, "ip": "10.9.9.9", "port": 22}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Neustart-Historie
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_restart_log_replaces_the_poe_only_page(session):
|
|
"""Die alte Adresse ist weg, die neue zeigt Neustarts jeder Art."""
|
|
assert session.get("/protokolle/poe").status_code == 404
|
|
response = session.get("/protokolle/neustarts")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "Neustarts" in body
|
|
assert "PoE-Vorgaenge" not in body
|
|
|
|
|
|
def test_restart_log_shows_both_methods(tesm_app, session, device):
|
|
from tesm.services import monitor
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=1,
|
|
action="cycle",
|
|
result="ok",
|
|
trigger="auto",
|
|
actor="system",
|
|
duration_ms=1200,
|
|
detail="Port getrennt und wieder zugeschaltet",
|
|
)
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=1,
|
|
action="reboot",
|
|
result="ok",
|
|
trigger="maintenance",
|
|
actor="root",
|
|
duration_ms=45000,
|
|
detail="Neustart ueber SSH",
|
|
method=monitor.METHOD_SSH,
|
|
)
|
|
|
|
body = session.get("/protokolle/neustarts").get_data(as_text=True)
|
|
assert "PoE-Port" in body
|
|
assert "SSH" in body
|
|
assert "Kamera-1" in body
|
|
|
|
# Filter je Methode
|
|
only_ssh = session.get("/protokolle/neustarts?methode=ssh").get_data(as_text=True)
|
|
assert "maintenance" in only_ssh
|
|
assert only_ssh.count("<tr data-filter-text") == 1
|
|
|
|
|
|
def test_unknown_filter_falls_back_to_everything(tesm_app, session, device):
|
|
from tesm.services import monitor
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=1,
|
|
action="cycle",
|
|
result="ok",
|
|
trigger="auto",
|
|
actor="system",
|
|
duration_ms=1,
|
|
detail="",
|
|
)
|
|
body = session.get("/protokolle/neustarts?methode=' OR 1=1 --").get_data(as_text=True)
|
|
assert body.count("<tr data-filter-text") == 1
|
|
|
|
|
|
def test_restart_counts_only_on_success(tesm_app, device):
|
|
from tesm.services import monitor
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO device_status (device_id, state) VALUES (1,'online')"
|
|
)
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=1,
|
|
action="reboot",
|
|
result="error",
|
|
trigger="maintenance",
|
|
actor="root",
|
|
duration_ms=5,
|
|
detail="fehlgeschlagen",
|
|
method=monitor.METHOD_SSH,
|
|
)
|
|
with extension.database.session() as conn:
|
|
assert Database.value(conn, "SELECT restart_count FROM device_status WHERE device_id=1") == 0
|
|
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=1,
|
|
action="reboot",
|
|
result="ok",
|
|
trigger="maintenance",
|
|
actor="root",
|
|
duration_ms=5,
|
|
detail="",
|
|
method=monitor.METHOD_SSH,
|
|
)
|
|
with extension.database.session() as conn:
|
|
assert Database.value(conn, "SELECT restart_count FROM device_status WHERE device_id=1") == 1
|
|
|
|
|
|
def test_maintenance_reboot_lands_in_the_restart_history(tesm_app, device):
|
|
"""Der Auftrag steht in ``jobs`` -- das Ereignis gehoert trotzdem ans Geraet."""
|
|
from tesm.services import maintenance
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
maintenance._log_restart( # noqa: SLF001 - genau dieser Pfad wird geprueft
|
|
extension.database,
|
|
{"id": 1, "name": "Kamera-1"},
|
|
result="ok",
|
|
actor="root",
|
|
duration_ms=42000,
|
|
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
|
)
|
|
with extension.database.session() as conn:
|
|
row = Database.one(conn, "SELECT * FROM restart_events WHERE device_id=1")
|
|
assert row is not None
|
|
assert row["method"] == "ssh"
|
|
assert row["trigger"] == "maintenance"
|
|
assert row["actor"] == "root"
|
|
|
|
|
|
def test_logging_a_restart_never_breaks_the_job(tesm_app):
|
|
"""Ein Protokollfehler darf einen laufenden Wartungsauftrag nicht kippen."""
|
|
from tesm.services import maintenance
|
|
|
|
class Broken:
|
|
def session(self):
|
|
raise RuntimeError("Datenbank weg")
|
|
|
|
# Kein Ausnahmefehler nach aussen -- der Auftrag laeuft weiter.
|
|
maintenance._log_restart( # noqa: SLF001
|
|
Broken(), {"id": 1}, result="ok", actor="root", duration_ms=1, detail=""
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Host-Schluessel auf Clients
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_device_detail_offers_the_host_key_card(session, device):
|
|
body = session.get("/clients/1").get_data(as_text=True)
|
|
assert "SSH-Host-Schluessel" in body
|
|
assert "Auslesen" in body
|
|
assert "Noch kein Schluessel hinterlegt" in body
|
|
|
|
|
|
def test_trusting_a_client_key_needs_a_valid_fingerprint(session, device):
|
|
response = session.post(
|
|
"/clients/1/hostkey/freigeben",
|
|
data={"csrf_token": _token(session, "/clients/1"), "key_type": "ssh-ed25519",
|
|
"fingerprint": "irgendwas"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "kein gueltiger Fingerprint" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_client_key_can_be_trusted_and_forgotten(tesm_app, session, device):
|
|
from tesm.services import ssh
|
|
from tesm_core.extension import core
|
|
|
|
fingerprint = "SHA256:" + "A" * 43
|
|
response = session.post(
|
|
"/clients/1/hostkey/freigeben",
|
|
data={"csrf_token": _token(session, "/clients/1"), "key_type": "ssh-ed25519",
|
|
"fingerprint": fingerprint},
|
|
follow_redirects=True,
|
|
)
|
|
assert "freigegeben" in response.get_data(as_text=True)
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
assert ssh.is_trusted(conn, "10.9.9.9", 22)
|
|
keys = ssh.all_known_keys(conn, "10.9.9.9", 22)
|
|
assert [key.fingerprint for key in keys] == [fingerprint]
|
|
|
|
body = session.get("/clients/1").get_data(as_text=True)
|
|
assert fingerprint in body
|
|
assert "freigegeben" in body
|
|
|
|
session.post(
|
|
"/clients/1/hostkey/verwerfen",
|
|
data={"csrf_token": _token(session, "/clients/1")},
|
|
follow_redirects=True,
|
|
)
|
|
with extension.database.session() as conn:
|
|
assert not ssh.is_trusted(conn, "10.9.9.9", 22)
|
|
|
|
|
|
def test_trusting_a_key_is_written_to_the_audit_log(session, device):
|
|
fingerprint = "SHA256:" + "B" * 43
|
|
session.post(
|
|
"/clients/1/hostkey/freigeben",
|
|
data={"csrf_token": _token(session, "/clients/1"), "key_type": "ssh-ed25519",
|
|
"fingerprint": fingerprint},
|
|
follow_redirects=True,
|
|
)
|
|
body = session.get("/protokolle/aenderungen").get_data(as_text=True)
|
|
assert "device.host_key_trusted" in body
|
|
|
|
|
|
def test_host_key_routes_need_the_edit_right(tesm_app, tclient, device):
|
|
"""Ein reiner Betrachter darf keinen Schluessel freigeben."""
|
|
from tesm_core.extension import core
|
|
from tesm_core.security import passwords
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, "
|
|
"updated_at) VALUES ('gast',?,0,'local',datetime('now'),datetime('now'))",
|
|
(passwords.hash_password("Ein-gutes-Passwort-2"),),
|
|
)
|
|
tclient.post(
|
|
"/login",
|
|
data={"username": "gast", "password": "Ein-gutes-Passwort-2",
|
|
"csrf_token": _token(tclient)},
|
|
follow_redirects=True,
|
|
)
|
|
response = tclient.post(
|
|
"/clients/1/hostkey/freigeben",
|
|
data={"key_type": "ssh-ed25519", "fingerprint": "SHA256:" + "C" * 43},
|
|
)
|
|
assert response.status_code in (302, 403)
|
|
|
|
|
|
def test_probe_description_names_all_three_cases(tesm_app, device):
|
|
from tesm.services import ssh
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
record = ssh.HostKeyRecord(
|
|
host="10.9.9.9", port=22, key_type="ssh-ed25519", fingerprint="SHA256:" + "D" * 43
|
|
)
|
|
with extension.database.session() as conn:
|
|
level, message = ssh.describe_probe(conn, record)
|
|
assert level == "info" and "pruefen" in message
|
|
|
|
with extension.database.transaction(conn):
|
|
ssh.trust_key(
|
|
conn,
|
|
host=record.host,
|
|
port=record.port,
|
|
key_type=record.key_type,
|
|
fingerprint=record.fingerprint,
|
|
actor="test",
|
|
)
|
|
level, message = ssh.describe_probe(conn, record)
|
|
assert level == "success"
|
|
|
|
other = ssh.HostKeyRecord(
|
|
host="10.9.9.9", port=22, key_type="ssh-ed25519", fingerprint="SHA256:" + "E" * 43
|
|
)
|
|
level, message = ssh.describe_probe(conn, other)
|
|
# Ein abweichender Schluessel ist nie eine Kleinigkeit.
|
|
assert level == "danger"
|
|
assert "Angriff" in message
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Client-Konsole
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.fixture()
|
|
def licensed(tesm_app: Any, monkeypatch: Any):
|
|
"""Schaltet das Modul Wartung frei, ohne eine echte Lizenz einzuspielen."""
|
|
from tesm_core.extension import core
|
|
|
|
manager = core(tesm_app).license
|
|
if manager is not None:
|
|
monkeypatch.setattr(manager, "module_active", lambda _name: True)
|
|
return manager
|
|
|
|
|
|
def test_client_console_is_gated_by_the_maintenance_module(session, device):
|
|
"""Ohne aktives Modul gibt es die Client-Konsole nicht -- und keinen Fehler."""
|
|
assert session.get("/clients/1/konsole").status_code == 404
|
|
|
|
|
|
def test_client_console_renders_with_an_active_module(session, device, licensed):
|
|
response = session.get("/clients/1/konsole")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "/ws/device/1" in body
|
|
# Ohne freigegebenen Schluessel weist die Seite den Weg zur Freigabe.
|
|
assert "Host-Schluessel fehlt" in body
|
|
assert "/clients/1" in body
|
|
# Der Unterschied zur Switch-Konsole muss dort stehen, wo er ueberrascht.
|
|
assert "Ein Kommando, eine Sitzung" in body
|
|
|
|
|
|
def test_switch_console_still_renders(tesm_app, session):
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO credentials (name, username, secret_encrypted, category, created_at, "
|
|
"updated_at) VALUES ('sw','admin','','switch',datetime('now'),datetime('now'))"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, created_at, updated_at) "
|
|
"VALUES ('sw-1','10.9.9.1',22,1,datetime('now'),datetime('now'))"
|
|
)
|
|
response = session.get("/switche/1/konsole")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "/ws/switch/1" in body
|
|
# Der Hinweis zur Einzelsitzung gilt nur fuer Clients.
|
|
assert "Ein Kommando, eine Sitzung" not in body
|
|
|
|
|
|
def test_client_console_needs_the_terminal_setting(tesm_app, session, device, licensed):
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
extension.settings.set(conn, "terminal_enabled", False, actor="test")
|
|
assert session.get("/clients/1/konsole").status_code == 404
|
|
|
|
|
|
def test_client_console_without_credentials_says_so(tesm_app, session, licensed):
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, ssh_port, is_active, created_at, updated_at) "
|
|
"VALUES ('Ohne-Login','aa:bb:cc:dd:ee:99','10.9.9.8',22,1,datetime('now'),"
|
|
"datetime('now'))"
|
|
)
|
|
response = session.get("/clients/1/konsole")
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_socket_permission_matrix():
|
|
"""Der WebSocket umgeht Dekoratoren -- die Rechte stehen dort ausdruecklich."""
|
|
import inspect
|
|
|
|
from tesm.blueprints import terminal
|
|
|
|
source = inspect.getsource(terminal._handle_socket) # noqa: SLF001
|
|
assert '"switches.execute" if kind == KIND_SWITCH else "maintenance.execute"' in source
|
|
assert "reauth_is_fresh()" in source
|
|
assert "terminal_enabled" in source
|
|
|
|
|
|
def test_transcript_name_cannot_escape_the_directory():
|
|
from tesm.blueprints.terminal import _transcript_name
|
|
|
|
assert _transcript_name("../../etc/passwd") == ".._.._etc_passwd"
|
|
assert _transcript_name("a/b\\c") == "a_b_c"
|
|
assert _transcript_name("") == "unbenannt"
|
|
assert len(_transcript_name("x" * 200)) == 60
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Kommandoausfuehrung auf dem Client
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class FakeChannel:
|
|
def __init__(self, output: bytes, status: int) -> None:
|
|
self._output = output
|
|
self._status = status
|
|
self._sent = False
|
|
self.closed = False
|
|
|
|
def settimeout(self, _value: float) -> None:
|
|
pass
|
|
|
|
def set_combine_stderr(self, _value: bool) -> None:
|
|
pass
|
|
|
|
def exec_command(self, _command: str) -> None:
|
|
pass
|
|
|
|
def recv_ready(self) -> bool:
|
|
return not self._sent
|
|
|
|
def recv(self, _size: int) -> bytes:
|
|
self._sent = True
|
|
return self._output
|
|
|
|
def exit_status_ready(self) -> bool:
|
|
return self._sent
|
|
|
|
def recv_exit_status(self) -> int:
|
|
return self._status
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class FakeTransport:
|
|
def __init__(self, channel: FakeChannel) -> None:
|
|
self.channel = channel
|
|
|
|
def is_active(self) -> bool:
|
|
return True
|
|
|
|
def open_session(self) -> FakeChannel:
|
|
return self.channel
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def test_shell_session_returns_output_and_status():
|
|
from tesm.services.shellcli import ShellSession
|
|
|
|
channel = FakeChannel(b"Linux poetest 6.8.0\n", 0)
|
|
session = ShellSession(None, host="h", port=22, username="u", password="p")
|
|
session._transport = FakeTransport(channel) # noqa: SLF001
|
|
|
|
result = session.run("uname -sr")
|
|
assert result["output"].startswith("Linux poetest")
|
|
assert result["status"] == 0
|
|
assert result["duration_ms"] >= 0
|
|
assert channel.closed, "Der Kanal muss geschlossen werden."
|
|
|
|
|
|
def test_shell_session_reports_a_failing_command():
|
|
from tesm.services.shellcli import ShellSession
|
|
|
|
session = ShellSession(None, host="h", port=22, username="u", password="p")
|
|
session._transport = FakeTransport(FakeChannel(b"bash: nope: not found\n", 127)) # noqa: SLF001
|
|
result = session.run("nope")
|
|
assert result["status"] == 127
|
|
|
|
|
|
def test_shell_session_refuses_a_dead_transport():
|
|
from tesm.services.shellcli import ShellSession
|
|
from tesm.services.ssh import SshError
|
|
|
|
session = ShellSession(None, host="h", port=22, username="u", password="p")
|
|
with pytest.raises(SshError):
|
|
session.run("uptime")
|
|
|
|
|
|
def test_shell_session_truncates_a_flood():
|
|
from tesm.services.shellcli import MAX_OUTPUT_BYTES, ShellSession
|
|
|
|
flood = b"x" * (MAX_OUTPUT_BYTES + 1024)
|
|
session = ShellSession(None, host="h", port=22, username="u", password="p")
|
|
session._transport = FakeTransport(FakeChannel(flood, 0)) # noqa: SLF001
|
|
result = session.run("yes")
|
|
assert "abgeschnitten" in result["output"]
|
|
assert len(result["output"]) < len(flood.decode())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auftragsausgabe
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_job_output_keeps_one_line_per_entry(tesm_app):
|
|
"""Jede Ausgabe gehoert auf eine eigene Zeile.
|
|
|
|
``splitlines()`` wirft das abschliessende Zeilenende weg. Ohne Korrektur
|
|
klebte jede neue Zeile an der vorherigen, und das ganze Protokoll stand am
|
|
Ende auf einer einzigen Zeile -- genau so im Betrieb aufgefallen.
|
|
"""
|
|
from tesm.services import maintenance
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
handle = maintenance.create_job(
|
|
conn, kind=maintenance.JOB_KIND_UPDATE, subject="Testgeraet", actor="root"
|
|
)
|
|
|
|
for line in ("--- Paketlisten aktualisieren ---", "sudo: a password is required",
|
|
"Schritt endete mit Rueckgabewert 1."):
|
|
maintenance.update_job(extension.database, handle.id, append_output=f"12:00:00 {line}\n")
|
|
|
|
with extension.database.session() as conn:
|
|
output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,)))
|
|
|
|
lines = output.splitlines()
|
|
assert len(lines) == 3, f"Erwartet drei Zeilen, bekommen: {output!r}"
|
|
assert lines[0].endswith("--- Paketlisten aktualisieren ---")
|
|
assert lines[1].endswith("sudo: a password is required")
|
|
assert "---sudo:" not in output, "Zeilen kleben aneinander."
|
|
|
|
|
|
def test_job_output_is_trimmed_but_stays_line_wise(tesm_app):
|
|
from tesm.services import maintenance
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
handle = maintenance.create_job(
|
|
conn, kind=maintenance.JOB_KIND_UPDATE, subject="Flut", actor="root"
|
|
)
|
|
for index in range(maintenance.MAX_OUTPUT_LINES + 50):
|
|
maintenance.update_job(extension.database, handle.id, append_output=f"Zeile {index}\n")
|
|
|
|
with extension.database.session() as conn:
|
|
output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,)))
|
|
lines = output.splitlines()
|
|
assert len(lines) == maintenance.MAX_OUTPUT_LINES
|
|
# Die juengsten Zeilen bleiben stehen, nicht die aeltesten.
|
|
assert lines[-1] == f"Zeile {maintenance.MAX_OUTPUT_LINES + 49}"
|
|
|
|
|
|
def test_known_failures_get_an_actionable_hint():
|
|
"""Die rohe Meldung sagt die Wahrheit, aber nicht, was zu tun ist."""
|
|
from tesm.services.maintenance import hint_for
|
|
|
|
# Passwort verlangt, aber keines hinterlegt -> auf die Zugangsdaten zeigen.
|
|
hint = hint_for("sudo: a password is required")
|
|
assert "Zugangsdaten" in hint
|
|
assert "NOPASSWD" not in hint, "Der Weg ueber die Zugangsdaten ist der einfachere."
|
|
|
|
# Passwort abgewiesen -> nicht die Zugangsdaten, sondern sudo ist das Problem.
|
|
assert "abgewiesen" in hint_for("sudo: 1 incorrect password attempt")
|
|
|
|
# Konto darf gar kein sudo -> sudoers-Regel noetig.
|
|
not_allowed = hint_for("wartung is not in the sudoers file. This incident will be reported.")
|
|
assert "sudoers" in not_allowed
|
|
assert "NOPASSWD" in not_allowed
|
|
|
|
assert "requiretty" in hint_for("sudo: no tty present and no askpass program specified")
|
|
assert "apt-get" in hint_for("bash: apt-get: command not found")
|
|
assert "unattended" in hint_for("E: Could not get lock /var/lib/dpkg/lock-frontend")
|
|
assert hint_for("alles in Ordnung") == ""
|
|
|
|
|
|
def test_hint_survives_surrounding_output():
|
|
from tesm.services.maintenance import hint_for
|
|
|
|
output = "\n".join(
|
|
["--- Paketlisten aktualisieren ---", "sudo: a password is required", "Ende"]
|
|
)
|
|
assert hint_for(output)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# sudo mit dem hinterlegten Passwort
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class SudoTransport:
|
|
"""Zeichnet auf, welche Kommandos laufen und was ueber stdin hereinkommt."""
|
|
|
|
def __init__(self, passwordless_ok: bool) -> None:
|
|
self.passwordless_ok = passwordless_ok
|
|
self.commands: list[str] = []
|
|
self.stdin: list[str] = []
|
|
|
|
def is_active(self) -> bool:
|
|
return True
|
|
|
|
def open_session(self): # type: ignore[no-untyped-def]
|
|
return SudoChannel(self)
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
class SudoChannel:
|
|
def __init__(self, transport: SudoTransport) -> None:
|
|
self.transport = transport
|
|
self.command = ""
|
|
self.written = b""
|
|
self._done = False
|
|
|
|
def settimeout(self, _v: float) -> None:
|
|
pass
|
|
|
|
def set_combine_stderr(self, _v: bool) -> None:
|
|
pass
|
|
|
|
def exec_command(self, command: str) -> None:
|
|
self.command = command
|
|
self.transport.commands.append(command)
|
|
|
|
def sendall(self, data: bytes) -> None:
|
|
self.written += data
|
|
|
|
def shutdown_write(self) -> None:
|
|
self.transport.stdin.append(self.written.decode())
|
|
|
|
def recv_ready(self) -> bool:
|
|
return False
|
|
|
|
def exit_status_ready(self) -> bool:
|
|
return True
|
|
|
|
def recv(self, _n: int) -> bytes:
|
|
return b""
|
|
|
|
def recv_exit_status(self) -> int:
|
|
if self.command.startswith("sudo -n true"):
|
|
return 0 if self.transport.passwordless_ok else 1
|
|
return 0
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def test_passwordless_sudo_keeps_the_password_on_the_server():
|
|
"""Geht es ohne, verlaesst das Passwort den Server gar nicht erst."""
|
|
from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode
|
|
|
|
transport = SudoTransport(passwordless_ok=True)
|
|
lines: list[str] = []
|
|
sudo, stdin = _sudo_mode(transport, "geheim", lines.append)
|
|
|
|
assert sudo == SUDO_PASSWORDLESS
|
|
assert stdin == ""
|
|
assert transport.stdin == [], "Ohne Not wird kein Passwort uebertragen."
|
|
assert "ohne Passwort" in " ".join(lines)
|
|
|
|
|
|
def test_sudo_falls_back_to_the_stored_password():
|
|
from tesm.services.maintenance import SUDO_WITH_PASSWORD, _sudo_mode
|
|
|
|
transport = SudoTransport(passwordless_ok=False)
|
|
lines: list[str] = []
|
|
sudo, stdin = _sudo_mode(transport, "geheim", lines.append)
|
|
|
|
assert sudo == SUDO_WITH_PASSWORD
|
|
assert stdin == "geheim\n"
|
|
assert "Zugangsdaten" in " ".join(lines)
|
|
|
|
|
|
def test_password_never_appears_on_the_command_line():
|
|
"""Auf dem Zielgeraet steht die Kommandozeile in ``ps`` -- fuer jeden lesbar."""
|
|
from tesm.services.maintenance import (
|
|
REBOOT_COMMAND,
|
|
UPDATE_COMMANDS,
|
|
_run_remote_command,
|
|
_sudo_mode,
|
|
)
|
|
|
|
transport = SudoTransport(passwordless_ok=False)
|
|
sudo, stdin = _sudo_mode(transport, "streng-geheim", lambda _line: None)
|
|
|
|
for _label, template in UPDATE_COMMANDS:
|
|
_run_remote_command(
|
|
transport, template.format(sudo=sudo), timeout=5, on_line=lambda _l: None,
|
|
stdin_text=stdin,
|
|
)
|
|
_run_remote_command(
|
|
transport, REBOOT_COMMAND.format(sudo=sudo), timeout=5, on_line=lambda _l: None,
|
|
stdin_text=stdin,
|
|
)
|
|
|
|
assert all("streng-geheim" not in command for command in transport.commands), (
|
|
"Das Passwort darf nie in einem Kommando stehen."
|
|
)
|
|
# Es geht ausschliesslich ueber stdin.
|
|
assert transport.stdin, "Das Passwort muss ueber stdin gehen."
|
|
assert all(entry == "streng-geheim\n" for entry in transport.stdin)
|
|
|
|
|
|
def test_sudo_prompt_is_suppressed():
|
|
"""Ohne ``-p ''`` landet die Passwortaufforderung mitten in der Ausgabe."""
|
|
from tesm.services.maintenance import SUDO_WITH_PASSWORD
|
|
|
|
assert "-S" in SUDO_WITH_PASSWORD
|
|
assert "-p ''" in SUDO_WITH_PASSWORD
|
|
assert "-k" in SUDO_WITH_PASSWORD
|
|
|
|
|
|
def test_commands_carry_no_hardcoded_sudo():
|
|
"""Das Verfahren wird zur Laufzeit gewaehlt -- nicht in der Konstante festgelegt."""
|
|
from tesm.services.maintenance import REBOOT_COMMAND, UPDATE_COMMANDS
|
|
|
|
for _label, template in UPDATE_COMMANDS:
|
|
assert "{sudo}" in template
|
|
assert "sudo -n" not in template
|
|
assert "{sudo}" in REBOOT_COMMAND
|
|
|
|
|
|
def test_without_a_stored_password_nothing_is_invented():
|
|
from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode
|
|
|
|
transport = SudoTransport(passwordless_ok=False)
|
|
lines: list[str] = []
|
|
sudo, stdin = _sudo_mode(transport, "", lines.append)
|
|
assert sudo == SUDO_PASSWORDLESS
|
|
assert stdin == ""
|
|
assert "keines hinterlegt" in " ".join(lines)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Rueckkehr nach einem Neustart
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_reboot_waits_on_tcp_not_on_icmp():
|
|
"""ICMP kann der Webprozess gar nicht.
|
|
|
|
Die systemd-Unit der Weboberflaeche hat ein leeres
|
|
Die systemd-Unit der Weboberflaeche bekommt keine AmbientCapabilities --
|
|
damit greift die Datei-Capability von ``/usr/bin/ping`` nicht. Jeder Ping
|
|
schlaegt dort fehl, und der Wartungsauftrag lief deshalb *immer* in die
|
|
Zeitueberschreitung, obwohl das Geraet laengst zurueck war.
|
|
"""
|
|
import inspect
|
|
|
|
from tesm.services import maintenance
|
|
|
|
source = inspect.getsource(maintenance.run_reboot)
|
|
assert "tcp_reachable(" in source
|
|
assert "ping(" not in source, "ICMP ist im Webprozess nicht verfuegbar."
|
|
|
|
|
|
def test_web_unit_has_no_net_raw_but_the_monitor_does():
|
|
"""Die Aufteilung ist Absicht und muss so bleiben."""
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent / "deploy" / "systemd"
|
|
web = (root / "tesm.service").read_text(encoding="utf-8")
|
|
monitor = (root / "tesm-monitor.service").read_text(encoding="utf-8")
|
|
|
|
# Kein CapabilityBoundingSet mehr -- es bricht sudo (gemessen). Was den
|
|
# Webprozess von ICMP fernhaelt, ist das Fehlen von AmbientCapabilities.
|
|
assert "CapabilityBoundingSet=" not in web
|
|
assert "AmbientCapabilities=CAP_NET_RAW" not in web, (
|
|
"Der Webprozess soll keine Rohsockets duerfen -- er ist der exponierte."
|
|
)
|
|
assert "AmbientCapabilities=CAP_NET_RAW" in monitor, (
|
|
"Der Ueberwachungsdienst braucht CAP_NET_RAW fuer ICMP."
|
|
)
|
|
|
|
|
|
def test_tcp_probe_needs_no_privileges():
|
|
import socket
|
|
import threading
|
|
|
|
from tesm.services.monitor import tcp_reachable
|
|
|
|
server = socket.socket()
|
|
server.bind(("127.0.0.1", 0))
|
|
server.listen(1)
|
|
port = server.getsockname()[1]
|
|
threading.Thread(target=lambda: server.accept(), daemon=True).start()
|
|
|
|
assert tcp_reachable("127.0.0.1", port, timeout=2)
|
|
server.close()
|
|
|
|
|
|
def test_tcp_probe_reports_a_closed_port():
|
|
from tesm.services.monitor import tcp_reachable
|
|
|
|
# Port 1 ist auf keinem sinnvoll konfigurierten System offen.
|
|
assert not tcp_reachable("127.0.0.1", 1, timeout=1)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Keine geschachtelten Datenbankverbindungen
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_restart_takes_the_keystore_not_a_callback():
|
|
"""Ein Rueckruf kannte die offene Verbindung nicht und oeffnete eine neue.
|
|
|
|
Der Ueberwachungsdienst starb dadurch bei jedem automatischen PoE-Neustart
|
|
mit ``NestedConnectionError`` -- und der Geraetestatus blieb stehen. Mit dem
|
|
Schluesselspeicher als Argument gibt es die falsche Variante nicht mehr.
|
|
"""
|
|
import inspect
|
|
|
|
from tesm.services import monitor
|
|
|
|
for function in (monitor.restart_device_poe, monitor.run_sweep):
|
|
signature = inspect.signature(function)
|
|
assert "keystore" in signature.parameters, function.__name__
|
|
assert "decrypt_secret" not in signature.parameters, function.__name__
|
|
|
|
source = inspect.getsource(monitor.restart_device_poe)
|
|
assert "credential_secret(conn," in source, "Die vorhandene Verbindung muss benutzt werden."
|
|
|
|
|
|
def test_no_caller_passes_a_decrypt_callback():
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent / "apps" / "tesm" / "src" / "tesm"
|
|
offenders = [
|
|
str(path.relative_to(root))
|
|
for path in root.rglob("*.py")
|
|
if "__pycache__" not in path.parts and "decrypt_secret" in path.read_text(encoding="utf-8")
|
|
]
|
|
assert not offenders, f"Rueckruf lebt noch in: {offenders}"
|
|
|
|
|
|
def test_sweep_survives_a_restart_without_nested_connection(tesm_app, device):
|
|
"""Der ganze Durchlauf, inklusive Neustartversuch, ohne zweite Verbindung."""
|
|
from tesm.services import monitor
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
live = extension.registry["live_log"]
|
|
|
|
with tesm_app.app_context():
|
|
report = monitor.run_sweep(
|
|
database=extension.database,
|
|
live=live,
|
|
keystore=extension.keystore,
|
|
license_allows_auto_restart=True,
|
|
failures_before_restart=1,
|
|
)
|
|
# Das Geraet ist nicht erreichbar -- entscheidend ist, dass der Durchlauf
|
|
# ueberhaupt durchlaeuft und nicht an einer geschachtelten Verbindung stirbt.
|
|
assert report.checked >= 1
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Windows: kein SSH, kein Host-Schluessel
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_windows_is_not_an_ssh_target():
|
|
from tesm.services import inventory
|
|
|
|
assert inventory.is_ssh_managed("linux")
|
|
assert inventory.is_ssh_managed("switch")
|
|
assert inventory.is_ssh_managed(None)
|
|
assert not inventory.is_ssh_managed("windows")
|
|
|
|
|
|
def test_windows_devices_do_not_appear_in_the_host_key_page(tesm_app, session):
|
|
"""Sonst stuenden sie fuer immer auf "Freigabe offen"."""
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO credentials (name, username, secret_encrypted, category, created_at, "
|
|
"updated_at) VALUES ('win','admin','','windows',datetime('now'),datetime('now'))"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO credentials (name, username, secret_encrypted, category, created_at, "
|
|
"updated_at) VALUES ('lin','wartung','','linux',datetime('now'),datetime('now'))"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, "
|
|
"updated_at) VALUES ('PC-Win','aa:bb:cc:00:00:01','10.5.5.1',22,1,1,datetime('now'),"
|
|
"datetime('now'))"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, "
|
|
"updated_at) VALUES ('PC-Linux','aa:bb:cc:00:00:02','10.5.5.2',22,2,1,datetime('now'),"
|
|
"datetime('now'))"
|
|
)
|
|
|
|
body = session.get("/hostschluessel/").get_data(as_text=True)
|
|
assert "PC-Linux" in body
|
|
assert "PC-Win" not in body, "Windows braucht keinen Host-Schluessel."
|
|
|
|
|
|
def test_windows_reboot_uses_rpc_not_ssh():
|
|
import inspect
|
|
|
|
from tesm.services import maintenance
|
|
|
|
source = inspect.getsource(maintenance.run_reboot)
|
|
assert "_reboot_windows(" in source
|
|
assert "WINDOWS_PROBE_PORT" in source
|
|
# Der SSH-Zweig darf fuer Windows gar nicht erst betreten werden.
|
|
assert "if is_windows:" in source
|
|
|
|
|
|
def test_windows_password_never_reaches_the_command_line(monkeypatch, tmp_path):
|
|
"""``net -U benutzer%pass`` waere in ``ps`` sichtbar -- auf beiden Seiten."""
|
|
import subprocess
|
|
|
|
from tesm.services import windowsops
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
def fake_run(command, **kwargs):
|
|
seen["command"] = command
|
|
authfile = command[command.index("--authentication-file") + 1]
|
|
seen["authfile_content"] = pathlib_read(authfile)
|
|
return subprocess.CompletedProcess(command, 0, "Shutdown ausgeloest", "")
|
|
|
|
def pathlib_read(path: str) -> str:
|
|
with open(path, encoding="utf-8") as handle:
|
|
return handle.read()
|
|
|
|
monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net")
|
|
monkeypatch.setattr(windowsops.subprocess, "run", fake_run)
|
|
|
|
result = windowsops.reboot("10.5.5.1", "administrator", "streng-geheim")
|
|
assert result.ok
|
|
|
|
command = " ".join(str(part) for part in seen["command"])
|
|
assert "streng-geheim" not in command, "Passwort steht in der Kommandozeile."
|
|
assert "%" not in command
|
|
# Es geht ausschliesslich ueber die Anmeldedatei.
|
|
assert "password=streng-geheim" in str(seen["authfile_content"])
|
|
|
|
|
|
def test_windows_auth_file_is_removed_afterwards(monkeypatch):
|
|
import os
|
|
import subprocess
|
|
|
|
from tesm.services import windowsops
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_run(command, **kwargs):
|
|
captured["path"] = command[command.index("--authentication-file") + 1]
|
|
return subprocess.CompletedProcess(command, 0, "", "")
|
|
|
|
monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net")
|
|
monkeypatch.setattr(windowsops.subprocess, "run", fake_run)
|
|
windowsops.reboot("10.5.5.1", "administrator", "geheim")
|
|
|
|
assert not os.path.exists(captured["path"]), "Die Anmeldedatei muss verschwinden."
|
|
|
|
|
|
def test_windows_domain_prefix_is_split():
|
|
from tesm.services import windowsops
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
class FakeCompleted:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def fake_run(command, **kwargs):
|
|
with open(command[command.index("--authentication-file") + 1], encoding="utf-8") as handle:
|
|
captured["content"] = handle.read()
|
|
return FakeCompleted()
|
|
|
|
import subprocess
|
|
|
|
original_which = windowsops.shutil.which
|
|
original_run = windowsops.subprocess.run
|
|
windowsops.shutil.which = lambda _name: "/usr/bin/net"
|
|
windowsops.subprocess.run = fake_run
|
|
try:
|
|
windowsops.reboot("10.5.5.1", "EERTMOED" + chr(92) + "administrator", "geheim")
|
|
finally:
|
|
windowsops.shutil.which = original_which
|
|
windowsops.subprocess.run = original_run
|
|
|
|
assert "username=administrator" in captured["content"]
|
|
assert "domain=EERTMOED" in captured["content"]
|
|
assert subprocess is not None
|
|
|
|
|
|
def test_missing_samba_is_explained_not_silently_ignored(monkeypatch):
|
|
from tesm.services import windowsops
|
|
|
|
monkeypatch.setattr(windowsops.shutil, "which", lambda _name: None)
|
|
result = windowsops.reboot("10.5.5.1", "administrator", "geheim")
|
|
assert not result.ok
|
|
assert windowsops.REQUIRED_PACKAGE in result.hint
|
|
|
|
|
|
def test_windows_errors_get_a_hint():
|
|
from tesm.services.windowsops import hint_for
|
|
|
|
assert "DOMAENE" in hint_for("NT_STATUS_LOGON_FAILURE")
|
|
assert "Remotesystem" in hint_for("NT_STATUS_ACCESS_DENIED")
|
|
assert "445" in hint_for("NT_STATUS_CONNECTION_REFUSED")
|
|
assert hint_for("alles gut") == ""
|
|
|
|
|
|
def test_samba_package_is_installable_through_the_helper():
|
|
from pathlib import Path
|
|
|
|
helper = (Path(__file__).resolve().parent.parent / "deploy" / "tesm-helper").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
from tesm.services.windowsops import REQUIRED_PACKAGE
|
|
|
|
assert REQUIRED_PACKAGE in helper, "Sonst laesst es sich nicht nachinstallieren."
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Seiten mit Inhalt rendern
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.fixture()
|
|
def populated(tesm_app: Any):
|
|
"""Ein Bestand, der jede Verzweigung der Vorlagen erreicht.
|
|
|
|
Ein Rauchtest auf leeren Seiten prueft zu wenig: Schleifenkoerper laufen
|
|
dort nie. Genau so ist eine fehlende Vorlagenvariable durchgerutscht --
|
|
lokal gruen, auf dem Zielsystem HTTP 500, sobald der erste Datensatz da war.
|
|
"""
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
for index, (name, category) in enumerate(
|
|
[("sw-login", "switch"), ("linux-login", "linux"), ("win-login", "windows")], start=1
|
|
):
|
|
conn.execute(
|
|
"INSERT INTO credentials (name, username, secret_encrypted, category, "
|
|
"created_at, updated_at) VALUES (?,?,'',?,datetime('now'),datetime('now'))",
|
|
(name, f"user{index}", category),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, created_at, updated_at) "
|
|
"VALUES ('sw-1','10.7.0.1',22,1,datetime('now'),datetime('now'))"
|
|
)
|
|
for index, (name, credential) in enumerate(
|
|
[("Linux-PC", 2), ("Windows-PC", 3), ("Ohne-Login", None)], start=1
|
|
):
|
|
conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, ssh_port, switch_id, port, credential_id, "
|
|
"is_active, created_at, updated_at) "
|
|
"VALUES (?,?,?,22,1,?,?,1,datetime('now'),datetime('now'))",
|
|
(name, f"aa:bb:cc:00:0{index}:01", f"10.7.0.{10 + index}", str(index), credential),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) "
|
|
"VALUES ('10.7.0.1',22,'ssh-ed25519',?,datetime('now'),'test')",
|
|
("SHA256:" + "A" * 43,),
|
|
)
|
|
return True
|
|
|
|
|
|
PAGES_WITH_CONTENT = [
|
|
"/",
|
|
"/clients/",
|
|
"/clients/1",
|
|
"/switche/",
|
|
"/switche/1",
|
|
"/zugangsdaten/",
|
|
"/hostschluessel/",
|
|
"/protokolle/neustarts",
|
|
"/papierkorb/",
|
|
"/diagnose/",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("path", PAGES_WITH_CONTENT)
|
|
def test_pages_render_with_real_data(session, populated, path):
|
|
response = session.get(path)
|
|
body = response.get_data(as_text=True)
|
|
assert response.status_code == 200, f"{path}: HTTP {response.status_code}"
|
|
assert "Interner Fehler" not in body, path
|
|
# Eine fehlende Vorlagenvariable schlaegt als UndefinedError durch -- die
|
|
# Fehlerseite nennt sie beim Namen.
|
|
assert "is undefined" not in body, path
|
|
|
|
|
|
def test_maintenance_pages_render_with_real_data(session, populated, licensed):
|
|
for path in ("/wartung/", "/dateifreigaben/", "/dhcp/"):
|
|
response = session.get(path)
|
|
assert response.status_code == 200, f"{path}: HTTP {response.status_code}"
|
|
assert "Interner Fehler" not in response.get_data(as_text=True), path
|
|
|
|
|
|
def test_device_form_offers_creating_credentials(session, populated):
|
|
body = session.get("/clients/").get_data(as_text=True)
|
|
assert "neue Zugangsdaten anlegen" in body
|
|
assert "new_credential_name" in body
|
|
assert "new_credential_secret" in body
|
|
|
|
|
|
def test_new_device_can_bring_its_own_credentials(session, populated, tesm_app):
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
response = session.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": _token(session, "/clients/"),
|
|
"name": "Frisch",
|
|
"mac": "aa:bb:cc:11:22:33",
|
|
"ip": "10.7.0.90",
|
|
"ssh_port": "22",
|
|
"credential_id": "__neu__",
|
|
"new_credential_name": "Frische Zugangsdaten",
|
|
"new_credential_username": "wartung",
|
|
"new_credential_secret": "Ein-Passwort-1",
|
|
"new_credential_category": "linux",
|
|
"is_active": "1",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert "angelegt" in response.get_data(as_text=True)
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
credential = Database.one(
|
|
conn, "SELECT * FROM credentials WHERE name='Frische Zugangsdaten'"
|
|
)
|
|
device = Database.one(conn, "SELECT * FROM devices WHERE name='Frisch'")
|
|
assert credential is not None, "Die Zugangsdaten muessen mit angelegt werden."
|
|
assert device is not None
|
|
assert device["credential_id"] == credential["id"], "Sie muessen auch zugeordnet sein."
|
|
assert credential["secret_encrypted"], "Das Passwort muss verschluesselt gespeichert sein."
|
|
|
|
|
|
def test_a_nameless_new_credential_blocks_the_device(session, populated, tesm_app):
|
|
"""Halb ausgefuehrt waere schlimmer als gar nicht: das Geraet haette dann
|
|
keine Zugangsdaten, obwohl welche gewollt waren."""
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
response = session.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": _token(session, "/clients/"),
|
|
"name": "Halb",
|
|
"mac": "aa:bb:cc:44:55:66",
|
|
"ip": "10.7.0.91",
|
|
"ssh_port": "22",
|
|
"credential_id": "__neu__",
|
|
"new_credential_name": "",
|
|
"is_active": "1",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert "Bezeichnung" in response.get_data(as_text=True)
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
assert Database.one(conn, "SELECT * FROM devices WHERE name='Halb'") is None
|
|
|
|
|
|
def test_sweep_holds_no_write_lock_across_the_ssh_session():
|
|
"""Eine SSH-Sitzung zum Switch dauert Sekunden -- eine Schreibsperre auch.
|
|
|
|
Auf POETEST dauerte ein Durchlauf mit drei nicht erreichbaren Switchen
|
|
knapp 40 Sekunden, und die Oberflaeche antwortete waehrenddessen mit
|
|
"database is locked". Die langsame Netzarbeit gehoert deshalb ausserhalb
|
|
der Transaktion; geschrieben wird kurz und am Ende.
|
|
"""
|
|
import inspect
|
|
|
|
from tesm.services import monitor
|
|
|
|
source = inspect.getsource(monitor.run_sweep)
|
|
assert "with database.session() as conn, database.transaction(conn):\n outcome" not in source
|
|
assert "database=database," in source, "Die Aufzeichnung braucht eigene Transaktionen."
|
|
|
|
restart = inspect.getsource(monitor.restart_device_poe)
|
|
assert "with write():" in restart
|
|
# Die SSH-Sitzung darf in keiner Transaktion stehen.
|
|
ssh_line = restart.index("with SwitchCli(")
|
|
assert "with write():" not in restart[max(0, ssh_line - 400) : ssh_line]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Neustart: mehrere Wege, klare Rangfolge
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _device(**overrides):
|
|
base = {
|
|
"id": 1,
|
|
"name": "Testgeraet",
|
|
"switch_id": None,
|
|
"port": "",
|
|
"credential_id": None,
|
|
"credential_category": "",
|
|
}
|
|
return {**base, **overrides}
|
|
|
|
|
|
def test_poe_wins_when_several_ways_are_open():
|
|
"""PoE zuerst: es wirkt auch, wenn das Betriebssystem nicht mehr antwortet."""
|
|
from tesm.services import restart
|
|
|
|
device = _device(switch_id=1, port="7", credential_id=2, credential_category="linux")
|
|
options = restart.available(device)
|
|
assert [entry.key for entry in options] == ["poe", "ssh"]
|
|
assert restart.preferred(device).key == "poe"
|
|
|
|
|
|
def test_only_ssh_when_there_is_no_port():
|
|
from tesm.services import restart
|
|
|
|
device = _device(credential_id=2, credential_category="linux")
|
|
assert [entry.key for entry in restart.available(device)] == ["ssh"]
|
|
|
|
|
|
def test_windows_offers_rpc_never_ssh():
|
|
from tesm.services import restart
|
|
|
|
device = _device(credential_id=2, credential_category="windows")
|
|
assert [entry.key for entry in restart.available(device)] == ["rpc"]
|
|
|
|
|
|
def test_a_switch_without_a_port_is_not_a_way():
|
|
"""Ein Knopf, der verlaesslich nichts bewirkt, ist schlimmer als kein Knopf."""
|
|
from tesm.services import restart
|
|
|
|
assert restart.available(_device(switch_id=1, port="")) == []
|
|
assert restart.available(_device(switch_id=None, port="7")) == []
|
|
|
|
|
|
def test_nothing_configured_means_no_button():
|
|
from tesm.services import restart
|
|
|
|
assert restart.available(_device()) == []
|
|
assert restart.preferred(_device()) is None
|
|
|
|
|
|
def test_ssh_and_rpc_need_the_maintenance_module():
|
|
"""PoE bleibt frei -- ein haengendes Geraet muss man immer neu starten koennen."""
|
|
from tesm.services import restart
|
|
|
|
linux = _device(switch_id=1, port="7", credential_id=2, credential_category="linux")
|
|
assert [entry.key for entry in restart.available(linux, maintenance_active=False)] == ["poe"]
|
|
|
|
windows = _device(credential_id=2, credential_category="windows")
|
|
assert restart.available(windows, maintenance_active=False) == []
|
|
|
|
|
|
def test_an_impossible_request_is_refused_not_substituted():
|
|
"""Sonst startet jemand ein Geraet anders neu, als er dachte."""
|
|
from tesm.services import restart
|
|
|
|
device = _device(switch_id=1, port="7")
|
|
assert restart.resolve(device, "ssh") is None
|
|
assert restart.resolve(device, "poe").key == "poe"
|
|
# Ohne Angabe gilt die Rangfolge.
|
|
assert restart.resolve(device, "").key == "poe"
|
|
|
|
|
|
def test_restart_route_refuses_an_unavailable_method(session, populated):
|
|
"""Geraet 1 ("Linux-PC") haengt an Switch 1 Port 1 und hat Linux-Zugangsdaten."""
|
|
response = session.post(
|
|
"/clients/3/neustart",
|
|
data={"csrf_token": _token(session, "/clients/"), "method": "poe"},
|
|
follow_redirects=True,
|
|
)
|
|
body = response.get_data(as_text=True)
|
|
# Geraet 3 ("Ohne-Login") hat Switch und Port, aber keine Zugangsdaten --
|
|
# PoE geht, SSH nicht. Umgekehrt geprueft:
|
|
response = session.post(
|
|
"/clients/3/neustart",
|
|
data={"csrf_token": _token(session, "/clients/"), "method": "ssh"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "steht fuer dieses Geraet nicht zur Verfuegung" in response.get_data(as_text=True)
|
|
assert body is not None
|
|
|
|
|
|
def test_device_list_shows_the_credential_category(session, populated):
|
|
body = session.get("/clients/").get_data(as_text=True)
|
|
assert "Linux-Client" in body
|
|
assert "Windows-Client" in body
|
|
|
|
|
|
def test_restart_buttons_appear_per_available_way(session, populated, licensed):
|
|
body = session.get("/clients/").get_data(as_text=True)
|
|
assert "/neustart" in body
|
|
assert "PoE-Port" in body
|
|
assert "Neustart ueber RPC" in body
|
|
|
|
|
|
def test_the_old_poe_only_endpoint_is_gone():
|
|
"""Ein zweiter Endpunkt fuer denselben Zweck waere eine Verzweigung zu viel."""
|
|
from tesm import create_app
|
|
|
|
rules = {rule.rule for rule in create_app(TESTING=True).url_map.iter_rules()}
|
|
assert "/clients/<int:device_id>/neustart" in rules
|
|
assert "/clients/<int:device_id>/poe-neustart" not in rules
|