454 lines
13 KiB
JavaScript
454 lines
13 KiB
JavaScript
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;
|