monitor/core/modules/WebServer/index.ts
2025-04-16 22:30:27 +07:00

271 lines
9.8 KiB
TypeScript

const modulename = 'WebServer';
import crypto from 'node:crypto';
import path from 'node:path';
import HttpClass from 'node:http';
import Koa from 'koa';
import KoaBodyParser from 'koa-bodyparser';
import KoaCors from '@koa/cors';
import { Server as SocketIO } from 'socket.io';
import WebSocket from './webSocket';
import { customAlphabet } from 'nanoid';
import dict49 from 'nanoid-dictionary/nolookalikes';
import { txDevEnv, txEnv, txHostConfig } from '@core/globalData';
import router from './router';
import consoleFactory from '@lib/console';
import topLevelMw from './middlewares/topLevelMw';
import ctxVarsMw from './middlewares/ctxVarsMw';
import ctxUtilsMw from './middlewares/ctxUtilsMw';
import { SessionMemoryStorage, koaSessMw, socketioSessMw } from './middlewares/sessionMws';
import checkRateLimit from './middlewares/globalRateLimiter';
import checkHttpLoad from './middlewares/httpLoadMonitor';
import cacheControlMw from './middlewares/cacheControlMw';
import fatalError from '@lib/fatalError';
import { isProxy } from 'node:util/types';
import serveStaticMw from './middlewares/serveStaticMw';
import serveRuntimeMw from './middlewares/serveRuntimeMw';
const console = consoleFactory(modulename);
const nanoid = customAlphabet(dict49, 32);
/**
* Module for the web server and socket.io.
* It defines behaviors through middlewares, and instantiates the Koa app and the SocketIO server.
*/
export default class WebServer {
public isListening = false;
public isServing = false;
private sessionCookieName: string;
public luaComToken: string;
//setupKoa
private app: Koa;
public sessionStore: SessionMemoryStorage;
private koaCallback: (req: any, res: any) => Promise<void>;
//setupWebSocket
private io: SocketIO;
public webSocket: WebSocket;
//setupServerCallbacks
private httpServer?: HttpClass.Server;
constructor() {
//Generate cookie key & luaComToken
const pathHash = crypto.createHash('shake256', { outputLength: 6 })
.update(txEnv.profilePath)
.digest('hex');
this.sessionCookieName = `tx:${pathHash}`;
this.luaComToken = nanoid();
// ===================
// Setting up Koa
// ===================
this.app = new Koa();
this.app.keys = ['txAdmin' + nanoid()];
// Some people might want to enable it, but we are not guaranteeing XFF security
// due to the many possible ways you can connect to koa.
// this.app.proxy = true;
//Setting up app
//@ts-ignore: no clue what this error is, but i'd bet it's just bad koa types
this.app.on('error', (error, ctx) => {
if (!(
error.code?.startsWith('HPE_')
|| error.code?.startsWith('ECONN')
|| error.code === 'EPIPE'
|| error.code === 'ECANCELED'
)) {
console.error(`Probably harmless error on ${ctx.path}`);
console.dir(error);
}
});
//Disable CORS on dev mode
if (txDevEnv.ENABLED) {
this.app.use(KoaCors());
}
//Setting up timeout/error/no-output/413
this.app.use(topLevelMw);
//Setting up additional middlewares:
this.app.use(serveRuntimeMw);
this.app.use(serveStaticMw({
noCaching: txDevEnv.ENABLED,
cacheMaxAge: 30 * 60, //30 minutes
//Scan Limits: (v8-dev prod build: 56 files, 11.25MB)
limits: {
MAX_BYTES: 75 * 1024 * 1024, //75MB
MAX_FILES: 300,
MAX_DEPTH: 10,
MAX_TIME: 2 * 60 * 1000, //2 minutes
},
roots: [
txDevEnv.ENABLED
? path.join(txDevEnv.SRC_PATH, 'panel/public')
: path.join(txEnv.txaPath, 'panel'),
path.join(txEnv.txaPath, 'web/public'),
],
onReady: () => {
this.isServing = true;
},
}));
this.app.use(KoaBodyParser({
// Heavy bodies can cause v8 mem exhaustion during a POST DDoS.
// The heaviest JSON is the /intercom/resources endpoint.
// Conservative estimate: 768kb/300b = 2621 resources
jsonLimit: '768kb',
}));
//Custom stuff
this.sessionStore = new SessionMemoryStorage();
this.app.use(cacheControlMw);
this.app.use(koaSessMw(this.sessionCookieName, this.sessionStore));
this.app.use(ctxVarsMw);
this.app.use(ctxUtilsMw);
//Setting up routes
const txRouter = router();
this.app.use(txRouter.routes());
this.app.use(txRouter.allowedMethods());
this.app.use(async (ctx) => {
if (typeof ctx._matchedRoute === 'undefined') {
if (ctx.path.startsWith('/legacy')) {
ctx.status = 404;
console.verbose.warn(`Request 404 error: ${ctx.path}`);
return ctx.send('Not found.');
} else if (ctx.path.endsWith('.map')) {
ctx.status = 404;
return ctx.send('Not found.');
} else {
return ctx.utils.serveReactIndex();
}
}
});
this.koaCallback = this.app.callback();
// ===================
// Setting up SocketIO
// ===================
this.io = new SocketIO(HttpClass.createServer(), { serveClient: false });
this.io.use(socketioSessMw(this.sessionCookieName, this.sessionStore));
this.webSocket = new WebSocket(this.io);
//@ts-ignore
this.io.on('connection', this.webSocket.handleConnection.bind(this.webSocket));
// ===================
// Setting up Callbacks
// ===================
this.setupServerCallbacks();
}
/**
* Handler for all HTTP requests
* Note: i gave up on typing these
*/
httpCallbackHandler(req: any, res: any) {
//Calls the appropriate callback
try {
// console.debug(`HTTP ${req.method} ${req.url}`);
if (!checkHttpLoad()) return;
if (!checkRateLimit(req?.socket?.remoteAddress)) return;
if (req.url.startsWith('/socket.io')) {
(this.io.engine as any).handleRequest(req, res);
} else {
this.koaCallback(req, res);
}
} catch (error) { }
}
/**
* Setup the HTTP server callbacks
*/
setupServerCallbacks() {
//Just in case i want to re-execute this function
this.isListening = false;
//HTTP Server
try {
const listenErrorHandler = (error: any) => {
if (error.code !== 'EADDRINUSE') return;
fatalError.WebServer(0, [
`Failed to start HTTP server, port ${error.port} is already in use.`,
'Maybe you already have another txAdmin running in this port.',
'If you want to run multiple txAdmin instances, check the documentation for the port convar.',
'You can also try restarting the host machine.',
]);
};
//@ts-ignore
this.httpServer = HttpClass.createServer(this.httpCallbackHandler.bind(this));
// this.httpServer = HttpClass.createServer((req, res) => {
// // const reqSize = parseInt(req.headers['content-length'] || '0');
// // if (req.method === 'POST' && reqSize > 0) {
// // console.debug(chalk.yellow(bytes(reqSize)), `HTTP ${req.method} ${req.url}`);
// // }
// this.httpCallbackHandler(req, res);
// // if(checkRateLimit(req?.socket?.remoteAddress)){
// // this.httpCallbackHandler(req, res);
// // }else {
// // req.destroy();
// // }
// });
this.httpServer.on('error', listenErrorHandler);
const netInterface = txHostConfig.netInterface ?? '0.0.0.0';
if (txHostConfig.netInterface) {
console.warn(`Starting with interface ${txHostConfig.netInterface}.`);
console.warn('If the HTTP server doesn\'t start, this is probably the reason.');
}
this.httpServer.listen(txHostConfig.txaPort, netInterface, async () => {
//Sanity check on globals, to _guarantee_ all routes will have access to them
if (!txCore || isProxy(txCore) || !txConfig || !txManager) {
console.dir({
txCore: Boolean(txCore),
txCoreType: isProxy(txCore) ? 'proxy' : 'not proxy',
txConfig: Boolean(txConfig),
txManager: Boolean(txManager),
});
fatalError.WebServer(2, [
'The HTTP server started before the globals were ready.',
'This error should NEVER happen.',
'Please report it to the developers.',
]);
}
if (txHostConfig.netInterface) {
console.ok(`Listening on ${netInterface}.`);
}
this.isListening = true;
});
} catch (error) {
fatalError.WebServer(1, 'Failed to start HTTP server.', error);
}
}
/**
* handler for the shutdown event
*/
public handleShutdown() {
return this.webSocket.handleShutdown();
}
/**
* Resetting lua comms token - called by fxRunner on spawnServer()
*/
resetToken() {
this.luaComToken = nanoid();
console.verbose.debug('Resetting luaComToken.');
}
};