353 lines
9.7 KiB
TypeScript
353 lines
9.7 KiB
TypeScript
import * as plugins from '../plugins.ts';
|
|
import { logger } from '../logging.ts';
|
|
import { getErrorMessage } from '../utils/error.ts';
|
|
import { OneboxDatabase } from './database.ts';
|
|
import type { IDomain, IService } from '../types.ts';
|
|
|
|
type TWorkHosterType = 'onebox';
|
|
|
|
interface IExternalGatewayConfig {
|
|
url: string;
|
|
apiToken: string;
|
|
workHosterId: string;
|
|
targetHost?: string;
|
|
targetPort?: number;
|
|
}
|
|
|
|
interface IWorkHosterDomain {
|
|
name: string;
|
|
capabilities?: {
|
|
canCreateSubdomains: boolean;
|
|
canManageDnsRecords: boolean;
|
|
canIssueCertificates: boolean;
|
|
canHostEmail: boolean;
|
|
};
|
|
}
|
|
|
|
interface IWorkAppRouteOwnership {
|
|
workHosterType: TWorkHosterType;
|
|
workHosterId: string;
|
|
workAppId: string;
|
|
hostname: string;
|
|
}
|
|
|
|
interface IWorkAppRouteSyncResult {
|
|
success: boolean;
|
|
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
|
routeId?: string;
|
|
message?: string;
|
|
}
|
|
|
|
interface IDcRouterCertificateExport {
|
|
success: boolean;
|
|
cert?: {
|
|
id: string;
|
|
domainName: string;
|
|
created: number;
|
|
validUntil: number;
|
|
privateKey: string;
|
|
publicKey: string;
|
|
csr: string;
|
|
};
|
|
message?: string;
|
|
}
|
|
|
|
interface IDcRouterRouteConfig {
|
|
name: string;
|
|
match: {
|
|
ports: number[];
|
|
domains: string[];
|
|
};
|
|
action: {
|
|
type: 'forward';
|
|
targets: Array<{ host: string; port: number }>;
|
|
tls: {
|
|
mode: 'terminate';
|
|
certificate: 'auto';
|
|
};
|
|
websocket: {
|
|
enabled: boolean;
|
|
};
|
|
};
|
|
}
|
|
|
|
export class ExternalGatewayManager {
|
|
private database: OneboxDatabase;
|
|
|
|
constructor(private oneboxRef: any) {
|
|
this.database = oneboxRef.database;
|
|
}
|
|
|
|
public async init(): Promise<void> {
|
|
if (!(await this.isConfigured())) {
|
|
logger.info('External dcrouter gateway not configured');
|
|
return;
|
|
}
|
|
|
|
await this.syncDomains();
|
|
}
|
|
|
|
public async isConfigured(): Promise<boolean> {
|
|
const config = await this.getConfig({ requireTarget: false });
|
|
return Boolean(config);
|
|
}
|
|
|
|
public async syncDomains(): Promise<IDomain[]> {
|
|
const config = await this.requireConfig({ requireTarget: false });
|
|
const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>(
|
|
'getWorkHosterDomains',
|
|
{},
|
|
config,
|
|
);
|
|
|
|
const activeDomainNames = new Set<string>();
|
|
const now = Date.now();
|
|
|
|
for (const gatewayDomain of response.domains) {
|
|
const domainName = gatewayDomain.name.trim().toLowerCase();
|
|
if (!domainName) continue;
|
|
|
|
activeDomainNames.add(domainName);
|
|
const existingDomain = this.database.getDomainByName(domainName);
|
|
const defaultWildcard = gatewayDomain.capabilities?.canIssueCertificates !== false;
|
|
|
|
if (existingDomain) {
|
|
this.database.updateDomain(existingDomain.id!, {
|
|
dnsProvider: 'dcrouter',
|
|
isObsolete: false,
|
|
defaultWildcard,
|
|
updatedAt: now,
|
|
});
|
|
} else {
|
|
this.database.createDomain({
|
|
domain: domainName,
|
|
dnsProvider: 'dcrouter',
|
|
isObsolete: false,
|
|
defaultWildcard,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const domain of this.database.getDomainsByProvider('dcrouter')) {
|
|
if (!activeDomainNames.has(domain.domain)) {
|
|
this.database.updateDomain(domain.id!, {
|
|
isObsolete: true,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.success(`Synced ${activeDomainNames.size} domain(s) from external dcrouter gateway`);
|
|
return this.database.getDomainsByProvider('dcrouter');
|
|
}
|
|
|
|
public async syncServiceRoute(service: IService): Promise<void> {
|
|
if (!service.domain) return;
|
|
|
|
const config = await this.getConfig({ requireTarget: true });
|
|
if (!config) return;
|
|
|
|
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
|
|
'syncWorkAppRoute',
|
|
{
|
|
ownership: this.buildOwnership(service, service.domain, config),
|
|
route: this.buildRoute(service, config),
|
|
enabled: service.status === 'running',
|
|
},
|
|
config,
|
|
);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.message || `dcrouter route sync failed for ${service.domain}`);
|
|
}
|
|
|
|
logger.success(`External gateway route ${result.action || 'synced'} for ${service.domain}`);
|
|
await this.importCertificateForDomain(service.domain).catch((error) => {
|
|
logger.debug(`External gateway certificate import skipped for ${service.domain}: ${getErrorMessage(error)}`);
|
|
});
|
|
}
|
|
|
|
public async deleteServiceRoute(service: Pick<IService, 'id' | 'name' | 'domain'>): Promise<void> {
|
|
if (!service.domain) return;
|
|
|
|
const config = await this.getConfig({ requireTarget: false });
|
|
if (!config) return;
|
|
|
|
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
|
|
'syncWorkAppRoute',
|
|
{
|
|
ownership: this.buildOwnership(service, service.domain, config),
|
|
delete: true,
|
|
},
|
|
config,
|
|
);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.message || `dcrouter route delete failed for ${service.domain}`);
|
|
}
|
|
|
|
logger.info(`External gateway route ${result.action || 'deleted'} for ${service.domain}`);
|
|
}
|
|
|
|
public async importCertificateForDomain(domain: string): Promise<boolean> {
|
|
const config = await this.getConfig({ requireTarget: false });
|
|
if (!config) return false;
|
|
|
|
const result = await this.fireDcRouterRequest<IDcRouterCertificateExport>(
|
|
'exportCertificate',
|
|
{ domain },
|
|
config,
|
|
);
|
|
|
|
if (!result.success || !result.cert) {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const existingCertificate = this.database.getSSLCertificate(domain);
|
|
if (existingCertificate) {
|
|
this.database.updateSSLCertificate(domain, {
|
|
certPem: result.cert.publicKey,
|
|
keyPem: result.cert.privateKey,
|
|
fullchainPem: result.cert.publicKey,
|
|
expiryDate: result.cert.validUntil,
|
|
updatedAt: now,
|
|
});
|
|
} else {
|
|
await this.database.createSSLCertificate({
|
|
domain,
|
|
certPem: result.cert.publicKey,
|
|
keyPem: result.cert.privateKey,
|
|
fullchainPem: result.cert.publicKey,
|
|
expiryDate: result.cert.validUntil,
|
|
issuer: 'dcrouter',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
|
logger.success(`Imported external gateway certificate for ${domain}`);
|
|
return true;
|
|
}
|
|
|
|
private async getConfig(options: { requireTarget?: boolean } = {}): Promise<IExternalGatewayConfig | null> {
|
|
const url = this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || '');
|
|
const apiToken = await this.database.getSecretSetting('dcrouterGatewayApiToken');
|
|
if (!url || !apiToken) {
|
|
return null;
|
|
}
|
|
|
|
const config: IExternalGatewayConfig = {
|
|
url,
|
|
apiToken,
|
|
workHosterId: this.ensureWorkHosterId(),
|
|
};
|
|
|
|
if (options.requireTarget !== false) {
|
|
config.targetHost = this.database.getSetting('dcrouterTargetHost')
|
|
|| this.database.getSetting('serverIP')
|
|
|| undefined;
|
|
const targetPort = this.parsePort(
|
|
this.database.getSetting('dcrouterTargetPort')
|
|
|| this.database.getSetting('httpPort')
|
|
|| '80',
|
|
);
|
|
config.targetPort = targetPort;
|
|
|
|
if (!config.targetHost) {
|
|
throw new Error('dcrouterTargetHost or serverIP must be configured for external gateway route sync');
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
private async requireConfig(options: { requireTarget?: boolean } = {}): Promise<IExternalGatewayConfig> {
|
|
const config = await this.getConfig(options);
|
|
if (!config) {
|
|
throw new Error('External dcrouter gateway is not configured');
|
|
}
|
|
return config;
|
|
}
|
|
|
|
private normalizeUrl(url: string): string {
|
|
const trimmedUrl = url.trim().replace(/\/+$/, '');
|
|
if (!trimmedUrl) return '';
|
|
if (/^https?:\/\//.test(trimmedUrl)) return trimmedUrl;
|
|
return `https://${trimmedUrl}`;
|
|
}
|
|
|
|
private parsePort(portValue: string): number {
|
|
const port = Number(portValue);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
throw new Error(`Invalid dcrouter target port: ${portValue}`);
|
|
}
|
|
return port;
|
|
}
|
|
|
|
private ensureWorkHosterId(): string {
|
|
let workHosterId = this.database.getSetting('dcrouterWorkHosterId');
|
|
if (!workHosterId) {
|
|
workHosterId = crypto.randomUUID();
|
|
this.database.setSetting('dcrouterWorkHosterId', workHosterId);
|
|
}
|
|
return workHosterId;
|
|
}
|
|
|
|
private buildOwnership(
|
|
service: Pick<IService, 'id' | 'name'>,
|
|
hostname: string,
|
|
config: IExternalGatewayConfig,
|
|
): IWorkAppRouteOwnership {
|
|
return {
|
|
workHosterType: 'onebox',
|
|
workHosterId: config.workHosterId,
|
|
workAppId: service.name || `service-${service.id}`,
|
|
hostname,
|
|
};
|
|
}
|
|
|
|
private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig {
|
|
return {
|
|
name: this.routeName(service.domain!),
|
|
match: {
|
|
ports: [443],
|
|
domains: [service.domain!],
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{ host: config.targetHost!, port: config.targetPort! }],
|
|
tls: {
|
|
mode: 'terminate',
|
|
certificate: 'auto',
|
|
},
|
|
websocket: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private routeName(domain: string): string {
|
|
return `onebox-${domain.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
|
}
|
|
|
|
private async fireDcRouterRequest<TResponse>(
|
|
method: string,
|
|
requestData: Record<string, unknown>,
|
|
config: IExternalGatewayConfig,
|
|
): Promise<TResponse> {
|
|
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
|
`${config.url}/typedrequest`,
|
|
method,
|
|
);
|
|
return await typedRequest.fire({
|
|
...requestData,
|
|
apiToken: config.apiToken,
|
|
}) as TResponse;
|
|
}
|
|
}
|