fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules

This commit is contained in:
2026-05-20 16:24:30 +00:00
parent a0c9d40e87
commit 707fbc2413
6 changed files with 307 additions and 61 deletions
+8
View File
@@ -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 ## 2026-05-19 - 13.32.0
### Features ### Features
+121 -22
View File
@@ -14,6 +14,7 @@ let previousAdminPassword: string | undefined;
let opsServer: OpsServer; let opsServer: OpsServer;
let testDb: DcRouterDb; let testDb: DcRouterDb;
let storagePath: string; let storagePath: string;
let dbName: string;
let bootstrapIdentity: interfaces.data.IIdentity; let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity; let persistedIdentity: interfaces.data.IIdentity;
let createdUserId: string; let createdUserId: string;
@@ -28,26 +29,9 @@ const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_Admin
'adminLoginWithUsernameAndPassword', 'adminLoginWithUsernameAndPassword',
); );
tap.test('setup db-backed OpsServer admin bootstrap test', async () => { const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
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: { options: {
opsServerPort: testPort, opsServerPort: portArg,
dbConfig: { enabled: true }, dbConfig: { enabled: true },
adminAuth: { adminAuth: {
idpClient: { idpClient: {
@@ -70,10 +54,34 @@ tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
}, },
}, },
typedrouter: new plugins.typedrequest.TypedRouter(), typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: testDb, dcRouterDb: dcRouterDbArg,
}; });
opsServer = new OpsServer(fakeDcRouter as any); 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;
storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
testDb = DcRouterDb.getInstance({
storagePath,
dbName,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start(); 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); expect(response.identity.userId).toEqual(persistedIdentity.userId);
}); });
tap.test('persists users across OpsServer restart', async () => {
const oldPersistedIdentity = persistedIdentity;
await restartOpsServer();
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
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 () => { tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false; let rejected = false;
try { try {
@@ -233,6 +265,28 @@ tap.test('lists persisted users without password material', async () => {
expect((response.users[0] as any).password).toBeUndefined(); 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 () => { tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop(); await opsServer.stop();
await testDb.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<interfaces.requests.IReq_GetAdminBootstrapStatus>(
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<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
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(); export default tap.start();
+80
View File
@@ -2,6 +2,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js'; import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js'; import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-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 () => { tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' }); 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']); 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 () => { tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({ const manager = new VpnManager({
serverEndpoint: 'vpn.example.com', serverEndpoint: 'vpn.example.com',
+35 -2
View File
@@ -608,9 +608,23 @@ export class RouteConfigManager {
routeId?: string, routeId?: string,
): plugins.smartproxy.IRouteConfig { ): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || []; 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 existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length const ipBlockList = vpnEntries.length
? existingBlockList ? existingBlockList
@@ -625,4 +639,23 @@ export class RouteConfigManager {
}, },
}; };
} }
private mergeIpAllowEntries(
existingEntries: TIpAllowEntry[],
vpnEntries: TIpAllowEntry[],
): TIpAllowEntry[] {
const merged: TIpAllowEntry[] = [];
const seen = new Set<string>();
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;
}
} }
+9 -6
View File
@@ -217,7 +217,7 @@ export class TargetProfileManager {
allRoutes: Map<string, IRoute> = new Map(), allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> { ): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = []; const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || []; const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes); const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) { for (const client of clients) {
@@ -298,15 +298,12 @@ export class TargetProfileManager {
profile, profile,
routeNameIndex, routeNameIndex,
)) { )) {
const routeDomains = (route.route.match as any)?.domains; for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d); domains.add(d);
} }
} }
} }
} }
}
return { return {
domains: [...domains], domains: [...domains],
@@ -327,7 +324,7 @@ export class TargetProfileManager {
profile: ITargetProfile, profile: ITargetProfile,
routeNameIndex: Map<string, string[]>, routeNameIndex: Map<string, string[]>,
): boolean { ): boolean {
const routeDomains: string[] = (route.match as any)?.domains || []; const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed( const result = this.routeMatchesProfileDetailed(
route, route,
routeId, routeId,
@@ -425,6 +422,12 @@ export class TargetProfileManager {
return false; 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 { private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>(); const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict'); return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
+45 -22
View File
@@ -24,7 +24,8 @@ export class AdminHandler {
// JWT instance // JWT instance
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>; public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
// 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<string, { private users = new Map<string, {
id: string; id: string;
username: string; username: string;
@@ -87,9 +88,12 @@ export class AdminHandler {
* Used by UsersHandler to serve the admin-only listUsers endpoint. * Used by UsersHandler to serve the admin-only listUsers endpoint.
*/ */
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> { public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
if (await this.hasPersistentAdminAccount()) { const accountState = await this.getPersistentAccountState();
const store = this.getAccountStore(); if (accountState.dbEnabled && !accountState.dbReady) {
const accounts = await store!.listAccounts(); 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)); return accounts.map((accountArg) => this.accountToUser(accountArg));
} }
@@ -101,16 +105,14 @@ export class AdminHandler {
} }
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> { public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false; const accountState = await this.getPersistentAccountState();
const store = this.getAccountStore(); const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
const dbReady = !!store;
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
return { return {
dbEnabled, dbEnabled: accountState.dbEnabled,
dbReady, dbReady: accountState.dbReady,
hasPersistentAdmin, hasPersistentAdmin: accountState.hasPersistentAdmin,
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin, needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
ephemeralAdminAvailable: !hasPersistentAdmin, ephemeralAdminAvailable: bootstrapAvailable,
idpGlobalConfigured: this.isIdpGlobalConfigured(), idpGlobalConfigured: this.isIdpGlobalConfigured(),
}; };
} }
@@ -408,10 +410,14 @@ export class AdminHandler {
password: string; password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource; authSource?: interfaces.requests.TAdminLoginAuthSource;
}): Promise<TAdminUser | null> { }): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) { const accountState = await this.getPersistentAccountState();
const store = this.getAccountStore(); if (accountState.dbEnabled && !accountState.dbReady) {
throw new plugins.typedrequest.TypedResponseError('database is not ready');
}
if (accountState.hasPersistentAdmin) {
const authService = new plugins.idpSdkServer.AccountAuthService({ const authService = new plugins.idpSdkServer.AccountAuthService({
store: store!, store: accountState.store!,
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined, idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
}); });
const result = await authService.authenticate({ const result = await authService.authenticate({
@@ -431,8 +437,13 @@ export class AdminHandler {
} }
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> { private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
if (await this.hasPersistentAdminAccount()) { const accountState = await this.getPersistentAccountState();
const account = await this.getAccountStore()!.getAccountById(userIdArg); if (accountState.dbEnabled && !accountState.dbReady) {
return null;
}
if (accountState.hasPersistentAdmin) {
const account = await accountState.store!.getAccountById(userIdArg);
if (!account || account.status !== 'active') { if (!account || account.status !== 'active') {
return null; return null;
} }
@@ -442,13 +453,25 @@ export class AdminHandler {
return this.users.get(userIdArg) || null; return this.users.get(userIdArg) || null;
} }
private async hasPersistentAdminAccount(): Promise<boolean> { private async getPersistentAccountState(): Promise<{
const store = this.getAccountStore(); dbEnabled: boolean;
return store ? store.hasActiveAdminAccount() : false; 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 { private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) { if (!this.isPersistenceEnabled()) {
return null; return null;
} }
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb; const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;