api.py nachgezogen: Schluessel und Bedienung (gleiche Datei wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
379afbd9c1
commit
7409fafb0b
1 changed files with 171 additions and 10 deletions
181
api.py
181
api.py
|
|
@ -18,11 +18,38 @@ einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
|
||||||
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
||||||
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
||||||
|
|
||||||
Zwei Nachrichten reichen:
|
Zwei Nachrichten reichen dafuer:
|
||||||
|
|
||||||
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
||||||
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
||||||
|
|
||||||
|
Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die
|
||||||
|
Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur
|
||||||
|
wegzunehmen:
|
||||||
|
|
||||||
|
{"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}}
|
||||||
|
|
||||||
|
DER SCHLUESSEL
|
||||||
|
--------------
|
||||||
|
Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten
|
||||||
|
Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im
|
||||||
|
gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner
|
||||||
|
lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand.
|
||||||
|
Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu
|
||||||
|
verraten, ob der Name ueberhaupt existiert.
|
||||||
|
|
||||||
|
`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw.
|
||||||
|
"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres
|
||||||
|
Steuerjournal nicht mehr anklopfen.
|
||||||
|
|
||||||
|
WAS EINE AKTION DARF
|
||||||
|
--------------------
|
||||||
|
Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im
|
||||||
|
Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur
|
||||||
|
Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen,
|
||||||
|
Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in
|
||||||
|
einer Buchhaltung nichts zu suchen.
|
||||||
|
|
||||||
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
||||||
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
||||||
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
||||||
|
|
@ -42,6 +69,8 @@ hinueber.
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -50,6 +79,33 @@ import threading
|
||||||
log = logging.getLogger("api")
|
log = logging.getLogger("api")
|
||||||
|
|
||||||
ANTWORT_HALLO = "hallo, bin noch da"
|
ANTWORT_HALLO = "hallo, bin noch da"
|
||||||
|
SCHLUESSEL_DATEI = "api_schluessel.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_holen(ordner):
|
||||||
|
"""Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen.
|
||||||
|
|
||||||
|
Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als
|
||||||
|
Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine
|
||||||
|
Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das
|
||||||
|
sich jemand merken muesste.
|
||||||
|
"""
|
||||||
|
if not ordner:
|
||||||
|
return ""
|
||||||
|
pfad = os.path.join(ordner, SCHLUESSEL_DATEI)
|
||||||
|
try:
|
||||||
|
if os.path.exists(pfad):
|
||||||
|
wert = open(pfad, encoding="utf-8").read().strip()
|
||||||
|
if wert:
|
||||||
|
return wert
|
||||||
|
wert = secrets.token_hex(32)
|
||||||
|
with open(pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write(wert)
|
||||||
|
log.info("neuer API-Schluessel angelegt: %s", pfad)
|
||||||
|
return wert
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("API-Schluessel nicht nutzbar (%s)", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class _Handler(socketserver.StreamRequestHandler):
|
class _Handler(socketserver.StreamRequestHandler):
|
||||||
|
|
@ -66,14 +122,17 @@ class _Handler(socketserver.StreamRequestHandler):
|
||||||
if not zeile:
|
if not zeile:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
was = str(json.loads(zeile).get("was", ""))
|
nachricht = json.loads(zeile)
|
||||||
|
was = str(nachricht.get("was", ""))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
was = ""
|
nachricht, was = {}, ""
|
||||||
if was == "ping":
|
if was == "ping":
|
||||||
antwort = ANTWORT_HALLO
|
antwort = ANTWORT_HALLO
|
||||||
elif was == "schau_nach":
|
elif was == "schau_nach":
|
||||||
antwort = "ok"
|
antwort = "ok"
|
||||||
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
||||||
|
elif was == "aktion":
|
||||||
|
antwort = self._aktion(nachricht)
|
||||||
else:
|
else:
|
||||||
antwort = "haeh?"
|
antwort = "haeh?"
|
||||||
try:
|
try:
|
||||||
|
|
@ -82,12 +141,35 @@ class _Handler(socketserver.StreamRequestHandler):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(self, nachricht):
|
||||||
|
"""Eine Bedienung ausfuehren - nur mit gueltigem Schluessel."""
|
||||||
|
erwartet = getattr(self.server, "schluessel", "")
|
||||||
|
gegeben = str(nachricht.get("schluessel", ""))
|
||||||
|
# compare_digest statt "==": ein Vergleich, der bei der ersten falschen
|
||||||
|
# Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag.
|
||||||
|
if not erwartet or not secrets.compare_digest(erwartet, gegeben):
|
||||||
|
log.warning("Aktion ohne gueltigen Schluessel abgewiesen")
|
||||||
|
return "kein zutritt"
|
||||||
|
if self.server.aktion is None:
|
||||||
|
return "keine bedienung moeglich"
|
||||||
|
name = str(nachricht.get("name", ""))
|
||||||
|
daten = nachricht.get("daten") or {}
|
||||||
|
log.info("Aktion ueber die API: %s", name)
|
||||||
|
try:
|
||||||
|
return self.server.aktion(name, daten)
|
||||||
|
except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben
|
||||||
|
log.warning("Aktion %r fehlgeschlagen: %s", name, e)
|
||||||
|
return {"fehler": str(e)}
|
||||||
|
|
||||||
|
|
||||||
class _Server(socketserver.ThreadingTCPServer):
|
class _Server(socketserver.ThreadingTCPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
def __init__(self, adresse, bei_push):
|
def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
|
||||||
self.melden = bei_push
|
self.melden = bei_push
|
||||||
|
self.aktion = aktion
|
||||||
|
self.schluessel = schluessel
|
||||||
super().__init__(adresse, _Handler)
|
super().__init__(adresse, _Handler)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,19 +181,30 @@ class MiniApi:
|
||||||
und holt sie im Fenster mit after() ab.
|
und holt sie im Fenster mit after() ab.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, programm, bei_push):
|
def __init__(self, programm, bei_push, aktion=None):
|
||||||
self.programm = programm
|
self.programm = programm
|
||||||
self.bei_push = bei_push
|
self.bei_push = bei_push
|
||||||
|
self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
|
||||||
self.server = None
|
self.server = None
|
||||||
self.faden = None
|
self.faden = None
|
||||||
self.port = 0
|
self.port = 0
|
||||||
|
self.schluessel = ""
|
||||||
|
|
||||||
def starten(self, buch):
|
def starten(self, buch, ordner=None):
|
||||||
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
"""Server aufmachen und im Telefonbuch (SQLite) eintragen.
|
||||||
|
|
||||||
|
Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er
|
||||||
|
fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen
|
||||||
|
Schluessel und damit auch keine Bedienung von aussen.
|
||||||
|
"""
|
||||||
if self.server is not None:
|
if self.server is not None:
|
||||||
return self.port
|
return self.port
|
||||||
|
if ordner is None and buch is not None:
|
||||||
|
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||||
|
self.schluessel = schluessel_holen(ordner)
|
||||||
try:
|
try:
|
||||||
self.server = _Server(("127.0.0.1", 0), self.bei_push)
|
self.server = _Server(("127.0.0.1", 0), self.bei_push,
|
||||||
|
aktion=self.aktion, schluessel=self.schluessel)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log.warning("API konnte nicht starten: %s", e)
|
log.warning("API konnte nicht starten: %s", e)
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
@ -150,12 +243,14 @@ class MiniApi:
|
||||||
self.port = 0
|
self.port = 0
|
||||||
|
|
||||||
|
|
||||||
def _reden(port, was, zeitgrenze=1.5):
|
def _reden(port, was, zeitgrenze=1.5, **rest):
|
||||||
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
||||||
|
nachricht = {"was": was}
|
||||||
|
nachricht.update(rest)
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
||||||
s.settimeout(zeitgrenze)
|
s.settimeout(zeitgrenze)
|
||||||
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
|
s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
|
||||||
roh = s.makefile("r", encoding="utf-8").readline()
|
roh = s.makefile("r", encoding="utf-8").readline()
|
||||||
return json.loads(roh).get("antwort", "")
|
return json.loads(roh).get("antwort", "")
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
|
@ -192,3 +287,69 @@ def anklopfen(buch, programm):
|
||||||
if not eintrag or not eintrag.get("port"):
|
if not eintrag or not eintrag.get("port"):
|
||||||
return False
|
return False
|
||||||
return _reden(eintrag["port"], "schau_nach") == "ok"
|
return _reden(eintrag["port"], "schau_nach") == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0):
|
||||||
|
"""Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt.
|
||||||
|
|
||||||
|
Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster
|
||||||
|
bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen.
|
||||||
|
|
||||||
|
Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den
|
||||||
|
lesen darf, darf bedienen.
|
||||||
|
"""
|
||||||
|
if buch is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
eintrag = buch.wer_laeuft(programm)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
||||||
|
return None
|
||||||
|
if not eintrag or not eintrag.get("port"):
|
||||||
|
return None
|
||||||
|
if ordner is None:
|
||||||
|
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||||
|
return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze,
|
||||||
|
schluessel=schluessel_holen(ordner), name=name,
|
||||||
|
daten=daten or {})
|
||||||
|
|
||||||
|
|
||||||
|
class Warteschlange:
|
||||||
|
"""Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck.
|
||||||
|
|
||||||
|
Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht
|
||||||
|
aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das
|
||||||
|
Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus
|
||||||
|
und legt die Antwort zurueck.
|
||||||
|
|
||||||
|
Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die
|
||||||
|
Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer
|
||||||
|
Verbindung, die nie antwortet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.eingang = queue.Queue()
|
||||||
|
|
||||||
|
def annehmen(self, name, daten, zeitgrenze=12.0):
|
||||||
|
"""Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten."""
|
||||||
|
fertig = threading.Event()
|
||||||
|
auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None}
|
||||||
|
self.eingang.put(auftrag)
|
||||||
|
if not fertig.wait(zeitgrenze):
|
||||||
|
return {"fehler": "das Fenster hat nicht geantwortet"}
|
||||||
|
return auftrag["antwort"]
|
||||||
|
|
||||||
|
def abarbeiten(self, ausfuehren):
|
||||||
|
"""Im Fensterfaden gerufen: alles erledigen, was eingegangen ist."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
auftrag = self.eingang.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"])
|
||||||
|
except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen
|
||||||
|
log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e)
|
||||||
|
auftrag["antwort"] = {"fehler": str(e)}
|
||||||
|
finally:
|
||||||
|
auftrag["fertig"].set()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue