Initial reverse proxy backend
This commit is contained in:
commit
35927939e0
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mtc_reverse_proxy?schema=public"
|
||||
PORT=4000
|
||||
FRONTEND_ORIGIN="http://localhost:5173"
|
||||
REVERSE_PROXY_TIMEOUT_MS=15000
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
40
README.md
Normal file
40
README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# MTC Reverse Proxy Backend
|
||||
|
||||
Express API with Prisma and PostgreSQL.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Set `DATABASE_URL` to your PostgreSQL database.
|
||||
3. Run:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## JSON Import Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"name": "Billing Server",
|
||||
"ipAddress": "192.168.1.50",
|
||||
"services": [
|
||||
{
|
||||
"name": "Invoice API",
|
||||
"port": 8080,
|
||||
"endpoint": "/api/invoices/sync",
|
||||
"method": "POST",
|
||||
"requestTemplate": { "limit": 10 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Exact duplicate services are skipped. Conflicting services are returned by the preview endpoint and require `mode: "overwrite"` in `/api/imports/apply`.
|
||||
3068
package-lock.json
generated
Normal file
3068
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mtc-reverse-proxy-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Express, Prisma, and PostgreSQL API for MTC Server Reverse Proxy Manager.",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "UNLICENSED",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
"prisma": "^7.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.22.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
13
prisma.config.js
Normal file
13
prisma.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { defineConfig } = require("prisma/config");
|
||||
|
||||
module.exports = defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
34
prisma/schema.prisma
Normal file
34
prisma/schema.prisma
Normal file
@ -0,0 +1,34 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Server {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
ipAddress String @unique
|
||||
description String?
|
||||
services Service[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Service {
|
||||
id String @id @default(cuid())
|
||||
serverId String
|
||||
name String
|
||||
port Int
|
||||
endpoint String
|
||||
method String @default("POST")
|
||||
requestTemplate Json?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([serverId, port, endpoint])
|
||||
@@index([serverId])
|
||||
}
|
||||
9
src/db.js
Normal file
9
src/db.js
Normal file
@ -0,0 +1,9 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/mtc_reverse_proxy?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
module.exports = prisma;
|
||||
86
src/importParser.js
Normal file
86
src/importParser.js
Normal file
@ -0,0 +1,86 @@
|
||||
const { asIntegerPort, normalizeEndpoint, normalizeIp, normalizeMethod } = require("./utils");
|
||||
|
||||
function pickFirst(source, keys) {
|
||||
for (const key of keys) {
|
||||
if (source && source[key] !== undefined && source[key] !== null) return source[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function arrayFromJson(input) {
|
||||
if (Array.isArray(input)) return input;
|
||||
if (Array.isArray(input.servers)) return input.servers;
|
||||
if (Array.isArray(input.items)) return input.items;
|
||||
if (input.server || input.ip || input.ipAddress || input.host) return [input];
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeService(rawService, parentServer) {
|
||||
const name = String(pickFirst(rawService, ["name", "serviceName", "title"]) || "Unnamed service").trim();
|
||||
const port = asIntegerPort(pickFirst(rawService, ["port", "servicePort"]));
|
||||
const endpoint = normalizeEndpoint(pickFirst(rawService, ["endpoint", "url", "path", "apiEndpoint"]));
|
||||
const method = normalizeMethod(pickFirst(rawService, ["method", "httpMethod"]));
|
||||
|
||||
if (!port || !endpoint) {
|
||||
return {
|
||||
error: "Each service needs a valid port and endpoint.",
|
||||
raw: rawService,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
port,
|
||||
endpoint,
|
||||
method,
|
||||
notes: rawService.notes || rawService.description || null,
|
||||
requestTemplate: rawService.requestTemplate || rawService.body || parentServer.requestTemplate || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseServerImport(payload) {
|
||||
const sourceServers = arrayFromJson(payload);
|
||||
const servers = [];
|
||||
const errors = [];
|
||||
|
||||
sourceServers.forEach((rawServer, serverIndex) => {
|
||||
const ipAddress = normalizeIp(pickFirst(rawServer, ["ipAddress", "ip", "host", "serverIp"]));
|
||||
const name = String(pickFirst(rawServer, ["name", "serverName", "title"]) || ipAddress || "Unnamed server").trim();
|
||||
const serviceList = Array.isArray(rawServer.services)
|
||||
? rawServer.services
|
||||
: rawServer.port || rawServer.endpoint
|
||||
? [rawServer]
|
||||
: [];
|
||||
|
||||
if (!ipAddress) {
|
||||
errors.push({ index: serverIndex, message: "Server IP address is required.", raw: rawServer });
|
||||
return;
|
||||
}
|
||||
|
||||
const services = [];
|
||||
serviceList.forEach((rawService, serviceIndex) => {
|
||||
const service = normalizeService(rawService, rawServer);
|
||||
if (service.error) {
|
||||
errors.push({
|
||||
index: serverIndex,
|
||||
serviceIndex,
|
||||
message: service.error,
|
||||
raw: service.raw,
|
||||
});
|
||||
return;
|
||||
}
|
||||
services.push(service);
|
||||
});
|
||||
|
||||
servers.push({
|
||||
name,
|
||||
ipAddress,
|
||||
description: rawServer.description || null,
|
||||
services,
|
||||
});
|
||||
});
|
||||
|
||||
return { servers, errors };
|
||||
}
|
||||
|
||||
module.exports = { parseServerImport };
|
||||
316
src/routes.js
Normal file
316
src/routes.js
Normal file
@ -0,0 +1,316 @@
|
||||
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(),
|
||||
});
|
||||
|
||||
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().min(1),
|
||||
method: z.string().optional(),
|
||||
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);
|
||||
newServices.push(...server.services.map((service) => ({ serverIp: server.ipAddress, ...service })));
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const service of server.services) {
|
||||
const existingService = existingServer.services.find(
|
||||
(item) => item.port === service.port && item.endpoint === service.endpoint,
|
||||
);
|
||||
|
||||
if (!existingService) {
|
||||
newServices.push({ serverId: existingServer.id, serverIp: server.ipAddress, ...service });
|
||||
continue;
|
||||
}
|
||||
|
||||
const sameService =
|
||||
existingService.name === service.name &&
|
||||
existingService.method === service.method &&
|
||||
JSON.stringify(existingService.requestTemplate || null) === JSON.stringify(service.requestTemplate || null);
|
||||
|
||||
if (sameService) {
|
||||
duplicates.push({
|
||||
type: "service",
|
||||
serverIp: server.ipAddress,
|
||||
name: service.name,
|
||||
port: service.port,
|
||||
endpoint: service.endpoint,
|
||||
});
|
||||
} else {
|
||||
conflicts.push({
|
||||
type: "service",
|
||||
serverIp: server.ipAddress,
|
||||
key: `${service.port} ${service.endpoint}`,
|
||||
existing: existingService,
|
||||
incoming: service,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { conflicts, duplicates, newServers, newServices };
|
||||
}
|
||||
|
||||
async function applyImport(parsedServers, mode) {
|
||||
const result = {
|
||||
serversCreated: 0,
|
||||
serversUpdated: 0,
|
||||
servicesCreated: 0,
|
||||
servicesUpdated: 0,
|
||||
duplicatesSkipped: 0,
|
||||
};
|
||||
|
||||
for (const server of parsedServers) {
|
||||
const existingServer = await prisma.server.findUnique({
|
||||
where: { ipAddress: server.ipAddress },
|
||||
include: { services: true },
|
||||
});
|
||||
|
||||
let savedServer = existingServer;
|
||||
if (!existingServer) {
|
||||
savedServer = await prisma.server.create({
|
||||
data: {
|
||||
name: server.name,
|
||||
ipAddress: server.ipAddress,
|
||||
description: server.description,
|
||||
},
|
||||
});
|
||||
result.serversCreated += 1;
|
||||
} else if (mode === "overwrite") {
|
||||
savedServer = await prisma.server.update({
|
||||
where: { id: existingServer.id },
|
||||
data: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
},
|
||||
});
|
||||
result.serversUpdated += 1;
|
||||
}
|
||||
|
||||
for (const service of server.services) {
|
||||
const existingService = existingServer?.services.find(
|
||||
(item) => item.port === service.port && item.endpoint === service.endpoint,
|
||||
);
|
||||
|
||||
if (!existingService) {
|
||||
await prisma.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 prisma.service.update({
|
||||
where: { id: existingService.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 data = serverSchema.parse({
|
||||
...req.body,
|
||||
ipAddress: normalizeIp(req.body.ipAddress),
|
||||
});
|
||||
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.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 applyImport(servers, mode);
|
||||
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 target = serviceTarget(service.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 body = req.body.body !== undefined ? req.body.body : service.requestTemplate;
|
||||
|
||||
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;
|
||||
40
src/server.js
Normal file
40
src/server.js
Normal file
@ -0,0 +1,40 @@
|
||||
require("dotenv").config();
|
||||
|
||||
const cors = require("cors");
|
||||
const express = require("express");
|
||||
const routes = require("./routes");
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 4000);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
|
||||
app.use("/api", routes);
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.name === "ZodError") {
|
||||
res.status(400).json({ message: "Validation failed.", issues: error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === "P2002") {
|
||||
res.status(409).json({ message: "A record with this unique value already exists.", target: error.meta?.target });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ message: error.message || "Server error." });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`MTC Reverse Proxy API running on http://localhost:${port}`);
|
||||
});
|
||||
34
src/utils.js
Normal file
34
src/utils.js
Normal file
@ -0,0 +1,34 @@
|
||||
function normalizeIp(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value) {
|
||||
const endpoint = String(value || "").trim();
|
||||
if (!endpoint) return "";
|
||||
if (/^https?:\/\//i.test(endpoint)) return endpoint;
|
||||
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
}
|
||||
|
||||
function normalizeMethod(value) {
|
||||
const method = String(value || "POST").trim().toUpperCase();
|
||||
return ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method) ? method : "POST";
|
||||
}
|
||||
|
||||
function serviceTarget(server, service) {
|
||||
const endpoint = normalizeEndpoint(service.endpoint);
|
||||
if (/^https?:\/\//i.test(endpoint)) return endpoint;
|
||||
return `http://${server.ipAddress}:${service.port}${endpoint}`;
|
||||
}
|
||||
|
||||
function asIntegerPort(value) {
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
asIntegerPort,
|
||||
normalizeEndpoint,
|
||||
normalizeIp,
|
||||
normalizeMethod,
|
||||
serviceTarget,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user