478 lines
16 KiB
JavaScript
478 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 { 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');
|
|
});
|