Drucker-Pi aus der Oberflaeche heraus einrichten

Die Einrichtung war Handarbeit: Dateien per scp auf den Pi, dort install.sh
mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
niemand mehr weiss. Jetzt macht das ein Knopf unter "Einstellungen":
Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an,
prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet
install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen
nach. Jeder Schritt steht mit Ergebnis im Protokoll.

Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der
Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf
den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim
allerersten Start, wie RATE_PER_10MIN auch.

Zwei Fehler, die beim Testen der Einrichtung auffielen:

install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei
einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token
weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart.

Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein
Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet
in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1),
und die Einrichtung prueft das mit.

Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token
wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal
geht. Neu dabei: ssh2, reines JavaScript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kerim (portable/claudecode)
2026-09-04 20:59:56 +02:00
co-authored by Claude Opus 5
parent 70756f3762
commit 5e23ccdcbc
13 changed files with 855 additions and 62 deletions
+8 -3
View File
@@ -59,10 +59,15 @@ RATE_PER_10MIN=10
# --- Bondrucker ------------------------------------------------------------
# Der Bondrucker haengt per USB an einem Raspberry Pi. Auf dem Pi laeuft die
# Bruecke aus deploy/bondrucker/ und nimmt fertige Druckdaten per HTTP an.
# Einrichtung des Pi: siehe deploy/bondrucker/README.md
#
# Bleibt PRINTER_HOST leer, ist die Druckfunktion aus - die App laeuft dann
# genau wie vorher, nur ohne Bon-Knoepfe.
# ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start.
# Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche
# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar
# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere
# Aenderung hier bleibt also wirkungslos.
#
# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie
# vorher, nur ohne Bon-Knoepfe.
PRINTER_HOST=
PRINTER_PORT=9100
+69 -1
View File
@@ -14,7 +14,8 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"openid-client": "^6.8.4",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"ssh2": "^1.17.0"
},
"engines": {
"node": ">=18"
@@ -183,6 +184,14 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -220,6 +229,14 @@
"node": ">= 10.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -293,6 +310,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -424,6 +450,20 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1382,6 +1422,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"optional": true
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2092,6 +2138,23 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/ssri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@@ -2231,6 +2294,11 @@
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+2 -1
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"openid-client": "^6.8.4",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"ssh2": "^1.17.0"
}
}
+63 -34
View File
@@ -5,39 +5,65 @@
* Server. Auf dem Pi laeuft die Bruecke aus deploy/bondrucker/, die fertige
* ESC/POS-Daten per HTTP annimmt. Hier entsteht nur der Aufruf.
*
* Zwei Grundsaetze, die den Rest erklaeren:
* Drei Grundsaetze, die den Rest erklaeren:
*
* 1. Drucken darf die Zeiterfassung nie aufhalten. Ein leerer Papierrollen-
* halter ist ein Papierproblem, kein Datenproblem: der Vorgang ist da
* bereits gespeichert, der Bon laesst sich aus der Historie nachdrucken.
* 2. Kein Fremdpaket. Node bringt einen HTTP-Client mit.
* 2. Die Zugangsdaten stehen in der Datenbank, nicht in der .env. Nur so kann
* die Oberflaeche einen neuen Pi einrichten, ohne dass jemand auf den
* Server muss. Die .env liefert weiterhin die Startwerte (siehe server.js).
* 3. Kein Fremdpaket fuers Drucken. Node bringt einen HTTP-Client mit.
*/
const http = require('http');
const HOST = (process.env.PRINTER_HOST || '').trim();
const PORT = parseInt(process.env.PRINTER_PORT) || 9100;
const TOKEN = (process.env.PRINTER_TOKEN || '').trim();
const TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000;
const WIDTH = parseInt(process.env.PRINTER_WIDTH) || 42;
// Laufende Konfiguration. Wird beim Start und nach jeder Aenderung in den
// Einstellungen aus der Datenbank gesetzt - siehe refreshPrinterConfig().
let cfg = {
host: '',
port: 9100,
token: '',
width: 42,
timeoutMs: parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000
};
// Zuletzt gesehener Zustand - fuer /health und die Anzeige in den Einstellungen.
let lastError = null;
let lastPrintAt = null;
function isConfigured() {
return HOST.length > 0;
function setConfig(next = {}) {
const port = parseInt(next.port);
const width = parseInt(next.width);
cfg = {
...cfg,
host: String(next.host == null ? cfg.host : next.host).trim(),
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port,
token: next.token == null ? cfg.token : String(next.token).trim(),
width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width
};
return cfg;
}
function isConfigured() {
return cfg.host.length > 0;
}
function width() {
return cfg.width;
}
/** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */
function config() {
return { host: HOST, port: PORT, width: WIDTH, timeoutMs: TIMEOUT_MS, tokenSet: !!TOKEN };
return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token };
}
function state() {
return {
configured: isConfigured(),
host: HOST || null,
port: HOST ? PORT : null,
host: cfg.host || null,
port: isConfigured() ? cfg.port : null,
width: cfg.width,
lastError,
lastPrintAt
};
@@ -47,8 +73,8 @@ function state() {
* Aufrufer zwingt, ihn zu behandeln - stattdessen {ok, error}. */
function request(pfad, method, body) {
return new Promise((resolve) => {
if (!isConfigured()) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet (PRINTER_HOST fehlt).' });
if (!cfg.host) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet.' });
}
const headers = {};
@@ -56,34 +82,37 @@ function request(pfad, method, body) {
headers['Content-Type'] = 'application/octet-stream';
headers['Content-Length'] = body.length;
}
if (TOKEN) headers['X-Print-Token'] = TOKEN;
if (cfg.token) headers['X-Print-Token'] = cfg.token;
const req = http.request({ host: HOST, port: PORT, path: pfad, method, headers }, (res) => {
const teile = [];
res.on('data', d => teile.push(d));
res.on('end', () => {
const roh = Buffer.concat(teile).toString('utf8');
let daten = null;
try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ }
const req = http.request(
{ host: cfg.host, port: cfg.port, path: pfad, method, headers },
(res) => {
const teile = [];
res.on('data', d => teile.push(d));
res.on('end', () => {
const roh = Buffer.concat(teile).toString('utf8');
let daten = null;
try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ }
if (res.statusCode >= 200 && res.statusCode < 300) {
return resolve({ ok: true, data: daten });
}
const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`;
resolve({ ok: false, error: meldung, data: daten });
});
});
if (res.statusCode >= 200 && res.statusCode < 300) {
return resolve({ ok: true, data: daten });
}
const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`;
resolve({ ok: false, error: meldung, data: daten });
});
}
);
req.setTimeout(TIMEOUT_MS, () => {
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${TIMEOUT_MS} ms`));
req.setTimeout(cfg.timeoutMs, () => {
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${cfg.timeoutMs} ms`));
});
req.on('error', (err) => {
// ECONNREFUSED/EHOSTUNREACH sind der Normalfall, wenn der Pi aus ist.
const meldung = err.code === 'ECONNREFUSED'
? `Druckdienst auf ${HOST}:${PORT} nimmt keine Verbindung an.`
? `Druckdienst auf ${cfg.host}:${cfg.port} nimmt keine Verbindung an.`
: err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH'
? `Drucker-Pi ${HOST} ist im Netz nicht erreichbar.`
? `Drucker-Pi ${cfg.host} ist im Netz nicht erreichbar.`
: (err.message || String(err));
resolve({ ok: false, error: meldung });
});
@@ -116,4 +145,4 @@ async function print(buffer) {
return res;
}
module.exports = { isConfigured, config, state, status, print, WIDTH };
module.exports = { setConfig, isConfigured, width, config, state, status, print };
+254
View File
@@ -0,0 +1,254 @@
/**
* Einrichtung eines Drucker-Pi aus der Oberflaeche heraus
*
* Bis hierher war das Handarbeit: Dateien per scp auf den Pi, dort install.sh
* mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
* starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
* niemand mehr weiss. Dieses Modul macht daraus einen Knopf.
*
* Was hier passiert, ist bewusst dasselbe wie von Hand - es laeuft nur
* ferngesteuert ab:
*
* 1. per SSH anmelden
* 2. pruefen, ob der Pi ueberhaupt taugt (Python, Druckergeraet, sudo)
* 3. die drei Dateien aus deploy/bondrucker/ uebertragen
* 4. install.sh mit einem hier erzeugten Token starten
* 5. Spuren im /tmp beseitigen
* 6. von aussen nachsehen, ob der Dienst antwortet
*
* Zum Umgang mit den Zugangsdaten: Passwort und Benutzername stehen nur
* waehrend des Vorgangs im Arbeitsspeicher. Nichts davon wird gespeichert,
* protokolliert oder an den Browser zurueckgegeben. Dauerhaft bleibt allein
* das erzeugte Drucker-Token.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { Client } = require('ssh2');
const QUELLE = path.join(__dirname, '../deploy/bondrucker');
const DATEIEN = ['print-server.py', 'bondrucker.service', 'install.sh', 'README.md'];
const VERBINDUNGS_TIMEOUT_MS = 15000;
const BEFEHL_TIMEOUT_MS = 180000;
/**
* Token fuer die Bruecke. Bewusst nur Buchstaben und Ziffern: der Wert wird
* auf dem Pi als Argument an install.sh weitergereicht, und was keine
* Sonderzeichen enthaelt, kann in keiner Shell etwas anderes bedeuten.
*/
function tokenErzeugen() {
return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
}
/** Fingerabdruck des Hostschluessels, im selben Format wie ssh es zeigt. */
function fingerabdruck(key) {
return 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
}
/**
* Einen Befehl auf dem Pi ausfuehren.
*
* Wichtig: Nutzereingaben werden NIE in die Befehlszeile geschrieben. Das
* Skript kommt fest aus dieser Datei, veraenderliche Werte gehen ueber stdin
* oder ueber Umgebungsvariablen, die ssh2 selbst quotet.
*/
function exec(conn, command, stdin = null) {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
stream.close();
reject(new Error('Zeitueberschreitung auf dem Pi'));
}, BEFEHL_TIMEOUT_MS);
stream.on('close', (code) => {
clearTimeout(timeout);
resolve({ code: code === null ? -1 : code, stdout, stderr });
});
stream.on('data', d => { stdout += d.toString('utf8'); });
stream.stderr.on('data', d => { stderr += d.toString('utf8'); });
if (stdin !== null) stream.write(stdin);
stream.end();
});
});
}
function verbinden({ host, port, username, password }) {
return new Promise((resolve, reject) => {
const conn = new Client();
let hostkey = null;
const abbruch = setTimeout(() => {
conn.end();
reject(new Error(`Keine Antwort von ${host}:${port} innerhalb von ${VERBINDUNGS_TIMEOUT_MS / 1000} s`));
}, VERBINDUNGS_TIMEOUT_MS);
conn.on('ready', () => {
clearTimeout(abbruch);
resolve({ conn, hostkey });
});
conn.on('error', (err) => {
clearTimeout(abbruch);
// Die Meldungen von ssh2 sind fuer eine Ladenoberflaeche zu technisch.
const roh = String(err.message || err);
if (/All configured authentication methods failed/i.test(roh)) {
return reject(new Error('Anmeldung abgelehnt - Benutzername oder Passwort stimmt nicht.'));
}
if (err.code === 'ECONNREFUSED') {
return reject(new Error(`${host}:${port} nimmt keine SSH-Verbindung an. Laeuft dort sshd?`));
}
if (err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH' || err.code === 'ETIMEDOUT') {
return reject(new Error(`${host} ist im Netz nicht erreichbar.`));
}
reject(new Error(roh));
});
conn.connect({
host, port, username, password,
readyTimeout: VERBINDUNGS_TIMEOUT_MS,
// Erstkontakt im eigenen Netz: der Schluessel wird angenommen und
// im Protokoll ausgewiesen, damit er nachvollziehbar bleibt.
hostVerifier: (key) => { hostkey = fingerabdruck(key); return true; }
});
});
}
/** Die vier Dateien per SFTP in ein frisches Verzeichnis unter /tmp legen. */
function uebertragen(conn, zielVerzeichnis) {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
const naechste = (i) => {
if (i >= DATEIEN.length) return resolve(DATEIEN.length);
const name = DATEIEN[i];
const inhalt = fs.readFileSync(path.join(QUELLE, name));
const strom = sftp.createWriteStream(`${zielVerzeichnis}/${name}`);
strom.on('close', () => naechste(i + 1));
strom.on('error', (e) => reject(new Error(`${name}: ${e.message}`)));
strom.end(inhalt);
};
naechste(0);
});
});
}
/**
* Einen Pi einrichten.
*
* @param {object} o
* @param {string} o.host IP oder Name des Pi
* @param {number} o.port SSH-Port
* @param {string} o.username SSH-Benutzer (muss sudo duerfen)
* @param {string} o.password SSH-Passwort, zugleich sudo-Passwort
* @returns {Promise<{ok, token, host, port, steps, fingerprint}>}
* steps = [{name, ok, detail}] - taugt unveraendert als Protokoll
* fuer die Oberflaeche.
*/
async function provision({ host, port = 22, username, password }) {
const steps = [];
const schritt = (name, ok, detail) => { steps.push({ name, ok, detail: detail || '' }); };
const token = tokenErzeugen();
const tmp = `/tmp/bondrucker-${crypto.randomBytes(6).toString('hex')}`;
let conn = null;
let fingerprint = null;
try {
// --- 1. Anmelden ----------------------------------------------------
const verbindung = await verbinden({ host, port, username, password });
conn = verbindung.conn;
fingerprint = verbindung.hostkey;
schritt('Verbindung', true, `als ${username}@${host}:${port} angemeldet` +
(fingerprint ? ` · Hostschlüssel ${fingerprint}` : ''));
// --- 2. Pi pruefen ---------------------------------------------------
// Ein Skript statt vieler Einzelbefehle: eine Runde ueber die Leitung,
// und die Ausgabe ist auch fuer Menschen lesbar.
const pruefung = await exec(conn, 'bash -s', `
printf 'SYSTEM=%s\\n' "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
printf 'ARCH=%s\\n' "$(uname -m)"
printf 'PYTHON=%s\\n' "$(command -v python3 || echo FEHLT)"
printf 'DRUCKER=%s\\n' "$(ls /dev/usb/lp* 2>/dev/null | head -1 || echo KEINER)"
printf 'SYSTEMD=%s\\n' "$(command -v systemctl || echo FEHLT)"
`);
const info = Object.fromEntries(
pruefung.stdout.split('\n').filter(z => z.includes('=')).map(z => {
const i = z.indexOf('=');
return [z.slice(0, i), z.slice(i + 1).trim()];
})
);
if (info.PYTHON === 'FEHLT') {
schritt('System geprüft', false, 'Auf dem Pi fehlt python3 - der Druckdienst braucht es.');
throw new Error('python3 fehlt auf dem Zielsystem.');
}
if (info.SYSTEMD === 'FEHLT') {
schritt('System geprüft', false, 'Kein systemd gefunden - der Dienst kann nicht eingerichtet werden.');
throw new Error('systemd fehlt auf dem Zielsystem.');
}
// Ein fehlender Drucker ist kein Abbruchgrund: der Dienst laeuft auch
// ohne und findet das Geraet, sobald es angesteckt wird.
const druckerHinweis = info.DRUCKER === 'KEINER'
? ' · ACHTUNG: gerade kein Drucker unter /dev/usb/lp* - Kabel prüfen'
: ` · Drucker ${info.DRUCKER}`;
schritt('System geprüft', true, `${info.SYSTEM || 'unbekannt'} (${info.ARCH})${druckerHinweis}`);
// --- 3. Dateien uebertragen ------------------------------------------
await exec(conn, `mkdir -p ${tmp} && chmod 700 ${tmp}`);
const anzahl = await uebertragen(conn, tmp);
schritt('Dateien übertragen', true, `${anzahl} Dateien nach ${tmp}`);
// --- 4. Einrichten ----------------------------------------------------
// Das Passwort geht ueber stdin an sudo, nie ueber die Befehlszeile -
// dort waere es fuer jeden sichtbar, der auf dem Pi "ps" tippt.
const install = await exec(
conn,
`cd ${tmp} && sudo -S -p '' bash install.sh ${token} 2>&1`,
password + '\n'
);
// install.sh gibt das Token am Ende aus - fuer den Menschen, der es von
// Hand aufsetzt, genau richtig. Hier ist es unnoetig: die App kennt es
// ohnehin. Und das Protokoll geht in den Browser und ins Journal.
const ausgabe = (install.stdout + install.stderr)
.split(token).join('(hier nicht angezeigt)')
.trim();
if (install.code !== 0) {
const grund = /incorrect password|Sorry, try again/i.test(ausgabe)
? 'sudo hat das Passwort abgelehnt.'
: `install.sh endete mit Rückgabewert ${install.code}.`;
schritt('Dienst eingerichtet', false, `${grund}\n${ausgabe}`);
throw new Error(grund);
}
schritt('Dienst eingerichtet', true, ausgabe);
// --- 5. Aufraeumen ----------------------------------------------------
await exec(conn, `rm -rf ${tmp}`);
schritt('Aufgeräumt', true, `${tmp} entfernt`);
return { ok: true, token, host, port: 9100, steps, fingerprint };
} catch (err) {
if (!steps.length || steps[steps.length - 1].ok) {
schritt('Abbruch', false, String(err.message || err));
}
return { ok: false, error: String(err.message || err), steps, fingerprint };
} finally {
if (conn) {
// Der Versuch aufzuraeumen darf das Ergebnis nicht mehr kippen.
try { await exec(conn, `rm -rf ${tmp}`); } catch (e) { /* egal */ }
conn.end();
}
}
}
module.exports = { provision };
+142 -2
View File
@@ -18,6 +18,7 @@ require('dotenv').config();
const oidc = require('./oidc');
const printer = require('./printer');
const { buildReceipt } = require('./receipt');
const { provision } = require('./provision');
const app = express();
@@ -151,6 +152,20 @@ async function initDatabase() {
['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`);
// Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die
// Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi
// einrichten kann, ohne dass jemand auf den Server muss.
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_host',?)`,
[(process.env.PRINTER_HOST || '').trim()]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_port',?)`,
[String(parseInt(process.env.PRINTER_PORT) || 9100)]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_token',?)`,
[(process.env.PRINTER_TOKEN || '').trim()]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`,
[String(parseInt(process.env.PRINTER_WIDTH) || 42)]);
await refreshPrinterConfig();
console.log('Datenbank bereit');
} catch (err) {
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
@@ -177,6 +192,27 @@ async function getRate() {
};
}
/**
* Druckerzugang aus der Datenbank in das printer-Modul uebernehmen.
* Wird beim Start und nach jeder Aenderung aufgerufen - ein Neustart des
* Dienstes ist fuer einen Druckerwechsel nicht noetig.
*/
async function refreshPrinterConfig() {
const rows = await dbAll(
`SELECT key, value FROM settings WHERE key LIKE 'printer_%'`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
const cfg = printer.setConfig({
host: map.printer_host || '',
port: map.printer_port,
token: map.printer_token || '',
width: map.printer_width
});
console.log(cfg.host
? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile`
: 'Bondrucker: nicht eingerichtet');
return cfg;
}
/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */
async function getPrintSettings() {
const rows = await dbAll(
@@ -241,7 +277,7 @@ async function printReceipt(vorgang) {
...vorgang,
header: texte.receiptHeader,
footer: texte.receiptFooter,
width: printer.WIDTH
width: printer.width()
});
const res = await printer.print(daten);
return { printed: !!res.ok, error: res.ok ? null : res.error };
@@ -638,6 +674,37 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0');
}
// Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die
// Einrichtung in dieser Oberflaeche aufgesetzt wurde.
let druckerGeaendert = false;
if (typeof b.printerHost === 'string') {
await setSetting('printer_host', b.printerHost.trim().slice(0, 120));
druckerGeaendert = true;
}
if (b.printerPort !== undefined) {
const p = parseInt(b.printerPort);
if (!Number.isInteger(p) || p < 1 || p > 65535) {
return res.status(400).json({ error: 'Drucker-Port muss zwischen 1 und 65535 liegen.' });
}
await setSetting('printer_port', String(p));
druckerGeaendert = true;
}
if (b.printerWidth !== undefined) {
const w = parseInt(b.printerWidth);
if (!Number.isInteger(w) || w < 24 || w > 96) {
return res.status(400).json({ error: 'Zeichen je Zeile muss zwischen 24 und 96 liegen.' });
}
await setSetting('printer_width', String(w));
druckerGeaendert = true;
}
// Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert,
// damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht.
if (typeof b.printerToken === 'string' && b.printerToken.trim()) {
await setSetting('printer_token', b.printerToken.trim().slice(0, 200));
druckerGeaendert = true;
}
if (druckerGeaendert) await refreshPrinterConfig();
res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() });
}));
@@ -648,11 +715,84 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
/** Erreichbarkeit und Zustand der Bruecke auf dem Drucker-Pi. */
app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
if (!printer.isConfigured()) {
return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet (PRINTER_HOST).' });
return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet.' });
}
res.json({ configured: true, ...await printer.status(), config: printer.config() });
}));
// Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich
// enger begrenzt als beim Drucken.
const provisionLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
});
/**
* Drucker-Pi per SSH einrichten und anschliessend uebernehmen.
*
* Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach
* vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token.
*/
app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => {
const b = req.body || {};
const host = String(b.host || '').trim();
const username = String(b.username || '').trim();
const password = String(b.password || '');
const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port);
if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' });
if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' });
if (!password) return res.status(400).json({ error: 'Passwort fehlt.' });
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' });
}
// Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment.
if (!/^[A-Za-z0-9.\-_]+$/.test(host)) {
return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' });
}
console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`);
const ergebnis = await provision({ host, port, username, password });
if (!ergebnis.ok) {
console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error);
return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps });
}
// Uebernehmen: ab hier druckt die App auf diesen Pi.
await setSetting('printer_host', ergebnis.host);
await setSetting('printer_port', String(ergebnis.port));
await setSetting('printer_token', ergebnis.token);
await refreshPrinterConfig();
// Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke
// meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit
// einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim
// ersten Bon mit 403 abbrechen.
const probe = await printer.status();
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
const bereit = probe.ok && tokenOk;
let detail;
if (bereit) {
detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` +
(probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : '');
} else if (probe.ok && !tokenOk) {
detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' +
'alte Fassung der Brücke? Einrichtung bitte wiederholen.';
} else {
detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` +
'Meist steht eine Firewall zwischen Server und Pi.';
}
ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail });
console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`);
res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() });
}));
/** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */
app.post('/api/printer/test', requireAuth, printLimiter, wrap(async (req, res) => {
if (!printer.isConfigured()) {