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