From 707fbc24130b881f931151bcdd6d953798c997c0 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 20 May 2026 16:24:30 +0000 Subject: [PATCH] fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules --- changelog.md | 8 + test/test.admin-bootstrap.node.ts | 159 ++++++++++++++++---- test/test.vpn-runtime.node.ts | 80 ++++++++++ ts/config/classes.route-config-manager.ts | 37 ++++- ts/config/classes.target-profile-manager.ts | 17 ++- ts/opsserver/handlers/admin.handler.ts | 67 ++++++--- 6 files changed, 307 insertions(+), 61 deletions(-) diff --git a/changelog.md b/changelog.md index c66c74b..c041fd3 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,14 @@ +### Fixes + +- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn) + - Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses. + - Preserve persisted admin accounts across OpsServer restarts with added regression coverage. + - Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries. + - Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches. + ## 2026-05-19 - 13.32.0 ### Features diff --git a/test/test.admin-bootstrap.node.ts b/test/test.admin-bootstrap.node.ts index 094f627..aad5f99 100644 --- a/test/test.admin-bootstrap.node.ts +++ b/test/test.admin-bootstrap.node.ts @@ -14,6 +14,7 @@ let previousAdminPassword: string | undefined; let opsServer: OpsServer; let testDb: DcRouterDb; let storagePath: string; +let dbName: string; let bootstrapIdentity: interfaces.data.IIdentity; let persistedIdentity: interfaces.data.IIdentity; let createdUserId: string; @@ -28,6 +29,40 @@ const createLoginRequest = () => new TypedRequest ({ + options: { + opsServerPort: portArg, + 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: dcRouterDbArg, +}); + +const restartOpsServer = async () => { + await opsServer.stop(); + opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any); + await opsServer.start(); +}; + tap.test('setup db-backed OpsServer admin bootstrap test', async () => { previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD; process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword; @@ -38,42 +73,15 @@ tap.test('setup db-backed OpsServer admin bootstrap test', async () => { ); DcRouterDb.resetInstance(); + dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`; testDb = DcRouterDb.getInstance({ storagePath, - dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`, + dbName, }); 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); + opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any); await opsServer.start(); }); @@ -170,6 +178,30 @@ tap.test('authenticates the persisted admin locally by normalized email', async expect(response.identity.userId).toEqual(persistedIdentity.userId); }); +tap.test('persists users across OpsServer restart', async () => { + const oldPersistedIdentity = persistedIdentity; + await restartOpsServer(); + + const verifyRequest = new TypedRequest( + baseUrl, + 'verifyIdentity', + ); + const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity }); + expect(verifyResponse.valid).toEqual(false); + + const loginResponse = await createLoginRequest().fire({ + username: 'admin@example.com', + password: persistedPassword, + authSource: 'local', + }); + + if (!loginResponse.identity) { + throw new Error('Expected persisted admin login identity after restart'); + } + expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId); + persistedIdentity = loginResponse.identity; +}); + tap.test('rejects idp.global login when IdP email does not match local account', async () => { let rejected = false; try { @@ -233,6 +265,28 @@ tap.test('lists persisted users without password material', async () => { expect((response.users[0] as any).password).toBeUndefined(); }); +tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => { + await testDb.stop(); + + const status = await createStatusRequest().fire({}); + expect(status.dbEnabled).toEqual(true); + expect(status.dbReady).toEqual(false); + expect(status.needsBootstrap).toEqual(false); + expect(status.ephemeralAdminAvailable).toEqual(false); + + let rejected = false; + try { + await createLoginRequest().fire({ + username: 'admin', + password: bootstrapPassword, + }); + } catch { + rejected = true; + } + + expect(rejected).toEqual(true); +}); + tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => { await opsServer.stop(); await testDb.stop(); @@ -246,4 +300,49 @@ tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => { } }); +tap.test('does not offer bootstrap while configured database is unavailable', async () => { + const unavailablePort = 3111; + const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`; + const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD; + process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password'; + DcRouterDb.resetInstance(); + + const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any); + try { + await unavailableOpsServer.start(); + const status = await new TypedRequest( + unavailableBaseUrl, + 'getAdminBootstrapStatus', + ).fire({}); + + expect(status.dbEnabled).toEqual(true); + expect(status.dbReady).toEqual(false); + expect(status.needsBootstrap).toEqual(false); + expect(status.ephemeralAdminAvailable).toEqual(false); + + let rejected = false; + try { + await new TypedRequest( + unavailableBaseUrl, + 'adminLoginWithUsernameAndPassword', + ).fire({ + username: 'admin', + password: 'unavailable-bootstrap-password', + }); + } catch { + rejected = true; + } + + expect(rejected).toEqual(true); + } finally { + await unavailableOpsServer.stop(); + DcRouterDb.resetInstance(); + if (previousUnavailableAdminPassword === undefined) { + delete process.env.DCROUTER_ADMIN_PASSWORD; + } else { + process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword; + } + } +}); + export default tap.start(); diff --git a/test/test.vpn-runtime.node.ts b/test/test.vpn-runtime.node.ts index cafac55..e8515d5 100644 --- a/test/test.vpn-runtime.node.ts +++ b/test/test.vpn-runtime.node.ts @@ -2,6 +2,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'; +import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js'; tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => { const manager = new VpnManager({ forwardingMode: 'socket' }); @@ -147,6 +148,85 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']); }); +tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => { + const manager = new RouteConfigManager( + () => undefined, + undefined, + () => ['10.8.0.2'], + ); + const route = { + name: 'shared-private-route', + match: { domains: ['app.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(['203.0.113.10', '10.8.0.2']); + expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']); +}); + +tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'hagen.team VPN access', + domains: ['*.hagen.team'], + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'hagen-app', + match: { domains: 'app.hagen.team', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + } as any, + 'route-1', + [{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + ); + + expect(entries).toEqual(['10.8.0.2']); +}); + +tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'hagen.team VPN access', + domains: ['*.hagen.team'], + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const routes = new Map([ + ['route-1', { + id: 'route-1', + enabled: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + origin: 'api', + route: { + name: 'hagen-app', + match: { domains: 'app.hagen.team', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + }, + }], + ]) as any; + + const accessSpec = manager.getClientAccessSpec(['profile-1'], routes); + + expect(accessSpec.domains).toContain('*.hagen.team'); + expect(accessSpec.domains).toContain('app.hagen.team'); +}); + tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => { const manager = new VpnManager({ serverEndpoint: 'vpn.example.com', diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index ae19742..0723922 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -608,9 +608,23 @@ export class RouteConfigManager { routeId?: string, ): plugins.smartproxy.IRouteConfig { const dcRoute = route as IDcRouterRouteConfig; - if (!dcRoute.vpnOnly) return route; - const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || []; + + if (!dcRoute.vpnOnly) { + const existingAllowList = route.security?.ipAllowList; + if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) { + return route; + } + + return { + ...route, + security: { + ...route.security, + ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries), + }, + }; + } + const existingBlockList = route.security?.ipBlockList || []; const ipBlockList = vpnEntries.length ? existingBlockList @@ -625,4 +639,23 @@ export class RouteConfigManager { }, }; } + + private mergeIpAllowEntries( + existingEntries: TIpAllowEntry[], + vpnEntries: TIpAllowEntry[], + ): TIpAllowEntry[] { + const merged: TIpAllowEntry[] = []; + const seen = new Set(); + + for (const entry of [...existingEntries, ...vpnEntries]) { + const key = typeof entry === 'string' + ? `ip:${entry}` + : `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + + return merged; + } } diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts index db111c5..caed8be 100644 --- a/ts/config/classes.target-profile-manager.ts +++ b/ts/config/classes.target-profile-manager.ts @@ -217,7 +217,7 @@ export class TargetProfileManager { allRoutes: Map = new Map(), ): Array { const entries: Array = []; - const routeDomains: string[] = (route.match as any)?.domains || []; + const routeDomains = this.getRouteDomains(route); const routeNameIndex = this.buildRouteNameIndex(allRoutes); for (const client of clients) { @@ -298,11 +298,8 @@ export class TargetProfileManager { profile, routeNameIndex, )) { - const routeDomains = (route.route.match as any)?.domains; - if (Array.isArray(routeDomains)) { - for (const d of routeDomains) { - domains.add(d); - } + for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) { + domains.add(d); } } } @@ -327,7 +324,7 @@ export class TargetProfileManager { profile: ITargetProfile, routeNameIndex: Map, ): boolean { - const routeDomains: string[] = (route.match as any)?.domains || []; + const routeDomains = this.getRouteDomains(route); const result = this.routeMatchesProfileDetailed( route, routeId, @@ -425,6 +422,12 @@ export class TargetProfileManager { return false; } + private getRouteDomains(route: IDcRouterRouteConfig): string[] { + const domains = (route.match as any)?.domains; + if (!domains) return []; + return Array.isArray(domains) ? domains : [domains]; + } + private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined { const allRoutes = this.getAllRoutes?.() || new Map(); return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict'); diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index 19f5e88..ba0e024 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -24,7 +24,8 @@ export class AdminHandler { // JWT instance public smartjwtInstance!: plugins.smartjwt.SmartJwt; - // Ephemeral bootstrap users. Persisted accounts take over once an active admin exists. + // Ephemeral bootstrap users. DB-backed instances may use these only until the + // database is ready and the first persistent admin account has been created. private users = new Map { - if (await this.hasPersistentAdminAccount()) { - const store = this.getAccountStore(); - const accounts = await store!.listAccounts(); + const accountState = await this.getPersistentAccountState(); + if (accountState.dbEnabled && !accountState.dbReady) { + throw new plugins.typedrequest.TypedResponseError('database is not ready'); + } + if (accountState.hasPersistentAdmin) { + const accounts = await accountState.store!.listAccounts(); return accounts.map((accountArg) => this.accountToUser(accountArg)); } @@ -101,16 +105,14 @@ export class AdminHandler { } public async getBootstrapStatus(): Promise { - const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false; - const store = this.getAccountStore(); - const dbReady = !!store; - const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false; + const accountState = await this.getPersistentAccountState(); + const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin); return { - dbEnabled, - dbReady, - hasPersistentAdmin, - needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin, - ephemeralAdminAvailable: !hasPersistentAdmin, + dbEnabled: accountState.dbEnabled, + dbReady: accountState.dbReady, + hasPersistentAdmin: accountState.hasPersistentAdmin, + needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin, + ephemeralAdminAvailable: bootstrapAvailable, idpGlobalConfigured: this.isIdpGlobalConfigured(), }; } @@ -408,10 +410,14 @@ export class AdminHandler { password: string; authSource?: interfaces.requests.TAdminLoginAuthSource; }): Promise { - if (await this.hasPersistentAdminAccount()) { - const store = this.getAccountStore(); + const accountState = await this.getPersistentAccountState(); + if (accountState.dbEnabled && !accountState.dbReady) { + throw new plugins.typedrequest.TypedResponseError('database is not ready'); + } + + if (accountState.hasPersistentAdmin) { const authService = new plugins.idpSdkServer.AccountAuthService({ - store: store!, + store: accountState.store!, idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined, }); const result = await authService.authenticate({ @@ -431,8 +437,13 @@ export class AdminHandler { } private async resolveUser(userIdArg: string): Promise { - if (await this.hasPersistentAdminAccount()) { - const account = await this.getAccountStore()!.getAccountById(userIdArg); + const accountState = await this.getPersistentAccountState(); + if (accountState.dbEnabled && !accountState.dbReady) { + return null; + } + + if (accountState.hasPersistentAdmin) { + const account = await accountState.store!.getAccountById(userIdArg); if (!account || account.status !== 'active') { return null; } @@ -442,13 +453,25 @@ export class AdminHandler { return this.users.get(userIdArg) || null; } - private async hasPersistentAdminAccount(): Promise { - const store = this.getAccountStore(); - return store ? store.hasActiveAdminAccount() : false; + private async getPersistentAccountState(): Promise<{ + dbEnabled: boolean; + dbReady: boolean; + store: plugins.idpSdkServer.SmartdataAccountStore | null; + hasPersistentAdmin: boolean; + }> { + const dbEnabled = this.isPersistenceEnabled(); + const store = dbEnabled ? this.getAccountStore() : null; + const dbReady = !!store; + const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false; + return { dbEnabled, dbReady, store, hasPersistentAdmin }; + } + + private isPersistenceEnabled(): boolean { + return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false; } private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null { - if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) { + if (!this.isPersistenceEnabled()) { return null; } const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;