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 @@
- +