/** * 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 };