feat(gateway-clients): add managed gateway client administration and token-bound route ownership
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user