Files

338 lines
9.9 KiB
TypeScript

import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import {
SmartProxy,
SocketHandlers,
type IRouteConfig,
type ISmartProxyOptions,
type ISmartProxySecurityPolicy,
} from '@push.rocks/smartproxy';
interface IHttpToHttpsRedirectConfig {
enabled?: boolean;
httpPort?: number;
httpsPort?: number;
statusCode?: number;
targetTemplate?: string;
}
interface ISmartProxyDaemonConfig extends Omit<ISmartProxyOptions, 'routes'> {
routes?: IRouteConfig[];
httpToHttpsRedirect?: IHttpToHttpsRedirectConfig;
}
const getEnvNumber = (envNameArg: string, defaultArg: number) => {
const value = process.env[envNameArg];
if (!value) {
return defaultArg;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`${envNameArg} must be a TCP port number`);
}
return parsed;
};
const getConfigPath = () => process.env.SMARTPROXY_CONFIG || '/etc/smartproxy/config.json';
const readJsonBody = async (reqArg: IncomingMessage) => {
const chunks: Buffer[] = [];
for await (const chunk of reqArg) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const body = Buffer.concat(chunks).toString('utf8').trim();
if (!body) {
return undefined;
}
return JSON.parse(body) as unknown;
};
const sendJson = (resArg: ServerResponse, statusCodeArg: number, dataArg: unknown) => {
resArg.writeHead(statusCodeArg, {
'content-type': 'application/json; charset=utf-8',
});
resArg.end(JSON.stringify(dataArg));
};
const isRouteArray = (valueArg: unknown): valueArg is IRouteConfig[] => {
return Array.isArray(valueArg);
};
const getRoutesFromPayload = (payloadArg: unknown): IRouteConfig[] => {
if (isRouteArray(payloadArg)) {
return payloadArg;
}
if (
payloadArg &&
typeof payloadArg === 'object' &&
'routes' in payloadArg &&
isRouteArray((payloadArg as { routes: unknown }).routes)
) {
return (payloadArg as { routes: IRouteConfig[] }).routes;
}
throw new Error('Expected request body to be a route array or an object with a routes array');
};
const getSecurityPolicyFromPayload = (payloadArg: unknown): ISmartProxySecurityPolicy => {
if (!payloadArg || typeof payloadArg !== 'object' || Array.isArray(payloadArg)) {
throw new Error('Expected request body to be a SmartProxy security policy object');
}
return payloadArg as ISmartProxySecurityPolicy;
};
class SmartProxyDaemon {
private smartProxy: SmartProxy | undefined;
private adminServer: Server | undefined;
private rawConfig: ISmartProxyDaemonConfig = { routes: [] };
private rawRoutes: IRouteConfig[] = [];
private activeRoutes: IRouteConfig[] = [];
private readonly configPath = getConfigPath();
private readonly adminHost = process.env.SMARTPROXY_ADMIN_HOST || '0.0.0.0';
private readonly adminPort = getEnvNumber('SMARTPROXY_ADMIN_PORT', 3000);
private readonly adminToken = process.env.SMARTPROXY_ADMIN_TOKEN;
public async check() {
this.rawConfig = await this.loadConfig();
this.rawRoutes = this.rawConfig.routes || [];
this.activeRoutes = this.createActiveRoutes();
console.log(
JSON.stringify({
ok: true,
configPath: this.configPath,
configuredRoutes: this.rawRoutes.length,
activeRoutes: this.activeRoutes.length,
})
);
}
public async start() {
this.rawConfig = await this.loadConfig();
this.rawRoutes = this.rawConfig.routes || [];
await this.startSmartProxy();
await this.startAdminServer();
console.log(
`SmartProxy daemon started: admin=${this.adminHost}:${this.adminPort}, routes=${this.activeRoutes.length}`
);
}
public async stop() {
if (this.adminServer) {
await new Promise<void>((resolveArg, rejectArg) => {
this.adminServer!.close((errorArg) => {
if (errorArg) {
rejectArg(errorArg);
} else {
resolveArg();
}
});
});
this.adminServer = undefined;
}
if (this.smartProxy) {
await this.smartProxy.stop();
this.smartProxy = undefined;
}
}
private async loadConfig(): Promise<ISmartProxyDaemonConfig> {
if (!existsSync(this.configPath)) {
return { routes: [] };
}
const configFile = await readFile(this.configPath, 'utf8');
const parsedConfig = JSON.parse(configFile) as ISmartProxyDaemonConfig;
return {
...parsedConfig,
routes: parsedConfig.routes || [],
};
}
private createActiveRoutes() {
const routes = [...this.rawRoutes];
const redirectConfig = this.rawConfig.httpToHttpsRedirect;
if (redirectConfig?.enabled) {
const httpPort = redirectConfig.httpPort || 80;
const httpsPort = redirectConfig.httpsPort || 443;
const statusCode = redirectConfig.statusCode || 301;
const targetTemplate =
redirectConfig.targetTemplate || `https://{domain}${httpsPort === 443 ? '' : `:${httpsPort}`}{path}`;
routes.unshift({
name: 'http-to-https-redirect',
match: {
ports: httpPort,
protocol: 'http',
},
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect(targetTemplate, statusCode),
},
});
}
return routes;
}
private createSmartProxyOptions(): ISmartProxyOptions {
const { httpToHttpsRedirect: _httpToHttpsRedirect, routes: _routes, ...smartProxyOptions } = this.rawConfig;
this.activeRoutes = this.createActiveRoutes();
return {
...smartProxyOptions,
routes: this.activeRoutes,
};
}
private async startSmartProxy() {
if (this.smartProxy) {
await this.smartProxy.stop();
}
this.smartProxy = new SmartProxy(this.createSmartProxyOptions());
await this.smartProxy.start();
}
private async updateRoutes(routesArg: IRouteConfig[]) {
this.rawRoutes = routesArg;
this.rawConfig.routes = routesArg;
this.activeRoutes = this.createActiveRoutes();
if (!this.smartProxy) {
throw new Error('SmartProxy is not running');
}
await this.smartProxy.updateRoutes(this.activeRoutes);
}
private async reloadFromDisk() {
this.rawConfig = await this.loadConfig();
this.rawRoutes = this.rawConfig.routes || [];
await this.startSmartProxy();
}
private isAuthorized(reqArg: IncomingMessage) {
if (!this.adminToken) {
return true;
}
return reqArg.headers.authorization === `Bearer ${this.adminToken}`;
}
private async startAdminServer() {
this.adminServer = createServer(async (reqArg, resArg) => {
const requestUrl = new URL(reqArg.url || '/', 'http://localhost');
const method = reqArg.method || 'GET';
try {
if (method === 'GET' && (requestUrl.pathname === '/health' || requestUrl.pathname === '/ready')) {
sendJson(resArg, 200, {
ok: true,
routes: this.rawRoutes.length,
activeRoutes: this.activeRoutes.length,
});
return;
}
if (!this.isAuthorized(reqArg)) {
sendJson(resArg, 401, { ok: false, error: 'unauthorized' });
return;
}
if (method === 'GET' && requestUrl.pathname === '/routes') {
sendJson(resArg, 200, {
routes: this.rawRoutes,
activeRoutes: this.activeRoutes,
});
return;
}
if ((method === 'PUT' || method === 'POST') && requestUrl.pathname === '/routes') {
const payload = await readJsonBody(reqArg);
await this.updateRoutes(getRoutesFromPayload(payload));
sendJson(resArg, 200, {
ok: true,
routes: this.rawRoutes.length,
activeRoutes: this.activeRoutes.length,
});
return;
}
if (method === 'POST' && requestUrl.pathname === '/reload') {
await this.reloadFromDisk();
sendJson(resArg, 200, {
ok: true,
routes: this.rawRoutes.length,
activeRoutes: this.activeRoutes.length,
});
return;
}
if (method === 'POST' && requestUrl.pathname === '/security-policy') {
if (!this.smartProxy) {
throw new Error('SmartProxy is not running');
}
const payload = await readJsonBody(reqArg);
const policy = getSecurityPolicyFromPayload(payload);
await this.smartProxy.updateSecurityPolicy(policy);
this.rawConfig.securityPolicy = policy;
sendJson(resArg, 200, { ok: true });
return;
}
if (method === 'GET' && requestUrl.pathname === '/statistics') {
if (!this.smartProxy) {
throw new Error('SmartProxy is not running');
}
sendJson(resArg, 200, await this.smartProxy.getStatistics());
return;
}
if (method === 'GET' && requestUrl.pathname === '/listening-ports') {
if (!this.smartProxy) {
throw new Error('SmartProxy is not running');
}
sendJson(resArg, 200, { ports: await this.smartProxy.getListeningPorts() });
return;
}
sendJson(resArg, 404, { ok: false, error: 'not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendJson(resArg, 500, { ok: false, error: message });
}
});
await new Promise<void>((resolveArg) => {
this.adminServer!.listen(this.adminPort, this.adminHost, resolveArg);
});
}
}
const daemon = new SmartProxyDaemon();
if (process.argv.includes('--check')) {
await daemon.check();
process.exit(0);
}
const stopAndExit = async (signalArg: NodeJS.Signals) => {
console.log(`Received ${signalArg}, stopping SmartProxy daemon...`);
await daemon.stop();
process.exit(0);
};
process.on('SIGTERM', () => {
void stopAndExit('SIGTERM');
});
process.on('SIGINT', () => {
void stopAndExit('SIGINT');
});
await daemon.start();