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:
Kerim (portable/claudecode)andClaude Opus 5 committed 2026-09-12 13:37:26 +02:00
1 parent 8c5cdf16c4
commit e68daee457
14 files changed
+834 -605

No files matched your search

+116 -51
View File
@@ -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?". */