feat(scan): add guided Linux agent setup in administration
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 29s

This commit is contained in:
2026-09-17 18:56:30 +02:00
parent 1329f164d8
commit 68c0a224e5
15 changed files with 869 additions and 89 deletions

View File

@@ -4,6 +4,28 @@ Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er h
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
## Geführte Linux-Installation in FEDEO
Unter **Administration → Scanner → Agent einrichten** führt ein Assistent durch die Einrichtung:
1. Rechner benennen und den Agenten anlegen.
2. Die vom Scanner-Rechner erreichbare FEDEO-Serveradresse prüfen. Ein Backend-Pfad wie `/backend` muss erhalten bleiben. Optional den Autostart bei der Linux-Anmeldung aktivieren.
3. **Linux-Installation herunterladen**. Die Datei enthält die Software und die persönliche Konfiguration; ein Projekt-Checkout oder `npm install` ist nicht nötig.
4. Auf dem Scanner-Rechner im Download-Ordner den angezeigten Befehl `bash fedeo-agent-<ID>.sh` ausführen, ohne vorangestelltes `sudo`. Fehlende Pakete werden auf Mint/Ubuntu/Debian über `sudo apt-get` installiert. Node.js muss mindestens Version 20 haben; bei einer älteren Distribution gegebenenfalls zuerst Node.js aktualisieren.
5. Der Assistent wartet auf eine aktuelle Meldung des Agenten. Danach den erkannten Scanner und beispielsweise das Profil **ScanSnap iX1600: A4, Duplex, PDF, 300 dpi** wählen und abschließen.
6. Optional **Testscan öffnen**. Der eingerichtete Agent ist vorausgewählt. Erst ein Klick auf **Scannen** startet den Einzug; das Ergebnis landet im Hauptordner des aktuellen Mandanten.
Die Installationsdatei enthält einen Zugangstoken. Nach erfolgreicher Einrichtung löschen oder geschützt aufbewahren. Unter **Einrichtung öffnen** lässt sich ein bestehender Agent erneut installieren. Dafür den gespeicherten Token einfügen oder ausdrücklich einen neuen erzeugen; der bisherige Zugang wird dabei sofort ungültig.
Die Installation ist je Agent getrennt:
- Software: `~/.local/share/fedeo-device-agent/<ID>/`
- Konfiguration mit Dateirechten `0600`: `~/.config/fedeo-device-agent/<ID>.env`
- Arbeitsdateien: `~/.local/state/fedeo-device-agent/<ID>/`
- Benutzerdienst: `fedeo-device-agent-<ID>.service`
Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten.
## Voraussetzungen
### macOS
@@ -36,7 +58,7 @@ Node.js muss mindestens Version 20 haben (`node --version`). CUPS ist nur für d
Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist `04c5:1632`; die WLAN-Schnittstelle wird von diesem Backend nicht unterstützt. Quellen: [SANE-Geräteliste](https://sane-project.gitlab.io/website/sane-mfgs.html), [Fujitsu-Backend](https://sane-project.gitlab.io/website/man/sane-fujitsu.5.html).
1. Scanner einschalten und per USB anschließen. `lsusb -d 04c5:1632` und `scanimage -L` müssen ihn anzeigen. Den vollständigen Gerätenamen übernehmen, beispielsweise `fujitsu:ScanSnap iX1600:123456`.
2. In FEDEO unter **Administration → Scanner** einen Agenten anlegen und den einmalig angezeigten Token sichern.
2. Den oben beschriebenen Einrichtungsassistenten verwenden. Für eine manuelle Installation kann alternativ die API `POST /api/instance-agents` als Administrator einen Agenten und dessen einmalig ausgegebenen Token erstellen.
3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren:
```bash
@@ -96,6 +118,14 @@ npm ci
npm run dev
```
Nach Änderungen am Agenten oder am Linux-Installationsskript das im Frontend ausgelieferte Paket neu erstellen und mitcommitten:
```bash
npm run build:installer
```
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
## OpenCV-Nachbearbeitung
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.

View File

@@ -10,9 +10,11 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
"build:installer": "npm run build && node scripts/build-installer.mjs",
"check:installer": "npm run build && node scripts/build-installer.mjs --check",
"dev": "tsx src/main.ts",
"start": "node dist/main.js",
"test": "node --import tsx --test tests/*.test.ts",
"test": "node --import tsx --test tests/*.test.ts && node --test tests/*.test.mjs && npm run check:installer",
"test:python": "python3 -m unittest discover -s tests -p '*_test.py'",
"setup:opencv": "sh scripts/setup-opencv.sh"
},

View File

@@ -0,0 +1,40 @@
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const output = path.resolve(root, '../../frontend/assets/device-agent/bundle.json')
async function filesUnder(directory) {
const entries = await readdir(path.join(root, directory), { withFileTypes: true })
const paths = await Promise.all(entries.map(entry => entry.isDirectory()
? filesUnder(`${directory}/${entry.name}`)
: [`${directory}/${entry.name}`]))
return paths.flat().sort()
}
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
const paths = [
...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
'scripts/scan_document.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
'requirements-opencv.txt',
]
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))
files['package.json'] = Buffer.from(JSON.stringify({
name: packageJson.name, version: packageJson.version, type: 'module', engines: packageJson.engines,
scripts: { start: 'node dist/main.js', 'setup:opencv': 'sh scripts/setup-opencv.sh' },
}, null, 2) + '\n').toString('base64')
const bundle = JSON.stringify({
version: packageJson.version,
installerTemplate: await readFile(path.join(root, 'scripts/install-linux.sh'), 'utf8'),
files,
}, null, 2) + '\n'
if (process.argv.includes('--check')) {
if (await readFile(output, 'utf8') !== bundle) {
throw new Error('Das Installationspaket ist veraltet. Bitte npm run build:installer ausführen und bundle.json mitcommitten.')
}
console.log('Installationspaket entspricht dem aktuellen Agenten.')
} else {
await mkdir(path.dirname(output), { recursive: true })
await writeFile(output, bundle)
console.log(`Installationspaket erstellt: ${output}`)
}

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Generated by the FEDEO setup wizard. This file contains the agent's access token.
set -euo pipefail
umask 077
if [[ "$(uname -s)" != Linux ]]; then
echo 'Diese Installation ist für Linux vorgesehen.' >&2
exit 1
fi
if [[ "$(id -u)" == 0 ]]; then
echo 'Bitte dieses Skript als normaler Benutzer ohne sudo starten.' >&2
exit 1
fi
if [[ "${1:-}" != '' && "${1:-}" != --no-start ]]; then
echo 'Aufruf: bash install.sh [--no-start]' >&2
exit 1
fi
FEDEO_SETUP_ID='@@AGENT_ID@@'
FEDEO_SETUP_AUTOSTART='@@AUTOSTART@@'
FEDEO_SETUP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/fedeo-device-agent/$FEDEO_SETUP_ID"
FEDEO_SETUP_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/fedeo-device-agent/$FEDEO_SETUP_ID.env"
FEDEO_SETUP_WORK="${XDG_STATE_HOME:-$HOME/.local/state}/fedeo-device-agent/$FEDEO_SETUP_ID"
FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then
if ! command -v apt-get >/dev/null; then
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow installieren und das Skript erneut starten.' >&2
exit 1
fi
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
sudo apt-get update
sudo apt-get install -y nodejs sane-utils python3-pil
fi
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
python3 -c 'import PIL'
FEDEO_SETUP_NODE="$(command -v node)"
FEDEO_SETUP_HAS_SYSTEMD=false
if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then
FEDEO_SETUP_HAS_SYSTEMD=true
fi
if [[ "$FEDEO_SETUP_AUTOSTART" == true && "$FEDEO_SETUP_HAS_SYSTEMD" != true ]]; then
echo 'Der Benutzerdienst von systemd ist nicht erreichbar. Bitte in der Desktop-Sitzung starten oder den Autostart im Assistenten ausschalten.' >&2
exit 1
fi
mkdir -p "$FEDEO_SETUP_DIR" "$(dirname "$FEDEO_SETUP_CONFIG")" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_UNITS"
# Stop only the matching installation before replacing its files.
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true && "${1:-}" != --no-start ]]; then
systemctl --user stop "$FEDEO_SETUP_UNIT" 2>/dev/null || true
fi
python3 - "$FEDEO_SETUP_DIR" "$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_NODE" "$FEDEO_SETUP_UNITS/$FEDEO_SETUP_UNIT" <<'FEDEO_PYTHON'
import base64
import json
import os
from pathlib import Path
import sys
agent_dir, config_path, work_dir, node, unit_path = map(Path, sys.argv[1:])
files = json.loads(base64.b64decode('@@PAYLOAD_BASE64@@'))
for name, encoded in files.items():
relative = Path(name)
if relative.is_absolute() or '..' in relative.parts:
raise ValueError('Ungültiger Dateiname im Installationspaket')
target = agent_dir / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(encoded, validate=True))
config = base64.b64decode('@@CONFIG_BASE64@@').decode('utf8')
config += 'FEDEO_WORK_DIR=' + str(work_dir) + '\n'
config_path.write_text(config, encoding='utf8')
config_path.chmod(0o600)
def unit_quote(value):
return '"' + str(value).replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%') + '"'
unit_path.write_text('\n'.join([
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
'[Service]', 'Type=simple',
'WorkingDirectory=' + unit_quote(agent_dir),
'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)),
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '',
'[Install]', 'WantedBy=default.target', '',
]), encoding='utf8')
FEDEO_PYTHON
echo "Agent installiert: $FEDEO_SETUP_DIR"
echo "Konfiguration: $FEDEO_SETUP_CONFIG"
if [[ "${1:-}" == --no-start ]]; then
echo 'Installation abgeschlossen; Agent wurde nicht gestartet.'
elif [[ "$FEDEO_SETUP_AUTOSTART" == true ]]; then
systemctl --user daemon-reload
systemctl --user enable "$FEDEO_SETUP_UNIT"
systemctl --user restart "$FEDEO_SETUP_UNIT"
echo 'Agent gestartet. Er startet künftig bei der Anmeldung.'
printf 'Protokoll: journalctl --user -u %q -f\n' "$FEDEO_SETUP_UNIT"
echo 'Jetzt im FEDEO-Assistenten die Verbindung prüfen. Die heruntergeladene Installationsdatei kann anschließend gelöscht werden.'
else
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true ]]; then
systemctl --user disable "$FEDEO_SETUP_UNIT" 2>/dev/null || true
systemctl --user daemon-reload
fi
echo 'Agent startet im Terminal. Dieses Fenster geöffnet lassen; Beenden mit Strg+C.'
cd "$FEDEO_SETUP_DIR"
FEDEO_AGENT_ENV="$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_NODE" dist/main.js
fi

View File

@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFile, writeFile, mkdir, mkdtemp, stat, rm } from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { agentBackendUrl, agentSetupReady, renderAgentInstaller } from '../../../frontend/utils/agentInstaller.mjs'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const bundle = JSON.parse(await readFile(path.resolve(root, '../../frontend/assets/device-agent/bundle.json'), 'utf8'))
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64)
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
const execute = promisify(execFile)
test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => {
assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl)
assert.equal(agentBackendUrl('', 'http://localhost:3100'), 'http://localhost:3100')
assert.throws(() => agentBackendUrl('javascript:alert(1)'))
assert.throws(() => agentBackendUrl('https://user:secret@example.com'))
assert.throws(() => agentBackendUrl('https://example.com?token=secret'))
})
test('installer validates tokens and identities before generating executable content', () => {
assert.throws(() => renderAgentInstaller(bundle, { ...options, token: 'not-a-token' }))
assert.throws(() => renderAgentInstaller(bundle, { ...options, agentId: "x'; touch /tmp/unwanted; '" }))
assert.throws(() => renderAgentInstaller(bundle, { ...options, backendUrl: 'https://example.com/\nFEDEO_AGENT_TOKEN=bad' }))
const script = renderAgentInstaller(bundle, options)
assert.ok(!script.includes('@@'))
})
test('connection readiness requires an active agent and a fresh heartbeat for this setup', () => {
const now = Date.now()
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now).toISOString() }, now - 1000, now), true)
assert.equal(agentSetupReady({ active: false, lastSeenAt: new Date(now).toISOString() }, 0, now), false)
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now - 5000).toISOString() }, now - 1000, now), false)
assert.equal(agentSetupReady({ active: true, lastSeenAt: 'invalid' }), false)
})
for (const start of [false, true]) {
test('downloaded installer writes runtime and credentials; start=' + start, { skip: process.platform !== 'linux' }, async (t) => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'fedeo-installer-test-'))
t.after(() => rm(temp, { recursive: true, force: true }))
const bin = path.join(temp, 'bin')
await mkdir(bin)
// No package manager, scanner or real systemd command may run during this test.
await writeFile(path.join(bin, 'sudo'), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
const env = {
...process.env, PATH: bin + ':' + process.env.PATH,
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
}
const scriptPath = path.join(temp, 'install.sh')
await writeFile(scriptPath, renderAgentInstaller(bundle, options))
await execute('bash', ['-n', scriptPath])
const { stdout: output } = await execute('bash', [scriptPath, ...(start ? [] : ['--no-start'])], { env, encoding: 'utf8' })
const agentDir = path.join(env.XDG_DATA_HOME, 'fedeo-device-agent', agentId)
const configPath = path.join(env.XDG_CONFIG_HOME, 'fedeo-device-agent', agentId + '.env')
const config = await readFile(configPath, 'utf8')
assert.ok(config.includes('FEDEO_URL=' + options.backendUrl + '\n'))
assert.ok(config.includes('FEDEO_AGENT_TOKEN=' + token + '\n'))
assert.equal((await stat(configPath)).mode & 0o777, 0o600)
assert.ok(!output.includes(token))
for (const [file, encoded] of Object.entries(bundle.files)) {
assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded)
}
const unit = await readFile(path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service'), 'utf8')
assert.ok(unit.includes('data with spaces %%'))
assert.ok(!unit.includes(token))
const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8')
assert.equal(calls.includes('--user enable fedeo-device-agent-'), start)
assert.equal(calls.includes('--user restart fedeo-device-agent-'), start)
assert.equal(calls.includes('--user stop fedeo-device-agent-'), start)
})
}