@lopatnov/express-reverse-proxy 3.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 +802 -17
- package/package.json +16 -5
- package/server-config.schema.json +321 -0
- package/server.js +284 -10
package/server.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
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';
|
|
8
|
+
import compression from 'compression';
|
|
9
|
+
import cors from 'cors';
|
|
7
10
|
import express from 'express';
|
|
11
|
+
import basicAuth from 'express-basic-auth';
|
|
8
12
|
import proxy from 'express-http-proxy';
|
|
13
|
+
import rateLimit from 'express-rate-limit';
|
|
14
|
+
import helmet from 'helmet';
|
|
9
15
|
import morgan from 'morgan';
|
|
16
|
+
import multer from 'multer';
|
|
17
|
+
import responseTime from 'response-time';
|
|
18
|
+
import favicon from 'serve-favicon';
|
|
10
19
|
|
|
11
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
21
|
const __dirname = path.dirname(__filename);
|
|
@@ -47,6 +56,10 @@ const possibleServerArgs = [
|
|
|
47
56
|
'--cluster restart --cluster-config /etc/myapp/ecosystem.config.cjs',
|
|
48
57
|
],
|
|
49
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: '--init',
|
|
61
|
+
description: 'interactively creates a server-config.json in the current directory',
|
|
62
|
+
},
|
|
50
63
|
];
|
|
51
64
|
|
|
52
65
|
function exitError(msg, code = -1) {
|
|
@@ -145,6 +158,30 @@ if (serverArgs['--cluster']) {
|
|
|
145
158
|
process.exit(result.status ?? 0);
|
|
146
159
|
}
|
|
147
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
|
+
|
|
148
185
|
let configFile = './server-config.json';
|
|
149
186
|
if (serverArgs['--config']) {
|
|
150
187
|
[configFile] = serverArgs['--config'].args;
|
|
@@ -250,12 +287,26 @@ function addStaticFolder(router, port, rootPath, folder) {
|
|
|
250
287
|
}
|
|
251
288
|
|
|
252
289
|
function addRemoteProxy(router, port, urlPath, proxyServer) {
|
|
253
|
-
if (
|
|
254
|
-
|
|
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
|
+
);
|
|
255
302
|
} else {
|
|
256
|
-
|
|
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}`);
|
|
257
309
|
}
|
|
258
|
-
console.log(`[proxy] http://localhost:${port}${urlPath || ''} <===> ${proxyServer}`);
|
|
259
310
|
}
|
|
260
311
|
|
|
261
312
|
function addMappedProxy(router, port, localRootPath, pathPairs) {
|
|
@@ -307,11 +358,6 @@ const servers = [];
|
|
|
307
358
|
|
|
308
359
|
configsByPort.forEach((portConfigs, p) => {
|
|
309
360
|
const app = express();
|
|
310
|
-
const loggingEnabled = portConfigs.every((c) => c.logging !== false);
|
|
311
|
-
if (loggingEnabled) {
|
|
312
|
-
app.use(morgan('combined'));
|
|
313
|
-
}
|
|
314
|
-
|
|
315
361
|
// Hot reload via SSE
|
|
316
362
|
const hotReloadEnabled = portConfigs.some((c) => c.hotReload === true);
|
|
317
363
|
if (hotReloadEnabled) {
|
|
@@ -361,6 +407,59 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
361
407
|
|
|
362
408
|
console.log(`[host] ${siteHost} → :${p}`);
|
|
363
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
|
+
|
|
420
|
+
if (siteConfig.responseTime) {
|
|
421
|
+
const opts = typeof siteConfig.responseTime === 'object' ? siteConfig.responseTime : {};
|
|
422
|
+
router.use(responseTime(opts));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (siteConfig.cors) {
|
|
426
|
+
const opts = typeof siteConfig.cors === 'object' ? siteConfig.cors : {};
|
|
427
|
+
router.use(cors(opts));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (siteConfig.compression) {
|
|
431
|
+
const opts = typeof siteConfig.compression === 'object' ? siteConfig.compression : {};
|
|
432
|
+
router.use(compression(opts));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (siteConfig.helmet) {
|
|
436
|
+
const opts = typeof siteConfig.helmet === 'object' ? siteConfig.helmet : {};
|
|
437
|
+
router.use(helmet(opts));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (siteConfig.favicon) {
|
|
441
|
+
router.use(favicon(path.resolve(configDir, siteConfig.favicon)));
|
|
442
|
+
}
|
|
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
|
+
|
|
364
463
|
if (siteConfig.headers) {
|
|
365
464
|
router.use((_req, res, next) => {
|
|
366
465
|
for (const h of Object.keys(siteConfig.headers)) res.setHeader(h, siteConfig.headers[h]);
|
|
@@ -368,10 +467,175 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
368
467
|
});
|
|
369
468
|
}
|
|
370
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
|
+
|
|
371
485
|
if (siteConfig.folders) {
|
|
372
486
|
addStaticFolder(router, p, null, siteConfig.folders);
|
|
373
487
|
}
|
|
374
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
|
+
|
|
375
639
|
if (siteConfig.proxy) {
|
|
376
640
|
addProxy(router, p, null, siteConfig.proxy);
|
|
377
641
|
}
|
|
@@ -417,6 +681,16 @@ configsByPort.forEach((portConfigs, p) => {
|
|
|
417
681
|
console.log(`[listen] https://localhost:${p}`);
|
|
418
682
|
if (process.send) process.send('ready');
|
|
419
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
|
+
}
|
|
420
694
|
} else {
|
|
421
695
|
server = app.listen(p, () => {
|
|
422
696
|
console.log(`[listen] http://localhost:${p}`);
|