From 5e23ccdcbcc7a0304dca5a91aeacd35bd1fa3799 Mon Sep 17 00:00:00 2001 From: "Kerim (portable/claudecode)" Date: Fri, 4 Sep 2026 20:59:56 +0200 Subject: [PATCH] Drucker-Pi aus der Oberflaeche heraus einrichten Die Einrichtung war 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. Jetzt macht das ein Knopf unter "Einstellungen": Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an, prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen nach. Jeder Schritt steht mit Ergebnis im Protokoll. Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim allerersten Start, wie RATE_PER_10MIN auch. Zwei Fehler, die beim Testen der Einrichtung auffielen: install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart. Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1), und die Einrichtung prueft das mit. Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal geht. Neu dabei: ssh2, reines JavaScript. Co-Authored-By: Claude Opus 5 (1M context) --- DEPLOY.md | 45 +++++- README.md | 14 ++ backend/.env.example | 11 +- backend/package-lock.json | 70 +++++++- backend/package.json | 3 +- backend/printer.js | 97 ++++++++---- backend/provision.js | 254 ++++++++++++++++++++++++++++++ backend/server.js | 144 ++++++++++++++++- deploy/bondrucker/README.md | 55 +++++-- deploy/bondrucker/install.sh | 6 +- deploy/bondrucker/print-server.py | 13 +- frontend/app.js | 94 ++++++++++- frontend/index.html | 111 ++++++++++++- 13 files changed, 855 insertions(+), 62 deletions(-) create mode 100644 backend/provision.js diff --git a/DEPLOY.md b/DEPLOY.md index 2a88c7d..8946fd4 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -18,8 +18,10 @@ 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:** 6 npm-Pakete, alle etabliert (express, express-session, - express-rate-limit, sqlite3, bcrypt, dotenv). +* **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). 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** @@ -213,6 +215,12 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen. | `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 | +**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur +Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt +der Druckerzugang in der Datenbank und wird über die Oberfläche gepflegt; eine +spätere Änderung in der `.env` bleibt wirkungslos. Der Grund steht in +Abschnitt 6: ein Druckerwechsel soll kein Server-Zugang sein. + `SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die Befehlssubstitution war beim Anlegen der Datei nie ausgeführt worden. Mit einem @@ -290,6 +298,37 @@ Kopf- und Fußzeilen des Bons stehen in der Datenbank und werden unter drucken". Der Probedruck dort beantwortet die Frage, ob es am Drucker oder an 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. + +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. + +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 +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 +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. + **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. @@ -323,6 +362,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 │ ├── package.json │ ├── package-lock.json │ ├── .env.example @@ -416,6 +456,7 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und | `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` | | `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 | | `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 550f5cb..b800cb6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,20 @@ 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 diff --git a/backend/.env.example b/backend/.env.example index b22c543..a9f9df1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -59,10 +59,15 @@ RATE_PER_10MIN=10 # --- Bondrucker ------------------------------------------------------------ # Der Bondrucker haengt per USB an einem Raspberry Pi. Auf dem Pi laeuft die # Bruecke aus deploy/bondrucker/ und nimmt fertige Druckdaten per HTTP an. -# Einrichtung des Pi: siehe deploy/bondrucker/README.md # -# Bleibt PRINTER_HOST leer, ist die Druckfunktion aus - die App laeuft dann -# genau wie vorher, nur ohne Bon-Knoepfe. +# 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. +# +# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie +# vorher, nur ohne Bon-Knoepfe. PRINTER_HOST= PRINTER_PORT=9100 diff --git a/backend/package-lock.json b/backend/package-lock.json index 0ceb8db..5779911 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,8 @@ "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", "openid-client": "^6.8.4", - "sqlite3": "^5.1.6" + "sqlite3": "^5.1.6", + "ssh2": "^1.17.0" }, "engines": { "node": ">=18" @@ -183,6 +184,14 @@ "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", @@ -220,6 +229,14 @@ "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", @@ -293,6 +310,15 @@ "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", @@ -424,6 +450,20 @@ "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", @@ -1382,6 +1422,12 @@ "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", @@ -2092,6 +2138,23 @@ "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", @@ -2231,6 +2294,11 @@ "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 a812eb7..170e777 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", "openid-client": "^6.8.4", - "sqlite3": "^5.1.6" + "sqlite3": "^5.1.6", + "ssh2": "^1.17.0" } } diff --git a/backend/printer.js b/backend/printer.js index e4fa4dc..1b2940b 100644 --- a/backend/printer.js +++ b/backend/printer.js @@ -5,39 +5,65 @@ * Server. Auf dem Pi laeuft die Bruecke aus deploy/bondrucker/, die fertige * ESC/POS-Daten per HTTP annimmt. Hier entsteht nur der Aufruf. * - * Zwei Grundsaetze, die den Rest erklaeren: + * Drei Grundsaetze, die den Rest erklaeren: * * 1. Drucken darf die Zeiterfassung nie aufhalten. Ein leerer Papierrollen- * halter ist ein Papierproblem, kein Datenproblem: der Vorgang ist da * bereits gespeichert, der Bon laesst sich aus der Historie nachdrucken. - * 2. Kein Fremdpaket. Node bringt einen HTTP-Client mit. + * 2. Die Zugangsdaten stehen in der Datenbank, nicht in der .env. Nur so kann + * die Oberflaeche einen neuen Pi einrichten, ohne dass jemand auf den + * Server muss. Die .env liefert weiterhin die Startwerte (siehe server.js). + * 3. Kein Fremdpaket fuers Drucken. Node bringt einen HTTP-Client mit. */ const http = require('http'); -const HOST = (process.env.PRINTER_HOST || '').trim(); -const PORT = parseInt(process.env.PRINTER_PORT) || 9100; -const TOKEN = (process.env.PRINTER_TOKEN || '').trim(); -const TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000; -const WIDTH = parseInt(process.env.PRINTER_WIDTH) || 42; +// Laufende Konfiguration. Wird beim Start und nach jeder Aenderung in den +// Einstellungen aus der Datenbank gesetzt - siehe refreshPrinterConfig(). +let cfg = { + host: '', + port: 9100, + token: '', + width: 42, + timeoutMs: parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000 +}; // Zuletzt gesehener Zustand - fuer /health und die Anzeige in den Einstellungen. let lastError = null; let lastPrintAt = null; -function isConfigured() { - return HOST.length > 0; +function setConfig(next = {}) { + const port = parseInt(next.port); + const width = parseInt(next.width); + cfg = { + ...cfg, + host: String(next.host == null ? cfg.host : next.host).trim(), + port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port, + token: next.token == null ? cfg.token : String(next.token).trim(), + width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width + }; + return cfg; } +function isConfigured() { + return cfg.host.length > 0; +} + +function width() { + return cfg.width; +} + +/** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */ function config() { - return { host: HOST, port: PORT, width: WIDTH, timeoutMs: TIMEOUT_MS, tokenSet: !!TOKEN }; + return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token }; } function state() { return { configured: isConfigured(), - host: HOST || null, - port: HOST ? PORT : null, + host: cfg.host || null, + port: isConfigured() ? cfg.port : null, + width: cfg.width, lastError, lastPrintAt }; @@ -47,8 +73,8 @@ function state() { * Aufrufer zwingt, ihn zu behandeln - stattdessen {ok, error}. */ function request(pfad, method, body) { return new Promise((resolve) => { - if (!isConfigured()) { - return resolve({ ok: false, error: 'Kein Drucker eingerichtet (PRINTER_HOST fehlt).' }); + if (!cfg.host) { + return resolve({ ok: false, error: 'Kein Drucker eingerichtet.' }); } const headers = {}; @@ -56,34 +82,37 @@ function request(pfad, method, body) { headers['Content-Type'] = 'application/octet-stream'; headers['Content-Length'] = body.length; } - if (TOKEN) headers['X-Print-Token'] = TOKEN; + if (cfg.token) headers['X-Print-Token'] = cfg.token; - const req = http.request({ host: HOST, port: PORT, path: pfad, method, headers }, (res) => { - const teile = []; - res.on('data', d => teile.push(d)); - res.on('end', () => { - const roh = Buffer.concat(teile).toString('utf8'); - let daten = null; - try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ } + const req = http.request( + { host: cfg.host, port: cfg.port, path: pfad, method, headers }, + (res) => { + const teile = []; + res.on('data', d => teile.push(d)); + res.on('end', () => { + const roh = Buffer.concat(teile).toString('utf8'); + let daten = null; + try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ } - if (res.statusCode >= 200 && res.statusCode < 300) { - return resolve({ ok: true, data: daten }); - } - const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`; - resolve({ ok: false, error: meldung, data: daten }); - }); - }); + if (res.statusCode >= 200 && res.statusCode < 300) { + return resolve({ ok: true, data: daten }); + } + const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`; + resolve({ ok: false, error: meldung, data: daten }); + }); + } + ); - req.setTimeout(TIMEOUT_MS, () => { - req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${TIMEOUT_MS} ms`)); + req.setTimeout(cfg.timeoutMs, () => { + req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${cfg.timeoutMs} ms`)); }); req.on('error', (err) => { // ECONNREFUSED/EHOSTUNREACH sind der Normalfall, wenn der Pi aus ist. const meldung = err.code === 'ECONNREFUSED' - ? `Druckdienst auf ${HOST}:${PORT} nimmt keine Verbindung an.` + ? `Druckdienst auf ${cfg.host}:${cfg.port} nimmt keine Verbindung an.` : err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH' - ? `Drucker-Pi ${HOST} ist im Netz nicht erreichbar.` + ? `Drucker-Pi ${cfg.host} ist im Netz nicht erreichbar.` : (err.message || String(err)); resolve({ ok: false, error: meldung }); }); @@ -116,4 +145,4 @@ async function print(buffer) { return res; } -module.exports = { isConfigured, config, state, status, print, WIDTH }; +module.exports = { setConfig, isConfigured, width, config, state, status, print }; diff --git a/backend/provision.js b/backend/provision.js new file mode 100644 index 0000000..5db0394 --- /dev/null +++ b/backend/provision.js @@ -0,0 +1,254 @@ +/** + * 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 91d1949..e346bca 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ require('dotenv').config(); const oidc = require('./oidc'); const printer = require('./printer'); const { buildReceipt } = require('./receipt'); +const { provision } = require('./provision'); const app = express(); @@ -151,6 +152,20 @@ async function initDatabase() { ['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`); + // Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die + // Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi + // einrichten kann, ohne dass jemand auf den Server muss. + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_host',?)`, + [(process.env.PRINTER_HOST || '').trim()]); + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_port',?)`, + [String(parseInt(process.env.PRINTER_PORT) || 9100)]); + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_token',?)`, + [(process.env.PRINTER_TOKEN || '').trim()]); + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`, + [String(parseInt(process.env.PRINTER_WIDTH) || 42)]); + + await refreshPrinterConfig(); + console.log('Datenbank bereit'); } catch (err) { console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message); @@ -177,6 +192,27 @@ async function getRate() { }; } +/** + * Druckerzugang aus der Datenbank in das printer-Modul uebernehmen. + * Wird beim Start und nach jeder Aenderung aufgerufen - ein Neustart des + * Dienstes ist fuer einen Druckerwechsel nicht noetig. + */ +async function refreshPrinterConfig() { + const rows = await dbAll( + `SELECT key, value FROM settings WHERE key LIKE 'printer_%'`); + const map = Object.fromEntries(rows.map(r => [r.key, r.value])); + const cfg = printer.setConfig({ + host: map.printer_host || '', + port: map.printer_port, + token: map.printer_token || '', + width: map.printer_width + }); + console.log(cfg.host + ? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile` + : 'Bondrucker: nicht eingerichtet'); + return cfg; +} + /** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */ async function getPrintSettings() { const rows = await dbAll( @@ -241,7 +277,7 @@ async function printReceipt(vorgang) { ...vorgang, header: texte.receiptHeader, footer: texte.receiptFooter, - width: printer.WIDTH + width: printer.width() }); const res = await printer.print(daten); return { printed: !!res.ok, error: res.ok ? null : res.error }; @@ -638,6 +674,37 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => { await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0'); } + // Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die + // Einrichtung in dieser Oberflaeche aufgesetzt wurde. + let druckerGeaendert = false; + if (typeof b.printerHost === 'string') { + await setSetting('printer_host', b.printerHost.trim().slice(0, 120)); + druckerGeaendert = true; + } + if (b.printerPort !== undefined) { + const p = parseInt(b.printerPort); + if (!Number.isInteger(p) || p < 1 || p > 65535) { + return res.status(400).json({ error: 'Drucker-Port muss zwischen 1 und 65535 liegen.' }); + } + await setSetting('printer_port', String(p)); + druckerGeaendert = true; + } + if (b.printerWidth !== undefined) { + const w = parseInt(b.printerWidth); + if (!Number.isInteger(w) || w < 24 || w > 96) { + return res.status(400).json({ error: 'Zeichen je Zeile muss zwischen 24 und 96 liegen.' }); + } + await setSetting('printer_width', String(w)); + druckerGeaendert = true; + } + // Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert, + // damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht. + if (typeof b.printerToken === 'string' && b.printerToken.trim()) { + await setSetting('printer_token', b.printerToken.trim().slice(0, 200)); + druckerGeaendert = true; + } + if (druckerGeaendert) await refreshPrinterConfig(); + res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() }); })); @@ -648,11 +715,84 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => { /** Erreichbarkeit und Zustand der Bruecke auf dem Drucker-Pi. */ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => { if (!printer.isConfigured()) { - return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet (PRINTER_HOST).' }); + return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet.' }); } 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({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' } +}); + +/** + * Drucker-Pi per SSH einrichten und anschliessend uebernehmen. + * + * Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach + * vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token. + */ +app.post('/api/printer/provision', requireAuth, provisionLimiter, 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); + + 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.' }); + 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.' }); + } + + console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`); + const ergebnis = await provision({ host, port, username, password }); + + 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 refreshPrinterConfig(); + + // 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. + 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() }); +})); + /** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */ app.post('/api/printer/test', requireAuth, printLimiter, wrap(async (req, res) => { if (!printer.isConfigured()) { diff --git a/deploy/bondrucker/README.md b/deploy/bondrucker/README.md index 106d61b..95f5380 100644 --- a/deploy/bondrucker/README.md +++ b/deploy/bondrucker/README.md @@ -12,7 +12,27 @@ Layout-Änderungen nicht auf dem Pi angefasst werden müssen. Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node. -## Installation auf dem Pi +## 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): ```bash # Dateien auf den Pi kopieren @@ -33,22 +53,25 @@ cd /tmp/bondrucker && sudo bash install.sh | udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` | | Dienst | `bondrucker.service`, aktiviert und gestartet | -Am Ende gibt das Skript den Wert für `PRINTER_TOKEN` aus. Der gehört in die -`.env` des Timer-Servers. +Am Ende gibt das Skript den Wert für das Token aus. + +`install.sh` 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. ## Timer-Server konfigurieren -In `backend/.env`: +Nach der Einrichtung aus der Oberfläche: nichts zu tun. -``` -PRINTER_HOST=192.168.8.157 -PRINTER_PORT=9100 -PRINTER_TOKEN= -PRINTER_WIDTH=42 -``` +Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten" +lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang +liegt in der Datenbank, nicht in der `.env` — ein Neustart des Dienstes ist für +einen Druckerwechsel nicht nötig. -Danach `systemctl restart timer-app`. Ist `PRINTER_HOST` leer, ist die -Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe. +Die Werte `PRINTER_HOST`, `PRINTER_PORT`, `PRINTER_TOKEN` und `PRINTER_WIDTH` +in der `.env` sind nur die **Startwerte beim allerersten Start**. Sind sie leer, +ist die Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe, +bis ein Drucker eingerichtet wird. ## Prüfen @@ -68,6 +91,12 @@ Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar. | `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist | | `POST /print` | Rumpf = rohe ESC/POS-Bytes, Kopfzeile `X-Print-Token`. Antwortet `{"ok":true,"bytes":N}` | +`/status` verlangt **kein** Token — der Zustand des Druckers ist nicht geheim +und soll für Health-Checks abfragbar bleiben. Wird trotzdem eines mitgeschickt, +meldet die Antwort in `tokenAccepted`, ob es passt. Die App nutzt das nach der +Einrichtung als Gegenprobe: erreichbar allein genügt nicht, das Token muss auch +angenommen werden. + Fehler kommen als HTTP 4xx/5xx mit `{"error": "..."}` zurück — die Timer-App zeigt diesen Text unverändert an. „Papier leer" und „Deckel offen" landen als `OSError` beim Schreiben und damit als HTTP 502 in der Anzeige. @@ -100,4 +129,6 @@ printf 'Test\n\n\n\n\x1dVB\x00' | sudo tee /dev/usb/lp0 > /dev/null | `/status` sagt `writable: false` | udev-Regel nicht gegriffen — `sudo udevadm trigger`, Dienst neu starten | | HTTP 403 | `PRINTER_TOKEN` in der `.env` weicht von `/etc/bondrucker/env` ab | | App meldet „nimmt keine Verbindung an" | Dienst läuft nicht, oder falsche IP/Port | +| Einrichtung bricht bei „Dienst eingerichtet" ab | sudo-Passwort falsch, oder der Benutzer darf kein sudo | +| Einrichtung meldet „lehnt das neue Token ab" | auf dem Pi läuft noch eine Brücke vor 1.1 — Einrichtung wiederholen | | Umlaute als Kringel | Drucker beherrscht Codepage 16 (WPC1252) nicht — siehe `backend/escpos.js` | diff --git a/deploy/bondrucker/install.sh b/deploy/bondrucker/install.sh index 3f8e0d1..9d2b8b4 100755 --- a/deploy/bondrucker/install.sh +++ b/deploy/bondrucker/install.sh @@ -56,7 +56,11 @@ udevadm trigger --subsystem-match=usbmisc || true echo "--- systemd" install -m 644 "$QUELLE/bondrucker.service" /etc/systemd/system/bondrucker.service systemctl daemon-reload -systemctl enable --now bondrucker.service +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 diff --git a/deploy/bondrucker/print-server.py b/deploy/bondrucker/print-server.py index 45e5a3e..ac0013f 100644 --- a/deploy/bondrucker/print-server.py +++ b/deploy/bondrucker/print-server.py @@ -32,7 +32,7 @@ import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -VERSION = "1.0" +VERSION = "1.1" DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0") HOST = os.environ.get("PRINT_HOST", "0.0.0.0") @@ -65,11 +65,18 @@ def find_device(): return found[0] if found else None -def device_status(): +def device_status(token_accepted=None): dev = find_device() status = { "ok": False, "version": VERSION, + # Ob das mitgeschickte Token passt. Der Zustand des Druckers selbst ist + # nicht geheim und bleibt fuer Health-Checks ohne Token abfragbar - aber + # wer einrichtet, muss pruefen koennen, ob auch das DRUCKEN klappen + # wird. Ohne diese Angabe meldet ein Dienst, der noch mit dem alten + # Token laeuft, faelschlich "alles gut". + "tokenRequired": bool(TOKEN), + "tokenAccepted": token_accepted, "device": dev or DEVICE, "deviceConfigured": DEVICE, "deviceExists": dev is not None, @@ -127,7 +134,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path.rstrip("/") in ("/status", "/health", ""): - st = device_status() + st = device_status(token_ok(self.headers.get("X-Print-Token"))) self._send(200 if st["ok"] else 503, st) else: self._send(404, {"ok": False, "error": "Unbekannter Pfad"}) diff --git a/frontend/app.js b/frontend/app.js index eb95744..c277c0f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -82,8 +82,13 @@ async function api(url, options = {}) { } if (!response.ok) { let msg = 'Serverfehler'; - try { msg = (await response.json()).error || msg; } catch (e) { /* egal */ } - throw new Error(msg); + let daten = null; + try { daten = await response.json(); msg = daten.error || msg; } catch (e) { /* egal */ } + const fehler = new Error(msg); + // Manche Endpunkte liefern im Fehlerfall Zusatzinfos mit (etwa das + // Protokoll der Druckereinrichtung). Die waeren sonst verloren. + if (daten && daten.steps) fehler.steps = daten.steps; + throw fehler; } return response.status === 204 ? null : response.json(); } @@ -658,6 +663,91 @@ async function testPrint() { } } +// --------------------------------------------------------------------------- +// Einrichtung eines Drucker-Pi +// --------------------------------------------------------------------------- + +function toggleSetup() { + const box = document.getElementById('setupBox'); + const offen = box.style.display !== 'none'; + box.style.display = offen ? 'none' : 'block'; + document.getElementById('setupToggleBtn').textContent = + offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen'; + if (!offen) document.getElementById('setupHost').focus(); +} + +/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */ +function renderSetupLog(steps, laufend) { + const el = document.getElementById('setupLog'); + el.classList.add('show'); + + const zeilen = (steps || []).map(s => ` +
+ ${s.ok ? '✅' : '❌'} +
+
${escapeHtml(s.name)}
+ ${s.detail ? `
${escapeHtml(s.detail)}
` : ''} +
+
`).join(''); + + el.innerHTML = zeilen + (laufend + ? `
+
läuft …
` + : ''); +} + +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; + + 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 }) + }); + + // Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts + // mehr verloren. + passwordEl.value = ''; + + renderSetupLog(res.steps, false); + state.printerConfigured = true; + + 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(() => {}); + } 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'); + } finally { + btn.disabled = false; + btn.textContent = '🚀 Jetzt einrichten'; + } +} + function updateSettingsPreview() { const amount = parseFloat(document.getElementById('rateAmount').value) || 0; const interval = parseInt(document.getElementById('rateInterval').value) || 1; diff --git a/frontend/index.html b/frontend/index.html index cc8516f..34dd819 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -610,6 +610,67 @@ border-left: 3px solid rgba(255, 255, 255, 0.25); } + .setup-box { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 1.25rem; + margin-top: 1rem; + } + + .setup-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; + } + + .setup-grid .form-group + .form-group { margin-top: 0; } + + @media (max-width: 600px) { + .setup-grid { grid-template-columns: 1fr; } + } + + .setup-hint { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.55); + margin-top: 1rem; + line-height: 1.5; + } + + /* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall + sichtbar ist, wie weit es gekommen ist. */ + .setup-log { + margin-top: 1rem; + font-size: 0.85rem; + display: none; + } + + .setup-log.show { display: block; } + + .setup-step { + display: flex; + gap: 0.65rem; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + + .setup-step:last-child { border-bottom: none; } + .setup-step-icon { flex-shrink: 0; } + .setup-step-name { font-weight: 600; } + + .setup-step-detail { + color: rgba(255, 255, 255, 0.6); + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.78rem; + margin-top: 0.3rem; + max-height: 11rem; + overflow-y: auto; + } + + .setup-step.fail .setup-step-name { color: #f87171; } + .printer-state.ok { border-left-color: #22c55e; } .printer-state.error { border-left-color: #ef4444; } .printer-state.off { border-left-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.6); } @@ -814,6 +875,54 @@ onclick="testPrint()">🖨️ Probedruck + + + + @@ -834,6 +943,6 @@
- +