diff --git a/agents/fedeo-device-agent/.env.example b/agents/fedeo-device-agent/.env.example new file mode 100644 index 0000000..9ed335f --- /dev/null +++ b/agents/fedeo-device-agent/.env.example @@ -0,0 +1,10 @@ +FEDEO_URL=https://fedeo.example.com +FEDEO_AGENT_TOKEN=fedeo_agent_REPLACE_ME +FEDEO_POLL_SECONDS=5 +FEDEO_WORK_DIR=/tmp/fedeo-device-agent +FEDEO_SCANNER_NAME= +FEDEO_PRINTER_NAME= +FEDEO_SCAN_FORMAT=pdf +FEDEO_SCAN_RESOLUTION=300 +FEDEO_SCAN_MODE=Color +FEDEO_SCAN_SOURCE= diff --git a/agents/fedeo-device-agent/.gitignore b/agents/fedeo-device-agent/.gitignore new file mode 100644 index 0000000..94c4183 --- /dev/null +++ b/agents/fedeo-device-agent/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +.env +*.log +*.tmp diff --git a/agents/fedeo-device-agent/README.md b/agents/fedeo-device-agent/README.md new file mode 100644 index 0000000..5d6490b --- /dev/null +++ b/agents/fedeo-device-agent/README.md @@ -0,0 +1,84 @@ +# FEDEO Geräte-Agent + +Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er holt instanzweite Scan-Aufträge von FEDEO ab, führt sie auf einem lokal angeschlossenen Scanner aus und lädt das Ergebnis wieder in FEDEO hoch. + +Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst. + +## Voraussetzungen + +### macOS + +```bash +brew install node sane-backends +scanimage -L +``` + +Drucken nutzt später das macOS-Drucksystem/CUPS: + +```bash +lpstat -p +``` + +### Linux und Raspberry Pi OS + +```bash +sudo apt update +sudo apt install -y nodejs npm sane-utils cups +scanimage -L +lpstat -p +``` + +## Konfiguration + +```bash +cp .env.example .env +nano .env +``` + +Wichtige Werte: + +```env +FEDEO_URL=https://deine-fedeo-instanz +FEDEO_AGENT_TOKEN=fedeo_agent_... +FEDEO_SCANNER_NAME= +FEDEO_POLL_SECONDS=5 +``` + +Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanner. + +## Entwicklung + +```bash +npm install +npm run dev +``` + +## Build + +```bash +npm run build +npm start +``` + +## FEDEO-Endpunkte + +Der Agent nutzt: + +- `POST /instance-agent/heartbeat` +- `GET /instance-agent/scan-jobs/next` +- `POST /instance-agent/scan-jobs/:id/status` +- `POST /instance-agent/scan-jobs/:id/upload` + +## macOS Autostart + +Die Vorlage liegt unter `system/macos/com.fedeo.device-agent.plist`. Nach Anpassung der Pfade kann sie als LaunchAgent installiert werden: + +```bash +mkdir -p ~/Library/LaunchAgents +cp system/macos/com.fedeo.device-agent.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.fedeo.device-agent.plist +``` + +## Linux Autostart + +Die Vorlage liegt unter `system/linux/fedeo-device-agent.service`. diff --git a/agents/fedeo-device-agent/package.json b/agents/fedeo-device-agent/package.json new file mode 100644 index 0000000..b2575b1 --- /dev/null +++ b/agents/fedeo-device-agent/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fedeo/device-agent", + "version": "0.1.0", + "private": true, + "description": "Lokaler FEDEO Druck- und Scan-Agent für macOS, Linux und Raspberry Pi OS.", + "type": "module", + "main": "dist/main.js", + "bin": { + "fedeo-device-agent": "dist/main.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx src/main.ts", + "start": "node dist/main.js" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^24.3.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/agents/fedeo-device-agent/src/api.ts b/agents/fedeo-device-agent/src/api.ts new file mode 100644 index 0000000..4af73a9 --- /dev/null +++ b/agents/fedeo-device-agent/src/api.ts @@ -0,0 +1,67 @@ +import { readFile } from "node:fs/promises" +import { basename } from "node:path" +import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js" + +export class FedeoApi { + constructor(private readonly config: AgentConfig) {} + + private url(path: string) { + return `${this.config.fedeoUrl}${path}` + } + + private headers(extra?: HeadersInit): HeadersInit { + return { + "X-Agent-Token": this.config.agentToken, + ...extra, + } + } + + private async request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(this.url(path), { + ...init, + headers: this.headers(init.headers), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`${init.method || "GET"} ${path} fehlgeschlagen: ${response.status} ${body}`) + } + + return await response.json() as T + } + + heartbeat(payload: AgentHeartbeat) { + return this.request<{ status: string; pendingScanJobs: number }>("/instance-agent/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + } + + nextScanJob() { + return this.request("/instance-agent/scan-jobs/next") + } + + updateScanJobStatus(jobId: string, status: "running" | "failed" | "canceled", message?: string) { + return this.request(`/instance-agent/scan-jobs/${jobId}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status, message }), + }) + } + + async uploadScan(jobId: string, result: ScanResult) { + const form = new FormData() + const fileBuffer = await readFile(result.path) + const file = new File([fileBuffer], result.filename || basename(result.path), { + type: result.mimeType, + }) + + form.append("file", file) + + return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, { + method: "POST", + body: form, + }) + } +} diff --git a/agents/fedeo-device-agent/src/commands.ts b/agents/fedeo-device-agent/src/commands.ts new file mode 100644 index 0000000..66fbf23 --- /dev/null +++ b/agents/fedeo-device-agent/src/commands.ts @@ -0,0 +1,48 @@ +import { spawn } from "node:child_process" + +export type CommandResult = { + stdout: string + stderr: string + code: number +} + +export const commandExists = (command: string) => + new Promise((resolve) => { + const child = spawn("sh", ["-lc", `command -v ${command}`]) + child.on("error", () => resolve(false)) + child.on("close", (code) => resolve(code === 0)) + }) + +export const runCommand = ( + command: string, + args: string[], + options: { timeoutMs?: number } = {} +) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + }) + + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + + const timeout = options.timeoutMs + ? setTimeout(() => { + child.kill("SIGTERM") + reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`)) + }, options.timeoutMs) + : null + + child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))) + child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))) + child.on("error", reject) + child.on("close", (code) => { + if (timeout) clearTimeout(timeout) + + resolve({ + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + code: code ?? 0, + }) + }) + }) diff --git a/agents/fedeo-device-agent/src/config.ts b/agents/fedeo-device-agent/src/config.ts new file mode 100644 index 0000000..fcb11e4 --- /dev/null +++ b/agents/fedeo-device-agent/src/config.ts @@ -0,0 +1,44 @@ +import path from "node:path" +import os from "node:os" +import { AgentConfig } from "./types.js" +import { loadDotEnv } from "./env.js" + +const optional = (value: string | undefined) => { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +const numberFromEnv = (value: string | undefined, fallback: number) => { + if (!value) return fallback + + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +const scanFormatFromEnv = (value: string | undefined): AgentConfig["scanFormat"] => { + if (value === "png" || value === "tiff" || value === "pdf") return value + return "pdf" +} + +export const loadConfig = (): AgentConfig => { + loadDotEnv(process.env.FEDEO_AGENT_ENV || ".env") + + const fedeoUrl = optional(process.env.FEDEO_URL) + const agentToken = optional(process.env.FEDEO_AGENT_TOKEN) + + if (!fedeoUrl) throw new Error("FEDEO_URL fehlt") + if (!agentToken) throw new Error("FEDEO_AGENT_TOKEN fehlt") + + return { + fedeoUrl: fedeoUrl.replace(/\/+$/, ""), + agentToken, + pollSeconds: numberFromEnv(process.env.FEDEO_POLL_SECONDS, 5), + workDir: optional(process.env.FEDEO_WORK_DIR) || path.join(os.tmpdir(), "fedeo-device-agent"), + scannerName: optional(process.env.FEDEO_SCANNER_NAME), + printerName: optional(process.env.FEDEO_PRINTER_NAME), + scanFormat: scanFormatFromEnv(process.env.FEDEO_SCAN_FORMAT), + scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300), + scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color", + scanSource: optional(process.env.FEDEO_SCAN_SOURCE), + } +} diff --git a/agents/fedeo-device-agent/src/env.ts b/agents/fedeo-device-agent/src/env.ts new file mode 100644 index 0000000..9b33b61 --- /dev/null +++ b/agents/fedeo-device-agent/src/env.ts @@ -0,0 +1,32 @@ +import { readFileSync, existsSync } from "node:fs" + +const parseEnvLine = (line: string) => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) return null + + const separator = trimmed.indexOf("=") + if (separator === -1) return null + + const key = trimmed.slice(0, separator).trim() + let value = trimmed.slice(separator + 1).trim() + + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + return { key, value } +} + +export const loadDotEnv = (path = ".env") => { + if (!existsSync(path)) return + + const content = readFileSync(path, "utf8") + for (const line of content.split(/\r?\n/)) { + const parsed = parseEnvLine(line) + if (!parsed) continue + if (process.env[parsed.key] === undefined) process.env[parsed.key] = parsed.value + } +} diff --git a/agents/fedeo-device-agent/src/logger.ts b/agents/fedeo-device-agent/src/logger.ts new file mode 100644 index 0000000..c7da5ec --- /dev/null +++ b/agents/fedeo-device-agent/src/logger.ts @@ -0,0 +1,30 @@ +const timestamp = () => new Date().toISOString() + +export const log = { + info(message: string, meta?: unknown) { + if (meta === undefined) { + console.log(`[${timestamp()}] INFO ${message}`) + return + } + + console.log(`[${timestamp()}] INFO ${message}`, meta) + }, + + warn(message: string, meta?: unknown) { + if (meta === undefined) { + console.warn(`[${timestamp()}] WARN ${message}`) + return + } + + console.warn(`[${timestamp()}] WARN ${message}`, meta) + }, + + error(message: string, meta?: unknown) { + if (meta === undefined) { + console.error(`[${timestamp()}] ERROR ${message}`) + return + } + + console.error(`[${timestamp()}] ERROR ${message}`, meta) + }, +} diff --git a/agents/fedeo-device-agent/src/main.ts b/agents/fedeo-device-agent/src/main.ts new file mode 100644 index 0000000..f7a44fe --- /dev/null +++ b/agents/fedeo-device-agent/src/main.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import os from "node:os" +import { FedeoApi } from "./api.js" +import { loadConfig } from "./config.js" +import { log } from "./logger.js" +import { listPrinters } from "./print/cups.js" +import { hasSane, listScanners, runScan } from "./scan/sane.js" + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const stringifyError = (error: unknown) => { + if (error instanceof Error) return error.message + return String(error) +} + +const main = async () => { + const config = loadConfig() + const api = new FedeoApi(config) + + log.info("FEDEO Geräte-Agent startet", { + platform: process.platform, + workDir: config.workDir, + pollSeconds: config.pollSeconds, + }) + + while (true) { + try { + const scannerNames = await listScanners() + const printerNames = await listPrinters() + const scanAvailable = await hasSane() + + const heartbeat = await api.heartbeat({ + capabilities: { + scan: scanAvailable, + print: printerNames.length > 0, + platform: process.platform, + }, + scannerNames, + printerNames, + debugInfo: { + hostname: os.hostname(), + release: os.release(), + arch: os.arch(), + node: process.version, + uptimeSeconds: Math.round(os.uptime()), + }, + }) + + if (heartbeat.pendingScanJobs > 0) { + log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`) + } + + const next = await api.nextScanJob() + if (!next.job) { + await sleep(config.pollSeconds * 1000) + continue + } + + log.info("Scan-Auftrag wird ausgeführt", { + jobId: next.job.id, + tenantId: next.job.tenantId, + scannerName: next.job.scannerName || config.scannerName || "default", + }) + + try { + await api.updateScanJobStatus(next.job.id, "running") + const scanResult = await runScan(config, next.job) + await api.uploadScan(next.job.id, scanResult) + + log.info("Scan-Auftrag abgeschlossen", { + jobId: next.job.id, + file: scanResult.filename, + }) + } catch (error) { + const message = stringifyError(error) + log.error("Scan-Auftrag fehlgeschlagen", { + jobId: next.job.id, + message, + }) + + await api.updateScanJobStatus(next.job.id, "failed", message) + } + } catch (error) { + log.error("Agent-Schleife fehlgeschlagen", stringifyError(error)) + await sleep(config.pollSeconds * 1000) + } + } +} + +main().catch((error) => { + log.error("Agent konnte nicht gestartet werden", stringifyError(error)) + process.exit(1) +}) diff --git a/agents/fedeo-device-agent/src/print/cups.ts b/agents/fedeo-device-agent/src/print/cups.ts new file mode 100644 index 0000000..0a5d32f --- /dev/null +++ b/agents/fedeo-device-agent/src/print/cups.ts @@ -0,0 +1,15 @@ +import { commandExists, runCommand } from "../commands.js" + +export const hasCups = () => commandExists("lpstat") + +export const listPrinters = async () => { + if (!await hasCups()) return [] + + const result = await runCommand("lpstat", ["-p"], { timeoutMs: 10_000 }) + if (result.code !== 0) return [] + + return result.stdout + .split(/\r?\n/) + .map((line) => line.match(/^printer\s+(\S+)/)?.[1]) + .filter((printer): printer is string => Boolean(printer)) +} diff --git a/agents/fedeo-device-agent/src/scan/sane.ts b/agents/fedeo-device-agent/src/scan/sane.ts new file mode 100644 index 0000000..ef1576e --- /dev/null +++ b/agents/fedeo-device-agent/src/scan/sane.ts @@ -0,0 +1,85 @@ +import { mkdirSync } from "node:fs" +import path from "node:path" +import { AgentConfig, ScanJob, ScanResult } from "../types.js" +import { commandExists, runCommand } from "../commands.js" + +const mimeTypes = { + pdf: "application/pdf", + png: "image/png", + tiff: "image/tiff", +} + +const stringSetting = (settings: Record | undefined, key: string) => { + const value = settings?.[key] + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +const numberSetting = (settings: Record | undefined, key: string) => { + const value = settings?.[key] + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + + return undefined +} + +export const hasSane = () => commandExists("scanimage") + +export const listScanners = async () => { + if (!await hasSane()) return [] + + const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 }) + if (result.code !== 0) return [] + + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith("device `")) + .map((line) => line.match(/device `([^']+)'/)?.[1]) + .filter((device): device is string => Boolean(device)) +} + +export const runScan = async (config: AgentConfig, job: ScanJob): Promise => { + if (!await hasSane()) { + throw new Error("scanimage ist nicht installiert oder nicht im PATH") + } + + mkdirSync(config.workDir, { recursive: true }) + + const settings = job.settings || {} + const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat + const resolution = numberSetting(settings, "resolution") || config.scanResolution + const mode = stringSetting(settings, "mode") || config.scanMode + const source = stringSetting(settings, "source") || config.scanSource + const scannerName = job.scannerName || config.scannerName + const filename = job.requestedFilename || `${job.id}.${format}` + const outputPath = path.join(config.workDir, filename) + + const args = [ + "--format", + format, + "--resolution", + String(resolution), + "--mode", + mode, + "--output-file", + outputPath, + ] + + if (source) args.push("--source", source) + if (scannerName) args.push("--device-name", scannerName) + + const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 }) + + if (result.code !== 0) { + throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`) + } + + return { + path: outputPath, + filename, + mimeType: mimeTypes[format] || "application/octet-stream", + } +} diff --git a/agents/fedeo-device-agent/src/types.ts b/agents/fedeo-device-agent/src/types.ts new file mode 100644 index 0000000..967d6d2 --- /dev/null +++ b/agents/fedeo-device-agent/src/types.ts @@ -0,0 +1,44 @@ +export type AgentConfig = { + fedeoUrl: string + agentToken: string + pollSeconds: number + workDir: string + scannerName?: string + printerName?: string + scanFormat: "pdf" | "png" | "tiff" + scanResolution: number + scanMode: string + scanSource?: string +} + +export type AgentHeartbeat = { + capabilities: { + scan: boolean + print: boolean + platform: NodeJS.Platform + } + scannerNames: string[] + printerNames: string[] + debugInfo: Record +} + +export type ScanJob = { + id: string + tenantId: number + agentId: string + status: string + scannerName?: string | null + requestedFilename?: string | null + settings?: Record + target?: Record +} + +export type NextScanJobResponse = { + job: ScanJob | null +} + +export type ScanResult = { + path: string + filename: string + mimeType: string +} diff --git a/agents/fedeo-device-agent/system/linux/fedeo-device-agent.service b/agents/fedeo-device-agent/system/linux/fedeo-device-agent.service new file mode 100644 index 0000000..9822d67 --- /dev/null +++ b/agents/fedeo-device-agent/system/linux/fedeo-device-agent.service @@ -0,0 +1,15 @@ +[Unit] +Description=FEDEO Geräte-Agent +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/fedeo-device-agent/config.env +WorkingDirectory=/opt/fedeo-device-agent +ExecStart=/usr/bin/node /opt/fedeo-device-agent/dist/main.js +Restart=always +RestartSec=5 +User=fedeo-agent + +[Install] +WantedBy=multi-user.target diff --git a/agents/fedeo-device-agent/system/macos/com.fedeo.device-agent.plist b/agents/fedeo-device-agent/system/macos/com.fedeo.device-agent.plist new file mode 100644 index 0000000..fb75c08 --- /dev/null +++ b/agents/fedeo-device-agent/system/macos/com.fedeo.device-agent.plist @@ -0,0 +1,33 @@ + + + + + Label + com.fedeo.device-agent + + ProgramArguments + + /usr/local/bin/node + /opt/fedeo-device-agent/dist/main.js + + + EnvironmentVariables + + FEDEO_AGENT_ENV + /opt/fedeo-device-agent/.env + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/fedeo-device-agent.log + + StandardErrorPath + /tmp/fedeo-device-agent.err.log + + diff --git a/agents/fedeo-device-agent/tsconfig.json b/agents/fedeo-device-agent/tsconfig.json new file mode 100644 index 0000000..0956a81 --- /dev/null +++ b/agents/fedeo-device-agent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "typeRoots": ["../../backend/node_modules/@types"], + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}