Files
harbor-net/test/server/startup-recovery.test.js
T
dokril aa9c959368
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
Refactor VPN proxy components and update related behavior
2026-08-11 01:27:46 +03:00

341 lines
11 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';
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');
});