Compare commits

...

13 Commits

Author SHA1 Message Date
jkunz 0b01a4c26b v13.30.0
Docker (tags) / release (push) Failing after 1s
2026-05-18 16:09:40 +00:00
jkunz 407c8eef8a feat(docs): document first-admin bootstrap flow and update authentication examples 2026-05-18 16:09:26 +00:00
jkunz aa0ef2f033 v13.29.1
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:43:14 +00:00
jkunz 7819f09625 fix(smartconfig): enable npm publishing in smartconfig 2026-05-14 00:42:58 +00:00
jkunz 3f8c0c4219 v13.29.0
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:37:15 +00:00
jkunz 70fcd46d52 feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication 2026-05-14 00:30:09 +00:00
jkunz 47a1f5d7db fix(vpn): harden VPN route access and wireguard client configuration handling 2026-05-13 13:42:12 +00:00
jkunz 67b9fb536c v13.28.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 22:35:07 +00:00
jkunz 8dd0c3def9 feat(gateway-clients): add managed gateway client administration and token-bound route ownership 2026-05-09 22:35:07 +00:00
jkunz d73b250382 v13.27.1
Docker (tags) / release (push) Failing after 1s
2026-05-09 20:02:45 +00:00
jkunz 1c1d55ab8a fix(docker): configure pnpm to use the verdaccio registry during Docker builds 2026-05-09 20:02:45 +00:00
jkunz 2596303c06 v13.27.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 17:30:37 +00:00
jkunz f78bddaede feat(api-token-manager): seed and rotate the environment-managed admin API token during initialization 2026-05-09 17:30:37 +00:00
45 changed files with 2927 additions and 498 deletions
+32 -10
View File
@@ -23,11 +23,14 @@
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/**/*.html"]
"includeFiles": [
"./html/**/*.html"
]
}
]
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
"module": {
"githost": "code.foss.global",
@@ -60,18 +63,37 @@
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"npm": {
"enabled": true,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": true,
"engine": "tsdocker"
}
}
}
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@ship.zone/szci": {}
}
+1
View File
@@ -5,6 +5,7 @@ FROM code.foss.global/host.today/ht-docker-node:lts AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set registry https://verdaccio.lossless.digital/
RUN pnpm config set store-dir .pnpm-store
RUN pnpm install --frozen-lockfile
+63 -1
View File
@@ -1,5 +1,67 @@
# Changelog
## Pending
## 2026-05-18 - 13.30.0
### Features
- document first-admin bootstrap flow and update authentication examples (docs)
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
- Refresh dependency versions in package.json to align documentation with current package releases.
## 2026-05-14 - 13.29.1
### Fixes
- enable npm publishing in smartconfig (smartconfig)
- Sets the npm integration flag to true in .smartconfig.json
- Keeps the configured Verdaccio and npmjs registries unchanged
## 2026-05-14 - 13.29.0
### Fixes
- harden VPN route access and wireguard client configuration handling (vpn)
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
### Features
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
- introduces bootstrap status and initial admin creation endpoints for OpsServer
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
- updates the web dashboard to prompt creation of the first persisted admin account
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
## 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)
configure pnpm to use the verdaccio registry during Docker builds
- Adds a pnpm registry configuration step before dependency installation in the Dockerfile.
- Ensures container builds resolve packages from the configured Verdaccio registry.
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
seed and rotate the environment-managed admin API token during initialization
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
- Ensure the environment-managed token is updated when the configured raw token changes
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
add policy-based gateway client tokens and gateway client route and DNS management endpoints
@@ -2591,4 +2653,4 @@ Applied a core fix.
- Fixed core functionality for version 1.0.1
–––––––––––––––––––––––
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
+15 -14
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.26.0",
"version": "13.30.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -22,22 +22,23 @@
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.1",
"@git.zone/tsdocker": "^2.2.5",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.3",
"@types/node": "^25.6.1"
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.0"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest": "^3.3.1",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@api.global/typedsocket": "^4.1.3",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.3.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
@@ -55,20 +56,20 @@
"@push.rocks/smartnetwork": "^4.7.1",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.0",
"@push.rocks/smartproxy": "^27.10.2",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2",
"@push.rocks/smartvpn": "1.19.4",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.5.0",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.17.1",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.6",
"lru-cache": "^11.4.0",
"qrcode": "^1.5.4",
"uuid": "^14.0.0"
},
+571 -337
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -73,10 +73,22 @@ await router.start();
After startup:
- open the dashboard at `http://localhost:3000`
- log in with the current built-in development credentials `admin` / `admin`
- complete the first-admin bootstrap flow if no persisted admin account exists yet
- send proxied traffic to `http://localhost:18080`
- stop gracefully with `await router.stop()`
## Initial Admin Bootstrap
When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
Bootstrap behavior:
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
- Optional `idp.global` authentication can be enabled for that local account; the local dcrouter role remains authoritative and the IdP email must match the local account email.
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
## Configuration Model
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
@@ -199,7 +211,7 @@ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await client.login('admin', 'admin');
await client.login('admin@example.com', 'strong-password');
const route = await client.routes.build()
.setName('api-gateway')
@@ -279,7 +291,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+208
View File
@@ -0,0 +1,208 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { OpsServer } from '../ts/opsserver/index.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const testPort = 3110;
const baseUrl = `http://localhost:${testPort}/typedrequest`;
const bootstrapPassword = 'temporary-bootstrap-password';
const persistedPassword = 'persisted-admin-password';
let previousAdminPassword: string | undefined;
let opsServer: OpsServer;
let testDb: DcRouterDb;
let storagePath: string;
let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity;
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
baseUrl,
'getAdminBootstrapStatus',
);
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
baseUrl,
'adminLoginWithUsernameAndPassword',
);
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
testDb = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
const fakeDcRouter = {
options: {
opsServerPort: testPort,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: testDb,
};
opsServer = new OpsServer(fakeDcRouter as any);
await opsServer.start();
});
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(true);
expect(status.hasPersistentAdmin).toEqual(false);
expect(status.needsBootstrap).toEqual(true);
expect(status.ephemeralAdminAvailable).toEqual(true);
});
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
const response = await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
if (!response.identity) {
throw new Error('Expected bootstrap login identity');
}
bootstrapIdentity = response.identity;
expect(bootstrapIdentity.role).toEqual('admin');
});
tap.test('creates the initial persisted admin explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
baseUrl,
'createInitialAdminUser',
);
const response = await request.fire({
identity: bootstrapIdentity,
email: 'Admin@Example.com',
name: 'Persisted Admin',
password: persistedPassword,
enableIdpGlobalAuth: true,
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('admin');
expect(response.user?.authSources).toContain('local');
expect(response.user?.authSources).toContain('idp.global');
if (!response.identity) {
throw new Error('Expected persisted admin identity');
}
persistedIdentity = response.identity;
});
tap.test('disables bootstrap mode after persisted admin exists', async () => {
const status = await createStatusRequest().fire({});
expect(status.hasPersistentAdmin).toEqual(true);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
});
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const response = await request.fire({ identity: bootstrapIdentity });
expect(response.valid).toEqual(false);
});
tap.test('authenticates the persisted admin locally by normalized email', async () => {
const response = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!response.identity) {
throw new Error('Expected persisted admin login identity');
}
expect(response.identity.userId).toEqual(persistedIdentity.userId);
});
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin@example.com',
password: 'idp-password',
authSource: 'idp.global',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('lists persisted users without password material', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
const response = await request.fire({ identity: persistedIdentity });
expect(response.users.length).toEqual(1);
expect(response.users[0].email).toEqual('Admin@Example.com');
expect((response.users[0] as any).password).toBeUndefined();
});
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop();
await testDb.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
if (previousAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
}
});
export default tap.start();
+75
View File
@@ -0,0 +1,75 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
const testDb = await createTestDb();
try {
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
const manager = new ApiTokenManager();
await manager.initialize();
const token1 = await manager.validateToken(rawToken1);
expect(token1?.id).toEqual('env-admin-token');
expect(token1?.name).toEqual('Onebox Managed Admin');
expect(token1?.policy?.role).toEqual('admin');
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
expect(listedToken.tokenHash).toBeUndefined();
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
const rotatedManager = new ApiTokenManager();
await rotatedManager.initialize();
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
const token2 = await rotatedManager.validateToken(rawToken2);
expect(token2?.id).toEqual('env-admin-token');
expect(token2?.policy?.role).toEqual('admin');
} finally {
if (previousToken === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
}
if (previousName === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
}
await testDb.cleanup();
}
});
export default tap.start();
+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 opsServerRef: any = {
typedrouter,
@@ -60,9 +64,13 @@ const setupHandler = (scopes: TScope[]) => {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
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');
});
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 () => {
const testDb = await testDbPromise;
await testDb.cleanup();
+67
View File
@@ -1,6 +1,7 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
@@ -107,4 +108,70 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
expect(dcRouter.vpnManager).toBeUndefined();
});
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
const manager = new RouteConfigManager(() => undefined);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: { ipAllowList: ['*'] },
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual([]);
expect(prepared.security.ipBlockList).toContain('*');
});
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['*', '203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
});
(manager as any).vpnServer = {
rotateClientKey: async () => ({
entry: {
clientId: 'client-1',
publicKey: 'noise-public-key',
wgPublicKey: 'wg-public-key',
},
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
}),
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
]);
(manager as any).persistClient = async () => {};
const bundle = await manager.rotateClientKey('client-1');
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
});
export default tap.start()
+169 -3
View File
@@ -21,7 +21,10 @@ const fireTypedRequest = async (
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeApiTokenManager = (scopes: TScope[]) => {
const makeApiTokenManager = (
scopes: TScope[],
policy?: interfaces.data.IApiTokenPolicy,
) => {
const token = {
id: 'token-1',
name: 'workhoster-test-token',
@@ -31,12 +34,26 @@ const makeApiTokenManager = (scopes: TScope[]) => {
expiresAt: null,
lastUsedAt: null,
enabled: true,
policy,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
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);
for (const policyScope of storedToken.policy?.scopes || []) {
scopes.add(policyScope);
}
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
'gateway-clients:read': ['workhosters:read'],
'gateway-clients:write': ['workhosters:write'],
@@ -111,6 +128,8 @@ const makeRouteConfigManager = () => {
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
isAdmin?: boolean;
dcRouterRef?: Record<string, any>;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
@@ -118,12 +137,12 @@ const setupHandler = (options: {
typedrouter,
adminHandler: {
adminIdentityGuard: {
exec: async () => false,
exec: async () => Boolean(options.isAdmin),
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes),
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
...options.dcRouterRef,
},
};
@@ -274,6 +293,153 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
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 () => {
const routeConfig = makeRouteConfigManager();
const { typedrouter } = setupHandler({
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.26.0',
version: '13.30.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+30 -6
View File
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/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 { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -167,6 +167,14 @@ export interface IDcRouterOptions {
/** Port for the OpsServer web UI (default: 3000) */
opsServerPort?: number;
/** Optional OpsServer account authentication settings. */
adminAuth?: {
/** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
idpGlobalUrl?: string;
/** Test/integration hook for injecting an idp.global-compatible password client. */
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
};
remoteIngressConfig?: {
/** Enable remote ingress hub (default: false) */
enabled?: boolean;
@@ -276,6 +284,7 @@ export class DcRouter {
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
public gatewayClientManager?: GatewayClientManager;
public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager;
@@ -617,6 +626,8 @@ export class DcRouter {
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
this.gatewayClientManager = new GatewayClientManager();
await this.gatewayClientManager.initialize();
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
@@ -634,6 +645,7 @@ export class DcRouter {
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.gatewayClientManager = undefined;
this.referenceResolver = undefined;
this.targetProfileManager = undefined;
})
@@ -736,10 +748,14 @@ export class DcRouter {
// VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) {
const vpnServiceDeps = ['SmartProxy'];
if (this.options.dbConfig?.enabled !== false) {
vpnServiceDeps.push('ConfigManagers');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn(...vpnServiceDeps)
.withStart(async () => {
await this.setupVpnServer();
})
@@ -1101,6 +1117,7 @@ export class DcRouter {
});
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFallbackToAcme = false;
smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
// If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
if (!this.smartAcmeReady) {
@@ -1149,10 +1166,10 @@ export class DcRouter {
await scheduler.clearBackoff(domain);
return result;
} catch (err: unknown) {
// Record failure for backoff tracking
await scheduler.recordFailure(domain, (err as Error).message);
eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`);
return 'http01';
const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`;
await scheduler.recordFailure(domain, message);
eventComms.warn(message);
throw new Error(message);
}
};
}
@@ -2413,6 +2430,13 @@ export class DcRouter {
return;
}
if (this.options.dbConfig?.enabled === false) {
throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb');
}
if (!this.routeConfigManager || !this.targetProfileManager) {
throw new Error('VPN requires initialized route and target profile managers');
}
logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager({
+41 -3
View File
@@ -9,6 +9,8 @@ import type {
} from '../../ts_interfaces/data/route-management.js';
const TOKEN_PREFIX_STR = 'dcr_';
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
@@ -17,6 +19,7 @@ export class ApiTokenManager {
public async initialize(): Promise<void> {
await this.loadTokens();
await this.ensureEnvAdminToken();
if (this.tokens.size > 0) {
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
}
@@ -41,7 +44,7 @@ export class ApiTokenManager {
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const tokenHash = this.hashToken(rawToken);
const now = Date.now();
const stored: IStoredApiToken = {
@@ -70,7 +73,7 @@ export class ApiTokenManager {
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const hash = this.hashToken(rawToken);
for (const stored of this.tokens.values()) {
if (stored.tokenHash === hash) {
@@ -162,7 +165,7 @@ export class ApiTokenManager {
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
stored.tokenHash = this.hashToken(rawToken);
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
@@ -204,6 +207,41 @@ export class ApiTokenManager {
}
}
private async ensureEnvAdminToken(): Promise<void> {
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
if (!rawToken) return;
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
}
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
}
const now = Date.now();
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
const stored: IStoredApiToken = {
id: ENV_ADMIN_TOKEN_ID,
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
tokenHash: this.hashToken(rawToken),
scopes: ['*'],
policy: { role: 'admin' },
createdAt: existing?.createdAt || now,
expiresAt: null,
lastUsedAt: existing?.lastUsedAt || null,
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
enabled: true,
};
this.tokens.set(stored.id, stored);
await this.persistToken(stored);
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
}
private hashToken(rawToken: string): string {
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
}
private async persistToken(stored: IStoredApiToken): Promise<void> {
const existing = await ApiTokenDoc.findById(stored.id);
if (existing) {
+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,
};
}
}
+8 -6
View File
@@ -607,19 +607,21 @@ export class RouteConfigManager {
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const vpnCallback = this.getVpnClientIpsForRoute;
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
: [...new Set([...existingBlockList, '*'])];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
ipAllowList: vpnEntries,
ipBlockList,
},
};
}
+2 -1
View File
@@ -2,6 +2,7 @@
export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-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 { 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
export * from './classes.route.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.target-profile.doc.js';
export * from './classes.network-target.doc.js';
+3
View File
@@ -113,6 +113,9 @@ export class OpsServer {
}
public async stop() {
if (this.adminHandler) {
await this.adminHandler.stop();
}
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
+244 -32
View File
@@ -8,19 +8,33 @@ export interface IJwtData {
expiresAt: number;
}
type TAdminUser = {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
};
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database)
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
private users = new Map<string, {
id: string;
username: string;
password: string;
role: string;
}>();
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
private ownsIdpClient = false;
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
@@ -32,6 +46,14 @@ export class AdminHandler {
this.initializeDefaultUsers();
this.registerHandlers();
}
public async stop(): Promise<void> {
if (this.ownsIdpClient) {
await this.idpClient?.stop();
}
this.idpClient = undefined;
this.ownsIdpClient = false;
}
private async initializeJwt(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
@@ -61,54 +83,120 @@ export class AdminHandler {
}
/**
* Return a safe projection of the users Map — excludes password fields.
* Return a safe projection of the active user source — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
if (await this.hasPersistentAdminAccount()) {
const store = this.getAccountStore();
const accounts = await store!.listAccounts();
return accounts.map((accountArg) => this.accountToUser(accountArg));
}
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
const store = this.getAccountStore();
const dbReady = !!store;
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
return {
dbEnabled,
dbReady,
hasPersistentAdmin,
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
ephemeralAdminAvailable: !hasPersistentAdmin,
idpGlobalConfigured: this.isIdpGlobalConfigured(),
};
}
public async createInitialAdminUser(optionsArg: {
email: string;
name?: string;
password: string;
enableIdpGlobalAuth?: boolean;
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
const store = this.getAccountStore();
if (!store) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (await store.hasActiveAdminAccount()) {
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
}
const password = String(optionsArg.password || '');
if (!password) {
throw new plugins.typedrequest.TypedResponseError('password is required');
}
const email = String(optionsArg.email || '').trim();
const authSources: Array<'local' | 'idp.global'> = ['local'];
if (optionsArg.enableIdpGlobalAuth) {
authSources.push('idp.global');
}
try {
const account = await store.createAccount({
email,
name: String(optionsArg.name || '').trim() || email,
role: 'admin',
authSources,
password,
});
const user = this.accountToUser(account);
return {
success: true,
identity: await this.createIdentityForUser(user),
user,
};
} catch (error) {
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
}
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
'getAdminBootstrapStatus',
async (_dataArg) => this.getBootstrapStatus()
)
);
this.opsServerRef.adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
'createInitialAdminUser',
async (dataArg) => this.createInitialAdminUser({
email: dataArg.email,
name: dataArg.name,
password: dataArg.password,
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
})
)
);
// Admin Login Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
const user = await this.authenticateUser({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
if (!user) {
throw new plugins.typedrequest.TypedResponseError('login failed');
}
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
identity: await this.createIdentityForUser(user),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
@@ -162,8 +250,7 @@ export class AdminHandler {
};
}
// Find user
const user = this.users.get(jwtData.userId);
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return {
valid: false,
@@ -175,7 +262,7 @@ export class AdminHandler {
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
@@ -224,6 +311,15 @@ export class AdminHandler {
return false;
}
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return false;
}
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
return false;
}
return true;
} catch (error) {
return false;
@@ -256,4 +352,120 @@ export class AdminHandler {
name: 'adminIdentityGuard',
}
);
private async authenticateUser(optionsArg: {
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) {
const store = this.getAccountStore();
const authService = new plugins.idpSdkServer.AccountAuthService({
store: store!,
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
});
const result = await authService.authenticate({
email: optionsArg.username,
password: optionsArg.password,
authSource: optionsArg.authSource || 'auto',
});
return result ? this.accountToUser(result.account) : null;
}
for (const [_, userData] of this.users) {
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
return userData;
}
}
return null;
}
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) {
const account = await this.getAccountStore()!.getAccountById(userIdArg);
if (!account || account.status !== 'active') {
return null;
}
return this.accountToUser(account);
}
return this.users.get(userIdArg) || null;
}
private async hasPersistentAdminAccount(): Promise<boolean> {
const store = this.getAccountStore();
return store ? store.hasActiveAdminAccount() : false;
}
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
return null;
}
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
if (!dcRouterDb?.isReady()) {
return null;
}
if (!this.accountStore) {
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
smartdataDb: dcRouterDb.getDb(),
});
}
return this.accountStore;
}
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
if (configuredClient) {
return configuredClient;
}
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
if (!baseUrl) {
return undefined;
}
if (!this.idpClient) {
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
this.ownsIdpClient = true;
}
return this.idpClient;
}
private isIdpGlobalConfigured(): boolean {
return !!(
this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
process.env.DCROUTER_IDP_GLOBAL_URL
);
}
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
return {
id: accountArg.id,
username: accountArg.email,
email: accountArg.email,
name: accountArg.name,
role: accountArg.role,
status: accountArg.status,
authSources: accountArg.authSources,
};
}
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
const jwt = await this.smartjwtInstance.createJWT({
userId: userArg.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
jwt,
userId: userArg.id,
name: userArg.name || userArg.username,
expiresAt: expiresAtTimestamp,
role: userArg.role,
type: 'user',
};
}
}
@@ -307,6 +307,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
@@ -88,6 +88,8 @@ export class TargetProfileHandler {
routeRefs: dataArg.routeRefs,
createdBy: userId,
});
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
return { success: true, id };
},
),
+1 -1
View File
@@ -21,7 +21,7 @@ export class UsersHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
const users = await this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
+192 -18
View File
@@ -45,6 +45,16 @@ export class WorkHosterHandler {
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 {
this.typedrouter.addTypedHandler(
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(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_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 {
return [
ownership.workHosterType,
@@ -212,15 +362,38 @@ export class WorkHosterHandler {
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;
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 || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
}
@@ -403,7 +576,8 @@ export class WorkHosterHandler {
enabled?: boolean,
deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
this.assertGatewayClientOwnership(auth, ownership);
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
@@ -411,7 +585,7 @@ export class WorkHosterHandler {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildGatewayClientExternalKey(ownership);
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) {
@@ -430,15 +604,15 @@ export class WorkHosterHandler {
const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'gatewayClient',
gatewayClientType: ownership.gatewayClientType,
gatewayClientId: ownership.gatewayClientId,
gatewayClientAppId: ownership.appId,
workHosterType: ownership.gatewayClientType,
workHosterId: ownership.gatewayClientId,
workAppId: ownership.appId,
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: resolvedOwnership.appId,
workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: resolvedOwnership.gatewayClientId,
workAppId: resolvedOwnership.appId,
externalKey,
};
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey);
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, {
@@ -455,7 +629,7 @@ export class WorkHosterHandler {
return { success: true, action: 'created', routeId };
}
private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string {
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [
ownership.gatewayClientType,
ownership.gatewayClientId,
@@ -478,7 +652,7 @@ export class WorkHosterHandler {
private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IGatewayClientOwnership,
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
+7
View File
@@ -41,6 +41,13 @@ export {
typedsocket,
}
// @idp.global scope
import * as idpSdkServer from '@idp.global/sdk/server';
export {
idpSdkServer,
}
// @push.rocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
+1 -1
View File
@@ -91,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+56 -25
View File
@@ -111,6 +111,7 @@ export class VpnManager {
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
const serverEndpoint = this.getWireGuardServerEndpoint();
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
@@ -133,21 +134,19 @@ export class VpnManager {
: { default: 'forceTarget' as const, target: '127.0.0.1' };
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
listenAddr: '127.0.0.1:0', // Required by smartvpn, unused in wireguard-only mode
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: forwardingMode as any,
transportMode: 'all',
transportMode: 'wireguard',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
serverEndpoint,
clientAllowedIPs: [subnet],
// Bridge-specific config
...(isBridge ? {
@@ -187,7 +186,7 @@ export class VpnManager {
} catch {
// Ignore stop errors
}
this.vpnServer.stop();
await this.vpnServer.stop();
this.vpnServer = undefined;
}
this.resolvedForwardingMode = undefined;
@@ -244,14 +243,10 @@ export class VpnManager {
vlanId: doc.vlanId,
});
// Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
doc.targetProfileIds || [],
);
// Persist client entry (including WG private key for export/QR)
doc.clientId = bundle.entry.clientId;
@@ -381,9 +376,13 @@ export class VpnManager {
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId);
const client = this.clients.get(clientId);
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
client?.targetProfileIds || [],
);
// Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) {
client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || '';
@@ -414,15 +413,7 @@ export class VpnManager {
);
}
// Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs) {
const profileIds = persisted?.targetProfileIds || [];
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
}
return config;
@@ -515,6 +506,46 @@ export class VpnManager {
}
}
private getWireGuardServerEndpoint(): string {
const endpoint = this.config.serverEndpoint?.trim();
if (!endpoint) {
throw new Error('vpnConfig.serverEndpoint is required when VPN is enabled');
}
if (endpoint.includes('://') || endpoint.includes('/')) {
throw new Error('vpnConfig.serverEndpoint must be a host or host:port, not a URL');
}
const host = endpoint.includes(':') ? endpoint.split(':')[0] : endpoint;
const lowerHost = host.toLowerCase();
if (
lowerHost === 'localhost'
|| lowerHost === '0.0.0.0'
|| lowerHost.startsWith('127.')
) {
throw new Error('vpnConfig.serverEndpoint must be reachable by VPN clients');
}
return endpoint.includes(':')
? endpoint
: `${endpoint}:${this.config.wgListenPort ?? 51820}`;
}
private async rewriteWireGuardAllowedIPs(
wireguardConfig: string,
targetProfileIds: string[],
): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig;
const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
if (/^AllowedIPs\s*=.*$/m.test(wireguardConfig)) {
return wireguardConfig.replace(/^AllowedIPs\s*=.*$/m, allowedLine);
}
return `${wireguardConfig.trimEnd()}\n${allowedLine}\n`;
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -532,7 +563,7 @@ export class VpnManager {
const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop();
await tempServer.stop();
const doc = stored || new VpnServerKeysDoc();
doc.noisePrivateKey = noiseKeys.privateKey;
+8
View File
@@ -12,6 +12,14 @@ export class WorkHosterManager {
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[]> {
const response = await this.clientRef.request<interfaces.requests.IReq_GetWorkHosterDomains>(
'getWorkHosterDomains',
+4 -4
View File
@@ -27,7 +27,7 @@ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await client.login('admin', 'admin');
await client.login('admin@example.com', 'strong-password');
const { routes, warnings } = await client.routes.list();
console.log(routes.length, warnings.length);
@@ -43,13 +43,13 @@ await route.toggle(true);
## Authentication
The client supports session login and API-token authentication.
The client supports persisted-admin session login and API-token authentication. Initial admin creation is a bootstrap flow exposed by the Ops dashboard and raw TypedRequest contracts; after a persisted admin exists, use that account with `login()`.
```typescript
const sessionClient = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
await sessionClient.login('admin', 'admin');
await sessionClient.login('admin@example.com', 'strong-password');
const tokenClient = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
@@ -153,7 +153,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+30 -3
View File
@@ -1,6 +1,6 @@
import type { IDomain } from './domain.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 {
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 {
capabilities: {
canCreateSubdomains: boolean;
@@ -49,8 +76,8 @@ export interface IGatewayClientDomain extends IDomain {
export type IWorkHosterDomain = IGatewayClientDomain;
export interface IGatewayClientOwnership {
gatewayClientType: TGatewayClientType;
gatewayClientId: string;
gatewayClientType?: TGatewayClientType;
gatewayClientId?: string;
appId: string;
hostname: string;
}
+6 -2
View File
@@ -30,7 +30,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Area | Examples |
| --- | --- |
| Auth | admin login, logout, identity verification, users |
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
| Access | API tokens, source profiles, target profiles, network targets |
| DNS and domains | DNS providers, domains, DNS records, ACME config |
@@ -66,6 +66,10 @@ for (const route of response.routes) {
}
```
## Bootstrap Contracts
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email.
## When To Use It
- Use it in custom CLIs that call dcrouter's TypedRequest API directly.
@@ -98,7 +102,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+51 -1
View File
@@ -1,6 +1,18 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
export type TAdminLoginAuthSource = 'auto' | 'local' | 'idp.global';
export interface IAdminUserProjection {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
}
// Admin Login
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
request: {
username: string;
password: string;
authSource?: TAdminLoginAuthSource;
};
response: {
identity?: authInterfaces.IIdentity;
};
}
// Admin bootstrap status
export interface IReq_GetAdminBootstrapStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAdminBootstrapStatus
> {
method: 'getAdminBootstrapStatus';
request: {};
response: {
dbEnabled: boolean;
dbReady: boolean;
hasPersistentAdmin: boolean;
needsBootstrap: boolean;
ephemeralAdminAvailable: boolean;
idpGlobalConfigured: boolean;
};
}
// Create the first persisted admin account. Requires the bootstrap/ephemeral admin identity.
export interface IReq_CreateInitialAdminUser extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateInitialAdminUser
> {
method: 'createInitialAdminUser';
request: {
identity: authInterfaces.IIdentity;
email: string;
name?: string;
password: string;
enableIdpGlobalAuth?: boolean;
};
response: {
success: boolean;
identity?: authInterfaces.IIdentity;
user?: IAdminUserProjection;
};
}
// Admin Logout
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
@@ -43,4 +93,4 @@ export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.impl
valid: boolean;
identity?: authInterfaces.IIdentity;
};
}
}
+2 -5
View File
@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IAdminUserProjection } from './admin.js';
/**
* List all OpsServer users (admin-only, read-only).
@@ -14,10 +15,6 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
identity: authInterfaces.IIdentity;
};
response: {
users: Array<{
id: string;
username: string;
role: string;
}>;
users: IAdminUserProjection[];
};
}
+108
View File
@@ -2,6 +2,8 @@ import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type {
IGatewayClientDnsRecord,
IGatewayClientContext,
IGatewayClient,
IGatewayClientDomain,
IGatewayClientOwnership,
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<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetWorkHosterDomains
+1 -1
View File
@@ -95,7 +95,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.26.0',
version: '13.30.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+160
View File
@@ -10,6 +10,8 @@ export interface ILoginState {
isLoggedIn: boolean;
}
export type IAdminBootstrapStatus = interfaces.requests.IReq_GetAdminBootstrapStatus['response'];
export interface IStatsState {
serverStats: interfaces.data.IServerStats | null;
emailStats: interfaces.data.IEmailStats | null;
@@ -285,6 +287,7 @@ export interface IRouteManagementState {
mergedRoutes: interfaces.data.IMergedRoute[];
warnings: interfaces.data.IRouteWarning[];
apiTokens: interfaces.data.IApiTokenInfo[];
gatewayClients: interfaces.data.IGatewayClient[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
@@ -296,6 +299,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
mergedRoutes: [],
warnings: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
error: null,
lastUpdated: 0,
@@ -310,7 +314,11 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
export interface IUser {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
}
export interface IUsersState {
@@ -349,6 +357,7 @@ const getActionContext = (): IActionContext => {
export const loginAction = loginStatePart.createAction<{
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
@@ -358,6 +367,7 @@ export const loginAction = loginStatePart.createAction<{
const response = await typedRequest.fire({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
if (response.identity) {
@@ -373,6 +383,47 @@ export const loginAction = loginStatePart.createAction<{
}
});
export async function getAdminBootstrapStatus(): Promise<IAdminBootstrapStatus> {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAdminBootstrapStatus
>('/typedrequest', 'getAdminBootstrapStatus');
return request.fire({});
}
export async function createInitialAdminUser(optionsArg: {
email: string;
name?: string;
password: string;
enableIdpGlobalAuth?: boolean;
}) {
const context = getActionContext();
if (!context.identity) {
throw new Error('No identity available for admin bootstrap');
}
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateInitialAdminUser
>('/typedrequest', 'createInitialAdminUser');
const response = await request.fire({
identity: context.identity,
email: optionsArg.email,
name: optionsArg.name,
password: optionsArg.password,
enableIdpGlobalAuth: optionsArg.enableIdpGlobalAuth,
});
if (response.identity) {
loginStatePart.setState({
identity: response.identity,
isLoggedIn: true,
});
}
return response;
}
// Logout Action — always clears state, even if identity is expired/missing
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
@@ -2477,6 +2528,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)
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
const context = getActionContext();
@@ -20,6 +20,7 @@ export class OpsViewApiTokens extends DeesElement {
mergedRoutes: [],
warnings: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
error: null,
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 { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import { appRouter } from '../../router.js';
declare global {
interface HTMLElementTagNameMap {
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
@state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
this.acmeState = newState;
});
this.rxSubscriptions.push(acmeSub);
const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(domainsSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
await Promise.all([
appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
]);
}
public static styles = [
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
.errorText {
font-size: 12px;
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
line-height: 1.35;
white-space: normal;
}
.errorStack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.backoffIndicator {
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
.expiryInfo .daysLeft.danger {
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">
${this.renderStatsTiles(summary)}
${this.renderAcmeSettingsTile()}
${this.renderManagedDomainWarnings()}
${this.renderCertificateTable()}
</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 {
const config = this.acmeState.config;
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate),
Error: cert.backoffInfo
? 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>`
: '',
Error: this.renderError(cert),
})}
.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 {
if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter);
@@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement {
mergedRoutes: [],
warnings: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
error: null,
lastUpdated: 0,
+102
View File
@@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
// Access group
import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
import { OpsViewUsers } from './access/ops-view-users.js';
@@ -65,6 +66,9 @@ export class OpsDashboard extends DeesElement {
isLoggedIn: false,
};
private bootstrapStepper?: any;
private bootstrapCheckPromise?: Promise<void>;
@state() accessor uiState: appstate.IUiState = {
activeView: 'overview',
activeSubview: null,
@@ -121,6 +125,7 @@ export class OpsDashboard extends DeesElement {
name: 'Access',
iconName: 'lucide:keyRound',
subViews: [
{ slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients },
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
],
@@ -334,6 +339,7 @@ export class OpsDashboard extends DeesElement {
await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
await this.ensureAdminBootstrap();
} else {
// Server rejected the JWT — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
@@ -368,10 +374,106 @@ export class OpsDashboard extends DeesElement {
await simpleLogin!.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
await this.ensureAdminBootstrap();
} else {
form!.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
form!.reset();
}
}
private async ensureAdminBootstrap(): Promise<void> {
if (!this.loginState.identity || this.bootstrapStepper?.isConnected) {
return;
}
if (this.bootstrapCheckPromise) {
return this.bootstrapCheckPromise;
}
this.bootstrapCheckPromise = (async () => {
try {
const status = await appstate.getAdminBootstrapStatus();
if (status.needsBootstrap) {
await this.showAdminBootstrapStepper(status);
}
} catch (error) {
console.error('Admin bootstrap status check failed:', error);
} finally {
this.bootstrapCheckPromise = undefined;
}
})();
return this.bootstrapCheckPromise;
}
private async showAdminBootstrapStepper(statusArg: appstate.IAdminBootstrapStatus): Promise<void> {
const { DeesStepper } = await import('@design.estate/dees-catalog');
this.bootstrapStepper = await DeesStepper.createAndShow({
cancelable: false,
steps: [
{
title: 'Create Persisted Admin',
content: html`
<div style="display: grid; gap: 16px; color: var(--dees-color-text-secondary); font-size: 14px; line-height: 1.5;">
<p style="margin: 0;">
This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue.
</p>
<dees-form>
<dees-input-text .key=${'email'} .label=${'Admin email'} .required=${true}></dees-input-text>
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
<dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
<dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
<dees-input-checkbox
.key=${'enableIdpGlobalAuth'}
.label=${'Allow idp.global login for this email'}
.description=${statusArg.idpGlobalConfigured
? 'The local account remains authoritative; idp.global only verifies identity.'
: 'Requires DCROUTER_IDP_GLOBAL_URL before idp.global logins can work.'}
></dees-input-checkbox>
</dees-form>
</div>
`,
menuOptions: [
{
name: 'Create admin',
action: async (stepperArg: any) => {
const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any;
if (!form) return;
const formData = await form.collectFormData();
const email = String(formData.email || '').trim();
const name = String(formData.name || '').trim();
const password = String(formData.password || '');
const passwordConfirm = String(formData.passwordConfirm || '');
if (!email || !password) {
form.setStatus?.('error', 'Email and password are required.');
return;
}
if (password !== passwordConfirm) {
form.setStatus?.('error', 'Passwords do not match.');
return;
}
try {
form.setStatus?.('pending', 'Creating persisted admin...');
await appstate.createInitialAdminUser({
email,
name,
password,
enableIdpGlobalAuth: Boolean(formData.enableIdpGlobalAuth),
});
form.setStatus?.('success', 'Persisted admin created.');
await stepperArg.destroy();
this.bootstrapStepper = undefined;
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
} catch (error) {
form.setStatus?.('error', error instanceof Error ? error.message : 'Failed to create admin.');
}
},
},
],
},
],
});
}
}
+5 -3
View File
@@ -12,8 +12,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
| --- | --- |
| `index.ts` | Initializes the app router and renders `<ops-dashboard>` into `document.body`. |
| `router.ts` | Defines top-level dashboard routes, subviews, redirects, and URL/state synchronization. |
| `appstate.ts` | Holds reactive login, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
| `elements/` | Contains the dashboard shell and feature-specific Dees web components. |
| `appstate.ts` | Holds reactive login, bootstrap, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
| `elements/` | Contains the dashboard shell, first-admin bootstrap stepper, and feature-specific Dees web components. |
## View Map
@@ -37,6 +37,8 @@ The dashboard talks to the dcrouter OpsServer through:
- Dees web components and app-state subscriptions for UI updates
- QR code rendering for VPN client UX
On a fresh DB-backed instance, the dashboard checks `getAdminBootstrapStatus` and shows a non-cancelable first-admin stepper before normal dashboard access.
## Usage
This package is primarily consumed by the main dcrouter build and served by OpsServer. Install it directly only when you intentionally need the dashboard module boundary.
@@ -79,7 +81,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
+2 -2
View File
@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] 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,
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
};
@@ -21,7 +21,7 @@ const defaultSubview: Record<string, string> = {
overview: 'stats',
network: 'activity',
email: 'log',
access: 'apitokens',
access: 'gatewayclients',
security: 'overview',
domains: 'domains',
};