Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user