Add Harbor Gateway auto-detection for client routing
This commit is contained in:
109
test/server/gateway-presence.test.js
Normal file
109
test/server/gateway-presence.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildGatewayPresence,
|
||||
createGatewayAutoState,
|
||||
nextGatewayAutoState,
|
||||
probeGatewayPresence,
|
||||
readHostNetworkState,
|
||||
verifyGatewayPresence,
|
||||
} from '../../src/server/gatewayPresence.js';
|
||||
|
||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||
const nonce = '0123456789abcdef0123456789abcdef';
|
||||
|
||||
test('Gateway presence is authenticated by the shared subscription secret', async () => {
|
||||
const payload = buildGatewayPresence({
|
||||
appMode: 'gateway',
|
||||
subscriptionUrl,
|
||||
gatewayId: 'gateway-1',
|
||||
nonce,
|
||||
});
|
||||
|
||||
assert.equal(verifyGatewayPresence(payload, { subscriptionUrl, nonce }), true);
|
||||
assert.equal(verifyGatewayPresence(payload, {
|
||||
subscriptionUrl: 'https://subscription.example/fedcba9876543210fedcba9876543210',
|
||||
nonce,
|
||||
}), false);
|
||||
|
||||
const result = await probeGatewayPresence({
|
||||
gateway: '192.168.50.111',
|
||||
subscriptionUrl,
|
||||
nonce,
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(
|
||||
url,
|
||||
`http://192.168.50.111:3456/api/gateway-presence?nonce=${nonce}`,
|
||||
);
|
||||
return { ok: true, json: async () => payload };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, { gatewayId: 'gateway-1' });
|
||||
|
||||
assert.equal(buildGatewayPresence({
|
||||
appMode: 'gateway',
|
||||
subscriptionUrl: 'https://subscription.example/public-feed',
|
||||
gatewayId: 'gateway-1',
|
||||
nonce,
|
||||
}).available, false);
|
||||
|
||||
const sharedPublicValue = 'https://public.example/sing-box-configuration-v1';
|
||||
const firstUrl = `${subscriptionUrl}?redirect=${encodeURIComponent(sharedPublicValue)}`;
|
||||
const secondUrl = `https://subscription.example/fedcba9876543210fedcba9876543210?redirect=${encodeURIComponent(sharedPublicValue)}`;
|
||||
const firstPayload = buildGatewayPresence({
|
||||
appMode: 'gateway',
|
||||
subscriptionUrl: firstUrl,
|
||||
gatewayId: 'gateway-1',
|
||||
nonce,
|
||||
});
|
||||
assert.equal(verifyGatewayPresence(firstPayload, {
|
||||
subscriptionUrl: secondUrl,
|
||||
nonce,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('host route freshness and Gateway failures drive a safe automatic fallback', () => {
|
||||
const now = Date.now();
|
||||
const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json');
|
||||
fs.writeFileSync(statePath, JSON.stringify({
|
||||
gateway: '192.168.50.111',
|
||||
interface: 'en0',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
observedAt: new Date(now).toISOString(),
|
||||
}));
|
||||
const network = readHostNetworkState(statePath, { now });
|
||||
assert.equal(network.gateway, '192.168.50.111');
|
||||
|
||||
let state = nextGatewayAutoState(createGatewayAutoState(), {
|
||||
network,
|
||||
verifiedGateway: { gatewayId: 'gateway-1' },
|
||||
});
|
||||
assert.equal(state.mode, 'gateway-direct');
|
||||
|
||||
state = nextGatewayAutoState(state, { network });
|
||||
state = nextGatewayAutoState(state, { network });
|
||||
assert.equal(state.mode, 'gateway-direct');
|
||||
state = nextGatewayAutoState(state, { network });
|
||||
assert.equal(state.mode, 'local-vpn');
|
||||
|
||||
const newNetwork = { ...network, mac: '11:22:33:44:55:66' };
|
||||
state = nextGatewayAutoState(nextGatewayAutoState(createGatewayAutoState(), {
|
||||
network,
|
||||
verifiedGateway: { gatewayId: 'gateway-1' },
|
||||
}), { network: newNetwork });
|
||||
assert.equal(state.mode, 'local-vpn');
|
||||
|
||||
assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null);
|
||||
|
||||
fs.writeFileSync(statePath, JSON.stringify({
|
||||
gateway: '192.168.50.111',
|
||||
interface: 'en0',
|
||||
mac: '',
|
||||
observedAt: new Date(now).toISOString(),
|
||||
}));
|
||||
assert.equal(readHostNetworkState(statePath, { now }), null);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import test from "node:test";
|
||||
|
||||
const {
|
||||
buildSharedProxyInfo,
|
||||
checkSharedProxyGateway,
|
||||
} = await import("../../src/server/sharedProxy.js");
|
||||
|
||||
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||
@@ -23,33 +22,3 @@ test("gateway shared proxy info exposes host and socks proxy when running", () =
|
||||
socksUrl: "socks5://192.168.50.111:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||
const patch = await checkSharedProxyGateway(
|
||||
"http://192.168.50.111:3456",
|
||||
async (url) => {
|
||||
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
available: true,
|
||||
proxy: {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(patch.sharedProxyEnabled, true);
|
||||
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||
assert.deepEqual(patch.sharedProxy, {
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
protocol: "socks5",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,3 +32,13 @@ test('client exposes one local proxy and routes it through the selected VPN', ()
|
||||
assert.equal(config.route.final, 'test-vpn');
|
||||
assert.equal(config.route.auto_detect_interface, undefined);
|
||||
});
|
||||
|
||||
test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true });
|
||||
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ['mixed-in'], outbound: 'direct' },
|
||||
]);
|
||||
assert.equal(config.route.final, 'direct');
|
||||
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['direct', 'block']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user