import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import dgram from 'node:dgram'; import fs from 'node:fs'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createFailoverService } from '../../dist/server/features/failover/failoverService.js'; import { createDomainTrafficService } from '../../dist/server/services/domainTrafficService.js'; import { DEFAULT_FAILOVER_POLICY, normalizeFailoverPolicy } from '../../dist/shared/failover.js'; const image = process.env.HARBOR_SINGBOX_IMAGE; function listen(server, host = '127.0.0.1') { return new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, host, () => resolve(server.address().port)); }); } function listenUdp(socket, host = '127.0.0.1') { return new Promise((resolve, reject) => { socket.once('error', reject); socket.bind(0, host, () => resolve(socket.address().port)); }); } function readExactly(socket, size) { return new Promise((resolve, reject) => { let value = Buffer.alloc(0); const onData = (chunk) => { value = Buffer.concat([value, chunk]); if (value.length < size) return; cleanup(); if (value.length > size) { socket.pause(); socket.unshift(value.subarray(size)); } resolve(value.subarray(0, size)); }; const cleanup = () => { socket.off('data', onData); socket.off('error', reject); socket.off('end', onEnd); }; const onEnd = () => { cleanup(); reject(new Error('Socket ended early')); }; socket.on('data', onData); socket.once('error', reject); socket.once('end', onEnd); socket.resume(); }); } async function openSocksConnection(proxyPort, targetPort, diagnostic = () => {}) { const socket = net.connect(proxyPort, '127.0.0.1'); socket.setTimeout(3_000, () => socket.destroy(new Error('SOCKS fixture timed out'))); await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); }); diagnostic('tcp connected'); socket.write(Buffer.from([5, 1, 0])); assert.deepEqual(await readExactly(socket, 2), Buffer.from([5, 0])); diagnostic('socks greeting accepted'); const host = Buffer.from('host.docker.internal'); socket.write(Buffer.from([5, 1, 0, 3, host.length, ...host, targetPort >> 8, targetPort & 255])); const response = await readExactly(socket, 4); diagnostic(`socks connect response ${response.toString('hex')}`); assert.equal(response[1], 0); const addressLength = response[3] === 0 ? 0 : response[3] === 1 ? 4 : response[3] === 4 ? 16 : (await readExactly(socket, 1))[0]; await readExactly(socket, addressLength + 2); return socket; } async function echo(socket, value) { socket.write(value); assert.equal((await readExactly(socket, Buffer.byteLength(value))).toString(), value); } async function openSocksUdpAssociation(proxyPort, targetPort, resolveRelayPort) { const control = net.connect(proxyPort, '127.0.0.1'); control.setTimeout(3_000, () => control.destroy(new Error('SOCKS UDP fixture timed out'))); await new Promise((resolve, reject) => { control.once('connect', resolve); control.once('error', reject); }); control.write(Buffer.from([5, 1, 0])); assert.deepEqual(await readExactly(control, 2), Buffer.from([5, 0])); control.write(Buffer.from([5, 3, 0, 1, 0, 0, 0, 0, 0, 0])); const response = await readExactly(control, 4); assert.equal(response[1], 0); const addressLength = response[3] === 1 ? 4 : response[3] === 4 ? 16 : (await readExactly(control, 1))[0]; const addressAndPort = await readExactly(control, addressLength + 2); const boundPort = addressAndPort.readUInt16BE(addressAndPort.length - 2); const relayPort = resolveRelayPort(boundPort); const socket = dgram.createSocket('udp4'); await listenUdp(socket); return { close() { socket.close(); control.destroy(); }, async echo(value) { const host = Buffer.from('host.docker.internal'); const payload = Buffer.from(value); const packet = Buffer.from([0, 0, 0, 3, host.length, ...host, targetPort >> 8, targetPort & 255, ...payload]); const reply = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`SOCKS UDP echo timed out; relay ${boundPort}`)), 3_000); socket.once('message', (message) => { clearTimeout(timeout); const headerLength = message[3] === 1 ? 10 : message[3] === 4 ? 22 : 7 + message[4]; resolve(message.subarray(headerLength).toString()); }); }); socket.send(packet, relayPort, '127.0.0.1'); assert.equal(await reply, value); }, }; } async function waitForApi(port) { for (let attempt = 0; attempt < 80; attempt += 1) { try { const response = await fetch(`http://127.0.0.1:${port}/proxies/channel-selector`); if (response.ok) return; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('Clash API did not start'); } test('sing-box 1.13 selector preserves inbound TCP and UDP connections and routes new connections', { skip: image ? false : 'Set HARBOR_SINGBOX_IMAGE to run the local Docker capability proof', timeout: 30_000, }, async (t) => { const echoServer = net.createServer((socket) => socket.pipe(socket)); const echoPort = await listen(echoServer, '0.0.0.0'); const udpEchoServer = dgram.createSocket('udp4'); udpEchoServer.on('message', (message, remote) => udpEchoServer.send(message, remote.port, remote.address)); const udpEchoPort = await listenUdp(udpEchoServer, '0.0.0.0'); const proxyReservation = net.createServer(); const proxyPort = await listen(proxyReservation); await new Promise((resolve) => proxyReservation.close(resolve)); const apiReservation = net.createServer(); const apiPort = await listen(apiReservation); await new Promise((resolve) => apiReservation.close(resolve)); const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-selector-')); const configPath = path.join(fixtureDir, 'config.json'); const config = { log: { level: 'error' }, experimental: { cache_file: { enabled: true, path: '/config/cache.db' }, clash_api: { external_controller: '0.0.0.0:19091' }, }, inbounds: [ { type: 'mixed', tag: 'mixed-in', listen: '0.0.0.0', listen_port: 18081 }, { type: 'mixed', tag: 'diagnostics-primary-in', listen: '0.0.0.0', listen_port: 18082 }, { type: 'mixed', tag: 'diagnostics-reserve-in', listen: '0.0.0.0', listen_port: 18083 }, ], outbounds: [ { type: 'direct', tag: 'channel-primary' }, { type: 'direct', tag: 'channel-reserve' }, { type: 'selector', tag: 'channel-selector', outbounds: ['channel-primary', 'channel-reserve'], default: 'channel-primary', interrupt_exist_connections: false, }, ], route: { rules: [ { inbound: ['diagnostics-primary-in'], outbound: 'channel-primary' }, { inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' }, { inbound: ['mixed-in'], outbound: 'channel-selector' }, ], final: 'channel-selector', }, }; fs.writeFileSync(configPath, JSON.stringify(config)); const mount = `${fixtureDir}:/config`; const check = spawnSync('docker', [ 'run', '--rm', '-v', mount, '--entrypoint', 'sing-box', image, 'check', '-c', '/config/config.json', ], { encoding: 'utf8' }); assert.equal(check.status, 0, check.stderr || check.stdout); t.diagnostic('config accepted'); const containerName = `harbor-selector-${process.pid}-${Date.now()}`; const udpRelayContainerPorts = Array.from({ length: 32 }, (_, index) => 18084 + index); const runtime = spawn('docker', [ 'run', '--rm', '--name', containerName, '--sysctl', `net.ipv4.ip_local_port_range=${udpRelayContainerPorts[0]} ${udpRelayContainerPorts.at(-1)}`, '-p', `${proxyPort}:18081/tcp`, ...udpRelayContainerPorts.flatMap((port) => ['-p', `127.0.0.1::${port}/udp`]), '-p', `${apiPort}:19091`, '-v', mount, '--entrypoint', 'sing-box', image, 'run', '-c', '/config/config.json', ], { stdio: ['ignore', 'pipe', 'pipe'] }); let runtimeOutput = ''; runtime.stdout.on('data', (chunk) => { runtimeOutput += chunk; }); runtime.stderr.on('data', (chunk) => { runtimeOutput += chunk; }); t.after(async () => { if (runtime.exitCode == null) { runtime.kill('SIGTERM'); await new Promise((resolve) => runtime.once('close', resolve)); } echoServer.close(); udpEchoServer.close(); fs.rmSync(fixtureDir, { recursive: true, force: true }); }); try { await waitForApi(apiPort); const runtimePid = runtime.pid; t.diagnostic('api ready'); const resolveUdpRelayPort = (containerPort) => { const mapping = spawnSync('docker', ['port', containerName, `${containerPort}/udp`], { encoding: 'utf8' }); assert.equal(mapping.status, 0, mapping.stderr || mapping.stdout); const port = Number(mapping.stdout.trim().split(':').at(-1)); assert.ok(Number.isSafeInteger(port) && port > 0, mapping.stdout); return port; }; const before = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json(); assert.equal(before.now, 'channel-primary'); const existing = await openSocksConnection(proxyPort, echoPort, (message) => t.diagnostic(message)); t.diagnostic('primary connection open'); t.after(() => existing.destroy()); await echo(existing, 'before-switch'); const existingUdp = await openSocksUdpAssociation(proxyPort, udpEchoPort, resolveUdpRelayPort); t.after(() => existingUdp.close()); await existingUdp.echo('before-switch-udp'); const policy = normalizeFailoverPolicy({ ...DEFAULT_FAILOVER_POLICY, enabled: true, primary: { profileId: 'primary-profile', serverId: 'primary-server' }, reserve: { profileId: 'reserve-profile', serverId: 'reserve-server' }, intervalMs: 15_000, failureWindowMs: 30_000, recoveryWindowMs: 60_000, trafficGuard: { enabled: true, thresholdBytesPerSecond: 1024, quietWindowMs: 5_000 }, minimumReserveMs: 60_000, }); const applied = { primary: policy.primary, reserve: policy.reserve, primaryConfigFingerprint: 'a'.repeat(64), reserveConfigFingerprint: 'b'.repeat(64), }; let clock = 0; let state = { revision: 1, failoverPolicy: policy, failoverRuntimeState: { lastSwitchAt: null, holdUntil: null, primaryQuarantineUntil: null, failoverHistory: [], reasonCode: null }, appliedFailoverPolicy: applied, appliedProfileId: policy.primary.profileId, appliedServerId: policy.primary.serverId, appliedServerSnapshot: { id: policy.primary.serverId, label: 'Primary' }, profiles: [ { id: policy.primary.profileId, servers: [{ id: policy.primary.serverId, label: 'Primary' }] }, { id: policy.reserve.profileId, servers: [{ id: policy.reserve.serverId, label: 'Reserve' }] }, ], diagnostics: { customServices: [] }, }; const readSelector = async () => { const value = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json(); return { role: value.now === 'channel-primary' ? 'primary' : value.now === 'channel-reserve' ? 'reserve' : 'other' }; }; const selectRole = async (role) => { const response = await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: role === 'primary' ? 'channel-primary' : 'channel-reserve' }), }); assert.equal(response.status, 204); const selected = await readSelector(); assert.equal(selected.role, role); return selected; }; const traffic = createDomainTrafficService({ observe: async () => (await fetch(`http://127.0.0.1:${apiPort}/connections`)).json(), devices: () => [], now: () => new Date(clock), }); await traffic.refresh(); const failover = createFailoverService({ state: { read: () => state, update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; return state; }, }, runtime: { isRunning: async () => true }, dataplane: { checkConfig: async () => ({}), runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] } }), readFailoverSelector: readSelector, selectFailoverRole: selectRole, setFailoverActivityEnabled: async (value) => value ? traffic.enableActivity() : traffic.disableActivity(), readFailoverActivity: async (threshold) => { await traffic.refresh(); return { activity: traffic.activitySnapshot(threshold) }; }, }, buildCandidate: () => ({ config: {}, applied }), serialize: (operation) => operation(), scheduler: { setTimeout: () => ({ unref() {} }), clearTimeout: () => {} }, now: () => new Date(clock), }); t.after(() => failover.shutdown()); await failover.reconcile(); for (clock of [0, 30_000, 35_000]) { await echo(existing, `active-${clock}-${'x'.repeat(64 * 1024)}`); await failover.runRound(); } assert.equal((await readSelector()).role, 'primary'); assert.equal(failover.snapshot().status, 'waiting-for-idle'); t.diagnostic('active traffic delayed selector switch'); clock = 46_000; await failover.runRound(); assert.equal((await readSelector()).role, 'primary'); clock = 51_000; await failover.runRound(); const after = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json(); assert.equal(after.now, 'channel-reserve'); t.diagnostic('quiet window elapsed; selector switched and read back'); await echo(existing, 'after-switch-existing'); await existingUdp.echo('after-switch-existing-udp'); const createdAfterSwitch = await openSocksConnection(proxyPort, echoPort, (message) => t.diagnostic(message)); t.diagnostic('reserve connection open'); t.after(() => createdAfterSwitch.destroy()); await echo(createdAfterSwitch, 'after-switch-new'); const udpCreatedAfterSwitch = await openSocksUdpAssociation(proxyPort, udpEchoPort, resolveUdpRelayPort); t.after(() => udpCreatedAfterSwitch.close()); await udpCreatedAfterSwitch.echo('after-switch-new-udp'); const connections = await (await fetch(`http://127.0.0.1:${apiPort}/connections`)).json(); const chains = connections.connections.map((connection) => connection.chains); assert.ok(chains.some((chain) => chain.includes('channel-primary')), JSON.stringify(chains)); assert.ok(chains.some((chain) => chain.includes('channel-reserve')), JSON.stringify(chains)); assert.equal(runtime.pid, runtimePid); assert.equal(runtime.exitCode, null); } catch (cause) { assert.fail(`${cause.stack || cause}\n${runtimeOutput}`); } });