feat(gateway-clients): add managed gateway client administration and token-bound route ownership

This commit is contained in:
2026-05-09 22:35:07 +00:00
parent d73b250382
commit 8dd0c3def9
22 changed files with 1287 additions and 48 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
add managed gateway client administration and token-bound route ownership
- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows
- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client
- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view
- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view
## 2026-05-09 - 13.27.1 - fix(docker) ## 2026-05-09 - 13.27.1 - fix(docker)
configure pnpm to use the verdaccio registry during Docker builds configure pnpm to use the verdaccio registry during Docker builds
+48 -3
View File
@@ -47,7 +47,11 @@ const makeApiTokenManager = (scopes: TScope[]) => {
}; };
}; };
const setupHandler = (scopes: TScope[]) => { const setupHandler = (scopes: TScope[], options?: {
routes?: any[];
certProvisionScheduler?: any;
certProvisionFunction?: (...args: any[]) => any;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter(); const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = { const opsServerRef: any = {
typedrouter, typedrouter,
@@ -60,9 +64,13 @@ const setupHandler = (scopes: TScope[]) => {
apiTokenManager: makeApiTokenManager(scopes), apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(), certificateStatusMap: new Map(),
smartProxy: { smartProxy: {
routeManager: { getRoutes: () => [] }, settings: options?.certProvisionFunction ? {
certProvisionFunction: options.certProvisionFunction,
} : {},
routeManager: { getRoutes: () => options?.routes ?? [] },
getCertificateStatus: async () => null,
}, },
certProvisionScheduler: null, certProvisionScheduler: options?.certProvisionScheduler ?? null,
}, },
}; };
@@ -147,6 +155,43 @@ tap.test('CertificateHandler allows API-token import with certificates:write', a
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid'); expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
}); });
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
await testDbPromise;
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const { typedrouter } = setupHandler(['certificates:read'], {
certProvisionFunction: async () => 'http01',
certProvisionScheduler: {
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
? { failures: 11, retryAfter, lastError }
: null,
},
routes: [
{
name: 'stack-gallery',
match: { domains: ['stack.gallery'] },
action: {
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
},
],
});
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.summary.failed).toEqual(1);
expect(result.response.certificates[0].status).toEqual('failed');
expect(result.response.certificates[0].error).toEqual(lastError);
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
});
tap.test('cleanup test db', async () => { tap.test('cleanup test db', async () => {
const testDb = await testDbPromise; const testDb = await testDbPromise;
await testDb.cleanup(); await testDb.cleanup();
+169 -3
View File
@@ -21,7 +21,10 @@ const fireTypedRequest = async (
} as any, { localRequest: true, skipHooks: true }) as any; } as any, { localRequest: true, skipHooks: true }) as any;
}; };
const makeApiTokenManager = (scopes: TScope[]) => { const makeApiTokenManager = (
scopes: TScope[],
policy?: interfaces.data.IApiTokenPolicy,
) => {
const token = { const token = {
id: 'token-1', id: 'token-1',
name: 'workhoster-test-token', name: 'workhoster-test-token',
@@ -31,12 +34,26 @@ const makeApiTokenManager = (scopes: TScope[]) => {
expiresAt: null, expiresAt: null,
lastUsedAt: null, lastUsedAt: null,
enabled: true, enabled: true,
policy,
} as interfaces.data.IStoredApiToken; } as interfaces.data.IStoredApiToken;
return { return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null, validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => { hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
if (storedToken.policy?.role === 'admin') return true;
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
const gatewayClientAllowedScopes = new Set<TScope>([
'gateway-clients:read',
'gateway-clients:write',
'workhosters:read',
'workhosters:write',
]);
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
const scopes = new Set(storedToken.scopes); const scopes = new Set(storedToken.scopes);
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = { const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
'gateway-clients:read': ['workhosters:read'], 'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'], 'gateway-clients:write': ['workhosters:write'],
@@ -111,6 +128,8 @@ const makeRouteConfigManager = () => {
const setupHandler = (options: { const setupHandler = (options: {
scopes: TScope[]; scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
isAdmin?: boolean;
dcRouterRef?: Record<string, any>; dcRouterRef?: Record<string, any>;
}) => { }) => {
const typedrouter = new plugins.typedrequest.TypedRouter(); const typedrouter = new plugins.typedrequest.TypedRouter();
@@ -118,12 +137,12 @@ const setupHandler = (options: {
typedrouter, typedrouter,
adminHandler: { adminHandler: {
adminIdentityGuard: { adminIdentityGuard: {
exec: async () => false, exec: async () => Boolean(options.isAdmin),
}, },
}, },
dcRouterRef: { dcRouterRef: {
options: {}, options: {},
apiTokenManager: makeApiTokenManager(options.scopes), apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
...options.dcRouterRef, ...options.dcRouterRef,
}, },
}; };
@@ -274,6 +293,153 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' }); expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
}); });
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
},
},
dcRouterRef: { options: {} },
});
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
});
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
hostnamePatterns: ['*.example.com'],
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
routeConfigManager: routeConfig.manager,
},
});
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
appId: 'app-1',
hostname: 'app.example.com',
},
route: {
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '10.0.0.2', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
});
expect(createResult.error).toBeUndefined();
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
apiToken: 'valid-token',
ownership: {
gatewayClientType: 'onebox',
gatewayClientId: 'other-box',
appId: 'app-1',
hostname: 'app.example.com',
},
delete: true,
});
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
});
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
const identity: interfaces.data.IIdentity = {
jwt: 'admin-jwt',
userId: 'admin-user',
name: 'admin',
expiresAt: Date.now() + 3600000,
};
const gatewayClient: interfaces.data.IGatewayClient = {
id: 'onebox-main',
type: 'onebox',
name: 'Main Onebox',
hostnamePatterns: ['*.apps.example.com'],
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'admin-user',
};
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
const { typedrouter } = setupHandler({
scopes: [],
isAdmin: true,
dcRouterRef: {
options: {},
gatewayClientManager: {
listClients: async () => [gatewayClient],
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
},
apiTokenManager: {
listTokens: () => [{
id: 'token-1',
name: 'token',
scopes: ['gateway-clients:read'],
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
createdAt: 1,
expiresAt: null,
lastUsedAt: null,
enabled: true,
}],
createToken: async (
_name: string,
_scopes: TScope[],
_expiresInDays: number | null,
_createdBy: string,
policy?: interfaces.data.IApiTokenPolicy,
) => {
createdTokenPolicy = policy;
return { id: 'new-token', rawToken: 'dcr_created' };
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
expect(listResult.error).toBeUndefined();
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
identity,
gatewayClientId: 'onebox-main',
});
expect(tokenResult.error).toBeUndefined();
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
});
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => { tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
const routeConfig = makeRouteConfigManager(); const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({ const { typedrouter } = setupHandler({
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.27.1', version: '13.28.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+10 -5
View File
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js'; import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -276,6 +276,7 @@ export class DcRouter {
// Programmatic config API // Programmatic config API
public routeConfigManager?: RouteConfigManager; public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager; public apiTokenManager?: ApiTokenManager;
public gatewayClientManager?: GatewayClientManager;
public referenceResolver?: ReferenceResolver; public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager; public targetProfileManager?: TargetProfileManager;
@@ -617,6 +618,8 @@ export class DcRouter {
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
this.gatewayClientManager = new GatewayClientManager();
await this.gatewayClientManager.initialize();
await this.routeConfigManager.initialize( await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
@@ -634,6 +637,7 @@ export class DcRouter {
.withStop(async () => { .withStop(async () => {
this.routeConfigManager = undefined; this.routeConfigManager = undefined;
this.apiTokenManager = undefined; this.apiTokenManager = undefined;
this.gatewayClientManager = undefined;
this.referenceResolver = undefined; this.referenceResolver = undefined;
this.targetProfileManager = undefined; this.targetProfileManager = undefined;
}) })
@@ -1101,6 +1105,7 @@ export class DcRouter {
}); });
const scheduler = this.certProvisionScheduler; const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFallbackToAcme = false;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => { smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01 // If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
if (!this.smartAcmeReady) { if (!this.smartAcmeReady) {
@@ -1149,10 +1154,10 @@ export class DcRouter {
await scheduler.clearBackoff(domain); await scheduler.clearBackoff(domain);
return result; return result;
} catch (err: unknown) { } catch (err: unknown) {
// Record failure for backoff tracking const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`;
await scheduler.recordFailure(domain, (err as Error).message); await scheduler.recordFailure(domain, message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`); eventComms.warn(message);
return 'http01'; throw new Error(message);
} }
}; };
} }
+117
View File
@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import { GatewayClientDoc } from '../db/index.js';
import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
const defaultCapabilities: IGatewayClient['capabilities'] = {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
syncDnsRecords: false,
requestCertificates: false,
};
export class GatewayClientManager {
public async initialize(): Promise<void> {}
public async listClients(): Promise<IGatewayClient[]> {
const docs = await GatewayClientDoc.findAll();
return docs.map((doc) => this.toPublicClient(doc));
}
public async getClient(id: string): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
return doc ? this.toPublicClient(doc) : null;
}
public async createClient(options: {
id?: string;
type: IGatewayClient['type'];
name: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
capabilities?: IGatewayClient['capabilities'];
createdBy: string;
}): Promise<IGatewayClient> {
const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
if (!id) {
throw new Error('gateway client id is required');
}
if (await GatewayClientDoc.findById(id)) {
throw new Error('gateway client already exists');
}
const now = Date.now();
const doc = new GatewayClientDoc();
doc.id = id;
doc.type = options.type;
doc.name = options.name.trim();
doc.description = options.description?.trim() || undefined;
doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
doc.enabled = true;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = options.createdBy;
await doc.save();
return this.toPublicClient(doc);
}
public async updateClient(
id: string,
patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
): Promise<IGatewayClient | null> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return null;
if (patch.name !== undefined) doc.name = patch.name.trim();
if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
doc.updatedAt = Date.now();
await doc.save();
return this.toPublicClient(doc);
}
public async deleteClient(id: string): Promise<boolean> {
const doc = await GatewayClientDoc.findById(id);
if (!doc) return false;
await doc.delete();
return true;
}
private normalizeId(id: string): string {
return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
private normalizeStringList(values: string[]): string[] {
return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
}
private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
return targets
.map((target) => ({
host: target.host.trim().toLowerCase(),
ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
}))
.filter((target) => target.host && target.ports.length > 0);
}
private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
return {
id: doc.id,
type: doc.type,
name: doc.name,
description: doc.description,
hostnamePatterns: doc.hostnamePatterns || [],
allowedRouteTargets: doc.allowedRouteTargets || [],
capabilities: doc.capabilities || {},
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}
+1
View File
@@ -2,6 +2,7 @@
export * from './validator.js'; export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js'; export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js'; export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js'; export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js'; export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js'; export { TargetProfileManager } from './classes.target-profile-manager.js';
@@ -0,0 +1,54 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public type!: TGatewayClientType;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public hostnamePatterns: string[] = [];
@plugins.smartdata.svDb()
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
@plugins.smartdata.svDb()
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<GatewayClientDoc | null> {
return await GatewayClientDoc.getInstance({ id });
}
public static async findAll(): Promise<GatewayClientDoc[]> {
return await GatewayClientDoc.getInstances({});
}
}
+1
View File
@@ -8,6 +8,7 @@ export * from './classes.security-policy-audit.doc.js';
// Config document classes // Config document classes
export * from './classes.route.doc.js'; export * from './classes.route.doc.js';
export * from './classes.api-token.doc.js'; export * from './classes.api-token.doc.js';
export * from './classes.gateway-client.doc.js';
export * from './classes.source-profile.doc.js'; export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js'; export * from './classes.target-profile.doc.js';
export * from './classes.network-target.doc.js'; export * from './classes.network-target.doc.js';
@@ -307,6 +307,11 @@ export class CertificateHandler {
} }
} }
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({ certificates.push({
domain, domain,
routeNames: info.routeNames, routeNames: info.routeNames,
+192 -18
View File
@@ -45,6 +45,16 @@ export class WorkHosterHandler {
throw new plugins.typedrequest.TypedResponseError('unauthorized'); throw new plugins.typedrequest.TypedResponseError('unauthorized');
} }
private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise<string> {
if (request.identity?.jwt) {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
}
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
private registerHandlers(): void { private registerHandlers(): void {
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
@@ -56,6 +66,122 @@ export class WorkHosterHandler {
), ),
); );
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
return {
context: this.getGatewayClientContext(auth),
capabilities: this.getGatewayCapabilities(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
'listGatewayClients',
async (dataArg) => {
await this.requireAdmin(dataArg);
return { gatewayClients: await this.listManagedGatewayClients() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClient>(
'createGatewayClient',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
try {
const gatewayClient = await manager.createClient({
id: dataArg.id,
type: dataArg.type,
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
createdBy: userId,
});
return { success: true, gatewayClient };
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateGatewayClient>(
'updateGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const gatewayClient = await manager.updateClient(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
enabled: dataArg.enabled,
});
return gatewayClient
? { success: true, gatewayClient }
: { success: false, message: 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteGatewayClient>(
'deleteGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const success = await manager.deleteClient(dataArg.id);
return { success, message: success ? undefined : 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
'createGatewayClientToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!gatewayClient || !gatewayClient.enabled) {
return { success: false, message: 'Gateway client not found or disabled' };
}
if (!tokenManager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await tokenManager.createToken(
dataArg.name?.trim() || `${gatewayClient.name} Token`,
['gateway-clients:read', 'gateway-clients:write'],
dataArg.expiresInDays ?? null,
userId,
{
role: 'gatewayClient',
scopes: ['gateway-clients:read', 'gateway-clients:write'],
gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
hostnamePatterns: gatewayClient.hostnamePatterns,
allowedRouteTargets: gatewayClient.allowedRouteTargets,
capabilities: gatewayClient.capabilities,
},
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
'getGatewayClientDomains', 'getGatewayClientDomains',
@@ -183,6 +309,30 @@ export class WorkHosterHandler {
}; };
} }
private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext {
const policy = auth.token?.policy;
const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
return {
role,
scopes: auth.token?.scopes || ['*'],
gatewayClient: policy?.gatewayClient,
hostnamePatterns: policy?.hostnamePatterns || [],
allowedRouteTargets: policy?.allowedRouteTargets || [],
capabilities: policy?.capabilities || {},
};
}
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return [];
const clients = await manager.listClients();
const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || [];
return clients.map((client) => ({
...client,
tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length,
}));
}
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string { private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
return [ return [
ownership.workHosterType, ownership.workHosterType,
@@ -212,15 +362,38 @@ export class WorkHosterHandler {
return policyClient.id; return policyClient.id;
} }
private assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void { private resolveGatewayClientOwnership(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
): Required<interfaces.data.IGatewayClientOwnership> {
const policy = auth.token?.policy;
if (policy?.role === 'gatewayClient') {
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
appId: ownership.appId,
hostname: ownership.hostname,
};
}
if (!ownership.gatewayClientType || !ownership.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id');
}
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy; const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return; if (!policy || policy.role !== 'gatewayClient') return;
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) { if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy'); throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
} }
@@ -403,7 +576,8 @@ export class WorkHosterHandler {
enabled?: boolean, enabled?: boolean,
deleteRoute?: boolean, deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> { ): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
this.assertGatewayClientOwnership(auth, ownership); const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route); this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager; const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
@@ -411,7 +585,7 @@ export class WorkHosterHandler {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const externalKey = this.buildGatewayClientExternalKey(ownership); const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey); const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) { if (deleteRoute) {
@@ -430,15 +604,15 @@ export class WorkHosterHandler {
const metadata: interfaces.data.IRouteMetadata = { const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'gatewayClient', ownerType: 'gatewayClient',
gatewayClientType: ownership.gatewayClientType, gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: ownership.gatewayClientId, gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: ownership.appId, gatewayClientAppId: resolvedOwnership.appId,
workHosterType: ownership.gatewayClientType, workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: ownership.gatewayClientId, workHosterId: resolvedOwnership.gatewayClientId,
workAppId: ownership.appId, workAppId: resolvedOwnership.appId,
externalKey, externalKey,
}; };
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey); const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) { if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, { const result = await manager.updateRoute(existingRoute.id, {
@@ -455,7 +629,7 @@ export class WorkHosterHandler {
return { success: true, action: 'created', routeId }; return { success: true, action: 'created', routeId };
} }
private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string { private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [ return [
ownership.gatewayClientType, ownership.gatewayClientType,
ownership.gatewayClientId, ownership.gatewayClientId,
@@ -478,7 +652,7 @@ export class WorkHosterHandler {
private normalizeGatewayClientRoute( private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig, route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IGatewayClientOwnership, ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string, externalKey: string,
): interfaces.data.IDcRouterRouteConfig { ): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route }; const normalizedRoute = { ...route };
+8
View File
@@ -12,6 +12,14 @@ export class WorkHosterManager {
return response.capabilities; return response.capabilities;
} }
public async getGatewayClientContext(): Promise<interfaces.data.IGatewayClientContext> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
this.clientRef.buildRequestPayload() as any,
);
return response.context;
}
public async getDomains(): Promise<interfaces.data.IWorkHosterDomain[]> { public async getDomains(): Promise<interfaces.data.IWorkHosterDomain[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetWorkHosterDomains>( const response = await this.clientRef.request<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains', 'getWorkHosterDomains',
+30 -3
View File
@@ -1,6 +1,6 @@
import type { IDomain } from './domain.js'; import type { IDomain } from './domain.js';
import type { IDnsRecord, TDnsRecordType } from './dns-record.js'; import type { IDnsRecord, TDnsRecordType } from './dns-record.js';
import type { TGatewayClientType } from './route-management.js'; import type { IApiTokenPolicy, TApiTokenScope, TGatewayClientType } from './route-management.js';
export interface IGatewayCapabilities { export interface IGatewayCapabilities {
routes: { routes: {
@@ -34,6 +34,33 @@ export interface IGatewayCapabilities {
}; };
} }
export interface IGatewayClientContext {
role: IApiTokenPolicy['role'];
scopes: TApiTokenScope[];
gatewayClient?: {
type: TGatewayClientType;
id: string;
};
hostnamePatterns: string[];
allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']>;
capabilities: NonNullable<IApiTokenPolicy['capabilities']>;
}
export interface IGatewayClient {
id: string;
type: TGatewayClientType;
name: string;
description?: string;
hostnamePatterns: string[];
allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']>;
capabilities: NonNullable<IApiTokenPolicy['capabilities']>;
enabled: boolean;
tokenCount?: number;
createdAt: number;
updatedAt: number;
createdBy: string;
}
export interface IGatewayClientDomain extends IDomain { export interface IGatewayClientDomain extends IDomain {
capabilities: { capabilities: {
canCreateSubdomains: boolean; canCreateSubdomains: boolean;
@@ -49,8 +76,8 @@ export interface IGatewayClientDomain extends IDomain {
export type IWorkHosterDomain = IGatewayClientDomain; export type IWorkHosterDomain = IGatewayClientDomain;
export interface IGatewayClientOwnership { export interface IGatewayClientOwnership {
gatewayClientType: TGatewayClientType; gatewayClientType?: TGatewayClientType;
gatewayClientId: string; gatewayClientId?: string;
appId: string; appId: string;
hostname: string; hostname: string;
} }
+108
View File
@@ -2,6 +2,8 @@ import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js'; import type * as authInterfaces from '../data/auth.js';
import type { import type {
IGatewayClientDnsRecord, IGatewayClientDnsRecord,
IGatewayClientContext,
IGatewayClient,
IGatewayClientDomain, IGatewayClientDomain,
IGatewayClientOwnership, IGatewayClientOwnership,
IGatewayClientRouteSyncResult, IGatewayClientRouteSyncResult,
@@ -30,6 +32,112 @@ export interface IReq_GetGatewayCapabilities extends plugins.typedrequestInterfa
}; };
} }
export interface IReq_GetGatewayClientContext extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetGatewayClientContext
> {
method: 'getGatewayClientContext';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
context: IGatewayClientContext;
capabilities: IGatewayCapabilities;
};
}
export interface IReq_ListGatewayClients extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListGatewayClients
> {
method: 'listGatewayClients';
request: {
identity: authInterfaces.IIdentity;
};
response: {
gatewayClients: IGatewayClient[];
};
}
export interface IReq_CreateGatewayClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateGatewayClient
> {
method: 'createGatewayClient';
request: {
identity: authInterfaces.IIdentity;
id?: string;
type: IGatewayClient['type'];
name: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
capabilities?: IGatewayClient['capabilities'];
};
response: {
success: boolean;
gatewayClient?: IGatewayClient;
message?: string;
};
}
export interface IReq_UpdateGatewayClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateGatewayClient
> {
method: 'updateGatewayClient';
request: {
identity: authInterfaces.IIdentity;
id: string;
name?: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
capabilities?: IGatewayClient['capabilities'];
enabled?: boolean;
};
response: {
success: boolean;
gatewayClient?: IGatewayClient;
message?: string;
};
}
export interface IReq_DeleteGatewayClient extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteGatewayClient
> {
method: 'deleteGatewayClient';
request: {
identity: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
export interface IReq_CreateGatewayClientToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateGatewayClientToken
> {
method: 'createGatewayClientToken';
request: {
identity: authInterfaces.IIdentity;
gatewayClientId: string;
name?: string;
expiresInDays?: number | null;
};
response: {
success: boolean;
tokenId?: string;
tokenValue?: string;
message?: string;
};
}
export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetWorkHosterDomains IReq_GetWorkHosterDomains
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.27.1', version: '13.28.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+111
View File
@@ -285,6 +285,7 @@ export interface IRouteManagementState {
mergedRoutes: interfaces.data.IMergedRoute[]; mergedRoutes: interfaces.data.IMergedRoute[];
warnings: interfaces.data.IRouteWarning[]; warnings: interfaces.data.IRouteWarning[];
apiTokens: interfaces.data.IApiTokenInfo[]; apiTokens: interfaces.data.IApiTokenInfo[];
gatewayClients: interfaces.data.IGatewayClient[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
lastUpdated: number; lastUpdated: number;
@@ -296,6 +297,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
mergedRoutes: [], mergedRoutes: [],
warnings: [], warnings: [],
apiTokens: [], apiTokens: [],
gatewayClients: [],
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdated: 0, lastUpdated: 0,
@@ -2477,6 +2479,115 @@ export const fetchApiTokensAction = routeManagementStatePart.createAction(async
} }
}); });
export const fetchGatewayClientsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListGatewayClients
>('/typedrequest', 'listGatewayClients');
const response = await request.fire({ identity: context.identity });
return {
...currentState,
gatewayClients: response.gatewayClients,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch gateway clients',
};
}
});
export async function createGatewayClient(data: {
id?: string;
type: interfaces.data.IGatewayClient['type'];
name: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets'];
}) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateGatewayClient
>('/typedrequest', 'createGatewayClient');
return request.fire({
identity: context.identity!,
capabilities: {
readDomains: true,
readDnsRecords: true,
syncRoutes: true,
syncDnsRecords: false,
requestCertificates: false,
},
...data,
});
}
export const updateGatewayClientAction = routeManagementStatePart.createAction<{
id: string;
name?: string;
description?: string;
hostnamePatterns?: string[];
allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets'];
enabled?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateGatewayClient
>('/typedrequest', 'updateGatewayClient');
await request.fire({ identity: context.identity!, ...dataArg });
return await actionContext!.dispatch(fetchGatewayClientsAction, null);
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to update gateway client',
};
}
});
export const deleteGatewayClientAction = routeManagementStatePart.createAction<string>(
async (statePartArg, gatewayClientId, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteGatewayClient
>('/typedrequest', 'deleteGatewayClient');
await request.fire({ identity: context.identity!, id: gatewayClientId });
return await actionContext!.dispatch(fetchGatewayClientsAction, null);
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete gateway client',
};
}
},
);
export async function createGatewayClientToken(
gatewayClientId: string,
name?: string,
expiresInDays?: number | null,
) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateGatewayClientToken
>('/typedrequest', 'createGatewayClientToken');
return request.fire({
identity: context.identity!,
gatewayClientId,
name,
expiresInDays,
});
}
// Users (read-only list) // Users (read-only list)
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => { export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
const context = getActionContext(); const context = getActionContext();
@@ -20,6 +20,7 @@ export class OpsViewApiTokens extends DeesElement {
mergedRoutes: [], mergedRoutes: [],
warnings: [], warnings: [],
apiTokens: [], apiTokens: [],
gatewayClients: [],
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdated: 0, lastUpdated: 0,
@@ -0,0 +1,250 @@
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ops-view-gatewayclients')
export class OpsViewGatewayClients extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.pill {
display: inline-flex;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: ${cssManager.bdTheme('rgba(37, 99, 235, 0.1)', 'rgba(96, 165, 250, 0.14)')};
color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')};
margin-right: 4px;
margin-bottom: 2px;
}
`,
];
public render(): TemplateResult {
return html`
<dees-heading level="3">Gateway Clients</dees-heading>
<dees-table
.heading1=${'Gateway Clients'}
.heading2=${'Create durable clients and token credentials for Onebox, Cloudly, or custom integrations'}
.data=${this.routeState.gatewayClients}
.dataName=${'gateway client'}
.searchable=${true}
.showColumnFilters=${true}
.displayFunction=${(client: interfaces.data.IGatewayClient) => ({
name: client.name,
id: client.id,
type: client.type,
hostnames: this.renderPills(client.hostnamePatterns),
targets: this.renderTargets(client.allowedRouteTargets),
tokens: client.tokenCount || 0,
status: client.enabled ? 'Active' : 'Disabled',
})}
.dataActions=${[
{
name: 'Create Client',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => await this.showCreateClientDialog(),
},
{
name: 'Create Token',
iconName: 'lucide:keyRound',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => await this.showCreateTokenDialog(actionData.item),
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
id: actionData.item.id,
enabled: true,
});
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
id: actionData.item.id,
enabled: false,
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await appstate.routeManagementStatePart.dispatchAction(appstate.deleteGatewayClientAction, actionData.item.id);
},
},
]}
></dees-table>
`;
}
private renderPills(values: string[]): TemplateResult {
if (!values.length) return html`<span>None</span>`;
return html`${values.map((value) => html`<span class="pill">${value}</span>`)}`;
}
private renderTargets(targets: interfaces.data.IGatewayClient['allowedRouteTargets']): TemplateResult {
if (!targets.length) return html`<span>None</span>`;
return html`${targets.map((target) => html`<span class="pill">${target.host}:${target.ports.join(',')}</span>`)}`;
}
private async showCreateClientDialog(): Promise<void> {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Create Gateway Client',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'type'} .label=${'Type'} .value=${'onebox'} .description=${'onebox, cloudly, or custom'}></dees-input-text>
<dees-input-text .key=${'id'} .label=${'Client ID'} .description=${'Optional stable ID; generated when empty'}></dees-input-text>
<dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
<dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. onebox-smartproxy:80'}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await (form as any).collectFormData();
const name = String(formData.name || '').trim();
if (!name) return;
await modalArg.destroy();
await appstate.createGatewayClient({
id: String(formData.id || '').trim() || undefined,
type: this.normalizeClientType(String(formData.type || 'onebox')),
name,
description: String(formData.description || '').trim() || undefined,
hostnamePatterns: this.parseList(String(formData.hostnamePatterns || '')),
allowedRouteTargets: this.parseAllowedRouteTargets(String(formData.allowedRouteTarget || '')),
});
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
},
},
],
});
}
private async showCreateTokenDialog(client: interfaces.data.IGatewayClient): Promise<void> {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Create Token for ${client.name}`,
content: html`
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
The token will be shown once. Configure Onebox with the dcrouter URL and this token.
</div>
<dees-form>
<dees-input-text .key=${'name'} .label=${'Token Name'} .value=${`${client.name} Token`}></dees-input-text>
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Create Token',
iconName: 'lucide:key',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await (form as any).collectFormData();
const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null;
await modalArg.destroy();
const response = await appstate.createGatewayClientToken(
client.id,
String(formData.name || '').trim() || undefined,
expiresInDays,
);
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
if (response.success && response.tokenValue) {
await DeesModal.createAndShow({
heading: 'Gateway Client Token Created',
content: html`
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
`,
menuOptions: [
{ name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy() },
],
});
}
},
},
],
});
}
private normalizeClientType(value: string): interfaces.data.IGatewayClient['type'] {
const normalized = value.trim().toLowerCase();
if (normalized === 'cloudly' || normalized === 'custom') return normalized;
return 'onebox';
}
private parseList(value: string): string[] {
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
}
private parseAllowedRouteTargets(value: string): interfaces.data.IGatewayClient['allowedRouteTargets'] {
const target = value.trim();
if (!target.includes(':')) return [];
const [host, portsValue] = target.split(':');
const ports = portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port));
return host.trim() && ports.length ? [{ host: host.trim(), ports }] : [];
}
}
+166 -11
View File
@@ -11,6 +11,7 @@ import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
import { appRouter } from '../../router.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
@state() @state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!; accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() { constructor() {
super(); super();
const certSub = appstate.certificateStatePart.select().subscribe((newState) => { const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
this.acmeState = newState; this.acmeState = newState;
}); });
this.rxSubscriptions.push(acmeSub); this.rxSubscriptions.push(acmeSub);
const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(domainsSub);
} }
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null); await Promise.all([
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null); appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
]);
} }
public static styles = [ public static styles = [
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
.errorText { .errorText {
font-size: 12px; font-size: 12px;
color: ${cssManager.bdTheme('#991b1b', '#f87171')}; color: ${cssManager.bdTheme('#991b1b', '#f87171')};
max-width: 200px; max-width: 420px;
overflow: hidden; line-height: 1.35;
text-overflow: ellipsis; white-space: normal;
white-space: nowrap; }
.errorStack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
} }
.backoffIndicator { .backoffIndicator {
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
.expiryInfo .daysLeft.danger { .expiryInfo .daysLeft.danger {
color: ${cssManager.bdTheme('#991b1b', '#f87171')}; color: ${cssManager.bdTheme('#991b1b', '#f87171')};
} }
.dnsWarningPanel {
border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')};
border-radius: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#fff7ed', '#1c1917')};
color: ${cssManager.bdTheme('#7c2d12', '#fdba74')};
}
.dnsWarningTitle {
font-weight: 700;
margin-bottom: 6px;
}
.dnsWarningText {
font-size: 13px;
line-height: 1.45;
color: ${cssManager.bdTheme('#9a3412', '#fed7aa')};
}
.dnsWarningList {
margin: 12px 0 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.5;
}
.dnsWarningActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
`, `,
]; ];
@@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
<div class="certificatesContainer"> <div class="certificatesContainer">
${this.renderStatsTiles(summary)} ${this.renderStatsTiles(summary)}
${this.renderAcmeSettingsTile()} ${this.renderAcmeSettingsTile()}
${this.renderManagedDomainWarnings()}
${this.renderCertificateTable()} ${this.renderCertificateTable()}
</div> </div>
`; `;
} }
private renderManagedDomainWarnings(): TemplateResult {
const issues = this.getMissingManagedDomainIssues();
if (issues.length === 0) {
return html``;
}
const shownIssues = issues.slice(0, 6);
const remaining = issues.length - shownIssues.length;
return html`
<div class="dnsWarningPanel">
<div class="dnsWarningTitle">DNS-01 certificate provisioning needs managed DNS domains</div>
<div class="dnsWarningText">
DcRouter can only create ACME TXT records for domains listed under Domains > Domains.
Add the zone directly or import it from a DNS provider before reprovisioning certificates.
</div>
<ul class="dnsWarningList">
${shownIssues.map((issue) => html`
<li>
<strong>${issue.domain}</strong>: no managed DNS domain covers
<code>${issue.challengeHost}</code>. Add/import <code>${issue.requiredDomain}</code>
or a parent zone.
</li>
`)}
${remaining > 0 ? html`<li>${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.</li>` : ''}
</ul>
<div class="dnsWarningActions">
<dees-button @click=${() => appRouter.navigateToView('domains', 'domains')}>Manage Domains</dees-button>
<dees-button @click=${() => appRouter.navigateToView('domains', 'providers')}>DNS Providers</dees-button>
</div>
</div>
`;
}
private getMissingManagedDomainIssues(): Array<{
domain: string;
challengeHost: string;
requiredDomain: string;
}> {
const managedDomains = this.domainsState.domains
.map((domain) => this.normalizeDomain(domain.name))
.filter(Boolean);
const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = [];
const seen = new Set<string>();
for (const cert of this.certState.certificates) {
if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) {
continue;
}
const requiredDomain = this.getAcmeChallengeDomain(cert.domain);
if (!requiredDomain) {
continue;
}
const covered = managedDomains.some((managedDomain) =>
requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`),
);
if (covered) {
continue;
}
const key = `${cert.domain}:${requiredDomain}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
issues.push({
domain: cert.domain,
challengeHost: `_acme-challenge.${requiredDomain}`,
requiredDomain,
});
}
return issues;
}
private getAcmeChallengeDomain(domain: string): string {
const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, '');
const parts = normalized.split('.').filter(Boolean);
if (parts.length >= 2 && parts.length <= 3) {
return parts.slice(-2).join('.');
}
return normalized;
}
private normalizeDomain(domain: string): string {
return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, '');
}
private renderAcmeSettingsTile(): TemplateResult { private renderAcmeSettingsTile(): TemplateResult {
const config = this.acmeState.config; const config = this.acmeState.config;
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
Status: this.renderStatusBadge(cert.status), Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source), Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate), Expires: this.renderExpiry(cert.expiryDate),
Error: cert.backoffInfo Error: this.renderError(cert),
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
: cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
})} })}
.dataActions=${[ .dataActions=${[
{ {
@@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement {
`; `;
} }
private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string {
if (cert.backoffInfo) {
const message = cert.backoffInfo.lastError || cert.error;
return html`
<span class="errorStack">
${message ? html`<span class="errorText" title=${message}>${message}</span>` : ''}
<span class="backoffIndicator">
${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}
</span>
</span>
`;
}
if (cert.error) {
return html`<span class="errorText" title=${cert.error}>${cert.error}</span>`;
}
return '';
}
private formatRetryTime(retryAfter?: string): string { private formatRetryTime(retryAfter?: string): string {
if (!retryAfter) return 'soon'; if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter); const retryDate = new Date(retryAfter);
@@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement {
mergedRoutes: [], mergedRoutes: [],
warnings: [], warnings: [],
apiTokens: [], apiTokens: [],
gatewayClients: [],
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdated: 0, lastUpdated: 0,
+2
View File
@@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js'; import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
// Access group // Access group
import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
import { OpsViewApiTokens } from './access/ops-view-apitokens.js'; import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
import { OpsViewUsers } from './access/ops-view-users.js'; import { OpsViewUsers } from './access/ops-view-users.js';
@@ -121,6 +122,7 @@ export class OpsDashboard extends DeesElement {
name: 'Access', name: 'Access',
iconName: 'lucide:keyRound', iconName: 'lucide:keyRound',
subViews: [ subViews: [
{ slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients },
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens }, { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers }, { slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
], ],
+2 -2
View File
@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const, overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const, network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security', 'domains'] as const, email: ['log', 'security', 'domains'] as const,
access: ['apitokens', 'users'] as const, access: ['gatewayclients', 'apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const, security: ['overview', 'blocked', 'authentication'] as const,
domains: ['providers', 'domains', 'dns', 'certificates'] as const, domains: ['providers', 'domains', 'dns', 'certificates'] as const,
}; };
@@ -21,7 +21,7 @@ const defaultSubview: Record<string, string> = {
overview: 'stats', overview: 'stats',
network: 'activity', network: 'activity',
email: 'log', email: 'log',
access: 'apitokens', access: 'gatewayclients',
security: 'overview', security: 'overview',
domains: 'domains', domains: 'domains',
}; };