Instanzweite Scan-Agenten vorbereiten

This commit is contained in:
2026-05-31 11:52:22 +02:00
parent 2a5071b15a
commit 384ea95fe5
7 changed files with 577 additions and 0 deletions

View File

@@ -33,6 +33,8 @@ import portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp";
import communicationRoutes from "./routes/communication";
import telephonyRoutes from "./routes/telephony";
import instanceAgentRoutes from "./routes/instanceAgents";
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -128,6 +130,10 @@ async function main() {
await devicesApp.register(devicesManagementRoutes)
},{prefix: "/devices"})
await app.register(async (agentApp) => {
await agentApp.register(instanceAgentGatewayRoutes)
},{prefix: "/instance-agent"})
await app.register(corsPlugin);
//Geschützte Routes
@@ -158,6 +164,7 @@ async function main() {
await subApp.register(mcpRoutes);
await subApp.register(communicationRoutes);
await subApp.register(telephonyRoutes);
await subApp.register(instanceAgentRoutes);
},{prefix: "/api"})

View File

@@ -0,0 +1,235 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import multipart from "@fastify/multipart"
import { createHash } from "node:crypto"
import { and, asc, eq, sql } from "drizzle-orm"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
import { saveFile } from "../utils/files"
const hashToken = (token: string) =>
createHash("sha256").update(token, "utf8").digest("hex")
const readAgentToken = (req: FastifyRequest) => {
const headerToken = req.headers["x-agent-token"]
if (typeof headerToken === "string" && headerToken.length > 0) return headerToken
const authHeader = req.headers.authorization
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7)
return null
}
const pickFileTargets = (target: unknown) => {
if (!target || typeof target !== "object" || Array.isArray(target)) return {}
const allowedFields = [
"project",
"customer",
"contract",
"vendor",
"incominginvoice",
"plant",
"createddocument",
"vehicle",
"product",
"check",
"inventoryitem",
"space",
"documentbox",
"authProfile",
]
return Object.fromEntries(
Object.entries(target as Record<string, any>)
.filter(([key, value]) => allowedFields.includes(key) && value !== undefined && value !== null)
)
}
export default async function instanceAgentGatewayRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 100 * 1024 * 1024 },
})
const authenticateAgent = async (req: FastifyRequest, reply: any) => {
const token = readAgentToken(req)
if (!token) {
reply.code(401).send({ error: "Agent token required" })
return null
}
const [agent] = await server.db
.select()
.from(instanceAgents)
.where(and(
eq(instanceAgents.tokenHash, hashToken(token)),
eq(instanceAgents.active, true)
))
.limit(1)
if (!agent) {
reply.code(401).send({ error: "Invalid agent token" })
return null
}
return agent
}
server.post("/heartbeat", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const body = (req.body || {}) as {
capabilities?: Record<string, any>
scannerNames?: string[]
printerNames?: string[]
debugInfo?: Record<string, any>
}
await server.db
.update(instanceAgents)
.set({
capabilities: body.capabilities || agent.capabilities,
scannerNames: body.scannerNames || agent.scannerNames,
printerNames: body.printerNames || agent.printerNames,
lastDebugInfo: body.debugInfo || null,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
.where(eq(instanceAgents.id, agent.id))
const [pending] = await server.db
.select({ count: sql<number>`count(*)::int` })
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.agentId, agent.id),
eq(instanceAgentScanJobs.status, "pending")
))
return {
status: "ok",
pendingScanJobs: pending?.count || 0,
}
})
server.get("/scan-jobs/next", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const [pendingJob] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.agentId, agent.id),
eq(instanceAgentScanJobs.status, "pending")
))
.orderBy(asc(instanceAgentScanJobs.createdAt))
.limit(1)
if (!pendingJob) return { job: null }
const [claimedJob] = await server.db
.update(instanceAgentScanJobs)
.set({
status: "running",
claimedAt: new Date(),
updatedAt: new Date(),
attempts: pendingJob.attempts + 1,
})
.where(and(
eq(instanceAgentScanJobs.id, pendingJob.id),
eq(instanceAgentScanJobs.status, "pending")
))
.returning()
return { job: claimedJob || null }
})
server.post<{ Params: { id: string } }>("/scan-jobs/:id/status", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const body = (req.body || {}) as { status?: string; message?: string }
const allowedStatuses = ["running", "failed", "canceled"]
if (!body.status || !allowedStatuses.includes(body.status)) {
return reply.code(400).send({ error: "Invalid status" })
}
const [job] = await server.db
.update(instanceAgentScanJobs)
.set({
status: body.status,
agentMessage: body.message,
finishedAt: ["failed", "canceled"].includes(body.status) ? new Date() : undefined,
updatedAt: new Date(),
})
.where(and(
eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id)
))
.returning()
if (!job) return reply.code(404).send({ error: "Scan job not found" })
return { job }
})
server.post<{ Params: { id: string } }>("/scan-jobs/:id/upload", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const [job] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id)
))
.limit(1)
if (!job) return reply.code(404).send({ error: "Scan job not found" })
if (!["running", "pending"].includes(job.status)) {
return reply.code(409).send({ error: "Scan job is not uploadable" })
}
const data: any = await req.file()
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer()
const filename = job.requestedFilename || data.filename || `${job.id}.pdf`
const createdFile = await saveFile(
server,
job.tenantId,
null,
{
filename,
content: fileBuffer,
contentType: data.mimetype || "application/pdf",
},
null,
null,
{
...pickFileTargets(job.target),
createdBy: job.requestedBy,
}
)
if (!createdFile) return reply.code(500).send({ error: "Could not save scan file" })
const [updatedJob] = await server.db
.update(instanceAgentScanJobs)
.set({
status: "completed",
fileId: createdFile.id,
finishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(instanceAgentScanJobs.id, job.id))
.returning()
return {
job: updatedJob,
file: createdFile,
}
})
}

View File

@@ -0,0 +1,193 @@
import { FastifyInstance } from "fastify"
import { createHash, randomBytes } from "node:crypto"
import { and, desc, eq } from "drizzle-orm"
import { z } from "zod"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
const createAgentSchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
})
const updateAgentSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
active: z.boolean().optional(),
})
const createScanJobSchema = z.object({
agentId: z.string().uuid(),
tenantId: z.number().int().positive().optional(),
scannerName: z.string().optional().nullable(),
requestedFilename: z.string().optional().nullable(),
settings: z.record(z.string(), z.any()).optional(),
target: z.record(z.string(), z.any()).optional(),
})
const hashToken = (token: string) =>
createHash("sha256").update(token, "utf8").digest("hex")
const createAgentToken = () => `fedeo_agent_${randomBytes(32).toString("hex")}`
const requireAdmin = (req: any, reply: any) => {
if (!req.user?.is_admin) {
reply.code(403).send({ error: "Admin required" })
return false
}
return true
}
export default async function instanceAgentRoutes(server: FastifyInstance) {
server.get("/instance-agents", async () => {
const rows = await server.db
.select({
id: instanceAgents.id,
createdAt: instanceAgents.createdAt,
updatedAt: instanceAgents.updatedAt,
name: instanceAgents.name,
description: instanceAgents.description,
tokenPrefix: instanceAgents.tokenPrefix,
active: instanceAgents.active,
capabilities: instanceAgents.capabilities,
scannerNames: instanceAgents.scannerNames,
printerNames: instanceAgents.printerNames,
lastSeenAt: instanceAgents.lastSeenAt,
lastDebugInfo: instanceAgents.lastDebugInfo,
})
.from(instanceAgents)
.orderBy(desc(instanceAgents.createdAt))
return { agents: rows }
})
server.post("/instance-agents", async (req, reply) => {
if (!requireAdmin(req, reply)) return
const body = createAgentSchema.parse(req.body)
const token = createAgentToken()
const [agent] = await server.db
.insert(instanceAgents)
.values({
name: body.name,
description: body.description,
tokenPrefix: token.slice(0, 24),
tokenHash: hashToken(token),
})
.returning({
id: instanceAgents.id,
name: instanceAgents.name,
description: instanceAgents.description,
tokenPrefix: instanceAgents.tokenPrefix,
active: instanceAgents.active,
createdAt: instanceAgents.createdAt,
})
return {
agent,
token,
}
})
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
if (!requireAdmin(req, reply)) return
const body = updateAgentSchema.parse(req.body)
const [agent] = await server.db
.update(instanceAgents)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(instanceAgents.id, req.params.id))
.returning({
id: instanceAgents.id,
name: instanceAgents.name,
description: instanceAgents.description,
active: instanceAgents.active,
updatedAt: instanceAgents.updatedAt,
})
if (!agent) return reply.code(404).send({ error: "Agent not found" })
return { agent }
})
server.post("/scan-jobs", async (req, reply) => {
const body = createScanJobSchema.parse(req.body)
const requestedTenantId = body.tenantId || req.user?.tenant_id
if (!requestedTenantId) {
return reply.code(400).send({ error: "tenantId required" })
}
if (body.tenantId && body.tenantId !== req.user?.tenant_id && !req.user?.is_admin) {
return reply.code(403).send({ error: "Cannot create scan job for another tenant" })
}
const [agent] = await server.db
.select({ id: instanceAgents.id, active: instanceAgents.active })
.from(instanceAgents)
.where(eq(instanceAgents.id, body.agentId))
.limit(1)
if (!agent || !agent.active) {
return reply.code(404).send({ error: "Active agent not found" })
}
const [job] = await server.db
.insert(instanceAgentScanJobs)
.values({
tenantId: requestedTenantId,
agentId: body.agentId,
requestedBy: req.user?.user_id,
scannerName: body.scannerName,
requestedFilename: body.requestedFilename,
settings: body.settings || {},
target: body.target || {},
})
.returning()
return { job }
})
server.get("/scan-jobs", async (req) => {
const query = req.query as { tenantId?: string }
const tenantId = req.user?.is_admin && query.tenantId
? Number(query.tenantId)
: req.user?.tenant_id
const rows = tenantId
? await server.db
.select()
.from(instanceAgentScanJobs)
.where(eq(instanceAgentScanJobs.tenantId, tenantId))
.orderBy(desc(instanceAgentScanJobs.createdAt))
: await server.db
.select()
.from(instanceAgentScanJobs)
.orderBy(desc(instanceAgentScanJobs.createdAt))
return { jobs: rows }
})
server.get<{ Params: { id: string } }>("/scan-jobs/:id", async (req, reply) => {
const conditions = [eq(instanceAgentScanJobs.id, req.params.id)]
if (!req.user?.is_admin) {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "tenant required" })
conditions.push(eq(instanceAgentScanJobs.tenantId, req.user.tenant_id))
}
const [job] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(...conditions))
.limit(1)
if (!job) return reply.code(404).send({ error: "Scan job not found" })
return { job }
})
}