468 lines
13 KiB
TypeScript
468 lines
13 KiB
TypeScript
/**
|
|
* SmartProxy Manager for Onebox
|
|
*
|
|
* Manages SmartProxy as a Docker Swarm service so it can route to services on
|
|
* the Onebox overlay network.
|
|
*/
|
|
|
|
import * as plugins from '../plugins.ts';
|
|
import { logger } from '../logging.ts';
|
|
import { getErrorMessage } from '../utils/error.ts';
|
|
|
|
const SMARTPROXY_SERVICE_NAME = 'onebox-smartproxy';
|
|
const LEGACY_CADDY_SERVICE_NAME = 'onebox-caddy';
|
|
const SMARTPROXY_IMAGE = 'code.foss.global/host.today/ht-docker-smartproxy:latest';
|
|
const SMARTPROXY_ADMIN_CONTAINER_PORT = 3000;
|
|
const SMARTPROXY_HTTP_CONTAINER_PORT = 80;
|
|
const SMARTPROXY_HTTPS_CONTAINER_PORT = 443;
|
|
|
|
export interface ISmartProxyRoute {
|
|
domain: string;
|
|
upstream: string;
|
|
}
|
|
|
|
export interface ISmartProxyCertificate {
|
|
domain: string;
|
|
certPem: string;
|
|
keyPem: string;
|
|
}
|
|
|
|
interface ISmartProxyRouteConfig {
|
|
name: string;
|
|
match: {
|
|
ports: number;
|
|
domains: string;
|
|
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3';
|
|
};
|
|
action: {
|
|
type: 'forward';
|
|
targets: Array<{ host: string; port: number }>;
|
|
tls?: {
|
|
mode: 'terminate';
|
|
certificate: {
|
|
key: string;
|
|
cert: string;
|
|
};
|
|
};
|
|
websocket?: {
|
|
enabled: boolean;
|
|
};
|
|
};
|
|
priority?: number;
|
|
}
|
|
|
|
export class SmartProxyManager {
|
|
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
|
|
private certsDir: string;
|
|
private adminUrl: string;
|
|
private adminPort: number;
|
|
private httpPort: number;
|
|
private httpsPort: number;
|
|
private routes: Map<string, ISmartProxyRoute> = new Map();
|
|
private certificates: Map<string, ISmartProxyCertificate> = new Map();
|
|
private networkName = 'onebox-network';
|
|
private serviceRunning = false;
|
|
|
|
constructor(options?: {
|
|
certsDir?: string;
|
|
adminPort?: number;
|
|
httpPort?: number;
|
|
httpsPort?: number;
|
|
}) {
|
|
this.certsDir = options?.certsDir || './.nogit/certs';
|
|
this.adminPort = options?.adminPort || 2019;
|
|
this.adminUrl = `http://localhost:${this.adminPort}`;
|
|
this.httpPort = options?.httpPort || 8080;
|
|
this.httpsPort = options?.httpsPort || 8443;
|
|
}
|
|
|
|
private async ensureDockerClient(): Promise<void> {
|
|
if (!this.dockerClient) {
|
|
this.dockerClient = new plugins.docker.Docker({
|
|
socketPath: 'unix:///var/run/docker.sock',
|
|
});
|
|
await this.dockerClient.start();
|
|
}
|
|
}
|
|
|
|
setPorts(httpPort: number, httpsPort: number): void {
|
|
this.httpPort = httpPort;
|
|
this.httpsPort = httpsPort;
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.serviceRunning) {
|
|
logger.warn('SmartProxy service is already running');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.ensureDockerClient();
|
|
await Deno.mkdir(this.certsDir, { recursive: true });
|
|
|
|
logger.info('Starting SmartProxy Docker service...');
|
|
|
|
const legacyService = await this.getExistingService(LEGACY_CADDY_SERVICE_NAME);
|
|
if (legacyService) {
|
|
logger.info('Legacy Caddy service exists, removing it before SmartProxy startup...');
|
|
await this.removeService(LEGACY_CADDY_SERVICE_NAME);
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
}
|
|
|
|
const existingService = await this.getExistingService();
|
|
if (existingService) {
|
|
logger.info('SmartProxy service exists, removing old service...');
|
|
await this.removeService();
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
}
|
|
|
|
const networkId = await this.getNetworkId();
|
|
|
|
const response = await this.dockerClient!.request('POST', '/services/create', {
|
|
Name: SMARTPROXY_SERVICE_NAME,
|
|
Labels: {
|
|
'managed-by': 'onebox',
|
|
'onebox-type': 'smartproxy',
|
|
},
|
|
TaskTemplate: {
|
|
ContainerSpec: {
|
|
Image: SMARTPROXY_IMAGE,
|
|
Env: [
|
|
'SMARTPROXY_ADMIN_HOST=0.0.0.0',
|
|
`SMARTPROXY_ADMIN_PORT=${SMARTPROXY_ADMIN_CONTAINER_PORT}`,
|
|
],
|
|
},
|
|
Networks: [
|
|
{
|
|
Target: networkId,
|
|
},
|
|
],
|
|
RestartPolicy: {
|
|
Condition: 'any',
|
|
MaxAttempts: 0,
|
|
},
|
|
},
|
|
Mode: {
|
|
Replicated: {
|
|
Replicas: 1,
|
|
},
|
|
},
|
|
EndpointSpec: {
|
|
Ports: [
|
|
{
|
|
Protocol: 'tcp',
|
|
TargetPort: SMARTPROXY_HTTP_CONTAINER_PORT,
|
|
PublishedPort: this.httpPort,
|
|
PublishMode: 'host',
|
|
},
|
|
{
|
|
Protocol: 'tcp',
|
|
TargetPort: SMARTPROXY_HTTPS_CONTAINER_PORT,
|
|
PublishedPort: this.httpsPort,
|
|
PublishMode: 'host',
|
|
},
|
|
{
|
|
Protocol: 'tcp',
|
|
TargetPort: SMARTPROXY_ADMIN_CONTAINER_PORT,
|
|
PublishedPort: this.adminPort,
|
|
PublishMode: 'host',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
if (response.statusCode >= 300) {
|
|
throw new Error(`Failed to create SmartProxy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
|
|
}
|
|
|
|
logger.info(`SmartProxy service created: ${response.body.ID}`);
|
|
|
|
await this.waitForReady();
|
|
this.serviceRunning = true;
|
|
await this.reloadConfig();
|
|
|
|
logger.success(`SmartProxy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
|
|
} catch (error) {
|
|
logger.error(`Failed to start SmartProxy: ${getErrorMessage(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async getExistingService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<any | null> {
|
|
try {
|
|
const response = await this.dockerClient!.request('GET', `/services/${serviceNameArg}`, {});
|
|
if (response.statusCode === 200) {
|
|
return response.body;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async removeService(serviceNameArg = SMARTPROXY_SERVICE_NAME): Promise<void> {
|
|
try {
|
|
await this.dockerClient!.request('DELETE', `/services/${serviceNameArg}`, {});
|
|
} catch {
|
|
// Service may not exist.
|
|
}
|
|
}
|
|
|
|
private async getNetworkId(): Promise<string> {
|
|
const networks = await this.dockerClient!.listNetworks();
|
|
const network = networks.find((n: any) => n.Name === this.networkName);
|
|
if (!network) {
|
|
throw new Error(`Network not found: ${this.networkName}`);
|
|
}
|
|
return network.Id;
|
|
}
|
|
|
|
private async waitForReady(maxAttempts = 120, intervalMs = 1000): Promise<void> {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
try {
|
|
const response = await fetch(`${this.adminUrl}/ready`);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Not ready yet.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
throw new Error('SmartProxy service failed to start within timeout');
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (!this.serviceRunning && !(await this.getExistingService())) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.ensureDockerClient();
|
|
|
|
logger.info('Stopping SmartProxy service...');
|
|
await this.removeService();
|
|
|
|
this.serviceRunning = false;
|
|
logger.info('SmartProxy service stopped');
|
|
} catch (error) {
|
|
logger.error(`Failed to stop SmartProxy: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
async isHealthy(): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${this.adminUrl}/health`);
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async isRunning(): Promise<boolean> {
|
|
try {
|
|
await this.ensureDockerClient();
|
|
const service = await this.getExistingService();
|
|
if (!service) return false;
|
|
|
|
const tasksResponse = await this.dockerClient!.request(
|
|
'GET',
|
|
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [SMARTPROXY_SERVICE_NAME] }))}`,
|
|
{},
|
|
);
|
|
|
|
if (tasksResponse.statusCode !== 200) return false;
|
|
|
|
const tasks = tasksResponse.body;
|
|
return tasks.some((task: any) => task.Status?.State === 'running');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private routeName(prefixArg: string, domainArg: string): string {
|
|
return `${prefixArg}-${domainArg.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
|
}
|
|
|
|
private parseUpstream(upstreamArg: string): { host: string; port: number } {
|
|
const separatorIndex = upstreamArg.lastIndexOf(':');
|
|
if (separatorIndex <= 0 || separatorIndex === upstreamArg.length - 1) {
|
|
throw new Error(`Invalid upstream target: ${upstreamArg}`);
|
|
}
|
|
|
|
const host = upstreamArg.slice(0, separatorIndex);
|
|
const port = Number(upstreamArg.slice(separatorIndex + 1));
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
throw new Error(`Invalid upstream port in target: ${upstreamArg}`);
|
|
}
|
|
|
|
return { host, port };
|
|
}
|
|
|
|
private buildRoutes(): ISmartProxyRouteConfig[] {
|
|
const routeConfigs: ISmartProxyRouteConfig[] = [];
|
|
|
|
for (const [domain, route] of this.routes) {
|
|
const target = this.parseUpstream(route.upstream);
|
|
const baseAction = {
|
|
type: 'forward' as const,
|
|
targets: [target],
|
|
websocket: {
|
|
enabled: true,
|
|
},
|
|
};
|
|
|
|
routeConfigs.push({
|
|
name: this.routeName('http', domain),
|
|
match: {
|
|
ports: SMARTPROXY_HTTP_CONTAINER_PORT,
|
|
domains: domain,
|
|
protocol: 'http',
|
|
},
|
|
action: baseAction,
|
|
priority: 10,
|
|
});
|
|
|
|
const certificate = this.certificates.get(domain);
|
|
if (certificate) {
|
|
routeConfigs.push({
|
|
name: this.routeName('https', domain),
|
|
match: {
|
|
ports: SMARTPROXY_HTTPS_CONTAINER_PORT,
|
|
domains: domain,
|
|
protocol: 'http',
|
|
},
|
|
action: {
|
|
...baseAction,
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: {
|
|
key: certificate.keyPem,
|
|
cert: certificate.certPem,
|
|
},
|
|
},
|
|
},
|
|
priority: 20,
|
|
});
|
|
}
|
|
}
|
|
|
|
return routeConfigs;
|
|
}
|
|
|
|
async reloadConfig(): Promise<void> {
|
|
const isRunning = await this.isRunning();
|
|
if (!isRunning) {
|
|
logger.warn('SmartProxy not running, cannot reload config');
|
|
return;
|
|
}
|
|
|
|
const routes = this.buildRoutes();
|
|
|
|
try {
|
|
const response = await fetch(`${this.adminUrl}/routes`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ routes }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Failed to reload SmartProxy routes: ${response.status} ${text}`);
|
|
}
|
|
|
|
logger.debug('SmartProxy routes reloaded');
|
|
} catch (error) {
|
|
logger.error(`Failed to reload SmartProxy routes: ${getErrorMessage(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async addRoute(domain: string, upstream: string): Promise<void> {
|
|
this.routes.set(domain, { domain, upstream });
|
|
|
|
if (await this.isRunning()) {
|
|
await this.reloadConfig();
|
|
}
|
|
|
|
logger.success(`Added SmartProxy route: ${domain} -> ${upstream}`);
|
|
}
|
|
|
|
async removeRoute(domain: string): Promise<void> {
|
|
if (this.routes.delete(domain)) {
|
|
if (await this.isRunning()) {
|
|
await this.reloadConfig();
|
|
}
|
|
logger.success(`Removed SmartProxy route: ${domain}`);
|
|
}
|
|
}
|
|
|
|
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
|
|
this.certificates.set(domain, {
|
|
domain,
|
|
certPem,
|
|
keyPem,
|
|
});
|
|
|
|
try {
|
|
await Deno.mkdir(this.certsDir, { recursive: true });
|
|
await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem);
|
|
await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem);
|
|
} catch (error) {
|
|
logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`);
|
|
}
|
|
|
|
if (await this.isRunning()) {
|
|
await this.reloadConfig();
|
|
}
|
|
|
|
logger.success(`Added TLS certificate for ${domain}`);
|
|
}
|
|
|
|
async removeCertificate(domain: string): Promise<void> {
|
|
if (this.certificates.delete(domain)) {
|
|
try {
|
|
await Deno.remove(`${this.certsDir}/${domain}.crt`);
|
|
await Deno.remove(`${this.certsDir}/${domain}.key`);
|
|
} catch {
|
|
// Files may not exist.
|
|
}
|
|
|
|
if (await this.isRunning()) {
|
|
await this.reloadConfig();
|
|
}
|
|
|
|
logger.success(`Removed TLS certificate for ${domain}`);
|
|
}
|
|
}
|
|
|
|
getRoutes(): ISmartProxyRoute[] {
|
|
return Array.from(this.routes.values());
|
|
}
|
|
|
|
getCertificates(): ISmartProxyCertificate[] {
|
|
return Array.from(this.certificates.values());
|
|
}
|
|
|
|
clear(): void {
|
|
this.routes.clear();
|
|
this.certificates.clear();
|
|
}
|
|
|
|
getStatus(): {
|
|
running: boolean;
|
|
httpPort: number;
|
|
httpsPort: number;
|
|
routes: number;
|
|
certificates: number;
|
|
} {
|
|
return {
|
|
running: this.serviceRunning,
|
|
httpPort: this.httpPort,
|
|
httpsPort: this.httpsPort,
|
|
routes: this.routes.size,
|
|
certificates: this.certificates.size,
|
|
};
|
|
}
|
|
}
|