const express = require("express"); const { z } = require("zod"); const prisma = require("./db"); const { parseServerImport } = require("./importParser"); const { asIntegerPort, normalizeEndpoint, normalizeIp, normalizeMethod, serviceTarget } = require("./utils"); const router = express.Router(); const serverSchema = z.object({ name: z.string().trim().min(1), ipAddress: z.string().trim().min(1), description: z.string().optional().nullable(), proxyPort: z.coerce.number().int().min(1).max(65535).optional().nullable(), proxyPath: z.string().trim().optional().nullable(), }); const serviceSchema = z.object({ serverId: z.string().min(1), name: z.string().trim().min(1), port: z.coerce.number().int().min(1).max(65535), endpoint: z.string().trim().optional().nullable(), method: z.string().optional().nullable(), requestTemplate: z.any().optional().nullable(), notes: z.string().optional().nullable(), }); function includeServices() { return { services: { orderBy: [{ port: "asc" }, { name: "asc" }], }, }; } async function findConflicts(parsedServers) { const conflicts = []; const duplicates = []; const newServers = []; const newServices = []; for (const server of parsedServers) { const existingServer = await prisma.server.findUnique({ where: { ipAddress: server.ipAddress }, include: { services: true }, }); if (!existingServer) { newServers.push(server); } else { const sameServer = existingServer.name === server.name && (existingServer.description || null) === (server.description || null); if (!sameServer) { conflicts.push({ type: "server", ipAddress: server.ipAddress, existing: { id: existingServer.id, name: existingServer.name, description: existingServer.description, }, incoming: { name: server.name, description: server.description, }, }); } } const batch = new Map(); for (const service of server.services) { const prior = batch.get(service.name); if (prior) { if (prior.port === service.port) { duplicates.push({ type: "service", serverIp: server.ipAddress, name: service.name, port: service.port, }); continue; } conflicts.push({ type: "service", serverIp: server.ipAddress, key: service.name, existing: { name: prior.name, port: prior.port }, incoming: { name: service.name, port: service.port }, }); newServices.push({ serverId: existingServer?.id, serverIp: server.ipAddress, ...service }); continue; } batch.set(service.name, service); if (!existingServer) { newServices.push({ serverIp: server.ipAddress, ...service }); continue; } const existingSamePort = existingServer.services.find( (item) => item.name === service.name && item.port === service.port, ); if (existingSamePort) { duplicates.push({ type: "service", serverIp: server.ipAddress, name: service.name, port: service.port, }); continue; } const existingDiffPort = existingServer.services.find( (item) => item.name === service.name && item.port !== service.port, ); if (existingDiffPort) { conflicts.push({ type: "service", serverIp: server.ipAddress, key: service.name, existing: { name: existingDiffPort.name, port: existingDiffPort.port, method: existingDiffPort.method }, incoming: { name: service.name, port: service.port, method: service.method }, }); } newServices.push({ serverId: existingServer.id, serverIp: server.ipAddress, ...service }); } } return { conflicts, duplicates, newServers, newServices }; } async function applyImport(parsedServers, mode, client = prisma) { const result = { serversCreated: 0, serversUpdated: 0, servicesCreated: 0, servicesUpdated: 0, duplicatesSkipped: 0, }; const guard = async (operation) => { try { return await operation(); } catch (error) { if (error.code === "P2002") { result.duplicatesSkipped += 1; return null; } console.error("applyImport guard failure:", error.code || error.name, error.message); throw error; } }; for (const server of parsedServers) { const existingServer = await client.server.findUnique({ where: { ipAddress: server.ipAddress }, include: { services: true }, }); let savedServer = existingServer; if (!existingServer) { savedServer = await client.server.create({ data: { name: server.name, ipAddress: server.ipAddress, description: server.description, proxyPort: server.proxyPort ?? null, proxyPath: server.proxyPath ?? null, }, }); result.serversCreated += 1; } else if (mode === "overwrite") { savedServer = await client.server.update({ where: { id: existingServer.id }, data: { name: server.name, description: server.description, proxyPort: server.proxyPort ?? undefined, proxyPath: server.proxyPath ?? undefined, }, }); result.serversUpdated += 1; } const seen = new Set(); for (const service of server.services) { const key = `${service.name}:${service.port}`; if (seen.has(key)) { result.duplicatesSkipped += 1; continue; } seen.add(key); const existingSamePort = existingServer?.services.find( (item) => item.name === service.name && item.port === service.port, ); if (!existingSamePort) { await guard(() => client.service.create({ data: { serverId: savedServer.id, name: service.name, port: service.port, endpoint: service.endpoint, method: service.method, requestTemplate: service.requestTemplate, notes: service.notes, }, }), ); result.servicesCreated += 1; } else if (mode === "overwrite") { await guard(() => client.service.update({ where: { id: existingSamePort.id }, data: { name: service.name, method: service.method, requestTemplate: service.requestTemplate, notes: service.notes, }, }), ); result.servicesUpdated += 1; } else { result.duplicatesSkipped += 1; } } } return result; } router.get("/health", (req, res) => { res.json({ ok: true, service: "mtc-reverse-proxy-backend" }); }); router.get("/servers", async (req, res, next) => { try { const servers = await prisma.server.findMany({ include: includeServices(), orderBy: { name: "asc" }, }); res.json(servers); } catch (error) { next(error); } }); router.post("/servers", async (req, res, next) => { try { const parsed = serverSchema.parse({ ...req.body, ipAddress: normalizeIp(req.body.ipAddress), }); const data = { ...parsed, proxyPath: parsed.proxyPath || null }; const server = await prisma.server.create({ data, include: includeServices() }); res.status(201).json(server); } catch (error) { next(error); } }); router.post("/services", async (req, res, next) => { try { const parsed = serviceSchema.parse(req.body); const service = await prisma.service.create({ data: { ...parsed, endpoint: normalizeEndpoint(parsed.endpoint || "/"), method: normalizeMethod(parsed.method), port: asIntegerPort(parsed.port), }, }); res.status(201).json(service); } catch (error) { next(error); } }); router.patch("/servers/:id", async (req, res, next) => { try { const parsed = serverSchema.partial().parse({ ...req.body, ipAddress: req.body.ipAddress ? normalizeIp(req.body.ipAddress) : undefined, }); const data = Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== undefined)); if (data.proxyPath === "") data.proxyPath = null; const server = await prisma.server.update({ where: { id: req.params.id }, data, include: includeServices(), }); res.json(server); } catch (error) { next(error); } }); router.delete("/servers/:id", async (req, res, next) => { try { await prisma.server.delete({ where: { id: req.params.id } }); res.status(204).end(); } catch (error) { next(error); } }); router.patch("/services/:id", async (req, res, next) => { try { const parsed = serviceSchema.partial().parse(req.body); const data = Object.fromEntries( Object.entries({ ...parsed, endpoint: parsed.endpoint ? normalizeEndpoint(parsed.endpoint) : undefined, method: parsed.method !== undefined ? normalizeMethod(parsed.method) : undefined, port: parsed.port !== undefined ? asIntegerPort(parsed.port) : undefined, }).filter(([, value]) => value !== undefined), ); const service = await prisma.service.update({ where: { id: req.params.id }, data, include: { server: true }, }); res.json(service); } catch (error) { next(error); } }); router.delete("/services/:id", async (req, res, next) => { try { await prisma.service.delete({ where: { id: req.params.id } }); res.status(204).end(); } catch (error) { next(error); } }); router.post("/imports/preview", async (req, res, next) => { try { const { servers, errors } = parseServerImport(req.body); const summary = await findConflicts(servers); res.json({ parsed: servers, errors, ...summary, canApply: errors.length === 0 && summary.conflicts.length === 0, }); } catch (error) { next(error); } }); router.post("/imports/apply", async (req, res, next) => { try { const mode = req.body.mode === "overwrite" ? "overwrite" : "skip"; const payload = req.body.payload || req.body; const { servers, errors } = parseServerImport(payload); const summary = await findConflicts(servers); if (errors.length > 0) { res.status(400).json({ message: "Import JSON has validation errors.", errors }); return; } if (summary.conflicts.length > 0 && mode !== "overwrite") { res.status(409).json({ message: "Import has conflicts that need confirmation.", ...summary }); return; } const applied = await prisma.$transaction( (tx) => applyImport(servers, mode, tx), { maxWait: 15000, timeout: 60000 }, ); res.json({ message: "Import applied.", applied }); } catch (error) { next(error); } }); router.post("/services/:id/reverse-proxy", async (req, res, next) => { try { const service = await prisma.service.findUnique({ where: { id: req.params.id }, include: { server: true }, }); if (!service) { res.status(404).json({ message: "Service not found." }); return; } const server = service.server; const gateway = server.proxyPort != null ? { port: server.proxyPort, path: normalizeEndpoint(server.proxyPath || "/api/nginx/app"), } : null; const target = gateway ? `http://${server.ipAddress}:${gateway.port}${gateway.path}` : serviceTarget(server, service); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Number(process.env.REVERSE_PROXY_TIMEOUT_MS || 15000)); const method = normalizeMethod(req.body.method || service.method); const suppliedBody = req.body.body !== undefined ? req.body.body : service.requestTemplate; const body = gateway ? { ...(suppliedBody && typeof suppliedBody === "object" && !Array.isArray(suppliedBody) ? suppliedBody : {}), domain: service.name, port: service.port, } : suppliedBody; const response = await fetch(target, { method, headers: { "content-type": "application/json", }, body: method === "GET" ? undefined : JSON.stringify(body || {}), signal: controller.signal, }); clearTimeout(timeout); const text = await response.text(); let data = text; try { data = text ? JSON.parse(text) : null; } catch { data = text; } res.json({ target, status: response.status, ok: response.ok, data, }); } catch (error) { if (error.name === "AbortError") { res.status(504).json({ message: "Reverse proxy request timed out." }); return; } next(error); } }); module.exports = router;