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
@@ -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 };
|
||||
Reference in New Issue
Block a user