feat: add SmartProxy Docker image
This commit is contained in:
+337
@@ -0,0 +1,337 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user