Initial reverse proxy backend

This commit is contained in:
MOHAN 2026-08-01 13:22:51 +05:30
commit 35927939e0
12 changed files with 3679 additions and 0 deletions

4
.env.example Normal file
View 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
View File

@ -0,0 +1,5 @@
node_modules
.env
dist
coverage
*.log

40
README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};