3.0.0 #220

Merged
flfeders merged 676 commits from dev into main 2026-09-20 16:10:48 +00:00
15 changed files with 869 additions and 89 deletions
Showing only changes of commit 68c0a224e5 - Show all commits

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)
})
}

View File

@@ -90,10 +90,22 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
createdAt: instanceAgents.createdAt,
})
return {
agent,
token,
}
reply.header("Cache-Control", "no-store")
return { agent, 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) => {

View 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)
})

File diff suppressed because one or more lines are too long

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

View File

@@ -101,7 +101,9 @@ const loadAgents = async () => {
agents.value = response?.agents || []
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)
} catch (err) {
toast.add({

View File

@@ -255,8 +255,10 @@ export const useAdmin = () => {
return await $api("/api/admin/system-status")
}
const getInstanceAgents = async (): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents") as any
const getInstanceAgents = async (quiet = false): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents", quiet ? {
retry: 0, timeout: 15000, onRequestError: () => {}, onResponseError: () => {},
} : {}) as any
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 {
getOverview,
getSystemStatus,
getInstanceAgents,
createInstanceAgent,
updateInstanceAgent,
rotateInstanceAgentToken,
createUser,
createUserForProfile,
updateUser,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
import AgentSetupWizard from "~/components/AgentSetupWizard.vue"
const auth = useAuthStore()
const router = useRouter()
@@ -8,15 +9,16 @@ const admin = useAdmin()
const loading = ref(true)
const saving = ref(false)
const creating = ref(false)
const agents = ref<InstanceAgent[]>([])
const selectedAgentId = ref<string | null>(null)
const generatedToken = ref("")
const newAgent = reactive({
name: "",
description: "",
})
const setupOpen = ref(false)
const setupTarget = ref<InstanceAgent | null>(null)
const setupKey = ref(0)
const openSetup = (agent: InstanceAgent | null = null) => {
setupTarget.value = agent
setupKey.value++
setupOpen.value = true
}
const editState = reactive({
name: "",
@@ -103,38 +105,9 @@ const loadAgents = async () => {
}
}
const createAgent = async () => {
if (!newAgent.name.trim()) return
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
const setupUpdated = async (id: string) => {
selectedAgentId.value = 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 () => {
@@ -192,6 +165,7 @@ onMounted(async () => {
<template>
<UDashboardNavbar title="Administration: Scanner">
<template #right>
<UButton v-if="!setupOpen" icon="i-heroicons-plus" @click="openSetup()">Agent einrichten</UButton>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
@@ -205,47 +179,17 @@ onMounted(async () => {
</UDashboardNavbar>
<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">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-plus-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agent anlegen</h2>
<div class="space-y-4">
<h2 class="text-base font-semibold text-highlighted">Neuen Scanner-Rechner verbinden</h2>
<p class="text-sm text-muted">Der Assistent erstellt die passende Linux-Installation und begleitet dich bis zum ersten Scan.</p>
<UButton icon="i-heroicons-sparkles" @click="openSetup()">Einrichtung starten</UButton>
</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>
<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' }">
<template #header>
<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>
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
</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">
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
</UBadge>

View 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)
})

View File

@@ -4,21 +4,21 @@ const fs = require('node:fs')
const vm = require('node:vm')
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 requests = []
let unmount
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
onBeforeUnmount: handler => { unmount = handler },
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }),
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document', ...scanData } }),
defineEmits: () => () => {},
useModal: () => ({ close() {} }),
useToast: () => ({ add: notice => notices.push(notice) }),
setTimeout: callback => queueMicrotask(callback),
useNuxtApp: () => ({ $api: async (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' } }
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.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)
})

View 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)
}