Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen
Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser, ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi. Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll. Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0". Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten. Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist eine Vorlage, in die setup.js Adresse, Code, print-server.py und die systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben. Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32 Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant verglichen, hinter einem eigenen Zaehler. Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript. Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen, Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die uebrige API weiterhin 401 liefert. Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c5cdf16c4
commit
e68daee457
14 files changed
+834
-605
No files matched your search
@@ -18,10 +18,8 @@ gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze paral
|
||||
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
|
||||
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
|
||||
Vanilla JS im Frontend, Express im Backend.
|
||||
* **Abhängigkeiten:** 8 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client, ssh2). `ssh2` ist
|
||||
reines JavaScript und wird nur für die Einrichtung eines Drucker-Pi gebraucht
|
||||
(Abschnitt 6).
|
||||
* **Abhängigkeiten:** 7 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client).
|
||||
|
||||
Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3
|
||||
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
|
||||
@@ -214,6 +212,7 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
|
||||
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen |
|
||||
| `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm |
|
||||
| `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
|
||||
| `PUBLIC_URL` | leer | Adresse, unter der der Pi die App erreicht. Leer = aus der Anfrage abgeleitet (siehe Abschnitt 6) |
|
||||
|
||||
**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur
|
||||
Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt
|
||||
@@ -321,38 +320,70 @@ der App liegt.
|
||||
|
||||
#### Einen Pi einrichten
|
||||
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** richtet einen frisch aufgesetzten
|
||||
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen,
|
||||
Knopf drücken, fertig — rund drei Sekunden.
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** → **🔗 Einrichtungslink erzeugen**.
|
||||
Die App zeigt einen fertigen Befehl an, der auf dem Pi ausgeführt wird:
|
||||
|
||||
Die App meldet sich per SSH an, prüft Python/systemd/Druckergerät, überträgt die
|
||||
vier Dateien aus `deploy/bondrucker/` per SFTP nach `/tmp`, startet `install.sh`
|
||||
mit einem selbst erzeugten Token, räumt auf und prüft von außen nach. Danach
|
||||
übernimmt sie den Drucker selbst. Jeder Schritt steht mit Ergebnis im Protokoll.
|
||||
```bash
|
||||
curl -fsSL "http://<timer-app>/setup/bondrucker.sh?code=…" -o bondrucker.sh
|
||||
sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
Das Skript prüft Python/systemd, **fragt, welcher Drucker verwendet werden
|
||||
soll** (mit Klartextnamen aus dem sysfs, also „EPSON TM-T88IV" statt „lp0"),
|
||||
legt Dienstbenutzer, Programm, udev-Regel und systemd-Unit an, erzeugt ein
|
||||
Token und meldet sich damit selbst bei der App an. Die Oberfläche pollt
|
||||
`/api/printer/setup-status` und springt um, sobald der Pi sich gemeldet hat.
|
||||
|
||||
**Warum der Pi sich meldet und nicht umgekehrt.** Vorher richtete die App den
|
||||
Pi per SSH ein. Das verlangte ein SSH-Passwort in einem Webformular, eine
|
||||
ssh2-Abhängigkeit und eine Verbindung vom Server zum Pi. Alle drei sind weg.
|
||||
Wer einen Drucker ansteckt, steht ohnehin am Pi — und genau dort gehört auch
|
||||
die Frage hin, welcher Drucker es sein soll.
|
||||
|
||||
**Das Skript ist zusammengesetzt, nicht statisch.** `deploy/bondrucker/einrichten.sh`
|
||||
ist eine Vorlage mit vier Marken: `@@APP_URL@@`, `@@SETUP_CODE@@`,
|
||||
`@@PRINT_SERVER@@` und `@@SERVICE@@`. `backend/setup.js` füllt sie beim Abholen
|
||||
und bettet `print-server.py` und `bondrucker.service` als Here-Dokumente ein.
|
||||
Heraus kommt **eine** Datei — auf dem Pi ist nichts nachzuladen. Enthielte eine
|
||||
eingebettete Datei zufällig eine Zeile mit dem Schlusswort des Here-Dokuments,
|
||||
bräche das Skript mittendrin ab; `einsetzen()` prüft das und verweigert lieber
|
||||
die Auslieferung.
|
||||
|
||||
**Der Einrichtungscode.** 32 Zeichen, gilt **einmal** und **30 Minuten**, liegt
|
||||
nur im Arbeitsspeicher — ein Neustart der App macht ihn ungültig. Verglichen
|
||||
wird zeitkonstant. Es gibt immer nur einen; ein neuer entwertet den alten. Wer
|
||||
ihn hat, kann den Drucker der App umbiegen, deshalb: nicht weiterreichen.
|
||||
|
||||
**Zwei Endpunkte ohne Anmeldung.** `GET /setup/bondrucker.sh` und
|
||||
`POST /api/setup/register` müssen ohne Session auskommen — der Pi kann sich bei
|
||||
Authentik nicht anmelden. Der Code ist dort der einzige Schlüssel, beide sind
|
||||
über `setupLimiter` begrenzt (30 Versuche je 15 Minuten und IP).
|
||||
|
||||
**Die Adresse des Pi kommt aus der Verbindung**, nicht aus dem Rumpf der
|
||||
Anmeldung: was der Pi über sich behauptet, könnte auch jemand anders behaupten.
|
||||
Nur wenn die Anmeldung über die Rückschleife kam (Tunnel, Testaufbau), zählt
|
||||
die Selbstauskunft `hostHint`.
|
||||
|
||||
**Die eigene Adresse leitet die App aus dem Host-Kopf ab** (`appBasisUrl`) —
|
||||
hinter einem nginx sieht sie von sich aus nur `127.0.0.1`. Wer die Einrichtung
|
||||
startet, hat die App gerade offen; unter genau dieser Adresse findet der Pi sie
|
||||
auch. Passt das nicht, setzt `PUBLIC_URL` in der `.env` den Wert fest. Der Wert
|
||||
landet in einem Shell-Skript und wird deshalb gegen
|
||||
`^https?://[A-Za-z0-9.\-]+(:\d{1,5})?$` geprüft, nicht maskiert.
|
||||
|
||||
Warum der Druckerzugang in der Datenbank liegt und nicht in der `.env`: Sonst
|
||||
wäre genau dieser Knopf sinnlos — die Werte müssten hinterher doch wieder von
|
||||
Hand auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
wäre die Anmeldung des Pi sinnlos — die Werte müssten hinterher doch von Hand
|
||||
auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
SSH-Zugang zum Timer-Server sein.
|
||||
|
||||
Zum Umgang mit den Zugangsdaten: Benutzername und Passwort stehen nur während
|
||||
des Vorgangs im Arbeitsspeicher. Sie werden nicht gespeichert, nicht ins Journal
|
||||
geschrieben und nicht an den Browser zurückgegeben; das erzeugte Drucker-Token
|
||||
wird vor der Ausgabe aus dem Protokoll entfernt. Dauerhaft bleibt allein dieses
|
||||
Token in der Datenbank.
|
||||
|
||||
Der Hostschlüssel des Pi wird beim Erstkontakt angenommen und im Protokoll
|
||||
ausgewiesen (`SHA256:…`) — im eigenen Netz vertretbar, aber es ist eben *kein*
|
||||
Schutz gegen einen Angreifer, der schon im Netz sitzt.
|
||||
|
||||
`install.sh` ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Das Skript ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Neustart liefe die Brücke mit dem alten Token weiter und würde jeden Bon mit
|
||||
HTTP 403 ablehnen — die Gegenprobe nach der Einrichtung prüft deshalb nicht nur
|
||||
die Erreichbarkeit, sondern auch, ob das neue Token angenommen wird.
|
||||
HTTP 403 ablehnen — es prüft deshalb nach dem Start selbst, ob das neue Token
|
||||
angenommen wird, und die App prüft danach noch einmal von außen.
|
||||
|
||||
**Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die
|
||||
App nicht mehr, bis `PRINTER_HOST` angepasst ist — eine DHCP-Reservierung im
|
||||
Router erspart das.
|
||||
App nicht mehr, bis der Drucker neu eingerichtet oder die Adresse in den
|
||||
Einstellungen angepasst ist — eine DHCP-Reservierung im Router erspart das.
|
||||
|
||||
### Backup
|
||||
|
||||
@@ -383,7 +414,7 @@ timer-app/
|
||||
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
|
||||
│ ├── receipt.js ← Layout des Bons
|
||||
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
|
||||
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein
|
||||
│ ├── setup.js ← Einrichtungscode und Skript für den Pi
|
||||
│ ├── package.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── .env.example
|
||||
@@ -401,7 +432,7 @@ timer-app/
|
||||
├── README.md ← Einrichtung des Pi
|
||||
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
|
||||
├── bondrucker.service
|
||||
└── install.sh
|
||||
└── einrichten.sh ← Vorlage, die die App beim Abholen füllt
|
||||
```
|
||||
|
||||
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
||||
@@ -477,7 +508,10 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` |
|
||||
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||||
| `POST` | `/api/printer/test` | Probedruck |
|
||||
| `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten |
|
||||
| `POST` | `/api/printer/setup-code` | erzeugt einen Einrichtungscode, liefert Abhol-Link und fertigen Befehl |
|
||||
| `GET` | `/api/printer/setup-status` | hat sich schon ein Pi gemeldet? |
|
||||
| `GET` | `/setup/bondrucker.sh` | **ohne Anmeldung**, Code als Parameter → das fertige Einrichtungsskript |
|
||||
| `POST` | `/api/setup/register` | **ohne Anmeldung**, `{code, port, token, device}` → der Pi meldet sich selbst an |
|
||||
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
||||
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
|
||||
|
||||
@@ -113,20 +113,6 @@ Darunter steht, was auf dem Bon gedruckt wird:
|
||||
* **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
|
||||
Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand.
|
||||
|
||||
### Einen neuen Drucker anschließen
|
||||
|
||||
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
|
||||
**🔧 Drucker einrichten** anklicken, Adresse, Benutzer und Passwort des
|
||||
Raspberry Pi eintragen, **🚀 Jetzt einrichten** drücken. Nach wenigen Sekunden
|
||||
ist der Drucker eingerichtet und übernommen — es ist nichts weiter zu tun.
|
||||
|
||||
Das Protokoll darunter zeigt jeden Schritt. Bleibt einer rot stehen, steht dort
|
||||
auch, woran es lag. Das Passwort wird nur für diesen Vorgang verwendet und
|
||||
danach verworfen.
|
||||
|
||||
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian), der
|
||||
eingetragene Benutzer darf `sudo`, und der Bondrucker hängt am USB-Anschluss.
|
||||
|
||||
Wichtig zu wissen:
|
||||
|
||||
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
|
||||
@@ -135,6 +121,30 @@ Wichtig zu wissen:
|
||||
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
|
||||
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
|
||||
|
||||
### Einen neuen Drucker anschließen
|
||||
|
||||
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
|
||||
**🔧 Drucker einrichten** anklicken, dann **🔗 Einrichtungslink erzeugen**.
|
||||
|
||||
Es erscheint ein Befehl. Der wird auf dem Raspberry Pi in ein Terminal
|
||||
eingegeben — über **📋 Kopieren** landet er in der Zwischenablage:
|
||||
|
||||
```
|
||||
curl -fsSL "…" -o bondrucker.sh && sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
Das Skript zeigt dann alle angeschlossenen Drucker mit Namen an und fragt,
|
||||
welcher verwendet werden soll. Danach richtet es sich selbst ein und meldet
|
||||
sich bei der Timer-App. Die Anzeige hier springt von „wartet auf den Pi" auf
|
||||
den fertigen Drucker um — es ist nichts weiter zu tun.
|
||||
|
||||
Der Link **gilt nur einmal und eine halbe Stunde lang**. Ist er abgelaufen,
|
||||
einfach einen neuen erzeugen. Weitergeben sollte man ihn nicht: wer ihn hat,
|
||||
kann einstellen, auf welchem Gerät hier gedruckt wird.
|
||||
|
||||
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian) und
|
||||
der Bondrucker hängt am USB-Anschluss.
|
||||
|
||||
## Abmelden
|
||||
|
||||
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon
|
||||
|
||||
+12
-4
@@ -62,9 +62,9 @@ RATE_PER_10MIN=10
|
||||
#
|
||||
# ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start.
|
||||
# Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche
|
||||
# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar
|
||||
# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere
|
||||
# Aenderung hier bleibt also wirkungslos.
|
||||
# gepflegt - "Einstellungen" -> "Drucker einrichten" legt einen Link bereit,
|
||||
# ueber den sich ein Pi selbst einrichtet und anschliessend hier anmeldet.
|
||||
# Eine spaetere Aenderung hier bleibt also wirkungslos.
|
||||
#
|
||||
# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie
|
||||
# vorher, nur ohne Bon-Knoepfe.
|
||||
@@ -73,7 +73,8 @@ PRINTER_HOST=
|
||||
PRINTER_PORT=9100
|
||||
|
||||
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
|
||||
# install.sh gibt den Wert am Ende aus.
|
||||
# Das Einrichtungsskript gibt den Wert am Ende aus, falls es sich nicht selbst
|
||||
# bei der App anmelden konnte.
|
||||
PRINTER_TOKEN=
|
||||
|
||||
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
|
||||
@@ -82,3 +83,10 @@ PRINTER_WIDTH=42
|
||||
# Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
|
||||
# darf das Beenden eines Timers nicht blockieren.
|
||||
PRINTER_TIMEOUT_MS=6000
|
||||
|
||||
# Adresse, unter der der Drucker-Pi diese App erreicht. Normalerweise LEER
|
||||
# lassen: die App leitet sie aus der Anfrage ab, also aus der Adresse, unter der
|
||||
# gerade jemand die Einstellungen offen hat. Nur setzen, wenn der Pi die App
|
||||
# unter einem anderen Namen erreichen muss als der Browser.
|
||||
# Beispiel: PUBLIC_URL=http://192.168.1.50:3003
|
||||
PUBLIC_URL=
|
||||
Generated
+1
-69
@@ -14,8 +14,7 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssh2": "^1.17.0"
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -184,14 +183,6 @@
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -229,14 +220,6 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -310,15 +293,6 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -450,20 +424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -1422,12 +1382,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -2138,23 +2092,6 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
|
||||
@@ -2294,11 +2231,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssh2": "^1.17.0"
|
||||
"sqlite3": "^5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -19,7 +19,7 @@ const oidc = require('./oidc');
|
||||
const printer = require('./printer');
|
||||
const { buildReceipt } = require('./receipt');
|
||||
const { barcodeMaxLength } = require('./escpos');
|
||||
const { provision } = require('./provision');
|
||||
const setup = require('./setup');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -755,77 +755,142 @@ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ configured: true, ...await printer.status(), config: printer.config() });
|
||||
}));
|
||||
|
||||
// Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich
|
||||
// enger begrenzt als beim Drucken.
|
||||
const provisionLimiter = rateLimit({
|
||||
// Einrichtung beruehrt ein fremdes Geraet und ist von aussen erreichbar -
|
||||
// hier deutlich enger begrenzt als beim Drucken.
|
||||
const setupLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
|
||||
});
|
||||
|
||||
/**
|
||||
* Drucker-Pi per SSH einrichten und anschliessend uebernehmen.
|
||||
* Adresse, unter der der Pi diese App erreicht.
|
||||
*
|
||||
* Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach
|
||||
* vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token.
|
||||
* Abgeleitet aus der Anfrage, denn die App weiss von sich aus nicht, unter
|
||||
* welchem Namen sie erreichbar ist - hinter einem nginx sieht sie nur
|
||||
* 127.0.0.1. Wer die Einrichtung startet, hat die App gerade offen; unter
|
||||
* genau dieser Adresse findet sie auch der Pi im selben Netz. PUBLIC_URL
|
||||
* ueberstimmt das, falls die Adresse fuer den Pi eine andere sein muss.
|
||||
*/
|
||||
app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => {
|
||||
function appBasisUrl(req) {
|
||||
const fest = (process.env.PUBLIC_URL || '').trim();
|
||||
if (fest) return fest.replace(/\/+$/, '');
|
||||
return `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Pi - aus der Verbindung, nicht aus dem Rumpf.
|
||||
*
|
||||
* Was der Pi ueber sich selbst behauptet, koennte auch jemand anders
|
||||
* behaupten. Nur wenn die Anmeldung ueber die Rueckschleife kam (Testaufbau
|
||||
* oder Tunnel), hilft die Selbstauskunft ueberhaupt weiter.
|
||||
*/
|
||||
function quelleAdresse(req, hinweis) {
|
||||
const ip = String(req.ip || '').replace(/^::ffff:/, '');
|
||||
if (ip && ip !== '::1' && ip !== '127.0.0.1') return ip;
|
||||
const h = String(hinweis || '').trim();
|
||||
return /^[A-Za-z0-9.\-]{1,120}$/.test(h) ? h : ip;
|
||||
}
|
||||
|
||||
/** Einen Einrichtungscode erzeugen und den Abhol-Link dazu liefern. */
|
||||
app.post('/api/printer/setup-code', requireAuth, setupLimiter, wrap(async (req, res) => {
|
||||
const basis = appBasisUrl(req);
|
||||
const { code, expiresAt } = setup.codeAnlegen();
|
||||
|
||||
// Das Skript gleich hier bauen: ein unbrauchbarer Hostname soll jetzt
|
||||
// auffallen und nicht erst, wenn jemand mit dem Laptop am Pi steht.
|
||||
try {
|
||||
setup.skriptBauen({ appUrl: basis, code });
|
||||
} catch (err) {
|
||||
console.error('[einrichtung] Skript nicht baubar:', err.message);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
const url = `${basis}/setup/bondrucker.sh?code=${code}`;
|
||||
console.log(`[einrichtung] Code erzeugt, gueltig bis ${new Date(expiresAt).toISOString()}`);
|
||||
res.json({
|
||||
url,
|
||||
expiresAt,
|
||||
command: `curl -fsSL "${url}" -o bondrucker.sh && sudo bash bondrucker.sh`
|
||||
});
|
||||
}));
|
||||
|
||||
/** Wartet noch jemand auf den Pi, oder hat er sich gemeldet? */
|
||||
app.get('/api/printer/setup-status', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ ...setup.zustand(), printer: printer.state() });
|
||||
}));
|
||||
|
||||
/**
|
||||
* Das Einrichtungsskript ausliefern.
|
||||
*
|
||||
* OHNE Anmeldung: der Pi holt es mit curl ab und kann sich bei Authentik nicht
|
||||
* anmelden. Der Einrichtungscode ist hier der Schluessel - er gilt einmal und
|
||||
* eine halbe Stunde lang.
|
||||
*/
|
||||
app.get('/setup/bondrucker.sh', setupLimiter, wrap(async (req, res) => {
|
||||
const eintrag = setup.codePruefen(req.query.code);
|
||||
if (!eintrag) {
|
||||
return res.status(403).type('text/plain; charset=utf-8').send(
|
||||
'# Einrichtungscode ungueltig, verbraucht oder abgelaufen.\n' +
|
||||
'# In der Timer-App unter Einstellungen einen neuen erzeugen.\n');
|
||||
}
|
||||
|
||||
let skript;
|
||||
try {
|
||||
skript = setup.skriptBauen({ appUrl: appBasisUrl(req), code: eintrag.code });
|
||||
} catch (err) {
|
||||
console.error('[einrichtung] Skript nicht baubar:', err.message);
|
||||
return res.status(500).type('text/plain; charset=utf-8').send(`# ${err.message}\n`);
|
||||
}
|
||||
|
||||
res.type('text/x-shellscript; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="bondrucker.sh"');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(skript);
|
||||
}));
|
||||
|
||||
/**
|
||||
* Der Pi meldet sich nach der Einrichtung selbst an.
|
||||
*
|
||||
* OHNE Anmeldung, aus demselben Grund wie oben. Der Code wird hier verbraucht:
|
||||
* ein zweiter Pi kann sich mit demselben Code nicht dazwischenschieben.
|
||||
*/
|
||||
app.post('/api/setup/register', setupLimiter, wrap(async (req, res) => {
|
||||
const b = req.body || {};
|
||||
const host = String(b.host || '').trim();
|
||||
const username = String(b.username || '').trim();
|
||||
const password = String(b.password || '');
|
||||
const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port);
|
||||
const eintrag = setup.codePruefen(b.code);
|
||||
if (!eintrag) {
|
||||
console.warn('[einrichtung] Anmeldung mit ungueltigem Code abgewiesen');
|
||||
return res.status(403).json({ error: 'Einrichtungscode ungueltig, verbraucht oder abgelaufen.' });
|
||||
}
|
||||
|
||||
if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' });
|
||||
if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' });
|
||||
if (!password) return res.status(400).json({ error: 'Passwort fehlt.' });
|
||||
const token = String(b.token || '').trim();
|
||||
const port = b.port === undefined ? 9100 : parseInt(b.port);
|
||||
const device = String(b.device || '').slice(0, 120);
|
||||
|
||||
if (!token) return res.status(400).json({ error: 'Drucker-Token fehlt.' });
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' });
|
||||
}
|
||||
// Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment.
|
||||
if (!/^[A-Za-z0-9.\-_]+$/.test(host)) {
|
||||
return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' });
|
||||
return res.status(400).json({ error: 'Port muss zwischen 1 und 65535 liegen.' });
|
||||
}
|
||||
|
||||
console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`);
|
||||
const ergebnis = await provision({ host, port, username, password });
|
||||
const host = quelleAdresse(req, b.hostHint);
|
||||
if (!host) return res.status(400).json({ error: 'Adresse des Pi nicht ermittelbar.' });
|
||||
|
||||
if (!ergebnis.ok) {
|
||||
console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error);
|
||||
return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps });
|
||||
}
|
||||
|
||||
// Uebernehmen: ab hier druckt die App auf diesen Pi.
|
||||
await setSetting('printer_host', ergebnis.host);
|
||||
await setSetting('printer_port', String(ergebnis.port));
|
||||
await setSetting('printer_token', ergebnis.token);
|
||||
await setSetting('printer_host', host);
|
||||
await setSetting('printer_port', String(port));
|
||||
await setSetting('printer_token', token);
|
||||
await refreshPrinterConfig();
|
||||
setup.codeEinloesen(host, device);
|
||||
|
||||
// Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke
|
||||
// meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit
|
||||
// einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim
|
||||
// ersten Bon mit 403 abbrechen.
|
||||
// Gegenprobe von aussen. Der Pi hat sich eben selbst geprueft, aber nur von
|
||||
// innen - ob die App wirklich durchkommt, zeigt erst dieser Griff.
|
||||
const probe = await printer.status();
|
||||
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
|
||||
const bereit = probe.ok && tokenOk;
|
||||
|
||||
let detail;
|
||||
if (bereit) {
|
||||
detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` +
|
||||
(probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : '');
|
||||
} else if (probe.ok && !tokenOk) {
|
||||
detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' +
|
||||
'alte Fassung der Brücke? Einrichtung bitte wiederholen.';
|
||||
} else {
|
||||
detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` +
|
||||
'Meist steht eine Firewall zwischen Server und Pi.';
|
||||
}
|
||||
ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail });
|
||||
|
||||
console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`);
|
||||
res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() });
|
||||
console.log(`[einrichtung] ${host}:${port} angemeldet, Drucker ${bereit ? 'bereit' : 'NICHT erreichbar'}`);
|
||||
res.json({ ok: true, host, port, ready: bereit });
|
||||
}));
|
||||
|
||||
/** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */
|
||||
|
||||
@@ -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
@@ -14,54 +14,69 @@ Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
|
||||
|
||||
## Installation — der übliche Weg
|
||||
|
||||
**Aus der Oberfläche:** ⚙️ Einstellungen → **🔧 Drucker einrichten**. Dort IP,
|
||||
Benutzer und Passwort des Pi eintragen und auf **🚀 Jetzt einrichten** klicken.
|
||||
|
||||
Die App macht dann per SSH genau das, was unten von Hand steht: Dateien
|
||||
übertragen, `install.sh` mit einem frisch erzeugten Token starten, aufräumen
|
||||
und nachsehen, ob der Dienst antwortet. Anschließend übernimmt sie den Drucker
|
||||
selbst — es ist **nichts** mehr in der `.env` einzutragen. Das Protokoll zeigt
|
||||
Schritt für Schritt, was passiert ist.
|
||||
|
||||
Der Pi braucht dafür nur: SSH, Python 3, systemd, einen Benutzer mit sudo — und
|
||||
den Drucker am USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt
|
||||
das ab Werk.
|
||||
|
||||
Die Zugangsdaten werden einmal benutzt und danach verworfen. Gespeichert wird
|
||||
allein das erzeugte Drucker-Token.
|
||||
|
||||
## Installation von Hand
|
||||
|
||||
Falls die App den Pi nicht erreicht (anderes Netz, kein sudo per Passwort):
|
||||
**In der Timer-App:** ⚙️ Einstellungen → **🔧 Drucker einrichten** →
|
||||
**🔗 Einrichtungslink erzeugen**. Die App zeigt einen Befehl an. Der wird auf
|
||||
dem Pi in ein Terminal getippt:
|
||||
|
||||
```bash
|
||||
# Dateien auf den Pi kopieren
|
||||
scp print-server.py bondrucker.service install.sh README.md pi@<PI-IP>:/tmp/bondrucker/
|
||||
|
||||
# Dort einrichten
|
||||
ssh pi@<PI-IP>
|
||||
cd /tmp/bondrucker && sudo bash install.sh
|
||||
curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
|
||||
sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
`install.sh` legt an:
|
||||
Das Skript fragt, welcher Drucker verwendet werden soll, richtet alles ein und
|
||||
meldet den Pi anschließend selbst bei der Timer-App an. In der Oberfläche
|
||||
springt die Anzeige von „wartet auf den Pi" auf den fertigen Drucker um. In der
|
||||
`.env` ist **nichts** einzutragen.
|
||||
|
||||
Der Pi braucht dafür nur Python 3, systemd, `curl` und den Drucker am
|
||||
USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt das ab Werk.
|
||||
|
||||
### Warum der Pi sich meldet und nicht umgekehrt
|
||||
|
||||
Früher hat die App den Pi per SSH eingerichtet. Das verlangte ein SSH-Passwort
|
||||
in einem Webformular und eine Verbindung vom Server zum Pi. Beides ist jetzt
|
||||
weg: kein Passwort verlässt den Laden, und die Verbindung muss nur in eine
|
||||
Richtung funktionieren. Wer einen Drucker anschließt, steht ohnehin am Pi.
|
||||
|
||||
Der Link enthält einen Einrichtungscode. Er gilt **einmal** und **30 Minuten**,
|
||||
liegt nur im Arbeitsspeicher der App und ist nach einem Neustart der App
|
||||
ungültig. Wer den Code hat, kann den Drucker der App umbiegen — er gehört
|
||||
deshalb nicht in einen Chatverlauf.
|
||||
|
||||
## Was das Skript anlegt
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Dienstbenutzer `bondrucker` (Gruppe `lp`) | — |
|
||||
| Programm | `/opt/bondrucker/print-server.py` |
|
||||
| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
|
||||
| Token und Gerät | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
|
||||
| udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` |
|
||||
| Dienst | `bondrucker.service`, aktiviert und gestartet |
|
||||
|
||||
Am Ende gibt das Skript den Wert für das Token aus.
|
||||
|
||||
`install.sh` ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
|
||||
Das Skript ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
|
||||
Token und **startet den Dienst neu**. Ohne den Neustart liefe er mit dem alten
|
||||
Token weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen.
|
||||
|
||||
Schalter für den Fall, dass nicht gefragt werden soll:
|
||||
|
||||
```bash
|
||||
sudo bash bondrucker.sh --device /dev/usb/lp1 --port 9100
|
||||
```
|
||||
|
||||
## Installation ohne die Timer-App
|
||||
|
||||
`einrichten.sh` im Quelltext ist eine Vorlage: die Stellen `@@APP_URL@@`,
|
||||
`@@SETUP_CODE@@`, `@@PRINT_SERVER@@` und `@@SERVICE@@` füllt die App beim
|
||||
Ausliefern. Direkt ausführbar ist sie deshalb nicht.
|
||||
|
||||
Ohne die App werden die Dateien von Hand abgelegt — `print-server.py` nach
|
||||
`/opt/bondrucker/`, `bondrucker.service` nach `/etc/systemd/system/`, dazu
|
||||
`/etc/bondrucker/env` mit `PRINT_TOKEN=` und `PRINT_DEVICE=`. Adresse, Port und
|
||||
Token kommen anschließend von Hand in die Einstellungen der Timer-App.
|
||||
|
||||
## Timer-Server konfigurieren
|
||||
|
||||
Nach der Einrichtung aus der Oberfläche: nichts zu tun.
|
||||
Nach der Einrichtung über den Link: nichts zu tun.
|
||||
|
||||
Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten"
|
||||
lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang
|
||||
|
||||
@@ -15,7 +15,7 @@ Group=bondrucker
|
||||
SupplementaryGroups=lp
|
||||
|
||||
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
|
||||
# Versionsverwaltung landet - die Datei legt install.sh an.
|
||||
# Versionsverwaltung landet - die Datei legt einrichten.sh an.
|
||||
Environment=PRINT_DEVICE=/dev/usb/lp0
|
||||
Environment=PRINT_HOST=0.0.0.0
|
||||
Environment=PRINT_PORT=9100
|
||||
|
||||
Executable
+286
@@ -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
|
||||
@@ -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
@@ -14,6 +14,8 @@ const state = {
|
||||
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
|
||||
tickHandle: null,
|
||||
syncHandle: null,
|
||||
setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi
|
||||
setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird
|
||||
history: { offset: 0, limit: 50, reachedEnd: false }
|
||||
};
|
||||
|
||||
@@ -692,7 +694,7 @@ function toggleSetup() {
|
||||
box.style.display = offen ? 'none' : 'block';
|
||||
document.getElementById('setupToggleBtn').textContent =
|
||||
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
|
||||
if (!offen) document.getElementById('setupHost').focus();
|
||||
if (offen) stopSetupWatch();
|
||||
}
|
||||
|
||||
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
|
||||
@@ -711,59 +713,109 @@ function renderSetupLog(steps, laufend) {
|
||||
|
||||
el.innerHTML = zeilen + (laufend
|
||||
? `<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() {
|
||||
const host = document.getElementById('setupHost').value.trim();
|
||||
const port = document.getElementById('setupPort').value;
|
||||
const username = document.getElementById('setupUser').value.trim();
|
||||
const passwordEl = document.getElementById('setupPassword');
|
||||
const password = passwordEl.value;
|
||||
|
||||
if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error');
|
||||
if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error');
|
||||
if (!password) return toast('Bitte das Passwort eintragen.', 'error');
|
||||
|
||||
if (state.printerConfigured && !confirm(
|
||||
'Es ist bereits ein Drucker eingerichtet.\n\n' +
|
||||
`Nach der Einrichtung druckt die App auf ${host}. Fortfahren?`
|
||||
)) return;
|
||||
|
||||
/** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */
|
||||
async function startSetup() {
|
||||
const btn = document.getElementById('setupStartBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Wird eingerichtet …';
|
||||
renderSetupLog([], true);
|
||||
|
||||
try {
|
||||
const res = await api('/api/printer/provision', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ host, port, username, password })
|
||||
});
|
||||
const res = await api('/api/printer/setup-code', { method: 'POST' });
|
||||
|
||||
// Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts
|
||||
// mehr verloren.
|
||||
passwordEl.value = '';
|
||||
state.setupCommand = res.command;
|
||||
document.getElementById('setupCommand').textContent = res.command;
|
||||
document.getElementById('setupLink').style.display = 'block';
|
||||
|
||||
renderSetupLog(res.steps, false);
|
||||
state.printerConfigured = true;
|
||||
const bis = new Date(res.expiresAt);
|
||||
document.getElementById('setupExpiry').textContent =
|
||||
`Der Link gilt einmal und läuft um ${bis.getHours()}:` +
|
||||
`${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` +
|
||||
'Danach hier einfach einen neuen erzeugen.';
|
||||
|
||||
if (res.ready) {
|
||||
toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success');
|
||||
} else {
|
||||
toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error');
|
||||
}
|
||||
await refreshPrinterStatus();
|
||||
await syncTimers().catch(() => {});
|
||||
renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true);
|
||||
startSetupWatch();
|
||||
toast('Link erzeugt. Jetzt am Pi ausführen.', 'success');
|
||||
} catch (e) {
|
||||
// Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind
|
||||
// das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen.
|
||||
renderSetupLog(e.steps, false);
|
||||
toast('Einrichtung fehlgeschlagen: ' + e.message, 'error');
|
||||
toast('Link konnte nicht erzeugt werden: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 Jetzt einrichten';
|
||||
btn.textContent = '🔗 Neuen Link erzeugen';
|
||||
}
|
||||
}
|
||||
|
||||
function copySetupCommand() {
|
||||
const text = state.setupCommand || '';
|
||||
if (!text) return;
|
||||
|
||||
// navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft
|
||||
// das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten
|
||||
// Weg zurueck, statt wortlos nichts zu tun.
|
||||
const alterWeg = () => {
|
||||
const feld = document.createElement('textarea');
|
||||
feld.value = text;
|
||||
feld.style.position = 'fixed';
|
||||
feld.style.opacity = '0';
|
||||
document.body.appendChild(feld);
|
||||
feld.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ }
|
||||
document.body.removeChild(feld);
|
||||
};
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toast('Befehl kopiert', 'success'),
|
||||
() => { alterWeg(); toast('Befehl kopiert', 'success'); }
|
||||
);
|
||||
} else {
|
||||
alterWeg();
|
||||
toast('Befehl kopiert', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf die Anmeldung des Pi warten.
|
||||
*
|
||||
* Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur
|
||||
* nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung
|
||||
* anzufuehlen, und selten genug, um niemandem wehzutun.
|
||||
*/
|
||||
function startSetupWatch() {
|
||||
stopSetupWatch();
|
||||
state.setupWatch = setInterval(async () => {
|
||||
let z;
|
||||
try {
|
||||
z = await api('/api/printer/setup-status');
|
||||
} catch (e) {
|
||||
return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder
|
||||
}
|
||||
|
||||
if (z.state === 'done') {
|
||||
stopSetupWatch();
|
||||
state.printerConfigured = true;
|
||||
renderSetupLog([
|
||||
{ name: 'Pi angemeldet', ok: true,
|
||||
detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` },
|
||||
{ name: 'Übernommen', ok: true,
|
||||
detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' }
|
||||
], false);
|
||||
toast('Drucker eingerichtet: ' + z.host, 'success');
|
||||
await refreshPrinterStatus();
|
||||
await syncTimers().catch(() => {});
|
||||
} else if (z.state === 'expired') {
|
||||
stopSetupWatch();
|
||||
renderSetupLog([{ name: 'Link abgelaufen', ok: false,
|
||||
detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopSetupWatch() {
|
||||
if (state.setupWatch) {
|
||||
clearInterval(state.setupWatch);
|
||||
state.setupWatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-36
@@ -644,6 +644,37 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-link { margin-top: 1.25rem; }
|
||||
|
||||
/* Der Befehl muss vollstaendig lesbar sein - er wird abgetippt, wenn
|
||||
das Kopieren ins Leere geht (kein HTTPS, fremdes Tablet). Deshalb
|
||||
umbrechen statt abschneiden. */
|
||||
.setup-command {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.setup-command code {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: #d7d7ff;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.setup-command .btn { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setup-command { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
|
||||
sichtbar ist, wie weit es gekommen ist. */
|
||||
.setup-log {
|
||||
@@ -903,47 +934,30 @@
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
Richtet den Druckdienst auf einem frisch aufgesetzten Raspberry Pi
|
||||
vollständig ein und übernimmt ihn anschließend. Der Pi braucht nur
|
||||
SSH, Python 3 und den Drucker am USB-Anschluss.
|
||||
Die App legt ein Einrichtungsskript bereit. Das wird auf dem Pi
|
||||
heruntergeladen und gestartet — dort wird auch ausgewählt, welcher
|
||||
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>
|
||||
|
||||
<div class="setup-grid">
|
||||
<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;">
|
||||
<div class="settings-actions" style="margin-bottom: 0;">
|
||||
<button type="button" class="btn" id="setupStartBtn"
|
||||
onclick="startProvision()">🚀 Jetzt einrichten</button>
|
||||
onclick="startSetup()">🔗 Einrichtungslink erzeugen</button>
|
||||
</div>
|
||||
|
||||
<p class="setup-hint">
|
||||
Das Passwort wird einmal verwendet und danach verworfen — gespeichert
|
||||
wird nur ein neu erzeugtes Drucker-Token. Ein vorhandener Druckdienst
|
||||
auf demselben Pi wird dabei überschrieben.
|
||||
</p>
|
||||
<div id="setupLink" class="setup-link" style="display: none;">
|
||||
<p class="setup-step-name" style="margin-bottom: 0.5rem;">
|
||||
Auf dem Pi im Terminal ausführen:
|
||||
</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>
|
||||
@@ -966,6 +980,6 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/app.js?v=20260912-barcode"></script>
|
||||
<script src="/app.js?v=20260912-setup"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user