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
+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 };
|
||||
Reference in new issue
Block a user