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:
co-authored by
Claude Opus 5
parent
70756f3762
commit
5e23ccdcbc
+142
-2
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user