2026-04-14 00:53:26 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-15 19:59:04 +00:00
|
|
|
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
2026-04-14 00:53:26 +00:00
|
|
|
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,
|
2026-04-15 19:59:04 +00:00
|
|
|
undefined,
|
|
|
|
|
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
2026-04-14 00:53:26 +00:00
|
|
|
);
|
|
|
|
|
|
2026-04-15 19:59:04 +00:00
|
|
|
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
2026-04-14 00:53:26 +00:00
|
|
|
|
|
|
|
|
const persistedRoutes = await RouteDoc.findAll();
|
2026-04-15 19:59:04 +00:00
|
|
|
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);
|
2026-04-14 00:53:26 +00:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 19:59:04 +00:00
|
|
|
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
|
2026-04-14 00:53:26 +00:00
|
|
|
await testDbPromise;
|
|
|
|
|
await clearTestState();
|
|
|
|
|
|
2026-04-15 19:59:04 +00:00
|
|
|
const dcRouter = new DcRouter({
|
|
|
|
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
|
|
|
|
dnsScopes: ['example.com'],
|
|
|
|
|
smartProxyConfig: { routes: [] },
|
|
|
|
|
dbConfig: { enabled: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 00:53:26 +00:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-15 19:59:04 +00:00
|
|
|
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 }));
|
2026-04-14 00:53:26 +00:00
|
|
|
|
|
|
|
|
const remainingRoutes = await RouteDoc.findAll();
|
2026-04-15 19:59:04 +00:00
|
|
|
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');
|
2026-04-14 00:53:26 +00:00
|
|
|
|
|
|
|
|
expect(appliedRoutes.length).toEqual(1);
|
2026-04-15 19:59:04 +00:00
|
|
|
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);
|
2026-04-14 00:53:26 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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<string, any>) => {
|
|
|
|
|
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();
|