feat(scan): add guided Linux agent setup in administration
This commit is contained in:
@@ -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.
|
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
|
## Voraussetzungen
|
||||||
|
|
||||||
### macOS
|
### 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).
|
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`.
|
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:
|
3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -96,6 +118,14 @@ npm ci
|
|||||||
npm run dev
|
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
|
## OpenCV-Nachbearbeitung
|
||||||
|
|
||||||
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"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",
|
"dev": "tsx src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"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'",
|
"test:python": "python3 -m unittest discover -s tests -p '*_test.py'",
|
||||||
"setup:opencv": "sh scripts/setup-opencv.sh"
|
"setup:opencv": "sh scripts/setup-opencv.sh"
|
||||||
},
|
},
|
||||||
|
|||||||
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal file
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal 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}`)
|
||||||
|
}
|
||||||
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal 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
|
||||||
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -90,10 +90,22 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
|||||||
createdAt: instanceAgents.createdAt,
|
createdAt: instanceAgents.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
reply.header("Cache-Control", "no-store")
|
||||||
agent,
|
return { agent, token }
|
||||||
token,
|
})
|
||||||
}
|
|
||||||
|
server.post<{ Params: { id: string } }>("/instance-agents/:id/token", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return
|
||||||
|
const id = z.string().uuid().parse(req.params.id)
|
||||||
|
const token = createAgentToken()
|
||||||
|
const [agent] = await server.db
|
||||||
|
.update(instanceAgents)
|
||||||
|
.set({ tokenPrefix: token.slice(0, 24), tokenHash: hashToken(token), lastSeenAt: null, updatedAt: new Date() })
|
||||||
|
.where(eq(instanceAgents.id, id))
|
||||||
|
.returning({ id: instanceAgents.id })
|
||||||
|
if (!agent) return reply.code(404).send({ error: "Agent not found" })
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
return { token }
|
||||||
})
|
})
|
||||||
|
|
||||||
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
|
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
|
||||||
|
|||||||
57
backend/tests/instanceAgentSetup.test.ts
Normal file
57
backend/tests/instanceAgentSetup.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import routes from "../src/routes/instanceAgents"
|
||||||
|
|
||||||
|
const id = "b48c0e33-1abd-4b11-89fa-1bb7b39a3057"
|
||||||
|
async function fixture(isAdmin: boolean) {
|
||||||
|
const updates: any[] = []
|
||||||
|
const app = Fastify()
|
||||||
|
app.addHook("onRequest", async (request) => { request.user = { is_admin: isAdmin } as any })
|
||||||
|
app.decorate("db", {
|
||||||
|
update: () => ({ set: (data: any) => ({ where: (condition: any) => ({ returning: async () => {
|
||||||
|
if (new PgDialect().sqlToQuery(condition).params[0] !== id) return []
|
||||||
|
updates.push(data)
|
||||||
|
return [{ id }]
|
||||||
|
} }) }) }),
|
||||||
|
})
|
||||||
|
await app.register(routes)
|
||||||
|
return { app, updates }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("only administrators can rotate an agent token", async (t) => {
|
||||||
|
const f = await fixture(false)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||||
|
assert.equal(response.statusCode, 403)
|
||||||
|
assert.equal(f.updates.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rotation returns a new token, stores only its hash and requires a new heartbeat", async (t) => {
|
||||||
|
const f = await fixture(true)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const tokens = []
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||||
|
assert.equal(response.statusCode, 200)
|
||||||
|
assert.equal(response.headers["cache-control"], "no-store")
|
||||||
|
const { token } = response.json()
|
||||||
|
assert.match(token, /^fedeo_agent_[a-f0-9]{64}$/)
|
||||||
|
assert.equal(f.updates[i].tokenHash, createHash("sha256").update(token).digest("hex"))
|
||||||
|
assert.equal(f.updates[i].tokenPrefix, token.slice(0, 24))
|
||||||
|
assert.equal(f.updates[i].lastSeenAt, null)
|
||||||
|
assert.equal(f.updates[i].token, undefined)
|
||||||
|
tokens.push(token)
|
||||||
|
}
|
||||||
|
assert.notEqual(tokens[0], tokens[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unknown agents cannot receive setup credentials", async (t) => {
|
||||||
|
const f = await fixture(true)
|
||||||
|
t.after(() => f.app.close())
|
||||||
|
const response = await f.app.inject({ method: "POST", url: "/instance-agents/00000000-0000-4000-8000-000000000000/token" })
|
||||||
|
assert.equal(response.statusCode, 404)
|
||||||
|
assert.equal(f.updates.length, 0)
|
||||||
|
})
|
||||||
21
frontend/assets/device-agent/bundle.json
Normal file
21
frontend/assets/device-agent/bundle.json
Normal file
File diff suppressed because one or more lines are too long
282
frontend/components/AgentSetupWizard.vue
Normal file
282
frontend/components/AgentSetupWizard.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { InstanceAgent } from "~/composables/useAdmin"
|
||||||
|
import FileScanModal from "~/components/FileScanModal.vue"
|
||||||
|
import bundle from "~/assets/device-agent/bundle.json"
|
||||||
|
import { agentBackendUrl, agentSetupReady, ix1600ScanDefaults, renderAgentInstaller } from "~/utils/agentInstaller.mjs"
|
||||||
|
|
||||||
|
const props = defineProps<{ initialAgent?: InstanceAgent | null }>()
|
||||||
|
const emit = defineEmits<{ updated: [id: string]; close: [] }>()
|
||||||
|
const admin = useAdmin()
|
||||||
|
const toast = useToast()
|
||||||
|
const modal = useModal()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const steps = ["Agent", "Installation", "Verbindung", "Scanner"]
|
||||||
|
const step = ref(0)
|
||||||
|
const agent = ref<InstanceAgent | null>(props.initialAgent || null)
|
||||||
|
const name = ref(props.initialAgent?.name || "Linux Büro")
|
||||||
|
const description = ref(props.initialAgent?.description || "")
|
||||||
|
const backendUrl = ref("")
|
||||||
|
const token = ref("")
|
||||||
|
const autostart = ref(true)
|
||||||
|
const rotateConfirmed = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const downloaded = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
const selectedScanner = ref(props.initialAgent?.preferredScannerName || "")
|
||||||
|
const profile = ref(props.initialAgent && !/ix1600/i.test(props.initialAgent.preferredScannerName || "") ? "existing" : "ix1600")
|
||||||
|
const now = ref(Date.now())
|
||||||
|
const checkingSince = ref(0)
|
||||||
|
const checking = ref(false)
|
||||||
|
const connectionError = ref("")
|
||||||
|
const configurationError = ref("")
|
||||||
|
let disposed = false
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const filename = computed(() => 'fedeo-agent-' + (agent.value?.id || 'setup') + '.sh')
|
||||||
|
const installCommand = computed(() => 'bash ' + filename.value)
|
||||||
|
const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim()))
|
||||||
|
const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value))
|
||||||
|
const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value })))
|
||||||
|
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && scannerOptions.value.some(item => item.value === selectedScanner.value))
|
||||||
|
const endpointPreview = computed(() => {
|
||||||
|
try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tokens stay in component memory and in the user's downloaded file only.
|
||||||
|
watch([backendUrl, token, autostart], () => { downloaded.value = false; configurationError.value = '' })
|
||||||
|
const fail = (error: any, title: string) => toast.add({ title, description: error?.data?.error || error?.message || 'Unbekannter Fehler', color: 'error' })
|
||||||
|
|
||||||
|
const prepareAgent = async () => {
|
||||||
|
if (busy.value || (!agent.value && !name.value.trim())) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (!agent.value) {
|
||||||
|
const result = await admin.createInstanceAgent({ name: name.value.trim(), description: description.value.trim() || null })
|
||||||
|
agent.value = result.agent
|
||||||
|
token.value = result.token
|
||||||
|
emit('updated', result.agent.id)
|
||||||
|
} else if (!agent.value.active) {
|
||||||
|
await admin.updateInstanceAgent(agent.value.id, { active: true })
|
||||||
|
agent.value = { ...agent.value, active: true }
|
||||||
|
emit('updated', agent.value.id)
|
||||||
|
}
|
||||||
|
step.value = 1
|
||||||
|
} catch (error) { fail(error, 'Agent konnte nicht vorbereitet werden') }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotateToken = async () => {
|
||||||
|
if (!agent.value || !rotateConfirmed.value || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
token.value = (await admin.rotateInstanceAgentToken(agent.value.id)).token
|
||||||
|
rotateConfirmed.value = false
|
||||||
|
emit('updated', agent.value.id)
|
||||||
|
} catch (error) { fail(error, 'Token konnte nicht erneuert werden') }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadInstaller = () => {
|
||||||
|
if (!agent.value) return
|
||||||
|
try {
|
||||||
|
const script = renderAgentInstaller(bundle, { agentId: agent.value.id, token: token.value.trim(), backendUrl: backendUrl.value, autostart: autostart.value })
|
||||||
|
const url = URL.createObjectURL(new Blob([script], { type: 'text/x-shellscript;charset=utf-8' }))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename.value
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
downloaded.value = true
|
||||||
|
} catch (error: any) { configurationError.value = error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCommand = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(installCommand.value)
|
||||||
|
toast.add({ title: 'Installationsbefehl kopiert', color: 'success' })
|
||||||
|
} catch { toast.add({ title: 'Bitte den angezeigten Befehl manuell kopieren', color: 'info' }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined }
|
||||||
|
const checkConnection = async () => {
|
||||||
|
if (!agent.value || checking.value || disposed) return
|
||||||
|
stopPolling()
|
||||||
|
checking.value = true
|
||||||
|
connectionError.value = ''
|
||||||
|
try {
|
||||||
|
const result = await admin.getInstanceAgents(true)
|
||||||
|
if (disposed) return
|
||||||
|
const current = result.agents.find(item => item.id === agent.value?.id)
|
||||||
|
if (!current) throw new Error('Dieser Agent wurde nicht gefunden. Bitte die Verwaltung aktualisieren.')
|
||||||
|
agent.value = current
|
||||||
|
now.value = Date.now()
|
||||||
|
if (!current.scannerNames?.includes(selectedScanner.value)) {
|
||||||
|
selectedScanner.value = current.scannerNames?.find(scanner => /ix1600/i.test(scanner)) || current.scannerNames?.[0] || ''
|
||||||
|
}
|
||||||
|
} catch (error: any) { connectionError.value = error?.data?.error || error?.message || 'Verbindung zu FEDEO fehlgeschlagen' }
|
||||||
|
finally {
|
||||||
|
checking.value = false
|
||||||
|
if (!disposed && step.value >= 2 && !saved.value && !connectionError.value) pollTimer = setTimeout(checkConnection, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const startConnectionCheck = () => { step.value = 2; checkingSince.value = Date.now() - 1000; checkConnection() }
|
||||||
|
const backToInstallation = () => { step.value = 1; stopPolling() }
|
||||||
|
|
||||||
|
const finishSetup = async () => {
|
||||||
|
if (!agent.value || !readyToSave.value || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
|
||||||
|
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
|
||||||
|
}
|
||||||
|
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
|
||||||
|
saved.value = true
|
||||||
|
token.value = ''
|
||||||
|
stopPolling()
|
||||||
|
emit('updated', agent.value.id)
|
||||||
|
} catch (error) { fail(error, 'Scanner konnte nicht gespeichert werden') }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
const openTestScan = () => modal.open(FileScanModal, { scanData: { agentId: agent.value?.id, folder: null, type: null } })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
backendUrl.value = agentBackendUrl(runtimeConfig.public.apiBase, window.location.origin)
|
||||||
|
clockTimer = setInterval(() => { now.value = Date.now() }, 1000)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true
|
||||||
|
token.value = ''
|
||||||
|
stopPolling()
|
||||||
|
if (clockTimer) clearInterval(clockTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard class="mb-6" :ui="{ root: 'rounded-lg border-primary/40' }">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-highlighted">{{ agent ? agent.name + ' einrichten' : 'Scanner-Agent einrichten' }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted">Schritt für Schritt auf deinem Linux-Rechner installieren und mit FEDEO verbinden.</p>
|
||||||
|
</div>
|
||||||
|
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" aria-label="Einrichtung schließen" :disabled="busy" @click="emit('close')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ol class="mb-7 grid grid-cols-2 gap-3 sm:grid-cols-4" aria-label="Einrichtungsschritte">
|
||||||
|
<li v-for="(label, index) in steps" :key="label" :aria-current="step === index ? 'step' : undefined"
|
||||||
|
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||||
|
:class="step === index ? 'bg-primary/10 font-semibold text-primary' : 'bg-muted/40 text-muted'">
|
||||||
|
<span class="flex size-6 shrink-0 items-center justify-center rounded-full border border-current text-xs">{{ saved || index < step ? '✓' : index + 1 }}</span>
|
||||||
|
{{ label }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="step === 0" class="max-w-2xl space-y-5">
|
||||||
|
<p class="text-sm text-muted">Der Agent läuft auf dem Rechner, an dem dein Scanner per USB angeschlossen ist. Der Download unterstützt Linux Mint, Ubuntu und Debian.</p>
|
||||||
|
<template v-if="!agent">
|
||||||
|
<UFormField label="Name des Rechners" required>
|
||||||
|
<UInput v-model="name" placeholder="Linux Büro" class="w-full" :disabled="busy" @keydown.enter.prevent="prepareAgent" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Beschreibung">
|
||||||
|
<UInput v-model="description" placeholder="Standort oder Arbeitsplatz" class="w-full" :disabled="busy" />
|
||||||
|
</UFormField>
|
||||||
|
</template>
|
||||||
|
<UAlert v-else color="info" variant="soft" title="Vorhandenen Agenten einrichten"
|
||||||
|
:description="agent.active ? 'Du kannst die Installation auf diesem Rechner erneuern.' : 'Beim Fortsetzen wird dieser Agent wieder aktiviert.'" />
|
||||||
|
<UButton :loading="busy" :disabled="!agent && !name.trim()" @click="prepareAgent">{{ agent ? 'Weiter zur Installation' : 'Agent anlegen und weiter' }}</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="step === 1" class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<UFormField label="FEDEO-Serveradresse" description="Vom Scanner-Rechner erreichbare Adresse. Ein vorhandener Pfad wie /backend muss erhalten bleiben.">
|
||||||
|
<UInput v-model="backendUrl" class="w-full" placeholder="https://fedeo.example.com/backend" />
|
||||||
|
</UFormField>
|
||||||
|
<p v-if="endpointPreview" class="break-all text-xs text-muted">Verbindungsziel: {{ endpointPreview }}</p>
|
||||||
|
<UAlert v-if="tokenReady" color="success" variant="soft" title="Zugangstoken bereit" description="Der Token wird in deine Installationsdatei übernommen. Er bleibt auf dieser Seite nur bis zum Schließen verfügbar." />
|
||||||
|
<template v-else>
|
||||||
|
<UFormField label="Vorhandener Agent-Token" description="Falls du ihn bereits gesichert hast, hier einfügen.">
|
||||||
|
<UInput v-model="token" type="password" autocomplete="off" class="w-full" placeholder="fedeo_agent_…" />
|
||||||
|
</UFormField>
|
||||||
|
<div class="space-y-3 rounded-lg border border-default p-4">
|
||||||
|
<p class="text-sm text-muted">Token nicht mehr vorhanden? Ein neuer Token ersetzt den bisherigen Zugang.</p>
|
||||||
|
<UCheckbox v-model="rotateConfirmed" label="Die bisherige Installation darf ihren Zugang verlieren." />
|
||||||
|
<UButton color="neutral" variant="outline" :disabled="!rotateConfirmed" :loading="busy" @click="rotateToken">Neuen Token erzeugen</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<UCheckbox v-model="autostart" label="Agent bei meiner Linux-Anmeldung automatisch starten" />
|
||||||
|
<p class="text-xs text-muted">Ohne Autostart läuft der Agent im Terminal. Der Rechner muss zum Scannen eingeschaltet und angemeldet sein.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 rounded-lg bg-muted/40 p-5">
|
||||||
|
<h3 class="font-semibold text-highlighted">Installation herunterladen</h3>
|
||||||
|
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie prüft Node.js ab Version 20, SANE und Python/Pillow und installiert fehlende Systempakete mit sudo.</p>
|
||||||
|
<p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p>
|
||||||
|
<UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" />
|
||||||
|
<UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton>
|
||||||
|
<p class="text-xs text-muted">Die Datei enthält Zugangsdaten. Bewahre sie geschützt auf oder lösche sie nach erfolgreicher Einrichtung.</p>
|
||||||
|
<UButton v-if="downloaded" icon="i-heroicons-arrow-right" @click="startConnectionCheck">Weiter: Starten und Verbindung prüfen</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="step === 2" class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-highlighted">Auf dem Scanner-Rechner starten</h3>
|
||||||
|
<ol class="list-decimal space-y-3 pl-5 text-sm text-muted">
|
||||||
|
<li>Scanner einschalten und per USB anschließen.</li>
|
||||||
|
<li>Im Download-Ordner ein Terminal öffnen und diesen Befehl ausführen:</li>
|
||||||
|
</ol>
|
||||||
|
<div class="rounded-lg bg-muted p-3">
|
||||||
|
<code class="block break-all text-sm text-highlighted">{{ installCommand }}</code>
|
||||||
|
<UButton class="mt-3" size="sm" color="neutral" variant="outline" icon="i-heroicons-clipboard-document" @click="copyCommand">Befehl kopieren</UButton>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted">Ohne sudo starten. Falls Systempakete fehlen, fragt die Installation nach deinem Passwort. Lass diese Seite geöffnet; der Status wird alle fünf Sekunden geprüft.</p>
|
||||||
|
<UButton color="neutral" variant="ghost" @click="backToInstallation">Zurück zur Konfiguration</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4" aria-live="polite">
|
||||||
|
<UAlert :color="online ? 'success' : 'info'" variant="soft" :title="online ? 'Agent mit FEDEO verbunden' : 'Warte auf Verbindung vom Agenten'"
|
||||||
|
:description="online ? 'Rechner: ' + (agent?.lastDebugInfo?.hostname || agent?.name) : 'Sobald die Installation gestartet ist und sich dieser Agent meldet, erscheint er hier.'" />
|
||||||
|
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
|
||||||
|
<p class="text-sm text-muted">Erkannte Scanner: {{ agent?.scannerNames?.length || 0 }}</p>
|
||||||
|
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Jetzt prüfen</UButton>
|
||||||
|
<UButton :disabled="!online" @click="step = 3">Weiter: Scanner auswählen</UButton>
|
||||||
|
<details class="text-sm text-muted">
|
||||||
|
<summary class="cursor-pointer font-medium">Verbindung kommt nicht zustande?</summary>
|
||||||
|
<p class="mt-3">Die Installationsausgabe auf Fehler prüfen. Die FEDEO-Adresse muss von diesem Rechner erreichbar sein und den Backend-Pfad enthalten. „localhost“ verweist immer auf den Scanner-Rechner. Bei einem erneuerten Token muss die neue Installationsdatei ausgeführt werden.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!saved" class="max-w-2xl space-y-5">
|
||||||
|
<UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." />
|
||||||
|
<UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar"
|
||||||
|
description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." />
|
||||||
|
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
|
||||||
|
<UFormField label="Scanner" required>
|
||||||
|
<USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Scan-Profil">
|
||||||
|
<USelectMenu v-model="profile" :items="[
|
||||||
|
{ label: 'ScanSnap iX1600: A4, Duplex, PDF, 300 dpi', value: 'ix1600' },
|
||||||
|
{ label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' }
|
||||||
|
]" value-key="value" label-key="label" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
<p class="text-sm text-muted">Das iX1600-Profil speichert den gesamten Stapel mit Vorder- und Rückseiten in einem PDF. Weitere Einstellungen kannst du anschließend in der Scannerverwaltung ändern.</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
|
||||||
|
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<UAlert color="success" variant="soft" title="Dein Scanner ist eingerichtet" description="Du kannst jetzt unter Dateien → Scan scannen. Das PDF wird im geöffneten Ordner gespeichert." />
|
||||||
|
<p class="text-sm text-muted">Für einen Test zwei Blätter einlegen. Der Testscan wird im Hauptordner des aktuell ausgewählten Mandanten gespeichert; der Scan startet erst nach deinem Klick im Scan-Dialog.</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<UButton icon="i-heroicons-document-magnifying-glass" @click="openTestScan">Testscan öffnen</UButton>
|
||||||
|
<UButton color="neutral" variant="outline" @click="emit('close')">Zur Scannerverwaltung</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
@@ -101,7 +101,9 @@ const loadAgents = async () => {
|
|||||||
agents.value = response?.agents || []
|
agents.value = response?.agents || []
|
||||||
|
|
||||||
const firstOnlineAgent = activeAgents.value.find(isOnline)
|
const firstOnlineAgent = activeAgents.value.find(isOnline)
|
||||||
selectedAgentId.value = firstOnlineAgent?.id || activeAgents.value[0]?.id || null
|
selectedAgentId.value = props.scanData.agentId
|
||||||
|
? activeAgents.value.find(agent => agent.id === props.scanData.agentId)?.id || null
|
||||||
|
: firstOnlineAgent?.id || activeAgents.value[0]?.id || null
|
||||||
applyAgentDefaults(selectedAgent.value)
|
applyAgentDefaults(selectedAgent.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|||||||
@@ -255,8 +255,10 @@ export const useAdmin = () => {
|
|||||||
return await $api("/api/admin/system-status")
|
return await $api("/api/admin/system-status")
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInstanceAgents = async (): Promise<{ agents: InstanceAgent[] }> => {
|
const getInstanceAgents = async (quiet = false): Promise<{ agents: InstanceAgent[] }> => {
|
||||||
const response = await $api("/api/instance-agents") as any
|
const response = await $api("/api/instance-agents", quiet ? {
|
||||||
|
retry: 0, timeout: 15000, onRequestError: () => {}, onResponseError: () => {},
|
||||||
|
} : {}) as any
|
||||||
return { agents: response?.agents || [] }
|
return { agents: response?.agents || [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,12 +276,17 @@ export const useAdmin = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotateInstanceAgentToken = async (id: string): Promise<{ token: string }> => {
|
||||||
|
return await $api(`/api/instance-agents/${id}/token`, { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getOverview,
|
getOverview,
|
||||||
getSystemStatus,
|
getSystemStatus,
|
||||||
getInstanceAgents,
|
getInstanceAgents,
|
||||||
createInstanceAgent,
|
createInstanceAgent,
|
||||||
updateInstanceAgent,
|
updateInstanceAgent,
|
||||||
|
rotateInstanceAgentToken,
|
||||||
createUser,
|
createUser,
|
||||||
createUserForProfile,
|
createUserForProfile,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InstanceAgent } from "~/composables/useAdmin"
|
import type { InstanceAgent } from "~/composables/useAdmin"
|
||||||
|
import AgentSetupWizard from "~/components/AgentSetupWizard.vue"
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -8,15 +9,16 @@ const admin = useAdmin()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const creating = ref(false)
|
|
||||||
const agents = ref<InstanceAgent[]>([])
|
const agents = ref<InstanceAgent[]>([])
|
||||||
const selectedAgentId = ref<string | null>(null)
|
const selectedAgentId = ref<string | null>(null)
|
||||||
const generatedToken = ref("")
|
const setupOpen = ref(false)
|
||||||
|
const setupTarget = ref<InstanceAgent | null>(null)
|
||||||
const newAgent = reactive({
|
const setupKey = ref(0)
|
||||||
name: "",
|
const openSetup = (agent: InstanceAgent | null = null) => {
|
||||||
description: "",
|
setupTarget.value = agent
|
||||||
})
|
setupKey.value++
|
||||||
|
setupOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const editState = reactive({
|
const editState = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -103,38 +105,9 @@ const loadAgents = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAgent = async () => {
|
const setupUpdated = async (id: string) => {
|
||||||
if (!newAgent.name.trim()) return
|
selectedAgentId.value = id
|
||||||
|
await loadAgents()
|
||||||
creating.value = true
|
|
||||||
generatedToken.value = ""
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await admin.createInstanceAgent({
|
|
||||||
name: newAgent.name.trim(),
|
|
||||||
description: newAgent.description.trim() || null,
|
|
||||||
})
|
|
||||||
|
|
||||||
generatedToken.value = response.token
|
|
||||||
newAgent.name = ""
|
|
||||||
newAgent.description = ""
|
|
||||||
selectedAgentId.value = response.agent.id
|
|
||||||
await loadAgents()
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: "Agent angelegt",
|
|
||||||
description: "Der Token wird nur jetzt vollständig angezeigt.",
|
|
||||||
color: "success",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.add({
|
|
||||||
title: "Agent konnte nicht angelegt werden",
|
|
||||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
|
||||||
color: "error",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveAgent = async () => {
|
const saveAgent = async () => {
|
||||||
@@ -192,6 +165,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar title="Administration: Scanner">
|
<UDashboardNavbar title="Administration: Scanner">
|
||||||
<template #right>
|
<template #right>
|
||||||
|
<UButton v-if="!setupOpen" icon="i-heroicons-plus" @click="openSetup()">Agent einrichten</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-arrow-path"
|
icon="i-heroicons-arrow-path"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -205,47 +179,17 @@ onMounted(async () => {
|
|||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<UDashboardPanelContent>
|
<UDashboardPanelContent>
|
||||||
<div class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<AgentSetupWizard v-if="setupOpen" :key="setupKey" :initial-agent="setupTarget" @updated="setupUpdated" @close="setupOpen = false" />
|
||||||
|
<div v-show="!setupOpen" class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UCard :ui="{ root: 'rounded-lg' }">
|
<UCard :ui="{ root: 'rounded-lg' }">
|
||||||
<template #header>
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-2">
|
<h2 class="text-base font-semibold text-highlighted">Neuen Scanner-Rechner verbinden</h2>
|
||||||
<UIcon name="i-heroicons-plus-circle" class="size-5 text-primary" />
|
<p class="text-sm text-muted">Der Assistent erstellt die passende Linux-Installation und begleitet dich bis zum ersten Scan.</p>
|
||||||
<h2 class="text-base font-semibold text-highlighted">Agent anlegen</h2>
|
<UButton icon="i-heroicons-sparkles" @click="openSetup()">Einrichtung starten</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<form class="space-y-3" @submit.prevent="createAgent">
|
|
||||||
<UFormField label="Name">
|
|
||||||
<UInput v-model="newAgent.name" placeholder="MacBook Büro" />
|
|
||||||
</UFormField>
|
|
||||||
<UFormField label="Beschreibung">
|
|
||||||
<UTextarea v-model="newAgent.description" :rows="2" placeholder="Arbeitsplatz oder Standort" />
|
|
||||||
</UFormField>
|
|
||||||
<UButton
|
|
||||||
type="submit"
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
:loading="creating"
|
|
||||||
:disabled="!newAgent.name.trim()"
|
|
||||||
>
|
|
||||||
Agent anlegen
|
|
||||||
</UButton>
|
|
||||||
</form>
|
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UAlert
|
|
||||||
v-if="generatedToken"
|
|
||||||
color="warning"
|
|
||||||
variant="soft"
|
|
||||||
icon="i-heroicons-key"
|
|
||||||
title="Agent-Token"
|
|
||||||
description="Diesen Token jetzt in die .env des lokalen Agenten übernehmen. Später wird er nicht erneut angezeigt."
|
|
||||||
>
|
|
||||||
<template #description>
|
|
||||||
<code class="mt-2 block break-all rounded bg-muted px-2 py-1 text-xs">{{ generatedToken }}</code>
|
|
||||||
</template>
|
|
||||||
</UAlert>
|
|
||||||
|
|
||||||
<UCard :ui="{ root: 'rounded-lg' }">
|
<UCard :ui="{ root: 'rounded-lg' }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
@@ -290,7 +234,8 @@ onMounted(async () => {
|
|||||||
<h2 class="text-base font-semibold text-highlighted">{{ selectedAgent.name }}</h2>
|
<h2 class="text-base font-semibold text-highlighted">{{ selectedAgent.name }}</h2>
|
||||||
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
|
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UButton color="neutral" variant="outline" size="sm" @click="openSetup(selectedAgent)">Einrichtung öffnen</UButton>
|
||||||
<UBadge :color="isOnline(selectedAgent) ? 'success' : 'neutral'" variant="soft">
|
<UBadge :color="isOnline(selectedAgent) ? 'success' : 'neutral'" variant="soft">
|
||||||
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
|
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
|||||||
129
frontend/tests/agent-setup.test.cjs
Normal file
129
frontend/tests/agent-setup.test.cjs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
|
||||||
|
const token = 'fedeo_agent_' + 'a'.repeat(64)
|
||||||
|
const scanner = 'fujitsu:ScanSnap iX1600:test'
|
||||||
|
const baseAgent = { id: agentId, name: 'Linux Büro', active: true, scannerNames: [], scanDefaults: {}, capabilities: {} }
|
||||||
|
async function setup(t, initialAgent = null, overrides = {}) {
|
||||||
|
const helpers = await import('../utils/agentInstaller.mjs')
|
||||||
|
const requests = [], notices = [], events = [], downloads = [], modals = []
|
||||||
|
const timers = new Map()
|
||||||
|
const mounted = [], unmounted = []
|
||||||
|
let timerId = 0
|
||||||
|
let reportedAgent = { ...baseAgent }
|
||||||
|
const context = vm.createContext({
|
||||||
|
...helpers, ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||||
|
defineProps: () => ({ initialAgent }),
|
||||||
|
defineEmits: () => (...args) => events.push(args),
|
||||||
|
onMounted: handler => mounted.push(handler), onBeforeUnmount: handler => unmounted.push(handler),
|
||||||
|
useAdmin: () => ({
|
||||||
|
createInstanceAgent: async body => { requests.push(['create', body]); return { agent: { ...baseAgent }, token } },
|
||||||
|
updateInstanceAgent: async (id, body) => { requests.push(['update', id, body]); return { agent: { ...baseAgent, ...body } } },
|
||||||
|
rotateInstanceAgentToken: async id => { requests.push(['rotate', id]); return { token } },
|
||||||
|
getInstanceAgents: async () => ({ agents: [reportedAgent] }),
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
useToast: () => ({ add: notice => notices.push(notice) }),
|
||||||
|
useModal: () => ({ open: (...args) => modals.push(args) }),
|
||||||
|
useRuntimeConfig: () => ({ public: { apiBase: '/backend' } }),
|
||||||
|
window: { location: { origin: 'https://fedeo.example.com' } },
|
||||||
|
bundle: JSON.parse(fs.readFileSync(__dirname + '/../assets/device-agent/bundle.json', 'utf8')),
|
||||||
|
FileScanModal: 'FileScanModal',
|
||||||
|
Blob, URL: { createObjectURL: blob => { downloads.push(blob); return 'blob:test' }, revokeObjectURL() {} },
|
||||||
|
document: { createElement: () => ({ click() {}, remove() {} }), body: { appendChild() {} } },
|
||||||
|
setTimeout: callback => { timers.set(++timerId, callback); return timerId },
|
||||||
|
clearTimeout: id => timers.delete(id), setInterval: () => -1, clearInterval() {},
|
||||||
|
})
|
||||||
|
const source = fs.readFileSync(__dirname + '/../components/AgentSetupWizard.vue', 'utf8')
|
||||||
|
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
|
||||||
|
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
|
||||||
|
mounted.forEach(handler => handler())
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
const close = () => unmounted.forEach(handler => handler())
|
||||||
|
t.after(close)
|
||||||
|
return { run: code => vm.runInContext(code, context), requests, notices, events, downloads, modals, timers, close, report: value => { reportedAgent = value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('new setup creates one agent and downloads software with the correct reverse-proxy URL', async t => {
|
||||||
|
const ui = await setup(t)
|
||||||
|
await ui.run('prepareAgent()')
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.equal(ui.run('step.value'), 1)
|
||||||
|
ui.run('downloadInstaller()')
|
||||||
|
assert.equal(ui.requests.filter(item => item[0] === 'create').length, 1)
|
||||||
|
assert.equal(ui.downloads.length, 1)
|
||||||
|
assert.ok((await ui.downloads[0].text()).startsWith('#!/usr/bin/env bash'))
|
||||||
|
assert.equal(ui.run('endpointPreview.value'), 'https://fedeo.example.com/backend/instance-agent/heartbeat')
|
||||||
|
assert.equal(ui.run('downloaded.value'), true)
|
||||||
|
ui.run('backendUrl.value="https://other.example.com/backend"')
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
assert.equal(ui.run('downloaded.value'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('existing agent requires explicit acknowledgement before token rotation', async t => {
|
||||||
|
const ui = await setup(t, { ...baseAgent })
|
||||||
|
await ui.run('prepareAgent()')
|
||||||
|
await ui.run('rotateToken()')
|
||||||
|
assert.equal(ui.requests.length, 0)
|
||||||
|
ui.run('rotateConfirmed.value=true')
|
||||||
|
await ui.run('rotateToken()')
|
||||||
|
assert.equal(ui.requests[0][0], 'rotate')
|
||||||
|
assert.equal(ui.run('tokenReady.value'), true)
|
||||||
|
assert.equal(ui.run('rotateConfirmed.value'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stale or inactive agents cannot finish setup; fresh reports save the chosen duplex scanner', async t => {
|
||||||
|
const ui = await setup(t, { ...baseAgent })
|
||||||
|
ui.report({ ...baseAgent, lastSeenAt: new Date(Date.now() - 300000).toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
|
||||||
|
ui.run('step.value=2; checkingSince.value=Date.now()-1000')
|
||||||
|
await ui.run('checkConnection()')
|
||||||
|
await ui.run('finishSetup()')
|
||||||
|
assert.equal(ui.run('online.value'), false)
|
||||||
|
assert.equal(ui.requests.length, 0)
|
||||||
|
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
|
||||||
|
await ui.run('checkConnection()')
|
||||||
|
ui.run('profile.value="ix1600"; step.value=3')
|
||||||
|
await ui.run('finishSetup()')
|
||||||
|
assert.equal(ui.run('saved.value'), true)
|
||||||
|
assert.equal(ui.requests[0][2].preferredScannerName, scanner)
|
||||||
|
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
|
||||||
|
assert.equal(ui.run('token.value'), '')
|
||||||
|
assert.equal(ui.timers.size, 0)
|
||||||
|
ui.run('openTestScan()')
|
||||||
|
assert.equal(ui.modals[0][1].scanData.agentId, agentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('online agent without a detected scanner remains incomplete', async t => {
|
||||||
|
const ui = await setup(t, { ...baseAgent })
|
||||||
|
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), capabilities: { scan: true } })
|
||||||
|
await ui.run('checkConnection()')
|
||||||
|
await ui.run('finishSetup()')
|
||||||
|
assert.equal(ui.run('online.value'), true)
|
||||||
|
assert.equal(ui.run('readyToSave.value'), false)
|
||||||
|
assert.equal(ui.requests.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connection errors stop automatic requests and are visible for retry', async t => {
|
||||||
|
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
|
||||||
|
ui.run('step.value=2')
|
||||||
|
await ui.run('checkConnection()')
|
||||||
|
assert.equal(ui.run('connectionError.value'), 'offline')
|
||||||
|
assert.equal(ui.timers.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closing setup clears credentials and does not schedule polling after an in-flight response', async t => {
|
||||||
|
let resolve
|
||||||
|
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: () => new Promise(done => { resolve = done }) })
|
||||||
|
ui.run('step.value=2; token.value="' + token + '"')
|
||||||
|
const pending = ui.run('checkConnection()')
|
||||||
|
ui.close()
|
||||||
|
resolve({ agents: [{ ...baseAgent, lastSeenAt: new Date().toISOString() }] })
|
||||||
|
await pending
|
||||||
|
assert.equal(ui.run('token.value'), '')
|
||||||
|
assert.equal(ui.timers.size, 0)
|
||||||
|
})
|
||||||
@@ -4,21 +4,21 @@ const fs = require('node:fs')
|
|||||||
const vm = require('node:vm')
|
const vm = require('node:vm')
|
||||||
const vue = require('vue')
|
const vue = require('vue')
|
||||||
|
|
||||||
async function setup(getJob) {
|
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
|
||||||
const notices = []
|
const notices = []
|
||||||
const requests = []
|
const requests = []
|
||||||
let unmount
|
let unmount
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
|
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
|
||||||
onBeforeUnmount: handler => { unmount = handler },
|
onBeforeUnmount: handler => { unmount = handler },
|
||||||
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }),
|
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document', ...scanData } }),
|
||||||
defineEmits: () => () => {},
|
defineEmits: () => () => {},
|
||||||
useModal: () => ({ close() {} }),
|
useModal: () => ({ close() {} }),
|
||||||
useToast: () => ({ add: notice => notices.push(notice) }),
|
useToast: () => ({ add: notice => notices.push(notice) }),
|
||||||
setTimeout: callback => queueMicrotask(callback),
|
setTimeout: callback => queueMicrotask(callback),
|
||||||
useNuxtApp: () => ({ $api: async (url, options) => {
|
useNuxtApp: () => ({ $api: async (url, options) => {
|
||||||
requests.push({ url, options })
|
requests.push({ url, options })
|
||||||
if (url === '/api/instance-agents') return { agents: [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }] }
|
if (url === '/api/instance-agents') return { agents }
|
||||||
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
|
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
|
||||||
return getJob()
|
return getJob()
|
||||||
} }),
|
} }),
|
||||||
@@ -70,3 +70,16 @@ test('iX1600 preset resets image settings and normalizes the output filename to
|
|||||||
assert.equal(ui.run('scanForm.postprocess'), false)
|
assert.equal(ui.run('scanForm.postprocess'), false)
|
||||||
assert.equal(ui.run('scanForm.resolution'), 300)
|
assert.equal(ui.run('scanForm.resolution'), 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('setup test scan uses the requested agent even when another agent is first', async () => {
|
||||||
|
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] }))
|
||||||
|
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
|
||||||
|
await ui.run('startScan()')
|
||||||
|
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unavailable setup agent never silently scans with another device', async () => {
|
||||||
|
const ui = await setup(() => ({}), { agentId: 'missing' })
|
||||||
|
await ui.run('startScan()')
|
||||||
|
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
|
||||||
|
})
|
||||||
|
|||||||
48
frontend/utils/agentInstaller.mjs
Normal file
48
frontend/utils/agentInstaller.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export function agentBackendUrl(value, origin) {
|
||||||
|
if (/[\r\n\t]/.test(String(value || ''))) throw new Error('Die Serveradresse darf keine Steuerzeichen enthalten.')
|
||||||
|
const url = new URL(String(value || '/').trim(), origin)
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
||||||
|
throw new Error('Bitte eine HTTP(S)-Adresse ohne Zugangsdaten, Suchparameter oder Fragment eingeben.')
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64(value) {
|
||||||
|
const bytes = new TextEncoder().encode(value)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAgentInstaller(bundle, options) {
|
||||||
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(options.agentId)) {
|
||||||
|
throw new Error('Ungültige Agent-ID')
|
||||||
|
}
|
||||||
|
if (!/^fedeo_agent_[a-f0-9]{64}$/.test(options.token)) throw new Error('Bitte den vollständigen Agent-Token eingeben.')
|
||||||
|
const url = agentBackendUrl(options.backendUrl)
|
||||||
|
if (/\s|["'`\\]/.test(url)) throw new Error('Die Serveradresse enthält ungültige Zeichen.')
|
||||||
|
const configuration = [
|
||||||
|
`FEDEO_URL=${url}`, `FEDEO_AGENT_TOKEN=${options.token}`,
|
||||||
|
'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300',
|
||||||
|
'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900',
|
||||||
|
'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '',
|
||||||
|
].join('\n')
|
||||||
|
const replacements = {
|
||||||
|
AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)),
|
||||||
|
PAYLOAD_BASE64: base64(JSON.stringify(bundle.files)), CONFIG_BASE64: base64(configuration),
|
||||||
|
}
|
||||||
|
return bundle.installerTemplate.replace(/@@([A-Z0-9_]+)@@/g, (_, key) => {
|
||||||
|
if (!(key in replacements)) throw new Error('Unbekannter Platzhalter im Installationspaket')
|
||||||
|
return replacements[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ix1600ScanDefaults = {
|
||||||
|
format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex',
|
||||||
|
paperSize: 'A4', postprocess: false, postprocessProfile: 'document',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentSetupReady(agent, since = 0, now = Date.now()) {
|
||||||
|
const lastSeen = Date.parse(agent?.lastSeenAt || '')
|
||||||
|
return Boolean(agent?.active && Number.isFinite(lastSeen) && lastSeen >= since && now - lastSeen < 120000)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user