Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen
Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser, ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi. Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll. Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0". Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten. Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist eine Vorlage, in die setup.js Adresse, Code, print-server.py und die systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben. Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32 Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant verglichen, hinter einem eigenen Zaehler. Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript. Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen, Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die uebrige API weiterhin 401 liefert. Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c5cdf16c4
commit
e68daee457
14 files changed
+834
-605
No files matched your search
@@ -18,10 +18,8 @@ gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze paral
|
||||
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
|
||||
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
|
||||
Vanilla JS im Frontend, Express im Backend.
|
||||
* **Abhängigkeiten:** 8 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client, ssh2). `ssh2` ist
|
||||
reines JavaScript und wird nur für die Einrichtung eines Drucker-Pi gebraucht
|
||||
(Abschnitt 6).
|
||||
* **Abhängigkeiten:** 7 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client).
|
||||
|
||||
Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3
|
||||
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
|
||||
@@ -214,6 +212,7 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
|
||||
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen |
|
||||
| `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm |
|
||||
| `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
|
||||
| `PUBLIC_URL` | leer | Adresse, unter der der Pi die App erreicht. Leer = aus der Anfrage abgeleitet (siehe Abschnitt 6) |
|
||||
|
||||
**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur
|
||||
Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt
|
||||
@@ -321,38 +320,70 @@ der App liegt.
|
||||
|
||||
#### Einen Pi einrichten
|
||||
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** richtet einen frisch aufgesetzten
|
||||
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen,
|
||||
Knopf drücken, fertig — rund drei Sekunden.
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** → **🔗 Einrichtungslink erzeugen**.
|
||||
Die App zeigt einen fertigen Befehl an, der auf dem Pi ausgeführt wird:
|
||||
|
||||
Die App meldet sich per SSH an, prüft Python/systemd/Druckergerät, überträgt die
|
||||
vier Dateien aus `deploy/bondrucker/` per SFTP nach `/tmp`, startet `install.sh`
|
||||
mit einem selbst erzeugten Token, räumt auf und prüft von außen nach. Danach
|
||||
übernimmt sie den Drucker selbst. Jeder Schritt steht mit Ergebnis im Protokoll.
|
||||
```bash
|
||||
curl -fsSL "http://<timer-app>/setup/bondrucker.sh?code=…" -o bondrucker.sh
|
||||
sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
Das Skript prüft Python/systemd, **fragt, welcher Drucker verwendet werden
|
||||
soll** (mit Klartextnamen aus dem sysfs, also „EPSON TM-T88IV" statt „lp0"),
|
||||
legt Dienstbenutzer, Programm, udev-Regel und systemd-Unit an, erzeugt ein
|
||||
Token und meldet sich damit selbst bei der App an. Die Oberfläche pollt
|
||||
`/api/printer/setup-status` und springt um, sobald der Pi sich gemeldet hat.
|
||||
|
||||
**Warum der Pi sich meldet und nicht umgekehrt.** Vorher richtete die App den
|
||||
Pi per SSH ein. Das verlangte ein SSH-Passwort in einem Webformular, eine
|
||||
ssh2-Abhängigkeit und eine Verbindung vom Server zum Pi. Alle drei sind weg.
|
||||
Wer einen Drucker ansteckt, steht ohnehin am Pi — und genau dort gehört auch
|
||||
die Frage hin, welcher Drucker es sein soll.
|
||||
|
||||
**Das Skript ist zusammengesetzt, nicht statisch.** `deploy/bondrucker/einrichten.sh`
|
||||
ist eine Vorlage mit vier Marken: `@@APP_URL@@`, `@@SETUP_CODE@@`,
|
||||
`@@PRINT_SERVER@@` und `@@SERVICE@@`. `backend/setup.js` füllt sie beim Abholen
|
||||
und bettet `print-server.py` und `bondrucker.service` als Here-Dokumente ein.
|
||||
Heraus kommt **eine** Datei — auf dem Pi ist nichts nachzuladen. Enthielte eine
|
||||
eingebettete Datei zufällig eine Zeile mit dem Schlusswort des Here-Dokuments,
|
||||
bräche das Skript mittendrin ab; `einsetzen()` prüft das und verweigert lieber
|
||||
die Auslieferung.
|
||||
|
||||
**Der Einrichtungscode.** 32 Zeichen, gilt **einmal** und **30 Minuten**, liegt
|
||||
nur im Arbeitsspeicher — ein Neustart der App macht ihn ungültig. Verglichen
|
||||
wird zeitkonstant. Es gibt immer nur einen; ein neuer entwertet den alten. Wer
|
||||
ihn hat, kann den Drucker der App umbiegen, deshalb: nicht weiterreichen.
|
||||
|
||||
**Zwei Endpunkte ohne Anmeldung.** `GET /setup/bondrucker.sh` und
|
||||
`POST /api/setup/register` müssen ohne Session auskommen — der Pi kann sich bei
|
||||
Authentik nicht anmelden. Der Code ist dort der einzige Schlüssel, beide sind
|
||||
über `setupLimiter` begrenzt (30 Versuche je 15 Minuten und IP).
|
||||
|
||||
**Die Adresse des Pi kommt aus der Verbindung**, nicht aus dem Rumpf der
|
||||
Anmeldung: was der Pi über sich behauptet, könnte auch jemand anders behaupten.
|
||||
Nur wenn die Anmeldung über die Rückschleife kam (Tunnel, Testaufbau), zählt
|
||||
die Selbstauskunft `hostHint`.
|
||||
|
||||
**Die eigene Adresse leitet die App aus dem Host-Kopf ab** (`appBasisUrl`) —
|
||||
hinter einem nginx sieht sie von sich aus nur `127.0.0.1`. Wer die Einrichtung
|
||||
startet, hat die App gerade offen; unter genau dieser Adresse findet der Pi sie
|
||||
auch. Passt das nicht, setzt `PUBLIC_URL` in der `.env` den Wert fest. Der Wert
|
||||
landet in einem Shell-Skript und wird deshalb gegen
|
||||
`^https?://[A-Za-z0-9.\-]+(:\d{1,5})?$` geprüft, nicht maskiert.
|
||||
|
||||
Warum der Druckerzugang in der Datenbank liegt und nicht in der `.env`: Sonst
|
||||
wäre genau dieser Knopf sinnlos — die Werte müssten hinterher doch wieder von
|
||||
Hand auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
wäre die Anmeldung des Pi sinnlos — die Werte müssten hinterher doch von Hand
|
||||
auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
SSH-Zugang zum Timer-Server sein.
|
||||
|
||||
Zum Umgang mit den Zugangsdaten: Benutzername und Passwort stehen nur während
|
||||
des Vorgangs im Arbeitsspeicher. Sie werden nicht gespeichert, nicht ins Journal
|
||||
geschrieben und nicht an den Browser zurückgegeben; das erzeugte Drucker-Token
|
||||
wird vor der Ausgabe aus dem Protokoll entfernt. Dauerhaft bleibt allein dieses
|
||||
Token in der Datenbank.
|
||||
|
||||
Der Hostschlüssel des Pi wird beim Erstkontakt angenommen und im Protokoll
|
||||
ausgewiesen (`SHA256:…`) — im eigenen Netz vertretbar, aber es ist eben *kein*
|
||||
Schutz gegen einen Angreifer, der schon im Netz sitzt.
|
||||
|
||||
`install.sh` ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Das Skript ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Neustart liefe die Brücke mit dem alten Token weiter und würde jeden Bon mit
|
||||
HTTP 403 ablehnen — die Gegenprobe nach der Einrichtung prüft deshalb nicht nur
|
||||
die Erreichbarkeit, sondern auch, ob das neue Token angenommen wird.
|
||||
HTTP 403 ablehnen — es prüft deshalb nach dem Start selbst, ob das neue Token
|
||||
angenommen wird, und die App prüft danach noch einmal von außen.
|
||||
|
||||
**Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die
|
||||
App nicht mehr, bis `PRINTER_HOST` angepasst ist — eine DHCP-Reservierung im
|
||||
Router erspart das.
|
||||
App nicht mehr, bis der Drucker neu eingerichtet oder die Adresse in den
|
||||
Einstellungen angepasst ist — eine DHCP-Reservierung im Router erspart das.
|
||||
|
||||
### Backup
|
||||
|
||||
@@ -383,7 +414,7 @@ timer-app/
|
||||
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
|
||||
│ ├── receipt.js ← Layout des Bons
|
||||
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
|
||||
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein
|
||||
│ ├── setup.js ← Einrichtungscode und Skript für den Pi
|
||||
│ ├── package.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── .env.example
|
||||
@@ -401,7 +432,7 @@ timer-app/
|
||||
├── README.md ← Einrichtung des Pi
|
||||
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
|
||||
├── bondrucker.service
|
||||
└── install.sh
|
||||
└── einrichten.sh ← Vorlage, die die App beim Abholen füllt
|
||||
```
|
||||
|
||||
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
||||
@@ -477,7 +508,10 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` |
|
||||
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||||
| `POST` | `/api/printer/test` | Probedruck |
|
||||
| `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten |
|
||||
| `POST` | `/api/printer/setup-code` | erzeugt einen Einrichtungscode, liefert Abhol-Link und fertigen Befehl |
|
||||
| `GET` | `/api/printer/setup-status` | hat sich schon ein Pi gemeldet? |
|
||||
| `GET` | `/setup/bondrucker.sh` | **ohne Anmeldung**, Code als Parameter → das fertige Einrichtungsskript |
|
||||
| `POST` | `/api/setup/register` | **ohne Anmeldung**, `{code, port, token, device}` → der Pi meldet sich selbst an |
|
||||
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
||||
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
|
||||
|
||||
Reference in new issue
Block a user