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