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'; import { DatabaseSync } from 'node:sqlite'; import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js'; import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js'; const root = path.resolve(import.meta.dirname, '../..'); test('startup recovery rejects a materialized native API secret as shared config truth', 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), services: [{ type: 'api', listen: '127.0.0.1', listen_port: 19091, dashboard: false, secret: 'a'.repeat(64), }], }, trafficSource: 'native', }); 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); }); 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: 6, 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, hostNetwork, gatewayPresencePort, trafficSource, expectMigrationError, }) { 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 hostNetworkPath = path.join(directory, 'host-network.json'); if (hostNetwork !== undefined) fs.writeFileSync(hostNetworkPath, JSON.stringify(hostNetwork)); 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: hostNetwork === undefined ? path.join(directory, 'missing-network.json') : hostNetworkPath, ...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}), HARBOR_TEST_RUN_MARKER: markerPath, ...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}), }, 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 }); }); if (expectMigrationError) { await assert.rejects(waitForState(port, child, () => stderr), expectMigrationError); assert.notEqual(child.exitCode, 0); assert.equal(fs.existsSync(markerPath), false); assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'state.json'), 'utf8')), state); assert.equal(fs.readFileSync(path.join(directory, 'subscription-cache.json'), 'utf8'), cacheContents); assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'sing-box-config.json'), 'utf8')), config); return { directory, markerPath }; } return { directory, markerPath, state: await waitForState(port, child, () => stderr), }; } function bootStateWithRules() { const normalized = normalizeSubscriptionConfig({ outbounds: [{ type: 'vless', tag: 'Boot VPN', server: 'boot.example', server_port: 443, uuid: '00000000-0000-4000-8000-000000000000', }], }); const server = normalized.servers[0]; const state = profileState(server); state.profiles[0].subscriptionUrl = 'https://provider.example/0123456789abcdef0123456789abcdef'; state.profiles[0].subscriptionConfig = normalized.config; state.routeRules = [ { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, ]; state.appliedRouteRules = []; return state; } test('local-vpn boot promotes the exact desired ordered rules to applied truth', async (t) => { const state = bootStateWithRules(); const fixture = await startClientFixture(t, { state }); const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8')); assert.equal(fixture.state.route.mode, 'local-vpn'); assert.deepEqual(fixture.state.route.activeLocalRules, state.routeRules); assert.equal(fixture.state.route.localRulesPendingRestart, false); assert.deepEqual(config.route.rules.slice(2, 4), [ { domain: ['api.example.com'], outbound: state.profiles[0].servers[0].id }, { domain_suffix: ['example.com'], outbound: 'direct' }, ]); }); test('gateway-direct boot keeps the local proxy and diagnostics but omits every user rule', async (t) => { const state = bootStateWithRules(); const gateway = http.createServer((req, res) => { const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('nonce'); const payload = buildGatewayPresence({ appMode: 'gateway', subscriptionUrl: state.profiles[0].subscriptionUrl, gatewayId: 'gateway-test', nonce, }); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(payload)); }); const address = await listen(gateway, 0, '127.0.0.1'); t.after(() => close(gateway)); const fixture = await startClientFixture(t, { state, gatewayPresencePort: address.port, hostNetwork: { gateway: '127.0.0.1', interface: 'lo0', mac: 'aa:bb:cc:dd:ee:ff', observedAt: new Date().toISOString(), }, }); const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8')); const db = new DatabaseSync(path.join(fixture.directory, 'harbor.sqlite'), { readOnly: true }); const stored = JSON.parse(db.prepare("SELECT value FROM documents WHERE key = 'state'").get().value); db.close(); assert.equal(fixture.state.route.mode, 'gateway-direct'); assert.deepEqual(fixture.state.route.activeLocalRules, []); assert.equal(fixture.state.route.localRulesPendingRestart, false); assert.deepEqual(stored.appliedRouteRules, []); assert.deepEqual(config.inbounds.map(({ tag }) => tag), ['mixed-in', 'diagnostics-vpn-in']); assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false); assert.deepEqual(config.route.rules[1], { inbound: ['diagnostics-vpn-in'], outbound: state.profiles[0].servers[0].id, }); }); 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', }; await startClientFixture(t, { state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' }, cacheContents: '{broken', config: generatedConfig(staleServer), expectMigrationError: /Cannot migrate subscription-cache.json/, }); }); test('legacy cache owned by another URL aborts migration without mixing providers or changing originals', 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, }; await startClientFixture(t, { expectMigrationError: /owner mismatch/, 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), }); }); 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('disabled traffic source rejects an existing config with any API service', 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), services: [{ type: 'api', listen: '0.0.0.0', listen_port: 19091, dashboard: false, }], }, trafficSource: 'disabled', }); 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: 6, 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'); });