475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
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' });
|
|
|
|
let stopCalls = 0;
|
|
let startCalls = 0;
|
|
|
|
(manager as any).vpnServer = { running: true };
|
|
(manager as any).resolvedForwardingMode = 'hybrid';
|
|
(manager as any).clients = new Map([
|
|
['client-1', { useHostIp: false }],
|
|
]);
|
|
(manager as any).stop = async () => {
|
|
stopCalls++;
|
|
};
|
|
(manager as any).start = async () => {
|
|
startCalls++;
|
|
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
|
|
(manager as any).forwardingModeOverride = undefined;
|
|
(manager as any).vpnServer = { running: true };
|
|
};
|
|
|
|
const restarted = await (manager as any).reconcileForwardingMode();
|
|
|
|
expect(restarted).toEqual(true);
|
|
expect(stopCalls).toEqual(1);
|
|
expect(startCalls).toEqual(1);
|
|
expect((manager as any).resolvedForwardingMode).toEqual('socket');
|
|
});
|
|
|
|
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
|
|
const manager = new VpnManager({ forwardingMode: 'hybrid' });
|
|
|
|
let stopCalls = 0;
|
|
let startCalls = 0;
|
|
|
|
(manager as any).vpnServer = { running: true };
|
|
(manager as any).resolvedForwardingMode = 'hybrid';
|
|
(manager as any).clients = new Map([
|
|
['client-1', { useHostIp: false }],
|
|
]);
|
|
(manager as any).stop = async () => {
|
|
stopCalls++;
|
|
};
|
|
(manager as any).start = async () => {
|
|
startCalls++;
|
|
};
|
|
|
|
const restarted = await (manager as any).reconcileForwardingMode();
|
|
|
|
expect(restarted).toEqual(false);
|
|
expect(stopCalls).toEqual(0);
|
|
expect(startCalls).toEqual(0);
|
|
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
|
|
});
|
|
|
|
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
|
|
const dcRouter = new DcRouter({
|
|
smartProxyConfig: { routes: [] },
|
|
dbConfig: { enabled: false },
|
|
vpnConfig: { enabled: false },
|
|
});
|
|
|
|
let stopCalls = 0;
|
|
let setupCalls = 0;
|
|
let applyCalls = 0;
|
|
const resolverValues: Array<unknown> = [];
|
|
|
|
dcRouter.vpnManager = {
|
|
stop: async () => {
|
|
stopCalls++;
|
|
},
|
|
} as any;
|
|
(dcRouter as any).routeConfigManager = {
|
|
setVpnClientIpsResolver: (resolver: unknown) => {
|
|
resolverValues.push(resolver);
|
|
},
|
|
applyRoutes: async () => {
|
|
applyCalls++;
|
|
},
|
|
};
|
|
(dcRouter as any).setupVpnServer = async () => {
|
|
setupCalls++;
|
|
dcRouter.vpnManager = {
|
|
stop: async () => {
|
|
stopCalls++;
|
|
},
|
|
} as any;
|
|
};
|
|
|
|
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
|
|
|
|
expect(stopCalls).toEqual(1);
|
|
expect(setupCalls).toEqual(1);
|
|
expect(applyCalls).toEqual(0);
|
|
expect(typeof resolverValues.at(-1)).toEqual('function');
|
|
|
|
await dcRouter.updateVpnConfig({ enabled: false });
|
|
|
|
expect(stopCalls).toEqual(2);
|
|
expect(setupCalls).toEqual(1);
|
|
expect(applyCalls).toEqual(1);
|
|
expect(resolverValues.at(-1)).toBeUndefined();
|
|
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('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('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
});
|
|
|
|
const entries = manager.getMatchingClientIps(
|
|
{
|
|
name: 'restricted-public-route',
|
|
match: { domains: 'app.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
security: { ipAllowList: ['203.0.113.10'] },
|
|
} as any,
|
|
'route-1',
|
|
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
|
new Map(),
|
|
new Map([['client-1', '203.0.113.10']]),
|
|
);
|
|
|
|
expect(entries).toEqual(['10.8.0.2']);
|
|
});
|
|
|
|
tap.test('TargetProfileManager does not allow non-matching client source IPs', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
});
|
|
|
|
const entries = manager.getMatchingClientIps(
|
|
{
|
|
name: 'restricted-public-route',
|
|
match: { domains: 'app.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
security: { ipAllowList: ['203.0.113.10'] },
|
|
} as any,
|
|
'route-1',
|
|
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
|
new Map(),
|
|
new Map([['client-1', '198.51.100.10']]),
|
|
);
|
|
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
tap.test('TargetProfileManager source-IP matching respects route block lists', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
});
|
|
|
|
const entries = manager.getMatchingClientIps(
|
|
{
|
|
name: 'blocked-route',
|
|
match: { domains: 'app.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
security: {
|
|
ipAllowList: ['203.0.113.0/24'],
|
|
ipBlockList: ['203.0.113.10'],
|
|
},
|
|
} as any,
|
|
'route-1',
|
|
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
|
new Map(),
|
|
new Map([['client-1', '203.0.113.10']]),
|
|
);
|
|
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
});
|
|
|
|
const entries = manager.getMatchingClientIps(
|
|
{
|
|
name: 'public-route',
|
|
match: { domains: 'public.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
} as any,
|
|
'route-1',
|
|
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
|
new Map(),
|
|
new Map([['client-1', '203.0.113.10']]),
|
|
);
|
|
|
|
expect(entries).toEqual(['10.8.0.2']);
|
|
});
|
|
|
|
tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP matching alone', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
});
|
|
|
|
const entries = manager.getMatchingClientIps(
|
|
{
|
|
name: 'vpn-only-route',
|
|
vpnOnly: true,
|
|
match: { domains: 'private.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
security: { ipAllowList: ['203.0.113.10'] },
|
|
} as any,
|
|
'route-1',
|
|
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
|
new Map(),
|
|
new Map([['client-1', '203.0.113.10']]),
|
|
);
|
|
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
|
|
const manager = new TargetProfileManager();
|
|
(manager as any).profiles.set('profile-1', {
|
|
id: 'profile-1',
|
|
name: 'source-ip access',
|
|
allowRoutesByClientSourceIp: true,
|
|
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: 'source-reachable-app',
|
|
match: { domains: 'app.example.com', ports: [443] },
|
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
|
security: { ipAllowList: ['203.0.113.0/24'] },
|
|
},
|
|
}],
|
|
]) as any;
|
|
|
|
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes, '203.0.113.10');
|
|
|
|
expect(accessSpec.domains).toContain('app.example.com');
|
|
});
|
|
|
|
tap.test('VpnManager normalizes real remote addresses', async () => {
|
|
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
|
|
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
|
|
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
|
|
});
|
|
|
|
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
|
|
const manager = new VpnManager({});
|
|
let sourceIpChangeCalls = 0;
|
|
(manager as any).config.onClientSourceIpsChanged = () => {
|
|
sourceIpChangeCalls++;
|
|
};
|
|
(manager as any).clients = new Map([
|
|
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
|
|
]);
|
|
(manager as any).vpnServer = {
|
|
listClients: async () => ([
|
|
{
|
|
clientId: 'runtime-client-1',
|
|
registeredClientId: 'client-1',
|
|
assignedIp: '10.8.0.2',
|
|
transportType: 'wireguard',
|
|
},
|
|
]),
|
|
listWgPeers: async () => ([
|
|
{
|
|
publicKey: 'wg-public-key',
|
|
allowedIps: ['10.8.0.2/32'],
|
|
endpoint: '198.51.100.44:61234',
|
|
bytesSent: 0,
|
|
bytesReceived: 0,
|
|
packetsSent: 0,
|
|
packetsReceived: 0,
|
|
},
|
|
]),
|
|
};
|
|
|
|
const changed = await manager.refreshClientSourceIps();
|
|
const changedAgain = await manager.refreshClientSourceIps();
|
|
|
|
expect(changed).toEqual(true);
|
|
expect(changedAgain).toEqual(false);
|
|
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
|
|
expect(sourceIpChangeCalls).toEqual(1);
|
|
});
|
|
|
|
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()
|