feat: add dcrouter external gateway sync
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
import { assert, assertEquals } from '@std/assert';
|
||||||
|
|
||||||
|
import { ExternalGatewayManager } from '../ts/classes/external-gateway.ts';
|
||||||
|
import type { IDomain, IService, ISslCertificate } from '../ts/types.ts';
|
||||||
|
|
||||||
|
class FakeDatabase {
|
||||||
|
public settings = new Map<string, string>();
|
||||||
|
public secretSettings = new Map<string, string>();
|
||||||
|
public domains: IDomain[] = [];
|
||||||
|
public certificates = new Map<string, ISslCertificate>();
|
||||||
|
private nextDomainId = 1;
|
||||||
|
|
||||||
|
getSetting(key: string): string | null {
|
||||||
|
return this.settings.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetting(key: string, value: string): void {
|
||||||
|
this.settings.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecretSetting(key: string): Promise<string | null> {
|
||||||
|
return this.secretSettings.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomainByName(domain: string): IDomain | null {
|
||||||
|
return this.domains.find((entry) => entry.domain === domain) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDomain(domain: Omit<IDomain, 'id'>): IDomain {
|
||||||
|
const createdDomain = { ...domain, id: this.nextDomainId++ };
|
||||||
|
this.domains.push(createdDomain);
|
||||||
|
return createdDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDomain(id: number, updates: Partial<IDomain>): void {
|
||||||
|
const index = this.domains.findIndex((entry) => entry.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
this.domains[index] = { ...this.domains[index], ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||||
|
return this.domains.filter((entry) => entry.dnsProvider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSSLCertificate(domain: string): ISslCertificate | null {
|
||||||
|
return this.certificates.get(domain) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSSLCertificate(domain: string, updates: Partial<ISslCertificate>): void {
|
||||||
|
const existing = this.certificates.get(domain);
|
||||||
|
if (!existing) return;
|
||||||
|
this.certificates.set(domain, { ...existing, ...updates });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSSLCertificate(cert: Omit<ISslCertificate, 'id'>): Promise<ISslCertificate> {
|
||||||
|
const storedCert = { ...cert, id: this.certificates.size + 1 };
|
||||||
|
this.certificates.set(cert.domain, storedCert);
|
||||||
|
return storedCert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeOneboxRef = () => {
|
||||||
|
const database = new FakeDatabase();
|
||||||
|
database.settings.set('dcrouterGatewayUrl', 'https://edge.example.com');
|
||||||
|
database.settings.set('dcrouterWorkHosterId', 'onebox-1');
|
||||||
|
database.secretSettings.set('dcrouterGatewayApiToken', 'dcr-token');
|
||||||
|
|
||||||
|
let reloadCount = 0;
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
reverseProxy: {
|
||||||
|
reloadCertificates: async () => {
|
||||||
|
reloadCount++;
|
||||||
|
},
|
||||||
|
get reloadCount() {
|
||||||
|
return reloadCount;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('ExternalGatewayManager syncs dcrouter domains into Onebox domains', async () => {
|
||||||
|
const oneboxRef = makeOneboxRef();
|
||||||
|
oneboxRef.database.domains.push({
|
||||||
|
id: 99,
|
||||||
|
domain: 'old.example.com',
|
||||||
|
dnsProvider: 'dcrouter',
|
||||||
|
isObsolete: false,
|
||||||
|
defaultWildcard: true,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||||
|
(manager as any).fireDcRouterRequest = async (method: string) => {
|
||||||
|
assertEquals(method, 'getWorkHosterDomains');
|
||||||
|
return {
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
name: 'example.com',
|
||||||
|
capabilities: {
|
||||||
|
canCreateSubdomains: true,
|
||||||
|
canManageDnsRecords: true,
|
||||||
|
canIssueCertificates: true,
|
||||||
|
canHostEmail: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const domains = await manager.syncDomains();
|
||||||
|
|
||||||
|
assertEquals(domains.length, 2);
|
||||||
|
assertEquals(oneboxRef.database.getDomainByName('example.com')?.dnsProvider, 'dcrouter');
|
||||||
|
assertEquals(oneboxRef.database.getDomainByName('example.com')?.defaultWildcard, true);
|
||||||
|
assertEquals(oneboxRef.database.getDomainByName('old.example.com')?.isObsolete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ExternalGatewayManager syncs service routes to dcrouter WorkHoster API', async () => {
|
||||||
|
const oneboxRef = makeOneboxRef();
|
||||||
|
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
|
||||||
|
oneboxRef.database.settings.set('httpPort', '8080');
|
||||||
|
|
||||||
|
const service: IService = {
|
||||||
|
id: 1,
|
||||||
|
name: 'hello',
|
||||||
|
image: 'nginx:latest',
|
||||||
|
envVars: {},
|
||||||
|
port: 3000,
|
||||||
|
domain: 'hello.example.com',
|
||||||
|
status: 'running',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requests: Array<{ method: string; requestData: Record<string, unknown> }> = [];
|
||||||
|
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||||
|
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||||
|
requests.push({ method, requestData });
|
||||||
|
if (method === 'exportCertificate') {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
return { success: true, action: 'created', routeId: 'route-1' };
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.syncServiceRoute(service);
|
||||||
|
|
||||||
|
const syncRequest = requests.find((request) => request.method === 'syncWorkAppRoute')!;
|
||||||
|
const route = syncRequest.requestData.route as any;
|
||||||
|
const ownership = syncRequest.requestData.ownership as any;
|
||||||
|
|
||||||
|
assertEquals(ownership, {
|
||||||
|
workHosterType: 'onebox',
|
||||||
|
workHosterId: 'onebox-1',
|
||||||
|
workAppId: 'hello',
|
||||||
|
hostname: 'hello.example.com',
|
||||||
|
});
|
||||||
|
assertEquals(route.match, { ports: [443], domains: ['hello.example.com'] });
|
||||||
|
assertEquals(route.action.targets, [{ host: '203.0.113.10', port: 8080 }]);
|
||||||
|
assertEquals(route.action.tls, { mode: 'terminate', certificate: 'auto' });
|
||||||
|
assertEquals(syncRequest.requestData.enabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ExternalGatewayManager deletes service routes through dcrouter WorkHoster API', async () => {
|
||||||
|
const oneboxRef = makeOneboxRef();
|
||||||
|
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||||
|
let deleteRequest: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||||
|
assertEquals(method, 'syncWorkAppRoute');
|
||||||
|
deleteRequest = requestData;
|
||||||
|
return { success: true, action: 'deleted', routeId: 'route-1' };
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.deleteServiceRoute({
|
||||||
|
id: 1,
|
||||||
|
name: 'hello',
|
||||||
|
domain: 'hello.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(deleteRequest);
|
||||||
|
const capturedDeleteRequest = deleteRequest as Record<string, unknown>;
|
||||||
|
assertEquals(capturedDeleteRequest.delete, true);
|
||||||
|
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ExternalGatewayManager imports exported dcrouter certificates into Onebox', async () => {
|
||||||
|
const oneboxRef = makeOneboxRef();
|
||||||
|
const manager = new ExternalGatewayManager(oneboxRef as any);
|
||||||
|
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
|
||||||
|
assertEquals(method, 'exportCertificate');
|
||||||
|
assertEquals(requestData.domain, 'hello.example.com');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: 'cert-1',
|
||||||
|
domainName: 'hello.example.com',
|
||||||
|
created: 1,
|
||||||
|
validUntil: 2,
|
||||||
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||||
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||||
|
csr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const imported = await manager.importCertificateForDomain('hello.example.com');
|
||||||
|
|
||||||
|
assert(imported);
|
||||||
|
assertEquals(oneboxRef.database.getSSLCertificate('hello.example.com')?.issuer, 'dcrouter');
|
||||||
|
assertEquals(oneboxRef.reverseProxy.reloadCount, 1);
|
||||||
|
});
|
||||||
@@ -59,3 +59,15 @@ Deno.test('secret settings canonicalize aliases and clear old secret entries', a
|
|||||||
secretSettings.clear('backupPassword');
|
secretSettings.clear('backupPassword');
|
||||||
assertEquals(await secretSettings.get('backupPassword'), null);
|
assertEquals(await secretSettings.get('backupPassword'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('secret settings treat dcrouter gateway token as encrypted secret', async () => {
|
||||||
|
const authRepo = new FakeAuthRepository();
|
||||||
|
authRepo.setSetting('externalGatewayApiToken', 'dcr-secret-token');
|
||||||
|
|
||||||
|
const secretSettings = new SecretSettingsManager(authRepo as any);
|
||||||
|
const token = await secretSettings.get('dcrouterGatewayApiToken');
|
||||||
|
|
||||||
|
assertEquals(token, 'dcr-secret-token');
|
||||||
|
assertEquals(authRepo.getSetting('externalGatewayApiToken'), null);
|
||||||
|
assert(authRepo.getSecretSetting('dcrouterGatewayApiToken')?.startsWith('enc:v1:'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { AppStoreManager } from './appstore.ts';
|
|||||||
import { ProxyLogReceiver } from './proxy-log-receiver.ts';
|
import { ProxyLogReceiver } from './proxy-log-receiver.ts';
|
||||||
import { BackupManager } from './backup-manager.ts';
|
import { BackupManager } from './backup-manager.ts';
|
||||||
import { BackupScheduler } from './backup-scheduler.ts';
|
import { BackupScheduler } from './backup-scheduler.ts';
|
||||||
|
import { ExternalGatewayManager } from './external-gateway.ts';
|
||||||
import { OpsServer } from '../opsserver/index.ts';
|
import { OpsServer } from '../opsserver/index.ts';
|
||||||
|
|
||||||
export class Onebox {
|
export class Onebox {
|
||||||
@@ -44,6 +45,7 @@ export class Onebox {
|
|||||||
public proxyLogReceiver: ProxyLogReceiver;
|
public proxyLogReceiver: ProxyLogReceiver;
|
||||||
public backupManager: BackupManager;
|
public backupManager: BackupManager;
|
||||||
public backupScheduler: BackupScheduler;
|
public backupScheduler: BackupScheduler;
|
||||||
|
public externalGateway: ExternalGatewayManager;
|
||||||
public opsServer: OpsServer;
|
public opsServer: OpsServer;
|
||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
@@ -86,6 +88,9 @@ export class Onebox {
|
|||||||
// Initialize Backup scheduler
|
// Initialize Backup scheduler
|
||||||
this.backupScheduler = new BackupScheduler(this);
|
this.backupScheduler = new BackupScheduler(this);
|
||||||
|
|
||||||
|
// Initialize optional dcrouter edge gateway integration
|
||||||
|
this.externalGateway = new ExternalGatewayManager(this);
|
||||||
|
|
||||||
// Initialize OpsServer (TypedRequest-based server)
|
// Initialize OpsServer (TypedRequest-based server)
|
||||||
this.opsServer = new OpsServer(this);
|
this.opsServer = new OpsServer(this);
|
||||||
}
|
}
|
||||||
@@ -160,6 +165,14 @@ export class Onebox {
|
|||||||
logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited');
|
logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize external dcrouter gateway (non-critical)
|
||||||
|
try {
|
||||||
|
await this.externalGateway.init();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('External dcrouter gateway initialization failed - edge sync will be disabled');
|
||||||
|
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Onebox Registry (non-critical)
|
// Initialize Onebox Registry (non-critical)
|
||||||
try {
|
try {
|
||||||
await this.registry.init();
|
await this.registry.init();
|
||||||
|
|||||||
+32
-2
@@ -34,6 +34,24 @@ export class OneboxServicesManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncExternalGatewayRoute(service: IService): Promise<void> {
|
||||||
|
if (!this.oneboxRef.externalGateway) return;
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.externalGateway.syncServiceRoute(service);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteExternalGatewayRoute(service: Pick<IService, 'id' | 'name' | 'domain'>): Promise<void> {
|
||||||
|
if (!this.oneboxRef.externalGateway) return;
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.externalGateway.deleteServiceRoute(service);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to delete external gateway route for ${service.domain}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy a new service (full workflow)
|
* Deploy a new service (full workflow)
|
||||||
*/
|
*/
|
||||||
@@ -210,6 +228,8 @@ export class OneboxServicesManager {
|
|||||||
|
|
||||||
// Note: SSL certificates are now handled automatically by CertRequirementManager
|
// Note: SSL certificates are now handled automatically by CertRequirementManager
|
||||||
// which processes pending requirements created above. No direct obtainCertificate call needed.
|
// which processes pending requirements created above. No direct obtainCertificate call needed.
|
||||||
|
|
||||||
|
await this.syncExternalGatewayRoute(this.database.getServiceByName(options.name)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`Service deployed successfully: ${options.name}`);
|
logger.success(`Service deployed successfully: ${options.name}`);
|
||||||
@@ -252,6 +272,8 @@ export class OneboxServicesManager {
|
|||||||
} catch (routeError) {
|
} catch (routeError) {
|
||||||
logger.warn(`Failed to add proxy route for ${service.domain}: ${getErrorMessage(routeError)}`);
|
logger.warn(`Failed to add proxy route for ${service.domain}: ${getErrorMessage(routeError)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncExternalGatewayRoute(this.database.getServiceByName(name)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`Service started: ${name}`);
|
logger.success(`Service started: ${name}`);
|
||||||
@@ -291,7 +313,8 @@ export class OneboxServicesManager {
|
|||||||
|
|
||||||
// Remove reverse proxy route if service has a domain
|
// Remove reverse proxy route if service has a domain
|
||||||
if (service.domain) {
|
if (service.domain) {
|
||||||
this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
await this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||||
|
await this.deleteExternalGatewayRoute(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`Service stopped: ${name}`);
|
logger.success(`Service stopped: ${name}`);
|
||||||
@@ -359,6 +382,8 @@ export class OneboxServicesManager {
|
|||||||
logger.warn(`Failed to remove reverse proxy route: ${getErrorMessage(error)}`);
|
logger.warn(`Failed to remove reverse proxy route: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.deleteExternalGatewayRoute(service);
|
||||||
|
|
||||||
// Note: We don't remove DNS records or SSL certs automatically
|
// Note: We don't remove DNS records or SSL certs automatically
|
||||||
// as they might be used by other services or need manual cleanup
|
// as they might be used by other services or need manual cleanup
|
||||||
}
|
}
|
||||||
@@ -617,10 +642,12 @@ export class OneboxServicesManager {
|
|||||||
// Remove old route if it existed
|
// Remove old route if it existed
|
||||||
if (oldDomain) {
|
if (oldDomain) {
|
||||||
try {
|
try {
|
||||||
this.oneboxRef.reverseProxy.removeRoute(oldDomain);
|
await this.oneboxRef.reverseProxy.removeRoute(oldDomain);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`);
|
logger.warn(`Failed to remove old reverse proxy route: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.deleteExternalGatewayRoute({ ...service, domain: oldDomain });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new route if domain specified
|
// Add new route if domain specified
|
||||||
@@ -650,6 +677,9 @@ export class OneboxServicesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshedService = this.database.getServiceByName(name)!;
|
const refreshedService = this.database.getServiceByName(name)!;
|
||||||
|
if (refreshedService.domain && refreshedService.status === 'running') {
|
||||||
|
await this.syncExternalGatewayRoute(refreshedService);
|
||||||
|
}
|
||||||
await this.broadcastServiceUpdate(name, 'updated');
|
await this.broadcastServiceUpdate(name, 'updated');
|
||||||
return refreshedService;
|
return refreshedService;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export class OneboxDatabase {
|
|||||||
return this.certificateRepo.getAllDomains();
|
return this.certificateRepo.getAllDomains();
|
||||||
}
|
}
|
||||||
|
|
||||||
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] {
|
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||||
return this.certificateRepo.getDomainsByProvider(provider);
|
return this.certificateRepo.getDomainsByProvider(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class CertificateRepository extends BaseRepository {
|
|||||||
return rows.map((row) => this.rowToDomain(row));
|
return rows.map((row) => this.rowToDomain(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] {
|
getDomainsByProvider(provider: NonNullable<IDomain['dnsProvider']>): IDomain[] {
|
||||||
const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [provider]);
|
const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [provider]);
|
||||||
return rows.map((row) => this.rowToDomain(row));
|
return rows.map((row) => this.rowToDomain(row));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const encryptedSecretPrefix = 'enc:v1:';
|
|||||||
const secretSettingAliases = {
|
const secretSettingAliases = {
|
||||||
backupPassword: ['backup_encryption_password'],
|
backupPassword: ['backup_encryption_password'],
|
||||||
cloudflareToken: ['cloudflareAPIKey'],
|
cloudflareToken: ['cloudflareAPIKey'],
|
||||||
|
dcrouterGatewayApiToken: ['externalGatewayApiToken'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type TCanonicalSecretSettingKey = keyof typeof secretSettingAliases;
|
type TCanonicalSecretSettingKey = keyof typeof secretSettingAliases;
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ export class SettingsHandler {
|
|||||||
private async getSettingsObject(): Promise<interfaces.data.ISettings> {
|
private async getSettingsObject(): Promise<interfaces.data.ISettings> {
|
||||||
const db = this.opsServerRef.oneboxRef.database;
|
const db = this.opsServerRef.oneboxRef.database;
|
||||||
const cloudflareToken = await db.getSecretSetting('cloudflareToken');
|
const cloudflareToken = await db.getSecretSetting('cloudflareToken');
|
||||||
|
const dcrouterGatewayApiToken = await db.getSecretSetting('dcrouterGatewayApiToken');
|
||||||
const settingsMap = db.getAllSettings();
|
const settingsMap = db.getAllSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cloudflareToken: cloudflareToken || '',
|
cloudflareToken: cloudflareToken || '',
|
||||||
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
|
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
|
||||||
|
dcrouterGatewayUrl: settingsMap['dcrouterGatewayUrl'] || '',
|
||||||
|
dcrouterGatewayApiToken: dcrouterGatewayApiToken || '',
|
||||||
|
dcrouterWorkHosterId: settingsMap['dcrouterWorkHosterId'] || '',
|
||||||
|
dcrouterTargetHost: settingsMap['dcrouterTargetHost'] || '',
|
||||||
|
dcrouterTargetPort: parseInt(settingsMap['dcrouterTargetPort'] || '0', 10),
|
||||||
autoRenewCerts: settingsMap['autoRenewCerts'] === 'true',
|
autoRenewCerts: settingsMap['autoRenewCerts'] === 'true',
|
||||||
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
|
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
|
||||||
acmeEmail: settingsMap['acmeEmail'] || '',
|
acmeEmail: settingsMap['acmeEmail'] || '',
|
||||||
|
|||||||
+6
-1
@@ -148,7 +148,7 @@ export interface INginxConfig {
|
|||||||
export interface IDomain {
|
export interface IDomain {
|
||||||
id?: number;
|
id?: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
|
||||||
cloudflareZoneId?: string;
|
cloudflareZoneId?: string;
|
||||||
isObsolete: boolean;
|
isObsolete: boolean;
|
||||||
defaultWildcard: boolean;
|
defaultWildcard: boolean;
|
||||||
@@ -259,6 +259,11 @@ export interface IAppSettings {
|
|||||||
serverIP?: string;
|
serverIP?: string;
|
||||||
cloudflareToken?: string;
|
cloudflareToken?: string;
|
||||||
cloudflareZoneId?: string;
|
cloudflareZoneId?: string;
|
||||||
|
dcrouterGatewayUrl?: string;
|
||||||
|
dcrouterGatewayApiToken?: string;
|
||||||
|
dcrouterWorkHosterId?: string;
|
||||||
|
dcrouterTargetHost?: string;
|
||||||
|
dcrouterTargetPort?: number;
|
||||||
acmeEmail?: string;
|
acmeEmail?: string;
|
||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
httpPort?: number;
|
httpPort?: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export interface IDomain {
|
export interface IDomain {
|
||||||
id?: number;
|
id?: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
dnsProvider: 'cloudflare' | 'manual' | null;
|
dnsProvider: 'cloudflare' | 'manual' | 'dcrouter' | null;
|
||||||
cloudflareZoneId?: string;
|
cloudflareZoneId?: string;
|
||||||
isObsolete: boolean;
|
isObsolete: boolean;
|
||||||
defaultWildcard: boolean;
|
defaultWildcard: boolean;
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
export interface ISettings {
|
export interface ISettings {
|
||||||
cloudflareToken: string;
|
cloudflareToken: string;
|
||||||
cloudflareZoneId: string;
|
cloudflareZoneId: string;
|
||||||
|
dcrouterGatewayUrl: string;
|
||||||
|
dcrouterGatewayApiToken: string;
|
||||||
|
dcrouterWorkHosterId: string;
|
||||||
|
dcrouterTargetHost: string;
|
||||||
|
dcrouterTargetPort: number;
|
||||||
autoRenewCerts: boolean;
|
autoRenewCerts: boolean;
|
||||||
renewalThreshold: number;
|
renewalThreshold: number;
|
||||||
acmeEmail: string;
|
acmeEmail: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user