Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
+145 -68
View File
@@ -60,7 +60,7 @@ async function waitForState(port, child, stderr) {
throw new Error(`Harbor did not start: ${stderr()}`);
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
test('state v1 projects legacy storage through the canonical profile snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({
@@ -79,9 +79,15 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
});
assert.equal(snapshot.apiVersion, 1);
assert.equal(snapshot.profiles.length, 1);
assert.equal(snapshot.profiles[0].id, 'profile_primary');
assert.equal(snapshot.profiles[0].desiredServerId, legacyServerId);
assert.deepEqual(snapshot.selection, {
desiredProfileId: 'profile_primary',
desiredServerId: legacyServerId,
appliedProfileId: 'profile_primary',
appliedServerId: legacyServerId,
appliedServerSnapshot: snapshot.profiles[0].servers[0],
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
@@ -145,7 +151,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
assert.equal(child.exitCode, null);
});
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
const config = {
@@ -177,12 +183,9 @@ setInterval(() => {}, 60_000);
fs.chmodSync(singboxPath, 0o755);
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let trafficExhaustedNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
const subscriptionServer = http.createServer((req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') {
@@ -234,11 +237,6 @@ setInterval(() => {}, 60_000);
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -256,6 +254,7 @@ setInterval(() => {}, 60_000);
url: subscriptionUrl,
config,
}));
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
@@ -289,7 +288,13 @@ setInterval(() => {}, 60_000);
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
assert.equal(initial.profiles.length, 1);
assert.equal(initial.profiles[0].id, 'profile_primary');
assert.equal(initial.profiles[0].label, 'Основной');
assert.equal(initial.selection.desiredProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerId, testServerId);
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
@@ -297,6 +302,15 @@ setInterval(() => {}, 60_000);
assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(migratedState.schemaVersion, 5);
assert.equal(migratedState.profiles.length, 1);
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
const stateKeys = Object.keys(initial).sort();
assert.deepEqual(stateKeys, [
'apiVersion',
@@ -309,6 +323,7 @@ setInterval(() => {}, 60_000);
'mode',
'operation',
'port',
'profiles',
'proxyPort',
'revision',
'route',
@@ -350,11 +365,11 @@ setInterval(() => {}, 60_000);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
const primaryProfileId = initial.profiles[0].id;
const preservedPrimary = structuredClone(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
);
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
@@ -362,46 +377,59 @@ setInterval(() => {}, 60_000);
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
['/disabled', 'SUBSCRIPTION_DISABLED'],
]) {
const failedImport = await rawRequest(
const failedAdd = await rawRequest(
port,
'/api/subscription/fetch',
'/api/profiles',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
{
label: `Новая ${pathname}`,
url: `http://127.0.0.1:${subscriptionPort}${pathname}`,
expectedRevision: revision,
},
);
assert.equal(failedImport.payload.error.code, expectedCode);
assert.equal(failedAdd.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
preservedConfig,
);
revision = (await request(port, '/api/state')).revision;
}
trafficExhaustedNextPath = '/subscription/test';
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
const exhaustedRefresh = await rawRequest(
port,
`/api/profiles/${primaryProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(exhaustedRefresh.response.status, 400);
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
const stateAfterExhaustedRefresh = await request(port, '/api/state');
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
assert.equal(stateAfterExhaustedRefresh.profiles[0].subscription.status, 'stale');
assert.equal(
stateAfterExhaustedRefresh.profiles[0].subscription.errorCode,
'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
);
assert.equal(stateAfterExhaustedRefresh.profiles[0].servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedConfig);
revision = stateAfterExhaustedRefresh.revision;
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
{ profileId: primaryProfileId, serverId: 'missing-server', expectedRevision: revision },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
const stateAfterMissingServer = await request(port, '/api/state');
assert.equal(stateAfterMissingServer.selection.desiredProfileId, primaryProfileId);
assert.equal(stateAfterMissingServer.selection.desiredServerId, testServerId);
revision = stateAfterMissingServer.revision;
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
@@ -417,27 +445,76 @@ setInterval(() => {}, 60_000);
return result;
}
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: testServerId,
appliedServerId: testServerId,
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
const fetchesBeforeDuplicate = providerFetchCount;
const duplicateProfile = await rawRequest(port, '/api/profiles', 'POST', {
label: 'основной',
url: replacementUrl,
expectedRevision: revision,
});
assert.equal(duplicateProfile.response.status, 409);
assert.equal(duplicateProfile.payload.error.code, 'PROFILE_NAME_CONFLICT');
assert.equal(providerFetchCount, fetchesBeforeDuplicate);
assert.equal((await request(port, '/api/state')).revision, revision);
const fetchesBeforeAdd = providerFetchCount;
const added = await mutation('/api/profiles', 'POST', {
label: 'Работа',
url: replacementUrl,
expectedRevision: revision,
});
const workProfileId = added.profileId;
assert.equal(providerFetchCount, fetchesBeforeAdd + 1);
assert.equal(added.state.profiles.length, 2);
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
testServerId,
);
const renamed = await mutation(`/api/profiles/${workProfileId}`, 'PATCH', {
label: 'Офис',
expectedRevision: revision,
});
assert.equal(renamed.state.profiles.find(({ id }) => id === workProfileId).label, 'Офис');
const selected = await mutation(`/api/profiles/${workProfileId}/server`, 'PUT', {
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
testServerId,
);
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
const applied = await mutation('/api/apply', 'POST', {
profileId: workProfileId,
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(applied.state.selection.desiredProfileId, workProfileId);
assert.equal(applied.state.selection.desiredServerId, testServerId);
assert.equal(applied.state.selection.appliedProfileId, workProfileId);
assert.equal(applied.state.selection.appliedServerId, testServerId);
assert.equal(applied.state.selection.appliedServerSnapshot.id, testServerId);
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
invalidNextPath = '/subscription/replacement';
const failedRefresh = await rawRequest(
port,
`/api/profiles/${workProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
assert.equal(staleWorkProfile.desiredServerId, testServerId);
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
assert.equal(staleWorkProfile.servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
@@ -549,28 +626,28 @@ if (process.argv[2] === 'check') {
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const inactiveDeleted = await mutation(`/api/profiles/${primaryProfileId}`, 'DELETE', {
mode: 'delete',
expectedRevision: revision,
});
assert.deepEqual(inactiveDeleted.state.profiles.map(({ id }) => id), [workProfileId]);
assert.equal(inactiveDeleted.state.selection.appliedProfileId, workProfileId);
const activeDeleted = await mutation(`/api/profiles/${workProfileId}`, 'DELETE', {
mode: 'stop-and-delete',
expectedRevision: revision,
});
assert.equal(activeDeleted.state.subscription.status, 'missing');
assert.deepEqual(activeDeleted.state.profiles, []);
assert.equal(activeDeleted.state.selection.desiredProfileId, '');
assert.equal(activeDeleted.state.selection.appliedProfileId, '');
assert.equal(activeDeleted.state.servers.length, 0);
assert.deepEqual(activeDeleted.state.route.localRules, routed.state.route.localRules);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.response.status, 404);
assert.equal(missingConfig.payload.error.code, 'PROFILE_NOT_FOUND');
assert.equal(missingConfig.payload.error.retryable, false);
});