import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; const root = path.resolve(import.meta.dirname, '../..'); function listen(server, ...args) { return new Promise((resolve, reject) => { server.once('error', reject); server.listen(...args, () => { server.off('error', reject); resolve(server.address()); }); }); } function close(server) { return new Promise((resolve) => server.close(resolve)); } async function freePort() { const server = http.createServer(); const address = await listen(server, 0, '127.0.0.1'); await close(server); return address.port; } async function waitForState(port, child, stderr) { for (let attempt = 0; attempt < 100; attempt += 1) { if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`); try { const response = await fetch(`http://127.0.0.1:${port}/api/state`); if (response.ok) return response.json(); } catch { // The disposable listener is still starting. } await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`Harbor did not start: ${stderr()}`); } async function stopChild(child) { if (child.exitCode !== null) return; child.kill('SIGTERM'); await new Promise((resolve) => child.once('exit', resolve)); } function fakeSingbox(directory) { const binDirectory = path.join(directory, 'bin'); const markerPath = path.join(directory, 'sing-box-runs.log'); fs.mkdirSync(binDirectory); const executable = path.join(binDirectory, 'sing-box'); fs.writeFileSync(executable, `#!/usr/bin/env node const fs = require('node:fs'); if (process.argv[2] === 'check') process.exit(0); if (process.argv[2] === 'version') { console.log('sing-box version 1.13.18'); process.exit(0); } if (process.argv[2] === 'run') { fs.appendFileSync(process.env.HARBOR_TEST_RUN_MARKER, 'run\\n'); setInterval(() => {}, 60_000); } `); fs.chmodSync(executable, 0o755); return { binDirectory, markerPath }; } function profileState(server) { return { schemaVersion: 5, revision: 4, profiles: [{ id: 'profile-a', label: 'Основной', subscriptionUrl: 'https://provider.example/subscription-a', subscriptionConfig: null, servers: [server], userInfo: {}, fetchedAt: null, desiredServerId: server.id, lastRefreshAttemptAt: null, lastRefreshErrorCode: null, }], desiredProfileId: 'profile-a', appliedProfileId: 'profile-a', appliedServerId: server.id, appliedServerSnapshot: server, routeRules: [], appliedRouteRules: [], routeRulesRevision: 0, connectionDesired: 'running', }; } function generatedConfig(server, final = server.id) { return { outbounds: [ { type: server.protocol, tag: server.id, server: server.host, server_port: server.port, }, { type: 'direct', tag: 'direct' }, ], route: { final }, }; } async function startClientFixture(t, { state, cacheContents, config, }) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-')); const { binDirectory, markerPath } = fakeSingbox(directory); if (state !== undefined) fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify(state)); if (cacheContents !== undefined) { fs.writeFileSync(path.join(directory, 'subscription-cache.json'), cacheContents); } if (config !== undefined) { fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config)); } const port = await freePort(); const child = spawn(process.execPath, ['dist/server/main.js'], { cwd: root, env: { ...process.env, APP_COMPONENT: 'control', APP_MODE: 'client', DATA_DIR: directory, PORT: String(port), PATH: `${binDirectory}:${process.env.PATH || ''}`, HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'), HARBOR_TEST_RUN_MARKER: markerPath, }, stdio: ['ignore', 'ignore', 'pipe'], }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); t.after(async () => { await stopChild(child); fs.rmSync(directory, { recursive: true, force: true }); }); return { directory, markerPath, state: await waitForState(port, child, () => stderr), }; } test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => { const staleServer = { id: 'stale-server', label: 'Stale server', host: 'stale.example', port: 443, protocol: 'vless', }; const fixture = await startClientFixture(t, { state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' }, cacheContents: '{broken', config: generatedConfig(staleServer), }); assert.equal(fixture.state.subscription.status, 'missing'); assert.deepEqual(fixture.state.profiles, []); assert.equal(fixture.state.connection.desired, 'stopped'); assert.equal(fixture.state.connection.process, 'stopped'); assert.equal(fs.existsSync(fixture.markerPath), false); assert.ok(fs.readdirSync(fixture.directory).some((name) => ( name.startsWith('subscription-cache.json.corrupt-') ))); }); test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => { const stateServer = { id: 'server-a', label: 'State server', host: 'state.example', port: 443, protocol: 'vless', }; const cacheServer = { id: 'server-b', label: 'Cache server', host: 'cache.example', port: 8443, protocol: 'vless', type: 'vless', tag: 'Cache server', server: 'cache.example', server_port: 8443, }; const fixture = await startClientFixture(t, { state: { schemaVersion: 4, revision: 2, subscriptionUrl: 'https://state.example/subscription-a', servers: [stateServer], selectedServerId: stateServer.id, appliedServerId: stateServer.id, connectionDesired: 'running', }, cacheContents: JSON.stringify({ url: 'https://cache.example/subscription-b', config: { outbounds: [cacheServer] }, servers: [cacheServer], }), config: generatedConfig(stateServer), }); assert.equal(fixture.state.profiles.length, 1); assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…'); assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]); assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false); assert.equal(fixture.state.connection.desired, 'stopped'); assert.equal(fixture.state.connection.process, 'stopped'); assert.equal(fixture.state.selection.appliedServerId, ''); assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false); assert.ok(fs.readdirSync(fixture.directory).some((name) => ( name.startsWith('subscription-cache.json.backup-v1-') ))); assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); assert.equal(fs.existsSync(fixture.markerPath), false); }); test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => { const server = { id: 'server-a', label: 'Server A', host: 'a.example', port: 443, protocol: 'vless', }; const fixture = await startClientFixture(t, { state: profileState(server), config: generatedConfig(server, 'direct'), }); assert.equal(fixture.state.route.mode, 'local-vpn'); assert.equal(fixture.state.connection.desired, 'stopped'); assert.equal(fixture.state.connection.process, 'stopped'); assert.equal(fs.existsSync(fixture.markerPath), false); assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); }); test('boot rejects an existing config owned by a different applied target', async (t) => { const appliedServer = { id: 'server-a', label: 'Server A', host: 'a.example', port: 443, protocol: 'vless', }; const otherServer = { id: 'server-b', label: 'Server B', host: 'b.example', port: 8443, protocol: 'vless', }; const fixture = await startClientFixture(t, { state: profileState(appliedServer), config: generatedConfig(otherServer), }); assert.equal(fixture.state.selection.appliedServerId, ''); assert.equal(fixture.state.connection.desired, 'stopped'); assert.equal(fixture.state.connection.process, 'stopped'); assert.equal(fs.existsSync(fixture.markerPath), false); assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); }); test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-')); const socketPath = path.join(directory, 'dataplane.sock'); const requests = []; let running = true; const dataplane = http.createServer((req, res) => { requests.push(`${req.method} ${req.url}`); if (req.method === 'GET' && req.url === '/status') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ running, startedAt: running ? '2026-08-11T12:00:00.000Z' : null })); } if (req.method === 'POST' && req.url === '/stop') { running = false; res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ running: false, startedAt: null })); } res.writeHead(503, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: 'not used by this fixture' })); }); await listen(dataplane, socketPath); fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({ schemaVersion: 5, revision: 3, profiles: [], desiredProfileId: '', appliedProfileId: '', appliedServerId: '', appliedServerSnapshot: null, routeRules: [], appliedRouteRules: [], routeRulesRevision: 0, connectionDesired: 'stopped', })); fs.writeFileSync(path.join(directory, 'sing-box-config.json'), '{}'); const port = await freePort(); const child = spawn(process.execPath, ['dist/server/main.js'], { cwd: root, env: { ...process.env, APP_COMPONENT: 'control', APP_MODE: 'gateway', DATA_DIR: directory, DATAPLANE_SOCKET: socketPath, PORT: String(port), }, stdio: ['ignore', 'ignore', 'pipe'], }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); t.after(async () => { await stopChild(child); await close(dataplane); fs.rmSync(directory, { recursive: true, force: true }); }); const state = await waitForState(port, child, () => stderr); assert.equal(requests.includes('POST /stop'), true); assert.equal(running, false); assert.equal(state.connection.desired, 'stopped'); assert.equal(state.connection.process, 'stopped'); });