Files
harbor-net/test/server/startup-recovery.test.js
T

486 lines
16 KiB
JavaScript

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 { 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,
}) {
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 });
});
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 stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8'));
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',
};
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('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');
});