154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.startProxy = startProxy;
|
|
exports.stopProxy = stopProxy;
|
|
const node_http_1 = __importDefault(require("node:http"));
|
|
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
const client_1 = require("@prisma/client");
|
|
const env_1 = require("../config/env");
|
|
const logger_1 = require("../utils/logger");
|
|
// HMAC secret — regenerated each server start (cookie sessions reset on restart)
|
|
const HMAC_SECRET = node_crypto_1.default.randomBytes(32);
|
|
const COOKIE_NAME = '_lmp_auth';
|
|
const COOKIE_MAX_AGE_S = 4 * 60 * 60; // 4 hours
|
|
// Extract Listmonk internal host/port from LISTMONK_URL (e.g. "http://listmonk-app:9000")
|
|
function parseListmonkTarget() {
|
|
const url = new URL(env_1.env.LISTMONK_URL);
|
|
return { hostname: url.hostname, port: parseInt(url.port || '9000', 10) };
|
|
}
|
|
// Basic auth header for Listmonk API user
|
|
function listmonkAuthHeader() {
|
|
const creds = Buffer.from(`${env_1.env.LISTMONK_ADMIN_USER}:${env_1.env.LISTMONK_ADMIN_PASSWORD}`).toString('base64');
|
|
return `Basic ${creds}`;
|
|
}
|
|
function signCookie(payload) {
|
|
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const sig = node_crypto_1.default.createHmac('sha256', HMAC_SECRET).update(data).digest('base64url');
|
|
return `${data}.${sig}`;
|
|
}
|
|
function verifyCookie(cookie) {
|
|
const dot = cookie.indexOf('.');
|
|
if (dot === -1)
|
|
return null;
|
|
const data = cookie.slice(0, dot);
|
|
const sig = cookie.slice(dot + 1);
|
|
const expected = node_crypto_1.default.createHmac('sha256', HMAC_SECRET).update(data).digest('base64url');
|
|
if (!node_crypto_1.default.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
|
|
return null;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
|
if (payload.exp < Date.now() / 1000)
|
|
return null;
|
|
return payload;
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
function parseCookies(header) {
|
|
const cookies = {};
|
|
if (!header)
|
|
return cookies;
|
|
for (const pair of header.split(';')) {
|
|
const eq = pair.indexOf('=');
|
|
if (eq === -1)
|
|
continue;
|
|
const key = pair.slice(0, eq).trim();
|
|
const val = pair.slice(eq + 1).trim();
|
|
cookies[key] = val;
|
|
}
|
|
return cookies;
|
|
}
|
|
let server = null;
|
|
function startProxy() {
|
|
const target = parseListmonkTarget();
|
|
server = node_http_1.default.createServer((req, res) => {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
// --- /auth?token=<jwt> — exchange JWT for proxy cookie ---
|
|
if (url.pathname === '/auth') {
|
|
const token = url.searchParams.get('token');
|
|
if (!token) {
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
res.end('Missing token parameter');
|
|
return;
|
|
}
|
|
try {
|
|
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_ACCESS_SECRET);
|
|
if (payload.role !== client_1.UserRole.SUPER_ADMIN) {
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
res.end('Forbidden — SUPER_ADMIN role required');
|
|
return;
|
|
}
|
|
const cookieVal = signCookie({
|
|
uid: payload.id,
|
|
exp: Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE_S,
|
|
});
|
|
res.writeHead(302, {
|
|
Location: '/admin/',
|
|
'Set-Cookie': `${COOKIE_NAME}=${cookieVal}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE_S}`,
|
|
});
|
|
res.end();
|
|
}
|
|
catch {
|
|
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
res.end('Invalid or expired token');
|
|
}
|
|
return;
|
|
}
|
|
// --- All other routes: verify cookie, proxy to Listmonk ---
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const cookieVal = cookies[COOKIE_NAME];
|
|
if (!cookieVal || !verifyCookie(cookieVal)) {
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
res.end('Forbidden — authenticate at /auth?token=<jwt> first');
|
|
return;
|
|
}
|
|
// Build proxy request headers (copy originals, inject auth, remove cookie)
|
|
const proxyHeaders = { ...req.headers };
|
|
delete proxyHeaders.host;
|
|
delete proxyHeaders.cookie;
|
|
proxyHeaders.authorization = listmonkAuthHeader();
|
|
const proxyReq = node_http_1.default.request({
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: proxyHeaders,
|
|
}, (proxyRes) => {
|
|
// Strip frame-busting headers from Listmonk responses
|
|
const responseHeaders = { ...proxyRes.headers };
|
|
delete responseHeaders['x-frame-options'];
|
|
delete responseHeaders['content-security-policy'];
|
|
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
proxyRes.pipe(res);
|
|
});
|
|
proxyReq.on('error', (err) => {
|
|
logger_1.logger.error('Listmonk proxy error:', err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
}
|
|
res.end('Listmonk is unreachable');
|
|
});
|
|
// Pipe request body for POST/PUT/PATCH
|
|
req.pipe(proxyReq);
|
|
});
|
|
server.listen(env_1.env.LISTMONK_PROXY_PORT, () => {
|
|
logger_1.logger.info(`Listmonk proxy listening on port ${env_1.env.LISTMONK_PROXY_PORT}`);
|
|
});
|
|
return server;
|
|
}
|
|
function stopProxy() {
|
|
return new Promise((resolve) => {
|
|
if (server) {
|
|
server.close(() => resolve());
|
|
}
|
|
else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
//# sourceMappingURL=listmonk-proxy.service.js.map
|