diff --git a/DEPLOY.md b/DEPLOY.md index 22b84d5..01ae6d9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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:///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 | diff --git a/README.md b/README.md index 98df4f2..d5072a2 100644 --- a/README.md +++ b/README.md @@ -113,20 +113,6 @@ Darunter steht, was auf dem Bon gedruckt wird: * **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand. -### Einen neuen Drucker anschließen - -Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt: -**🔧 Drucker einrichten** anklicken, Adresse, Benutzer und Passwort des -Raspberry Pi eintragen, **🚀 Jetzt einrichten** drücken. Nach wenigen Sekunden -ist der Drucker eingerichtet und übernommen — es ist nichts weiter zu tun. - -Das Protokoll darunter zeigt jeden Schritt. Bleibt einer rot stehen, steht dort -auch, woran es lag. Das Passwort wird nur für diesen Vorgang verwendet und -danach verworfen. - -Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian), der -eingetragene Benutzer darf `sudo`, und der Bondrucker hängt am USB-Anschluss. - Wichtig zu wissen: * **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde @@ -135,6 +121,30 @@ Wichtig zu wissen: * Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte Einträge in der Historie behalten den Tarif, der beim Beenden galt. +### Einen neuen Drucker anschließen + +Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt: +**🔧 Drucker einrichten** anklicken, dann **🔗 Einrichtungslink erzeugen**. + +Es erscheint ein Befehl. Der wird auf dem Raspberry Pi in ein Terminal +eingegeben — über **📋 Kopieren** landet er in der Zwischenablage: + +``` +curl -fsSL "…" -o bondrucker.sh && sudo bash bondrucker.sh +``` + +Das Skript zeigt dann alle angeschlossenen Drucker mit Namen an und fragt, +welcher verwendet werden soll. Danach richtet es sich selbst ein und meldet +sich bei der Timer-App. Die Anzeige hier springt von „wartet auf den Pi" auf +den fertigen Drucker um — es ist nichts weiter zu tun. + +Der Link **gilt nur einmal und eine halbe Stunde lang**. Ist er abgelaufen, +einfach einen neuen erzeugen. Weitergeben sollte man ihn nicht: wer ihn hat, +kann einstellen, auf welchem Gerät hier gedruckt wird. + +Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian) und +der Bondrucker hängt am USB-Anschluss. + ## Abmelden **🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon diff --git a/backend/.env.example b/backend/.env.example index a9f9df1..a8111a1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -62,9 +62,9 @@ RATE_PER_10MIN=10 # # ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start. # Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche -# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar -# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere -# Aenderung hier bleibt also wirkungslos. +# gepflegt - "Einstellungen" -> "Drucker einrichten" legt einen Link bereit, +# ueber den sich ein Pi selbst einrichtet und anschliessend hier anmeldet. +# Eine spaetere Aenderung hier bleibt also wirkungslos. # # Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie # vorher, nur ohne Bon-Knoepfe. @@ -73,7 +73,8 @@ PRINTER_HOST= PRINTER_PORT=9100 # Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen. -# install.sh gibt den Wert am Ende aus. +# Das Einrichtungsskript gibt den Wert am Ende aus, falls es sich nicht selbst +# bei der App anmelden konnte. PRINTER_TOKEN= # Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier. @@ -82,3 +83,10 @@ PRINTER_WIDTH=42 # Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker # darf das Beenden eines Timers nicht blockieren. PRINTER_TIMEOUT_MS=6000 + +# Adresse, unter der der Drucker-Pi diese App erreicht. Normalerweise LEER +# lassen: die App leitet sie aus der Anfrage ab, also aus der Adresse, unter der +# gerade jemand die Einstellungen offen hat. Nur setzen, wenn der Pi die App +# unter einem anderen Namen erreichen muss als der Browser. +# Beispiel: PUBLIC_URL=http://192.168.1.50:3003 +PUBLIC_URL= diff --git a/backend/package-lock.json b/backend/package-lock.json index 5779911..0ceb8db 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,8 +14,7 @@ "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", "openid-client": "^6.8.4", - "sqlite3": "^5.1.6", - "ssh2": "^1.17.0" + "sqlite3": "^5.1.6" }, "engines": { "node": ">=18" @@ -184,14 +183,6 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -229,14 +220,6 @@ "node": ">= 10.0.0" } }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -310,15 +293,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buildcheck": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", - "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", - "optional": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -450,20 +424,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/cpu-features": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", - "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "buildcheck": "~0.0.6", - "nan": "^2.19.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1422,12 +1382,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/nan": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", - "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", - "optional": true - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2138,23 +2092,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" }, - "node_modules/ssh2": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", - "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", - "hasInstallScript": true, - "dependencies": { - "asn1": "^0.2.6", - "bcrypt-pbkdf": "^1.0.2" - }, - "engines": { - "node": ">=10.16.0" - }, - "optionalDependencies": { - "cpu-features": "~0.0.10", - "nan": "^2.23.0" - } - }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -2294,11 +2231,6 @@ "node": "*" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/backend/package.json b/backend/package.json index 170e777..a812eb7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,6 @@ "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", "openid-client": "^6.8.4", - "sqlite3": "^5.1.6", - "ssh2": "^1.17.0" + "sqlite3": "^5.1.6" } } diff --git a/backend/provision.js b/backend/provision.js deleted file mode 100644 index 5db0394..0000000 --- a/backend/provision.js +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Einrichtung eines Drucker-Pi aus der Oberflaeche heraus - * - * Bis hierher war das Handarbeit: Dateien per scp auf den Pi, dort install.sh - * mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu - * starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch - * niemand mehr weiss. Dieses Modul macht daraus einen Knopf. - * - * Was hier passiert, ist bewusst dasselbe wie von Hand - es laeuft nur - * ferngesteuert ab: - * - * 1. per SSH anmelden - * 2. pruefen, ob der Pi ueberhaupt taugt (Python, Druckergeraet, sudo) - * 3. die drei Dateien aus deploy/bondrucker/ uebertragen - * 4. install.sh mit einem hier erzeugten Token starten - * 5. Spuren im /tmp beseitigen - * 6. von aussen nachsehen, ob der Dienst antwortet - * - * Zum Umgang mit den Zugangsdaten: Passwort und Benutzername stehen nur - * waehrend des Vorgangs im Arbeitsspeicher. Nichts davon wird gespeichert, - * protokolliert oder an den Browser zurueckgegeben. Dauerhaft bleibt allein - * das erzeugte Drucker-Token. - */ - -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const { Client } = require('ssh2'); - -const QUELLE = path.join(__dirname, '../deploy/bondrucker'); -const DATEIEN = ['print-server.py', 'bondrucker.service', 'install.sh', 'README.md']; - -const VERBINDUNGS_TIMEOUT_MS = 15000; -const BEFEHL_TIMEOUT_MS = 180000; - -/** - * Token fuer die Bruecke. Bewusst nur Buchstaben und Ziffern: der Wert wird - * auf dem Pi als Argument an install.sh weitergereicht, und was keine - * Sonderzeichen enthaelt, kann in keiner Shell etwas anderes bedeuten. - */ -function tokenErzeugen() { - return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32); -} - -/** Fingerabdruck des Hostschluessels, im selben Format wie ssh es zeigt. */ -function fingerabdruck(key) { - return 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, ''); -} - -/** - * Einen Befehl auf dem Pi ausfuehren. - * - * Wichtig: Nutzereingaben werden NIE in die Befehlszeile geschrieben. Das - * Skript kommt fest aus dieser Datei, veraenderliche Werte gehen ueber stdin - * oder ueber Umgebungsvariablen, die ssh2 selbst quotet. - */ -function exec(conn, command, stdin = null) { - return new Promise((resolve, reject) => { - conn.exec(command, (err, stream) => { - if (err) return reject(err); - - let stdout = ''; - let stderr = ''; - const timeout = setTimeout(() => { - stream.close(); - reject(new Error('Zeitueberschreitung auf dem Pi')); - }, BEFEHL_TIMEOUT_MS); - - stream.on('close', (code) => { - clearTimeout(timeout); - resolve({ code: code === null ? -1 : code, stdout, stderr }); - }); - stream.on('data', d => { stdout += d.toString('utf8'); }); - stream.stderr.on('data', d => { stderr += d.toString('utf8'); }); - - if (stdin !== null) stream.write(stdin); - stream.end(); - }); - }); -} - -function verbinden({ host, port, username, password }) { - return new Promise((resolve, reject) => { - const conn = new Client(); - let hostkey = null; - - const abbruch = setTimeout(() => { - conn.end(); - reject(new Error(`Keine Antwort von ${host}:${port} innerhalb von ${VERBINDUNGS_TIMEOUT_MS / 1000} s`)); - }, VERBINDUNGS_TIMEOUT_MS); - - conn.on('ready', () => { - clearTimeout(abbruch); - resolve({ conn, hostkey }); - }); - - conn.on('error', (err) => { - clearTimeout(abbruch); - // Die Meldungen von ssh2 sind fuer eine Ladenoberflaeche zu technisch. - const roh = String(err.message || err); - if (/All configured authentication methods failed/i.test(roh)) { - return reject(new Error('Anmeldung abgelehnt - Benutzername oder Passwort stimmt nicht.')); - } - if (err.code === 'ECONNREFUSED') { - return reject(new Error(`${host}:${port} nimmt keine SSH-Verbindung an. Laeuft dort sshd?`)); - } - if (err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH' || err.code === 'ETIMEDOUT') { - return reject(new Error(`${host} ist im Netz nicht erreichbar.`)); - } - reject(new Error(roh)); - }); - - conn.connect({ - host, port, username, password, - readyTimeout: VERBINDUNGS_TIMEOUT_MS, - // Erstkontakt im eigenen Netz: der Schluessel wird angenommen und - // im Protokoll ausgewiesen, damit er nachvollziehbar bleibt. - hostVerifier: (key) => { hostkey = fingerabdruck(key); return true; } - }); - }); -} - -/** Die vier Dateien per SFTP in ein frisches Verzeichnis unter /tmp legen. */ -function uebertragen(conn, zielVerzeichnis) { - return new Promise((resolve, reject) => { - conn.sftp((err, sftp) => { - if (err) return reject(err); - - const naechste = (i) => { - if (i >= DATEIEN.length) return resolve(DATEIEN.length); - const name = DATEIEN[i]; - const inhalt = fs.readFileSync(path.join(QUELLE, name)); - const strom = sftp.createWriteStream(`${zielVerzeichnis}/${name}`); - strom.on('close', () => naechste(i + 1)); - strom.on('error', (e) => reject(new Error(`${name}: ${e.message}`))); - strom.end(inhalt); - }; - - naechste(0); - }); - }); -} - -/** - * Einen Pi einrichten. - * - * @param {object} o - * @param {string} o.host IP oder Name des Pi - * @param {number} o.port SSH-Port - * @param {string} o.username SSH-Benutzer (muss sudo duerfen) - * @param {string} o.password SSH-Passwort, zugleich sudo-Passwort - * @returns {Promise<{ok, token, host, port, steps, fingerprint}>} - * steps = [{name, ok, detail}] - taugt unveraendert als Protokoll - * fuer die Oberflaeche. - */ -async function provision({ host, port = 22, username, password }) { - const steps = []; - const schritt = (name, ok, detail) => { steps.push({ name, ok, detail: detail || '' }); }; - - const token = tokenErzeugen(); - const tmp = `/tmp/bondrucker-${crypto.randomBytes(6).toString('hex')}`; - let conn = null; - let fingerprint = null; - - try { - // --- 1. Anmelden ---------------------------------------------------- - const verbindung = await verbinden({ host, port, username, password }); - conn = verbindung.conn; - fingerprint = verbindung.hostkey; - schritt('Verbindung', true, `als ${username}@${host}:${port} angemeldet` + - (fingerprint ? ` · Hostschlüssel ${fingerprint}` : '')); - - // --- 2. Pi pruefen --------------------------------------------------- - // Ein Skript statt vieler Einzelbefehle: eine Runde ueber die Leitung, - // und die Ausgabe ist auch fuer Menschen lesbar. - const pruefung = await exec(conn, 'bash -s', ` - printf 'SYSTEM=%s\\n' "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" - printf 'ARCH=%s\\n' "$(uname -m)" - printf 'PYTHON=%s\\n' "$(command -v python3 || echo FEHLT)" - printf 'DRUCKER=%s\\n' "$(ls /dev/usb/lp* 2>/dev/null | head -1 || echo KEINER)" - printf 'SYSTEMD=%s\\n' "$(command -v systemctl || echo FEHLT)" - `); - const info = Object.fromEntries( - pruefung.stdout.split('\n').filter(z => z.includes('=')).map(z => { - const i = z.indexOf('='); - return [z.slice(0, i), z.slice(i + 1).trim()]; - }) - ); - - if (info.PYTHON === 'FEHLT') { - schritt('System geprüft', false, 'Auf dem Pi fehlt python3 - der Druckdienst braucht es.'); - throw new Error('python3 fehlt auf dem Zielsystem.'); - } - if (info.SYSTEMD === 'FEHLT') { - schritt('System geprüft', false, 'Kein systemd gefunden - der Dienst kann nicht eingerichtet werden.'); - throw new Error('systemd fehlt auf dem Zielsystem.'); - } - - // Ein fehlender Drucker ist kein Abbruchgrund: der Dienst laeuft auch - // ohne und findet das Geraet, sobald es angesteckt wird. - const druckerHinweis = info.DRUCKER === 'KEINER' - ? ' · ACHTUNG: gerade kein Drucker unter /dev/usb/lp* - Kabel prüfen' - : ` · Drucker ${info.DRUCKER}`; - schritt('System geprüft', true, `${info.SYSTEM || 'unbekannt'} (${info.ARCH})${druckerHinweis}`); - - // --- 3. Dateien uebertragen ------------------------------------------ - await exec(conn, `mkdir -p ${tmp} && chmod 700 ${tmp}`); - const anzahl = await uebertragen(conn, tmp); - schritt('Dateien übertragen', true, `${anzahl} Dateien nach ${tmp}`); - - // --- 4. Einrichten ---------------------------------------------------- - // Das Passwort geht ueber stdin an sudo, nie ueber die Befehlszeile - - // dort waere es fuer jeden sichtbar, der auf dem Pi "ps" tippt. - const install = await exec( - conn, - `cd ${tmp} && sudo -S -p '' bash install.sh ${token} 2>&1`, - password + '\n' - ); - // install.sh gibt das Token am Ende aus - fuer den Menschen, der es von - // Hand aufsetzt, genau richtig. Hier ist es unnoetig: die App kennt es - // ohnehin. Und das Protokoll geht in den Browser und ins Journal. - const ausgabe = (install.stdout + install.stderr) - .split(token).join('(hier nicht angezeigt)') - .trim(); - - if (install.code !== 0) { - const grund = /incorrect password|Sorry, try again/i.test(ausgabe) - ? 'sudo hat das Passwort abgelehnt.' - : `install.sh endete mit Rückgabewert ${install.code}.`; - schritt('Dienst eingerichtet', false, `${grund}\n${ausgabe}`); - throw new Error(grund); - } - schritt('Dienst eingerichtet', true, ausgabe); - - // --- 5. Aufraeumen ---------------------------------------------------- - await exec(conn, `rm -rf ${tmp}`); - schritt('Aufgeräumt', true, `${tmp} entfernt`); - - return { ok: true, token, host, port: 9100, steps, fingerprint }; - } catch (err) { - if (!steps.length || steps[steps.length - 1].ok) { - schritt('Abbruch', false, String(err.message || err)); - } - return { ok: false, error: String(err.message || err), steps, fingerprint }; - } finally { - if (conn) { - // Der Versuch aufzuraeumen darf das Ergebnis nicht mehr kippen. - try { await exec(conn, `rm -rf ${tmp}`); } catch (e) { /* egal */ } - conn.end(); - } - } -} - -module.exports = { provision }; diff --git a/backend/server.js b/backend/server.js index 52b0816..cc274aa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,7 +19,7 @@ const oidc = require('./oidc'); const printer = require('./printer'); const { buildReceipt } = require('./receipt'); const { barcodeMaxLength } = require('./escpos'); -const { provision } = require('./provision'); +const setup = require('./setup'); const app = express(); @@ -755,77 +755,142 @@ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => { res.json({ configured: true, ...await printer.status(), config: printer.config() }); })); -// Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich -// enger begrenzt als beim Drucken. -const provisionLimiter = rateLimit({ +// Einrichtung beruehrt ein fremdes Geraet und ist von aussen erreichbar - +// hier deutlich enger begrenzt als beim Drucken. +const setupLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - max: 10, + max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' } }); /** - * Drucker-Pi per SSH einrichten und anschliessend uebernehmen. + * Adresse, unter der der Pi diese App erreicht. * - * Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach - * vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token. + * Abgeleitet aus der Anfrage, denn die App weiss von sich aus nicht, unter + * welchem Namen sie erreichbar ist - hinter einem nginx sieht sie nur + * 127.0.0.1. Wer die Einrichtung startet, hat die App gerade offen; unter + * genau dieser Adresse findet sie auch der Pi im selben Netz. PUBLIC_URL + * ueberstimmt das, falls die Adresse fuer den Pi eine andere sein muss. */ -app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => { +function appBasisUrl(req) { + const fest = (process.env.PUBLIC_URL || '').trim(); + if (fest) return fest.replace(/\/+$/, ''); + return `${req.protocol}://${req.get('host')}`; +} + +/** + * Adresse des Pi - aus der Verbindung, nicht aus dem Rumpf. + * + * Was der Pi ueber sich selbst behauptet, koennte auch jemand anders + * behaupten. Nur wenn die Anmeldung ueber die Rueckschleife kam (Testaufbau + * oder Tunnel), hilft die Selbstauskunft ueberhaupt weiter. + */ +function quelleAdresse(req, hinweis) { + const ip = String(req.ip || '').replace(/^::ffff:/, ''); + if (ip && ip !== '::1' && ip !== '127.0.0.1') return ip; + const h = String(hinweis || '').trim(); + return /^[A-Za-z0-9.\-]{1,120}$/.test(h) ? h : ip; +} + +/** Einen Einrichtungscode erzeugen und den Abhol-Link dazu liefern. */ +app.post('/api/printer/setup-code', requireAuth, setupLimiter, wrap(async (req, res) => { + const basis = appBasisUrl(req); + const { code, expiresAt } = setup.codeAnlegen(); + + // Das Skript gleich hier bauen: ein unbrauchbarer Hostname soll jetzt + // auffallen und nicht erst, wenn jemand mit dem Laptop am Pi steht. + try { + setup.skriptBauen({ appUrl: basis, code }); + } catch (err) { + console.error('[einrichtung] Skript nicht baubar:', err.message); + return res.status(500).json({ error: err.message }); + } + + const url = `${basis}/setup/bondrucker.sh?code=${code}`; + console.log(`[einrichtung] Code erzeugt, gueltig bis ${new Date(expiresAt).toISOString()}`); + res.json({ + url, + expiresAt, + command: `curl -fsSL "${url}" -o bondrucker.sh && sudo bash bondrucker.sh` + }); +})); + +/** Wartet noch jemand auf den Pi, oder hat er sich gemeldet? */ +app.get('/api/printer/setup-status', requireAuth, wrap(async (req, res) => { + res.json({ ...setup.zustand(), printer: printer.state() }); +})); + +/** + * Das Einrichtungsskript ausliefern. + * + * OHNE Anmeldung: der Pi holt es mit curl ab und kann sich bei Authentik nicht + * anmelden. Der Einrichtungscode ist hier der Schluessel - er gilt einmal und + * eine halbe Stunde lang. + */ +app.get('/setup/bondrucker.sh', setupLimiter, wrap(async (req, res) => { + const eintrag = setup.codePruefen(req.query.code); + if (!eintrag) { + return res.status(403).type('text/plain; charset=utf-8').send( + '# Einrichtungscode ungueltig, verbraucht oder abgelaufen.\n' + + '# In der Timer-App unter Einstellungen einen neuen erzeugen.\n'); + } + + let skript; + try { + skript = setup.skriptBauen({ appUrl: appBasisUrl(req), code: eintrag.code }); + } catch (err) { + console.error('[einrichtung] Skript nicht baubar:', err.message); + return res.status(500).type('text/plain; charset=utf-8').send(`# ${err.message}\n`); + } + + res.type('text/x-shellscript; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="bondrucker.sh"'); + res.setHeader('Cache-Control', 'no-store'); + res.send(skript); +})); + +/** + * Der Pi meldet sich nach der Einrichtung selbst an. + * + * OHNE Anmeldung, aus demselben Grund wie oben. Der Code wird hier verbraucht: + * ein zweiter Pi kann sich mit demselben Code nicht dazwischenschieben. + */ +app.post('/api/setup/register', setupLimiter, wrap(async (req, res) => { const b = req.body || {}; - const host = String(b.host || '').trim(); - const username = String(b.username || '').trim(); - const password = String(b.password || ''); - const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port); + const eintrag = setup.codePruefen(b.code); + if (!eintrag) { + console.warn('[einrichtung] Anmeldung mit ungueltigem Code abgewiesen'); + return res.status(403).json({ error: 'Einrichtungscode ungueltig, verbraucht oder abgelaufen.' }); + } - if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' }); - if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' }); - if (!password) return res.status(400).json({ error: 'Passwort fehlt.' }); + const token = String(b.token || '').trim(); + const port = b.port === undefined ? 9100 : parseInt(b.port); + const device = String(b.device || '').slice(0, 120); + + if (!token) return res.status(400).json({ error: 'Drucker-Token fehlt.' }); if (!Number.isInteger(port) || port < 1 || port > 65535) { - return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' }); - } - // Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment. - if (!/^[A-Za-z0-9.\-_]+$/.test(host)) { - return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' }); + return res.status(400).json({ error: 'Port muss zwischen 1 und 65535 liegen.' }); } - console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`); - const ergebnis = await provision({ host, port, username, password }); + const host = quelleAdresse(req, b.hostHint); + if (!host) return res.status(400).json({ error: 'Adresse des Pi nicht ermittelbar.' }); - if (!ergebnis.ok) { - console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error); - return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps }); - } - - // Uebernehmen: ab hier druckt die App auf diesen Pi. - await setSetting('printer_host', ergebnis.host); - await setSetting('printer_port', String(ergebnis.port)); - await setSetting('printer_token', ergebnis.token); + await setSetting('printer_host', host); + await setSetting('printer_port', String(port)); + await setSetting('printer_token', token); await refreshPrinterConfig(); + setup.codeEinloesen(host, device); - // Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke - // meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit - // einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim - // ersten Bon mit 403 abbrechen. + // Gegenprobe von aussen. Der Pi hat sich eben selbst geprueft, aber nur von + // innen - ob die App wirklich durchkommt, zeigt erst dieser Griff. const probe = await printer.status(); const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false; const bereit = probe.ok && tokenOk; - let detail; - if (bereit) { - detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` + - (probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : ''); - } else if (probe.ok && !tokenOk) { - detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' + - 'alte Fassung der Brücke? Einrichtung bitte wiederholen.'; - } else { - detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` + - 'Meist steht eine Firewall zwischen Server und Pi.'; - } - ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail }); - - console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`); - res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() }); + console.log(`[einrichtung] ${host}:${port} angemeldet, Drucker ${bereit ? 'bereit' : 'NICHT erreichbar'}`); + res.json({ ok: true, host, port, ready: bereit }); })); /** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */ diff --git a/backend/setup.js b/backend/setup.js new file mode 100644 index 0000000..0d1f0a4 --- /dev/null +++ b/backend/setup.js @@ -0,0 +1,139 @@ +/** + * Einrichtung eines Drucker-Pi - die Holschuld-Variante + * + * Frueher hat die App den Pi selbst eingerichtet: per SSH anmelden, Dateien + * schieben, Skript starten. Das verlangte, dass jemand ein SSH-Passwort in ein + * Webformular tippt und dass der Server den Pi ueberhaupt erreicht. + * + * Jetzt andersherum: die App legt ein Skript zum Abholen bereit und wartet. + * Wer den Drucker ansteckt, steht ohnehin am Pi - dort wird das Skript + * gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi + * von selbst bei der App an. Kein Passwort verlaesst den Laden, und die + * Verbindung muss nur in eine Richtung funktionieren. + * + * Das Skript wird beim Abholen zusammengesetzt: die Vorlage aus + * deploy/bondrucker/einrichten.sh bekommt die Adresse dieser App, den + * einmaligen Code sowie den Druckdienst und die systemd-Unit eingesetzt. + * Heraus kommt eine einzige Datei - herunterladen, starten, fertig. + * + * Der Code lebt nur im Arbeitsspeicher. Ein Neustart der App macht ihn + * ungueltig; das ist gewollt, denn ein Einrichtungscode auf der Platte waere + * ein Dauerschluessel zum Umbiegen des Druckers. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const QUELLE = path.join(__dirname, '../deploy/bondrucker'); +const VORLAGE = path.join(QUELLE, 'einrichten.sh'); + +// Halbe Stunde: lang genug, um vom Schreibtisch zum Pi zu gehen und ein Kabel +// zu suchen; kurz genug, dass ein vergessener Code nicht wochenlang gilt. +const GUELTIG_MS = 30 * 60 * 1000; + +// Bewusst nur ein Code gleichzeitig. Zwei offene Einrichtungen waeren ein +// Rennen darum, welcher Pi am Ende der Drucker ist. +let aktuell = null; + +/** Nur Buchstaben und Ziffern - der Wert landet in einer URL und in einem + * Shell-Skript, und was keine Sonderzeichen hat, kann dort nichts anrichten. */ +function codeErzeugen() { + return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32); +} + +/** + * Neuen Einrichtungscode anlegen. Ein vorhandener wird dabei ungueltig. + * @returns {{code: string, expiresAt: number}} + */ +function codeAnlegen() { + aktuell = { + code: codeErzeugen(), + expiresAt: Date.now() + GUELTIG_MS, + erzeugtAm: Date.now(), + eingeloestAm: null, + host: null, + device: null + }; + return { code: aktuell.code, expiresAt: aktuell.expiresAt }; +} + +/** Gueltiger, noch nicht eingeloester Code? Sonst null. */ +function codePruefen(code) { + if (!aktuell || !code) return null; + if (aktuell.eingeloestAm) return null; + if (Date.now() > aktuell.expiresAt) return null; + + // Zeitkonstanter Vergleich: sonst verraet die Laufzeit, wie viele Zeichen + // schon stimmen, und der Code liesse sich Stueck fuer Stueck erraten. + const a = Buffer.from(String(code)); + const b = Buffer.from(aktuell.code); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; + return aktuell; +} + +/** Code als verbraucht markieren - er gilt genau einmal. */ +function codeEinloesen(host, device) { + if (!aktuell) return; + aktuell.eingeloestAm = Date.now(); + aktuell.host = host; + aktuell.device = device; +} + +/** Zustand fuer die Oberflaeche: wartet noch jemand, oder ist es durch? */ +function zustand() { + if (!aktuell) return { state: 'none' }; + if (aktuell.eingeloestAm) { + return { state: 'done', host: aktuell.host, device: aktuell.device, at: aktuell.eingeloestAm }; + } + if (Date.now() > aktuell.expiresAt) return { state: 'expired', expiresAt: aktuell.expiresAt }; + return { state: 'waiting', expiresAt: aktuell.expiresAt }; +} + +/** + * Eine Datei als Inhalt eines Here-Dokuments einsetzen. + * + * Der Abschluss eines Here-Dokuments ist eine Zeile, die genau aus dem + * Schlusswort besteht. Steht dieselbe Zeile zufaellig in der eingesetzten + * Datei, endet das Dokument zu frueh und der Rest wird als Shell-Befehl + * gelesen. Das darf nicht stillschweigend passieren. + */ +function einsetzen(skript, marke, dateiname, schlusswort) { + const inhalt = fs.readFileSync(path.join(QUELLE, dateiname), 'utf8').replace(/\n$/, ''); + if (inhalt.split('\n').some(z => z.trim() === schlusswort)) { + throw new Error(`${dateiname} enthaelt die Zeile "${schlusswort}" und kann so nicht eingebettet werden.`); + } + if (!skript.includes(marke)) { + throw new Error(`In einrichten.sh fehlt die Marke ${marke}.`); + } + return skript.replace(marke, inhalt); +} + +/** + * Das fertige Einrichtungsskript bauen. + * + * @param {object} o + * @param {string} o.appUrl Adresse dieser App, wie der Pi sie erreicht + * @param {string} o.code Einrichtungscode + * @returns {string} + */ +function skriptBauen({ appUrl, code }) { + // appUrl stammt aus dem Host-Kopf der Anfrage und ist damit von aussen + // bestimmbar. Der Wert landet in einem Shell-Skript - hier wird deshalb + // streng geprueft statt maskiert. + if (!/^https?:\/\/[A-Za-z0-9.\-]+(:\d{1,5})?$/.test(appUrl)) { + throw new Error(`Unbrauchbare Adresse fuer die Rueckmeldung: ${appUrl}`); + } + if (!/^[A-Za-z0-9]{1,64}$/.test(code)) { + throw new Error('Unbrauchbarer Einrichtungscode.'); + } + + let skript = fs.readFileSync(VORLAGE, 'utf8'); + skript = skript.split('@@APP_URL@@').join(appUrl); + skript = skript.split('@@SETUP_CODE@@').join(code); + skript = einsetzen(skript, '@@PRINT_SERVER@@', 'print-server.py', 'PRINT_SERVER_ENDE'); + skript = einsetzen(skript, '@@SERVICE@@', 'bondrucker.service', 'SERVICE_ENDE'); + return skript; +} + +module.exports = { codeAnlegen, codePruefen, codeEinloesen, zustand, skriptBauen, GUELTIG_MS }; diff --git a/deploy/bondrucker/README.md b/deploy/bondrucker/README.md index 95f5380..65006eb 100644 --- a/deploy/bondrucker/README.md +++ b/deploy/bondrucker/README.md @@ -14,54 +14,69 @@ Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node. ## Installation — der übliche Weg -**Aus der Oberfläche:** ⚙️ Einstellungen → **🔧 Drucker einrichten**. Dort IP, -Benutzer und Passwort des Pi eintragen und auf **🚀 Jetzt einrichten** klicken. - -Die App macht dann per SSH genau das, was unten von Hand steht: Dateien -übertragen, `install.sh` mit einem frisch erzeugten Token starten, aufräumen -und nachsehen, ob der Dienst antwortet. Anschließend übernimmt sie den Drucker -selbst — es ist **nichts** mehr in der `.env` einzutragen. Das Protokoll zeigt -Schritt für Schritt, was passiert ist. - -Der Pi braucht dafür nur: SSH, Python 3, systemd, einen Benutzer mit sudo — und -den Drucker am USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt -das ab Werk. - -Die Zugangsdaten werden einmal benutzt und danach verworfen. Gespeichert wird -allein das erzeugte Drucker-Token. - -## Installation von Hand - -Falls die App den Pi nicht erreicht (anderes Netz, kein sudo per Passwort): +**In der Timer-App:** ⚙️ Einstellungen → **🔧 Drucker einrichten** → +**🔗 Einrichtungslink erzeugen**. Die App zeigt einen Befehl an. Der wird auf +dem Pi in ein Terminal getippt: ```bash -# Dateien auf den Pi kopieren -scp print-server.py bondrucker.service install.sh README.md pi@:/tmp/bondrucker/ - -# Dort einrichten -ssh pi@ -cd /tmp/bondrucker && sudo bash install.sh +curl -fsSL "" -o bondrucker.sh +sudo bash bondrucker.sh ``` -`install.sh` legt an: +Das Skript fragt, welcher Drucker verwendet werden soll, richtet alles ein und +meldet den Pi anschließend selbst bei der Timer-App an. In der Oberfläche +springt die Anzeige von „wartet auf den Pi" auf den fertigen Drucker um. In der +`.env` ist **nichts** einzutragen. + +Der Pi braucht dafür nur Python 3, systemd, `curl` und den Drucker am +USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt das ab Werk. + +### Warum der Pi sich meldet und nicht umgekehrt + +Früher hat die App den Pi per SSH eingerichtet. Das verlangte ein SSH-Passwort +in einem Webformular und eine Verbindung vom Server zum Pi. Beides ist jetzt +weg: kein Passwort verlässt den Laden, und die Verbindung muss nur in eine +Richtung funktionieren. Wer einen Drucker anschließt, steht ohnehin am Pi. + +Der Link enthält einen Einrichtungscode. Er gilt **einmal** und **30 Minuten**, +liegt nur im Arbeitsspeicher der App und ist nach einem Neustart der App +ungültig. Wer den Code hat, kann den Drucker der App umbiegen — er gehört +deshalb nicht in einen Chatverlauf. + +## Was das Skript anlegt | Was | Wo | |---|---| | Dienstbenutzer `bondrucker` (Gruppe `lp`) | — | | Programm | `/opt/bondrucker/print-server.py` | -| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) | +| Token und Gerät | `/etc/bondrucker/env` (`0640`, root:bondrucker) | | udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` | | Dienst | `bondrucker.service`, aktiviert und gestartet | -Am Ende gibt das Skript den Wert für das Token aus. - -`install.sh` ist wiederholbar: ein erneuter Aufruf überschreibt Programm und +Das Skript ist wiederholbar: ein erneuter Aufruf überschreibt Programm und Token und **startet den Dienst neu**. Ohne den Neustart liefe er mit dem alten Token weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen. +Schalter für den Fall, dass nicht gefragt werden soll: + +```bash +sudo bash bondrucker.sh --device /dev/usb/lp1 --port 9100 +``` + +## Installation ohne die Timer-App + +`einrichten.sh` im Quelltext ist eine Vorlage: die Stellen `@@APP_URL@@`, +`@@SETUP_CODE@@`, `@@PRINT_SERVER@@` und `@@SERVICE@@` füllt die App beim +Ausliefern. Direkt ausführbar ist sie deshalb nicht. + +Ohne die App werden die Dateien von Hand abgelegt — `print-server.py` nach +`/opt/bondrucker/`, `bondrucker.service` nach `/etc/systemd/system/`, dazu +`/etc/bondrucker/env` mit `PRINT_TOKEN=` und `PRINT_DEVICE=`. Adresse, Port und +Token kommen anschließend von Hand in die Einstellungen der Timer-App. + ## Timer-Server konfigurieren -Nach der Einrichtung aus der Oberfläche: nichts zu tun. +Nach der Einrichtung über den Link: nichts zu tun. Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten" lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang diff --git a/deploy/bondrucker/bondrucker.service b/deploy/bondrucker/bondrucker.service index 17d646f..a6a47c8 100644 --- a/deploy/bondrucker/bondrucker.service +++ b/deploy/bondrucker/bondrucker.service @@ -15,7 +15,7 @@ Group=bondrucker SupplementaryGroups=lp # Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der -# Versionsverwaltung landet - die Datei legt install.sh an. +# Versionsverwaltung landet - die Datei legt einrichten.sh an. Environment=PRINT_DEVICE=/dev/usb/lp0 Environment=PRINT_HOST=0.0.0.0 Environment=PRINT_PORT=9100 diff --git a/deploy/bondrucker/einrichten.sh b/deploy/bondrucker/einrichten.sh new file mode 100755 index 0000000..a48d7c3 --- /dev/null +++ b/deploy/bondrucker/einrichten.sh @@ -0,0 +1,286 @@ +#!/bin/bash +# --------------------------------------------------------------------------- +# Bondrucker-Bruecke einrichten - wird AUF DEM PI ausgefuehrt. +# +# Dieses Skript wird nicht von Hand gepflegt heruntergeladen, sondern von der +# Timer-App ausgeliefert: sie setzt ihre eigene Adresse und einen einmaligen +# Einrichtungscode ein und haengt den Druckdienst sowie die systemd-Unit +# gleich mit an. Heraus kommt eine einzige Datei, die alles enthaelt. +# +# Aufruf auf dem Pi: +# curl -fsSL "" -o bondrucker.sh +# sudo bash bondrucker.sh +# +# Der Link steht in der Timer-App unter Einstellungen -> Drucker einrichten. +# +# Ohne Netz zur Timer-App laeuft das Skript trotzdem durch; es zeigt am Ende +# Adresse und Token an, die dann von Hand in die Einstellungen gehoeren. +# +# Schalter: +# --device /dev/usb/lp0 Drucker vorgeben, statt danach zu fragen +# --port 9100 Lausch-Port der Bruecke +# --------------------------------------------------------------------------- +set -euo pipefail + +# Diese beiden Werte ersetzt die Timer-App beim Ausliefern. +APP_URL="@@APP_URL@@" +SETUP_CODE="@@SETUP_CODE@@" + +ZIEL=/opt/bondrucker +GERAET="" +PORT=9100 + +# --- Schalter -------------------------------------------------------------- + +while [ $# -gt 0 ]; do + case "$1" in + --device) GERAET="${2:-}"; shift 2 ;; + --port) PORT="${2:-9100}"; shift 2 ;; + -h|--help) sed -n '2,22p' "$0"; exit 0 ;; + *) echo "Unbekannter Schalter: $1" >&2; exit 1 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "Bitte mit sudo starten: sudo bash $0" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Hilfsmittel +# --------------------------------------------------------------------------- + +titel() { printf '\n\033[1m%s\033[0m\n' "$1"; } +punkt() { printf ' %s\n' "$1"; } +warnung() { printf ' \033[33m%s\033[0m\n' "$1"; } +fehler() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; } + +# Fragt den Benutzer und gibt die Antwort auf stdout zurueck. +# +# Die Umleitung auf /dev/tty ist der Grund, warum auch +# "curl ... | sudo bash" noch bedienbar bleibt: dort ist die Standardeingabe +# die Pipe und nicht die Tastatur. Gibt es gar kein Terminal, wird die Vorgabe +# genommen, statt in einer Endlosschleife zu haengen. +frage() { + local text="$1" vorgabe="${2:-}" antwort="" + if [ -t 0 ]; then + read -r -p "$text" antwort || true + elif [ -e /dev/tty ]; then + read -r -p "$text" antwort < /dev/tty || true + fi + printf '%s' "${antwort:-$vorgabe}" +} + +# Klartextname eines USB-Druckers aus dem sysfs, damit in der Auswahl nicht +# nur "lp0" steht, sondern "EPSON TM-T88IV". +usb_name() { + local pfad + pfad=$(readlink -f "/sys/class/usbmisc/$(basename "$1")/device" 2>/dev/null) || return 1 + while [ -n "$pfad" ] && [ "$pfad" != "/" ]; do + if [ -r "$pfad/product" ]; then + printf '%s %s' "$(cat "$pfad/manufacturer" 2>/dev/null || true)" "$(cat "$pfad/product")" + return 0 + fi + pfad=$(dirname "$pfad") + done + return 1 +} + +# --------------------------------------------------------------------------- +# 1. System pruefen +# --------------------------------------------------------------------------- + +titel "1. System" + +command -v python3 >/dev/null || fehler "python3 fehlt. Bitte nachinstallieren: apt install python3" +command -v systemctl >/dev/null || fehler "systemd fehlt - dieses Skript richtet einen systemd-Dienst ein." + +punkt "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s) ($(uname -m))" +punkt "$(python3 --version)" + +# --------------------------------------------------------------------------- +# 2. Drucker auswaehlen +# --------------------------------------------------------------------------- + +titel "2. Drucker" + +if [ -z "$GERAET" ]; then + GEFUNDEN=() + for d in /dev/usb/lp* /dev/lp*; do + [ -e "$d" ] && GEFUNDEN+=("$d") + done + + if [ "${#GEFUNDEN[@]}" -eq 0 ]; then + warnung "Gerade haengt kein Drucker unter /dev/usb/lp* am Pi." + warnung "Das ist kein Hindernis: der Dienst findet ihn, sobald er angesteckt wird." + GERAET=$(frage " Geraetepfad [/dev/usb/lp0]: " "/dev/usb/lp0") + else + echo + for i in "${!GEFUNDEN[@]}"; do + printf ' %d) %-16s %s\n' "$((i + 1))" "${GEFUNDEN[$i]}" "$(usb_name "${GEFUNDEN[$i]}" || echo '')" + done + printf ' a) anderer Pfad\n\n' + + WAHL=$(frage " Welcher Drucker? [1]: " "1") + if [ "$WAHL" = "a" ] || [ "$WAHL" = "A" ]; then + GERAET=$(frage " Geraetepfad: " "/dev/usb/lp0") + elif [ "$WAHL" -ge 1 ] 2>/dev/null && [ "$WAHL" -le "${#GEFUNDEN[@]}" ]; then + GERAET="${GEFUNDEN[$((WAHL - 1))]}" + else + fehler "Ungueltige Auswahl: $WAHL" + fi + fi +fi + +punkt "gewaehlt: $GERAET" + +# --------------------------------------------------------------------------- +# 3. Dienstbenutzer +# --------------------------------------------------------------------------- + +titel "3. Dienstbenutzer" + +if ! id bondrucker >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker + punkt "Benutzer 'bondrucker' angelegt (Gruppe lp)." +else + usermod -aG lp bondrucker + punkt "Benutzer 'bondrucker' war schon da." +fi + +# --------------------------------------------------------------------------- +# 4. Programmdateien +# --------------------------------------------------------------------------- + +titel "4. Programmdateien" + +install -d -m 755 "$ZIEL" + +cat > "$ZIEL/print-server.py" <<'PRINT_SERVER_ENDE' +@@PRINT_SERVER@@ +PRINT_SERVER_ENDE +chmod 755 "$ZIEL/print-server.py" +punkt "$ZIEL/print-server.py" + +cat > /etc/systemd/system/bondrucker.service <<'SERVICE_ENDE' +@@SERVICE@@ +SERVICE_ENDE +chmod 644 /etc/systemd/system/bondrucker.service +punkt "/etc/systemd/system/bondrucker.service" + +# --------------------------------------------------------------------------- +# 5. Konfiguration +# --------------------------------------------------------------------------- + +titel "5. Konfiguration" + +TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)" + +install -d -m 750 /etc/bondrucker +{ + printf 'PRINT_TOKEN=%s\n' "$TOKEN" + printf 'PRINT_DEVICE=%s\n' "$GERAET" + printf 'PRINT_PORT=%s\n' "$PORT" +} > /etc/bondrucker/env +chmod 640 /etc/bondrucker/env +chown root:bondrucker /etc/bondrucker/env +punkt "/etc/bondrucker/env geschrieben, neues Token erzeugt." + +# Ohne diese Regel gehoert /dev/usb/lp0 nach jedem Einstecken wieder root +# allein - der Dienst laeuft aber nicht als root und koennte nicht drucken. +cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE' +# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker' +# ohne root drucken kann. Gilt fuer alle usblp-Geraete. +SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660" +SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660" +RULE +udevadm control --reload-rules >/dev/null 2>&1 || true +udevadm trigger --subsystem-match=usbmisc >/dev/null 2>&1 || true +punkt "udev-Regel gesetzt (Geraet ueberlebt das Aus- und Einstecken)." + +# --------------------------------------------------------------------------- +# 6. Dienst starten +# --------------------------------------------------------------------------- + +titel "6. Dienst" + +systemctl daemon-reload +systemctl enable bondrucker.service >/dev/null 2>&1 +# Bewusst "restart" und nicht "enable --now": bei einer erneuten Einrichtung +# laeuft der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er +# liefe dann mit dem ALTEN Token weiter und wuerde jeden Bon ablehnen. +systemctl restart bondrucker.service +sleep 1 + +if ! systemctl is-active --quiet bondrucker.service; then + systemctl --no-pager --lines=20 status bondrucker.service || true + fehler "Der Dienst laeuft nicht. Die Ausgabe oben sagt, woran es liegt." +fi +punkt "bondrucker.service laeuft." + +# Gegenprobe von innen: antwortet die Bruecke, und nimmt sie das neue Token? +ANTWORT="$(curl -fsS -m 5 -H "X-Print-Token: $TOKEN" "http://127.0.0.1:$PORT/status" 2>/dev/null || true)" +case "$ANTWORT" in + *'"tokenAccepted": true'*|*'"tokenAccepted":true'*) punkt "Bruecke antwortet und nimmt das Token an." ;; + "") warnung "Die Bruecke antwortet auf 127.0.0.1:$PORT noch nicht." ;; + *) warnung "Die Bruecke antwortet, lehnt das Token aber ab: $ANTWORT" ;; +esac + +case "$ANTWORT" in + *'"deviceExists": false'*|*'"deviceExists":false'*) + warnung "Das Geraet $GERAET ist gerade nicht da - Kabel pruefen." ;; +esac + +# --------------------------------------------------------------------------- +# 7. Bei der Timer-App anmelden +# --------------------------------------------------------------------------- + +IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + +titel "7. Timer-App" + +angemeldet=0 +case "$APP_URL" in + @@*|"") + punkt "Kein Einrichtungscode enthalten - dieses Skript wurde nicht ueber" + punkt "die Timer-App geladen. Anmeldung wird uebersprungen." + ;; + *) + if ! command -v curl >/dev/null; then + warnung "curl fehlt - die Anmeldung muss von Hand erfolgen." + else + RUMPF="$(printf '{"code":"%s","port":%s,"token":"%s","device":"%s","hostHint":"%s"}' \ + "$SETUP_CODE" "$PORT" "$TOKEN" "$GERAET" "$IP")" + if ERGEBNIS="$(curl -fsS -m 15 -X POST \ + -H 'Content-Type: application/json' \ + -d "$RUMPF" "$APP_URL/api/setup/register" 2>&1)"; then + punkt "Bei $APP_URL angemeldet." + punkt "Die Timer-App druckt ab jetzt auf diesen Pi." + angemeldet=1 + else + warnung "Anmeldung fehlgeschlagen: $ERGEBNIS" + warnung "Meist ist der Code abgelaufen - in der Timer-App einen neuen erzeugen." + fi + fi + ;; +esac + +# --------------------------------------------------------------------------- +# Fertig +# --------------------------------------------------------------------------- + +titel "Fertig" +punkt "Drucker: $GERAET" +punkt "Adresse: ${IP:-}" +punkt "Port: $PORT" + +if [ "$angemeldet" -eq 0 ]; then + echo + punkt "Von Hand eintragen in der Timer-App unter Einstellungen:" + printf ' Adresse: %s\n Port: %s\n Token: %s\n' "${IP:-?}" "$PORT" "$TOKEN" +fi + +echo +punkt "Zustand abfragen: curl -s http://${IP:-127.0.0.1}:$PORT/status" +punkt "Protokoll: journalctl -u bondrucker -f" +echo diff --git a/deploy/bondrucker/install.sh b/deploy/bondrucker/install.sh deleted file mode 100755 index 9d2b8b4..0000000 --- a/deploy/bondrucker/install.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash -# --------------------------------------------------------------------------- -# Bondrucker-Bruecke auf dem Raspberry Pi einrichten. -# -# Aufruf auf dem Pi: sudo bash install.sh [TOKEN] -# Ohne TOKEN wird eins erzeugt und am Ende ausgegeben - dieser Wert gehoert -# anschliessend in die .env des Timer-Servers (PRINTER_TOKEN). -# --------------------------------------------------------------------------- -set -euo pipefail - -QUELLE="$(cd "$(dirname "$0")" && pwd)" -ZIEL=/opt/bondrucker -TOKEN="${1:-}" - -if [ "$(id -u)" -ne 0 ]; then - echo "Bitte mit sudo starten." >&2 - exit 1 -fi - -echo "--- Dienstbenutzer" -if ! id bondrucker >/dev/null 2>&1; then - useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker - echo " Benutzer 'bondrucker' angelegt (Gruppe lp)." -else - usermod -aG lp bondrucker - echo " Benutzer 'bondrucker' war schon da." -fi - -echo "--- Programmdateien nach $ZIEL" -install -d -m 755 "$ZIEL" -install -m 755 "$QUELLE/print-server.py" "$ZIEL/print-server.py" -[ -f "$QUELLE/README.md" ] && install -m 644 "$QUELLE/README.md" "$ZIEL/README.md" - -echo "--- Konfiguration" -install -d -m 750 /etc/bondrucker -if [ -f /etc/bondrucker/env ] && [ -z "$TOKEN" ]; then - echo " /etc/bondrucker/env besteht bereits - Token bleibt unveraendert." -else - [ -n "$TOKEN" ] || TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)" - printf 'PRINT_TOKEN=%s\n' "$TOKEN" > /etc/bondrucker/env - chmod 640 /etc/bondrucker/env - chown root:bondrucker /etc/bondrucker/env - echo " Token geschrieben." -fi - -echo "--- udev-Regel (Geraet ueberlebt das Aus- und Einstecken)" -cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE' -# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker' -# ohne root drucken kann. Gilt fuer alle usblp-Geraete. -SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660" -SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660" -RULE -udevadm control --reload-rules || true -udevadm trigger --subsystem-match=usbmisc || true - -echo "--- systemd" -install -m 644 "$QUELLE/bondrucker.service" /etc/systemd/system/bondrucker.service -systemctl daemon-reload -systemctl enable bondrucker.service -# Bewusst "restart" statt "enable --now": bei einer erneuten Einrichtung laeuft -# der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er liefe dann -# mit dem ALTEN Token weiter und wuerde jeden Druckauftrag ablehnen. -systemctl restart bondrucker.service -sleep 1 -systemctl --no-pager --lines=5 status bondrucker.service || true - -echo -echo "--- Fertig" -echo "Status abfragen: curl -s http://$(hostname -I | awk '{print $1}'):9100/status" -echo "Token fuer die .env des Timer-Servers:" -echo " PRINTER_TOKEN=$(sed -n 's/^PRINT_TOKEN=//p' /etc/bondrucker/env)" diff --git a/frontend/app.js b/frontend/app.js index 19166a5..9da36e6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -14,6 +14,8 @@ const state = { printAutoOnStop: true, // Bon beim Beenden automatisch drucken tickHandle: null, syncHandle: null, + setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi + setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird history: { offset: 0, limit: 50, reachedEnd: false } }; @@ -692,7 +694,7 @@ function toggleSetup() { box.style.display = offen ? 'none' : 'block'; document.getElementById('setupToggleBtn').textContent = offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen'; - if (!offen) document.getElementById('setupHost').focus(); + if (offen) stopSetupWatch(); } /** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */ @@ -711,59 +713,109 @@ function renderSetupLog(steps, laufend) { el.innerHTML = zeilen + (laufend ? `
⏳ -
läuft …
` +
wartet auf den Pi …
` : ''); } -async function startProvision() { - const host = document.getElementById('setupHost').value.trim(); - const port = document.getElementById('setupPort').value; - const username = document.getElementById('setupUser').value.trim(); - const passwordEl = document.getElementById('setupPassword'); - const password = passwordEl.value; - - if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error'); - if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error'); - if (!password) return toast('Bitte das Passwort eintragen.', 'error'); - - if (state.printerConfigured && !confirm( - 'Es ist bereits ein Drucker eingerichtet.\n\n' + - `Nach der Einrichtung druckt die App auf ${host}. Fortfahren?` - )) return; - +/** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */ +async function startSetup() { const btn = document.getElementById('setupStartBtn'); btn.disabled = true; - btn.textContent = '⏳ Wird eingerichtet …'; - renderSetupLog([], true); try { - const res = await api('/api/printer/provision', { - method: 'POST', - body: JSON.stringify({ host, port, username, password }) - }); + const res = await api('/api/printer/setup-code', { method: 'POST' }); - // Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts - // mehr verloren. - passwordEl.value = ''; + state.setupCommand = res.command; + document.getElementById('setupCommand').textContent = res.command; + document.getElementById('setupLink').style.display = 'block'; - renderSetupLog(res.steps, false); - state.printerConfigured = true; + const bis = new Date(res.expiresAt); + document.getElementById('setupExpiry').textContent = + `Der Link gilt einmal und läuft um ${bis.getHours()}:` + + `${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` + + 'Danach hier einfach einen neuen erzeugen.'; - if (res.ready) { - toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success'); - } else { - toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error'); - } - await refreshPrinterStatus(); - await syncTimers().catch(() => {}); + renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true); + startSetupWatch(); + toast('Link erzeugt. Jetzt am Pi ausführen.', 'success'); } catch (e) { - // Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind - // das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen. - renderSetupLog(e.steps, false); - toast('Einrichtung fehlgeschlagen: ' + e.message, 'error'); + toast('Link konnte nicht erzeugt werden: ' + e.message, 'error'); } finally { btn.disabled = false; - btn.textContent = '🚀 Jetzt einrichten'; + btn.textContent = '🔗 Neuen Link erzeugen'; + } +} + +function copySetupCommand() { + const text = state.setupCommand || ''; + if (!text) return; + + // navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft + // das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten + // Weg zurueck, statt wortlos nichts zu tun. + const alterWeg = () => { + const feld = document.createElement('textarea'); + feld.value = text; + feld.style.position = 'fixed'; + feld.style.opacity = '0'; + document.body.appendChild(feld); + feld.select(); + try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ } + document.body.removeChild(feld); + }; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then( + () => toast('Befehl kopiert', 'success'), + () => { alterWeg(); toast('Befehl kopiert', 'success'); } + ); + } else { + alterWeg(); + toast('Befehl kopiert', 'success'); + } +} + +/** + * Auf die Anmeldung des Pi warten. + * + * Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur + * nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung + * anzufuehlen, und selten genug, um niemandem wehzutun. + */ +function startSetupWatch() { + stopSetupWatch(); + state.setupWatch = setInterval(async () => { + let z; + try { + z = await api('/api/printer/setup-status'); + } catch (e) { + return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder + } + + if (z.state === 'done') { + stopSetupWatch(); + state.printerConfigured = true; + renderSetupLog([ + { name: 'Pi angemeldet', ok: true, + detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` }, + { name: 'Übernommen', ok: true, + detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' } + ], false); + toast('Drucker eingerichtet: ' + z.host, 'success'); + await refreshPrinterStatus(); + await syncTimers().catch(() => {}); + } else if (z.state === 'expired') { + stopSetupWatch(); + renderSetupLog([{ name: 'Link abgelaufen', ok: false, + detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false); + } + }, 3000); +} + +function stopSetupWatch() { + if (state.setupWatch) { + clearInterval(state.setupWatch); + state.setupWatch = null; } } diff --git a/frontend/index.html b/frontend/index.html index 28af6a0..b792438 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -644,6 +644,37 @@ line-height: 1.5; } + .setup-link { margin-top: 1.25rem; } + + /* Der Befehl muss vollstaendig lesbar sein - er wird abgetippt, wenn + das Kopieren ins Leere geht (kein HTTPS, fremdes Tablet). Deshalb + umbrechen statt abschneiden. */ + .setup-command { + display: flex; + align-items: flex-start; + gap: 0.75rem; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.85rem 1rem; + } + + .setup-command code { + flex: 1; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8rem; + line-height: 1.6; + color: #d7d7ff; + word-break: break-all; + user-select: all; + } + + .setup-command .btn { flex-shrink: 0; } + + @media (max-width: 600px) { + .setup-command { flex-direction: column; } + } + /* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall sichtbar ist, wie weit es gekommen ist. */ .setup-log { @@ -903,47 +934,30 @@