Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen

Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins
Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser,
ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi.

Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird
das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am
Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht
ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll.

Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0".
Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten.

Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist
eine Vorlage, in die setup.js Adresse, Code, print-server.py und die
systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts
nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben.

Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik
nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32
Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant
verglichen, hinter einem eigenen Zaehler.

Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der
Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders
behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft
sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript.

Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen,
Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die
Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die
uebrige API weiterhin 401 liefert.

Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kerim (portable/claudecode)andClaude Opus 5 committed 2026-09-12 13:37:26 +02:00
1 parent 8c5cdf16c4
commit e68daee457
14 files changed
+834 -605

No files matched your search

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