@lopatnov/express-reverse-proxy 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +192 -192
- package/README.md +658 -17
- package/package.json +13 -8
- package/server-config.schema.json +321 -0
- package/server.js +255 -10
package/server.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import https from 'node:https';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import compression from 'compression';
|
|
8
9
|
import cors from 'cors';
|
|
9
10
|
import express from 'express';
|
|
11
|
+
import basicAuth from 'express-basic-auth';
|
|
10
12
|
import proxy from 'express-http-proxy';
|
|
13
|
+
import rateLimit from 'express-rate-limit';
|
|
11
14
|
import helmet from 'helmet';
|
|
12
15
|
import morgan from 'morgan';
|
|
16
|
+
import multer from 'multer';
|
|
13
17
|
import responseTime from 'response-time';
|
|
14
18
|
import favicon from 'serve-favicon';
|
|
15
19
|
|
|
@@ -52,6 +56,10 @@ const possibleServerArgs = [
|
|
|
52
56
|
'--cluster restart --cluster-config /etc/myapp/ecosystem.config.cjs',
|
|
53
57
|
],
|
|
54
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: '--init',
|
|
61
|
+
description: 'interactively creates a server-config.json in the current directory',
|
|
62
|
+
},
|
|
55
63
|
];
|
|
56
64
|
|
|
57
65
|
function exitError(msg, code = -1) {
|
|
@@ -150,6 +158,30 @@ if (serverArgs['--cluster']) {
|
|
|
150
158
|
process.exit(result.status ?? 0);
|
|
151
159
|
}
|
|
152
160
|
|
|
161
|
+
if (serverArgs['--init']) {
|
|
162
|
+
const configOut = path.resolve(process.cwd(), 'server-config.json');
|
|
163
|
+
if (fs.existsSync(configOut)) {
|
|
164
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
165
|
+
const ans = await rl2.question(`${configOut} already exists. Overwrite? [y/N]: `);
|
|
166
|
+
rl2.close();
|
|
167
|
+
if (ans.trim().toLowerCase() !== 'y') process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
170
|
+
const port = (await rl.question('Port [8000]: ')).trim() || '8000';
|
|
171
|
+
const folder = (await rl.question('Static folder [.]: ')).trim() || '.';
|
|
172
|
+
const proxyPath = (await rl.question('Proxy path (e.g. /api) [skip]: ')).trim();
|
|
173
|
+
let proxyTarget = '';
|
|
174
|
+
if (proxyPath) proxyTarget = (await rl.question(`Proxy target for ${proxyPath}: `)).trim();
|
|
175
|
+
const hotReload = (await rl.question('Hot reload? [y/N]: ')).trim().toLowerCase() === 'y';
|
|
176
|
+
rl.close();
|
|
177
|
+
const cfg = { port: parseInt(port, 10), folders: folder };
|
|
178
|
+
if (proxyPath && proxyTarget) cfg.proxy = { [proxyPath]: proxyTarget };
|
|
179
|
+
if (hotReload) cfg.hotReload = true;
|
|
180
|
+
fs.writeFileSync(configOut, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
181
|
+
console.log(`[init] Created ${configOut}`);
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
153
185
|
let configFile = './server-config.json';
|
|
154
186
|
if (serverArgs['--config']) {
|
|
155
187
|
[configFile] = serverArgs['--config'].args;
|
|
@@ -255,12 +287,26 @@ function addStaticFolder(router, port, rootPath, folder) {
|
|
|
255
287
|
}
|
|
256
288
|
|
|
257
289
|
function addRemoteProxy(router, port, urlPath, proxyServer) {
|
|
258
|
-
if (
|
|
259
|
-
|
|
290
|
+
if (Array.isArray(proxyServer)) {
|
|
291
|
+
let i = 0;
|
|
292
|
+
const targets = proxyServer;
|
|
293
|
+
const balancedProxy = proxy((_req) => targets[i++ % targets.length]);
|
|
294
|
+
if (urlPath) {
|
|
295
|
+
router.use(urlPath, balancedProxy);
|
|
296
|
+
} else {
|
|
297
|
+
router.use(balancedProxy);
|
|
298
|
+
}
|
|
299
|
+
console.log(
|
|
300
|
+
`[proxy] http://localhost:${port}${urlPath || ''} <===> [${targets.join(', ')}] (round-robin)`,
|
|
301
|
+
);
|
|
260
302
|
} else {
|
|
261
|
-
|
|
303
|
+
if (urlPath) {
|
|
304
|
+
router.use(urlPath, proxy(proxyServer));
|
|
305
|
+
} else {
|
|
306
|
+
router.use(proxy(proxyServer));
|
|
307
|
+
}
|
|
308
|
+
console.log(`[proxy] http://localhost:${port}${urlPath || ''} <===> ${proxyServer}`);
|
|
262
309
|
}
|
|
263
|
-
console.log(`[proxy] http://localhost:${port}${urlPath || ''} <===> ${proxyServer}`);
|
|
264
310
|
}
|
|
265
311
|
|
|
266
312
|
function addMappedProxy(router, port, localRootPath, pathPairs) {
|
|
@@ -312,11 +358,6 @@ const servers = [];
|
|
|
312
358
|
|
|
313
359
|
configsByPort.forEach((portConfigs, p) => {
|
|
314
360
|
const app = express();
|
|
315
|
-
const loggingEnabled = portConfigs.every((c) => c.logging !== false);
|
|
316
|
-
if (loggingEnabled) {
|
|
317
|
-
app.use(morgan('combined'));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
361
|
// Hot reload via SSE
|
|
321
362
|
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
322
363
|
if (hotReloadEnabled) {
|
|
@@ -366,6 +407,16 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
366
407
|
|
|
367
408
|
console.log(`[host] ${siteHost} → :${p}`);
|
|
368
409
|
|
|
410
|
+
if (siteConfig.logging !== false) {
|
|
411
|
+
const lc = siteConfig.logging;
|
|
412
|
+
if (typeof lc === 'object' && lc.file) {
|
|
413
|
+
const stream = fs.createWriteStream(path.resolve(configDir, lc.file), { flags: 'a' });
|
|
414
|
+
router.use(morgan(lc.format || 'combined', { stream }));
|
|
415
|
+
} else {
|
|
416
|
+
router.use(morgan((typeof lc === 'object' ? lc.format : null) || 'dev'));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
369
420
|
if (siteConfig.responseTime) {
|
|
370
421
|
const opts = typeof siteConfig.responseTime === 'object' ? siteConfig.responseTime : {};
|
|
371
422
|
router.use(responseTime(opts));
|
|
@@ -390,6 +441,25 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
390
441
|
router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
|
|
391
442
|
}
|
|
392
443
|
|
|
444
|
+
if (siteConfig.healthCheck) {
|
|
445
|
+
const hcPath =
|
|
446
|
+
(typeof siteConfig.healthCheck === 'object' && siteConfig.healthCheck.path) ||
|
|
447
|
+
'/__health__';
|
|
448
|
+
router.get(hcPath, (_req, res) => {
|
|
449
|
+
res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (siteConfig.rateLimit) {
|
|
454
|
+
const opts = typeof siteConfig.rateLimit === 'object' ? siteConfig.rateLimit : {};
|
|
455
|
+
router.use(rateLimit(opts));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (siteConfig.basicAuth) {
|
|
459
|
+
const opts = typeof siteConfig.basicAuth === 'object' ? siteConfig.basicAuth : {};
|
|
460
|
+
router.use(basicAuth(opts));
|
|
461
|
+
}
|
|
462
|
+
|
|
393
463
|
if (siteConfig.headers) {
|
|
394
464
|
router.use((_req, res, next) => {
|
|
395
465
|
for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
|
|
@@ -397,10 +467,175 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
397
467
|
});
|
|
398
468
|
}
|
|
399
469
|
|
|
470
|
+
if (siteConfig.redirects) {
|
|
471
|
+
const redirects = siteConfig.redirects;
|
|
472
|
+
if (Array.isArray(redirects)) {
|
|
473
|
+
for (const r of redirects) {
|
|
474
|
+
router.all(r.from, (_req, res) => res.redirect(r.status || 301, r.to));
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
for (const [from, to] of Object.entries(redirects)) {
|
|
478
|
+
const dest = typeof to === 'string' ? to : to.to;
|
|
479
|
+
const status = typeof to === 'object' ? to.status || 301 : 301;
|
|
480
|
+
router.all(from, (_req, res) => res.redirect(status, dest));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
400
485
|
if (siteConfig.folders) {
|
|
401
486
|
addStaticFolder(router, p, null, siteConfig.folders);
|
|
402
487
|
}
|
|
403
488
|
|
|
489
|
+
if (siteConfig.cgi) {
|
|
490
|
+
const cgiRaw = siteConfig.cgi;
|
|
491
|
+
const cgiConfigs = Array.isArray(cgiRaw)
|
|
492
|
+
? cgiRaw
|
|
493
|
+
: [typeof cgiRaw === 'string' ? { dir: cgiRaw } : cgiRaw];
|
|
494
|
+
|
|
495
|
+
for (const cgiConfig of cgiConfigs) {
|
|
496
|
+
const cgiUrlPath = cgiConfig.path || '/cgi-bin';
|
|
497
|
+
const cgiDirResolved = path.resolve(configDir, cgiConfig.dir || './cgi-bin');
|
|
498
|
+
const cgiExts = new Set(cgiConfig.extensions || ['.cgi', '.pl', '.py', '.sh']);
|
|
499
|
+
const interps = cgiConfig.interpreters || {};
|
|
500
|
+
|
|
501
|
+
router.use(cgiUrlPath, (req, res, next) => {
|
|
502
|
+
const scriptPath = path.resolve(path.join(cgiDirResolved, req.path));
|
|
503
|
+
if (!scriptPath.startsWith(cgiDirResolved + path.sep)) return next();
|
|
504
|
+
|
|
505
|
+
const ext = path.extname(scriptPath);
|
|
506
|
+
if (!cgiExts.has(ext) || !fs.existsSync(scriptPath)) return next();
|
|
507
|
+
|
|
508
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
509
|
+
const env = {
|
|
510
|
+
...process.env,
|
|
511
|
+
GATEWAY_INTERFACE: 'CGI/1.1',
|
|
512
|
+
SERVER_PROTOCOL: 'HTTP/1.1',
|
|
513
|
+
SERVER_SOFTWARE: 'express-reverse-proxy',
|
|
514
|
+
REQUEST_METHOD: req.method.toUpperCase(),
|
|
515
|
+
SCRIPT_FILENAME: scriptPath,
|
|
516
|
+
SCRIPT_NAME: cgiUrlPath + req.path,
|
|
517
|
+
PATH_INFO: '',
|
|
518
|
+
QUERY_STRING: url.search ? url.search.slice(1) : '',
|
|
519
|
+
REMOTE_ADDR: req.ip || '127.0.0.1',
|
|
520
|
+
CONTENT_TYPE: req.headers['content-type'] || '',
|
|
521
|
+
CONTENT_LENGTH: req.headers['content-length'] || '0',
|
|
522
|
+
SERVER_NAME: req.hostname || 'localhost',
|
|
523
|
+
SERVER_PORT: String(p),
|
|
524
|
+
};
|
|
525
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
526
|
+
env[`HTTP_${k.toUpperCase().replace(/-/g, '_')}`] = Array.isArray(v) ? v.join(', ') : v;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const interpreter = interps[ext];
|
|
530
|
+
const command = interpreter || scriptPath;
|
|
531
|
+
const args = interpreter ? [scriptPath] : [];
|
|
532
|
+
const child = spawn(command, args, { env, cwd: path.dirname(scriptPath) });
|
|
533
|
+
|
|
534
|
+
child.stdin.on('error', (_err) => {});
|
|
535
|
+
req.pipe(child.stdin);
|
|
536
|
+
|
|
537
|
+
let headersParsed = false;
|
|
538
|
+
let rawBuf = '';
|
|
539
|
+
child.stdout.on('data', (chunk) => {
|
|
540
|
+
if (!headersParsed) {
|
|
541
|
+
rawBuf += chunk.toString('binary');
|
|
542
|
+
const m = /\r?\n\r?\n/.exec(rawBuf);
|
|
543
|
+
if (m) {
|
|
544
|
+
const rawHeaders = rawBuf.substring(0, m.index);
|
|
545
|
+
const bodyStart = Buffer.from(rawBuf.substring(m.index + m[0].length), 'binary');
|
|
546
|
+
headersParsed = true;
|
|
547
|
+
let statusCode = 200;
|
|
548
|
+
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
549
|
+
const colon = line.indexOf(':');
|
|
550
|
+
if (colon === -1) continue;
|
|
551
|
+
const name = line.substring(0, colon).trim();
|
|
552
|
+
const value = line.substring(colon + 1).trim();
|
|
553
|
+
if (name.toLowerCase() === 'status') {
|
|
554
|
+
statusCode = Number.parseInt(value, 10) || 200;
|
|
555
|
+
} else {
|
|
556
|
+
res.setHeader(name, value);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
res.status(statusCode);
|
|
560
|
+
if (bodyStart.length) res.write(bodyStart);
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
res.write(chunk);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
child.stdout.on('end', () => {
|
|
567
|
+
if (!headersParsed) res.status(500).send('CGI script produced no output');
|
|
568
|
+
else res.end();
|
|
569
|
+
});
|
|
570
|
+
child.stderr.on('data', (data) => console.error(`[cgi] ${scriptPath}: ${data}`));
|
|
571
|
+
child.on('error', (err) => {
|
|
572
|
+
console.error(`[cgi] spawn error for ${scriptPath}: ${err.message}`);
|
|
573
|
+
if (!res.headersSent) res.status(500).send(`CGI error: ${err.message}`);
|
|
574
|
+
});
|
|
575
|
+
console.log(`[cgi] ${req.method} ${cgiUrlPath}${req.path} → ${scriptPath}`);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (siteConfig.upload) {
|
|
581
|
+
const uploadRaw = siteConfig.upload;
|
|
582
|
+
const uploadConfigs = Array.isArray(uploadRaw)
|
|
583
|
+
? uploadRaw
|
|
584
|
+
: [typeof uploadRaw === 'string' ? { dir: uploadRaw } : uploadRaw];
|
|
585
|
+
|
|
586
|
+
for (const uploadConfig of uploadConfigs) {
|
|
587
|
+
const uploadUrlPath = uploadConfig.path || '/upload';
|
|
588
|
+
const uploadDir = path.resolve(configDir, uploadConfig.dir || './uploads');
|
|
589
|
+
const allowedTypes = uploadConfig.allowedTypes ? new Set(uploadConfig.allowedTypes) : null;
|
|
590
|
+
|
|
591
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
592
|
+
|
|
593
|
+
const storage = multer.diskStorage({
|
|
594
|
+
destination: uploadDir,
|
|
595
|
+
filename: (_req, file, cb) => {
|
|
596
|
+
const ext = path.extname(file.originalname);
|
|
597
|
+
const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
598
|
+
cb(null, `${base}-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const fileFilter = allowedTypes
|
|
603
|
+
? (_req, file, cb) => {
|
|
604
|
+
if (allowedTypes.has(file.mimetype)) cb(null, true);
|
|
605
|
+
else
|
|
606
|
+
cb(
|
|
607
|
+
Object.assign(new Error(`File type not allowed: ${file.mimetype}`), {
|
|
608
|
+
status: 400,
|
|
609
|
+
}),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
: undefined;
|
|
613
|
+
|
|
614
|
+
const limits = {};
|
|
615
|
+
if (uploadConfig.maxFileSize) limits.fileSize = uploadConfig.maxFileSize;
|
|
616
|
+
if (uploadConfig.maxFiles) limits.files = uploadConfig.maxFiles;
|
|
617
|
+
|
|
618
|
+
const uploader = multer({ storage, limits, fileFilter });
|
|
619
|
+
const multerMiddleware = uploadConfig.fieldName
|
|
620
|
+
? uploader.array(uploadConfig.fieldName)
|
|
621
|
+
: uploader.any();
|
|
622
|
+
|
|
623
|
+
router.post(uploadUrlPath, multerMiddleware, (req, res) => {
|
|
624
|
+
if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
|
625
|
+
res.json({
|
|
626
|
+
files: req.files.map((f) => ({
|
|
627
|
+
file: f.filename,
|
|
628
|
+
size: f.size,
|
|
629
|
+
originalName: f.originalname,
|
|
630
|
+
})),
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
router.use(uploadUrlPath, express.static(uploadDir));
|
|
635
|
+
console.log(`[upload] POST ${uploadUrlPath} → ${uploadDir}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
404
639
|
if (siteConfig.proxy) {
|
|
405
640
|
addProxy(router, p, null, siteConfig.proxy);
|
|
406
641
|
}
|
|
@@ -446,6 +681,16 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
446
681
|
console.log(`[listen] https://localhost:${p}`);
|
|
447
682
|
if (process.send) process.send('ready');
|
|
448
683
|
});
|
|
684
|
+
if (sslConfig.redirect) {
|
|
685
|
+
const redirectPort = sslConfig.redirect;
|
|
686
|
+
const redirectApp = express();
|
|
687
|
+
redirectApp.use((req, res) => {
|
|
688
|
+
res.redirect(301, `https://${req.hostname}:${p}${req.url}`);
|
|
689
|
+
});
|
|
690
|
+
redirectApp.listen(redirectPort, () => {
|
|
691
|
+
console.log(`[listen] http redirect :${redirectPort} → https :${p}`);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
449
694
|
} else {
|
|
450
695
|
server = app.listen(p, () => {
|
|
451
696
|
console.log(`[listen] http://localhost:${p}`);
|