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
@@ -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