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 { 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((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 { 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((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();