import { tap, expect } from '@git.zone/tstest/tapbundle'; import { DcRouter } from '../ts/classes.dcrouter.js'; import { RouteConfigManager } from '../ts/config/index.js'; import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js'; import { DnsManager } from '../ts/dns/manager.dns.js'; import { logger } from '../ts/logger.js'; import * as plugins from '../ts/plugins.js'; const createTestDb = async () => { const storagePath = plugins.path.join( plugins.os.tmpdir(), `dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`, ); DcRouterDb.resetInstance(); const db = DcRouterDb.getInstance({ storagePath, dbName: `dcrouter-test-${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 }); }, }; }; const testDbPromise = createTestDb(); const clearTestState = async () => { for (const route of await RouteDoc.findAll()) { await route.delete(); } for (const domain of await DomainDoc.findAll()) { await domain.delete(); } }; tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => { await testDbPromise; await clearTestState(); const dcRouter = new DcRouter({ dnsNsDomains: ['ns1.example.com', 'ns2.example.com'], dnsScopes: ['example.com'], smartProxyConfig: { routes: [] }, dbConfig: { enabled: false }, }); const appliedRoutes: any[][] = []; const smartProxy = { updateRoutes: async (routes: any[]) => { appliedRoutes.push(routes); }, }; const routeManager = new RouteConfigManager( () => smartProxy as any, undefined, undefined, undefined, undefined, undefined, (storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute), ); await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false })); const persistedRoutes = await RouteDoc.findAll(); expect(persistedRoutes.length).toEqual(2); expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true); expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query'); expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve'); const mergedRoutes = routeManager.getMergedRoutes().routes; expect(mergedRoutes.length).toEqual(2); expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true); expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true); expect(appliedRoutes.length).toEqual(1); for (const routeSet of appliedRoutes) { const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query'); const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve'); expect(dnsQueryRoute).toBeDefined(); expect(resolveRoute).toBeDefined(); expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function'); expect(typeof resolveRoute.action.socketHandler).toEqual('function'); } }); tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => { await testDbPromise; await clearTestState(); const dcRouter = new DcRouter({ dnsNsDomains: ['ns1.example.com', 'ns2.example.com'], dnsScopes: ['example.com'], smartProxyConfig: { routes: [] }, dbConfig: { enabled: false }, }); const staleDnsQueryRoute = new RouteDoc(); staleDnsQueryRoute.id = 'stale-doh-query'; staleDnsQueryRoute.route = { name: 'dns-over-https-dns-query', match: { ports: [443], domains: ['ns1.example.com'], path: '/dns-query', }, action: { type: 'socket-handler' as any, } as any, }; staleDnsQueryRoute.enabled = true; staleDnsQueryRoute.createdAt = Date.now(); staleDnsQueryRoute.updatedAt = Date.now(); staleDnsQueryRoute.createdBy = 'test'; staleDnsQueryRoute.origin = 'dns'; await staleDnsQueryRoute.save(); const appliedRoutes: any[][] = []; const smartProxy = { updateRoutes: async (routes: any[]) => { appliedRoutes.push(routes); }, }; const routeManager = new RouteConfigManager( () => smartProxy as any, undefined, undefined, undefined, undefined, undefined, (storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute), ); await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false })); const remainingRoutes = await RouteDoc.findAll(); expect(remainingRoutes.length).toEqual(2); expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1); expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1); const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query'); expect(queryRoute?.id).toEqual('stale-doh-query'); expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query'); const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve'); expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve'); expect(appliedRoutes.length).toEqual(1); expect(appliedRoutes[0].length).toEqual(2); expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true); }); tap.test('RouteConfigManager only allows toggling system routes', async () => { await testDbPromise; await clearTestState(); const smartProxy = { updateRoutes: async (_routes: any[]) => { return; }, }; const routeManager = new RouteConfigManager(() => smartProxy as any); await routeManager.initialize([ { name: 'system-config-route', match: { ports: [443], domains: ['app.example.com'], }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8443 }], tls: { mode: 'terminate' as const }, }, } as any, ], [], []); const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route'); expect(systemRoute).toBeDefined(); const updateResult = await routeManager.updateRoute(systemRoute!.id, { route: { name: 'renamed-system-route' } as any, }); expect(updateResult.success).toEqual(false); expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled'); const deleteResult = await routeManager.deleteRoute(systemRoute!.id); expect(deleteResult.success).toEqual(false); expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted'); const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false); expect(toggleResult.success).toEqual(true); expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false); }); tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => { await testDbPromise; await clearTestState(); const originalLog = logger.log.bind(logger); const warningMessages: string[] = []; (logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record) => { if (level === 'warn') { warningMessages.push(message); } return originalLog(level, message, context || {}); }; try { const existingDomain = new DomainDoc(); existingDomain.id = 'existing-domain'; existingDomain.name = 'example.com'; existingDomain.source = 'dcrouter'; existingDomain.authoritative = true; existingDomain.createdAt = Date.now(); existingDomain.updatedAt = Date.now(); existingDomain.createdBy = 'test'; await existingDomain.save(); const dnsManager = new DnsManager({ dnsNsDomains: ['ns1.example.com'], dnsScopes: ['example.com'], dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }], smartProxyConfig: { routes: [] }, }); await dnsManager.start(); expect( warningMessages.some((message) => message.includes('ignoring legacy dnsScopes/dnsRecords constructor config') && message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'), ), ).toEqual(true); expect( warningMessages.some((message) => message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'), ), ).toEqual(false); } finally { (logger as any).log = originalLog; } }); tap.test('cleanup test db', async () => { await clearTestState(); const testDb = await testDbPromise; await testDb.cleanup(); }); export default tap.start();