338 lines
9.9 KiB
TypeScript
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();
|