Add local routing rules to Harbor
This commit is contained in:
@@ -21,13 +21,20 @@ const subscriptionConfig = {
|
||||
}],
|
||||
};
|
||||
|
||||
test('client exposes one local proxy and routes it through the selected VPN', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
||||
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||
routeRules: [
|
||||
{ type: 'domain', value: 'example.com' },
|
||||
{ type: 'domain_keyword', value: 'cdn' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
|
||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||
{ domain: ['example.com'], outbound: 'direct' },
|
||||
{ domain_keyword: ['cdn'], outbound: 'direct' },
|
||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||
]);
|
||||
assert.equal(config.route.final, 'test-vpn');
|
||||
|
||||
@@ -169,6 +169,10 @@ setInterval(() => {}, 60_000);
|
||||
});
|
||||
assertStateSnapshot(initial);
|
||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||
assert.deepEqual(initial.route.localRules, {
|
||||
builtIn: [{ type: 'domain_suffix', value: 'ru' }],
|
||||
custom: [],
|
||||
});
|
||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||
const stateKeys = Object.keys(initial).sort();
|
||||
let revision = initial.revision;
|
||||
@@ -234,6 +238,66 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||
|
||||
const rulesRevision = revision;
|
||||
const routed = await mutation('/api/route-rules', 'PUT', {
|
||||
expectedRevision: rulesRevision,
|
||||
rules: [
|
||||
{ type: 'domain', value: 'https://Example.com/private?q=1' },
|
||||
{ type: 'domain_suffix', value: '*.Example.org' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(routed.state.route.localRules.custom, [
|
||||
{ type: 'domain', value: 'example.com' },
|
||||
{ type: 'domain_suffix', value: 'example.org' },
|
||||
]);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||
{ domain: ['example.com'], outbound: 'direct' },
|
||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||
]);
|
||||
|
||||
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
expectedRevision: revision,
|
||||
rules: [{ type: 'domain_regex', value: '.*' }],
|
||||
});
|
||||
assert.equal(invalidRules.response.status, 400);
|
||||
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
|
||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||
|
||||
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
expectedRevision: rulesRevision,
|
||||
rules: [],
|
||||
});
|
||||
assert.equal(staleRules.response.status, 409);
|
||||
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
||||
assert.deepEqual((await request(port, '/api/state')).route.localRules.custom, routed.state.route.localRules.custom);
|
||||
|
||||
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === 'check') {
|
||||
const config = fs.readFileSync(process.argv[4], 'utf8');
|
||||
process.exit(config.includes('broken.example') ? 1 : 0);
|
||||
}
|
||||
if (process.argv[2] === 'version') process.exit(0);
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
expectedRevision: revision,
|
||||
rules: [{ type: 'domain', value: 'broken.example' }],
|
||||
});
|
||||
assert.equal(failedRules.response.status, 422);
|
||||
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
|
||||
const rolledBack = await request(port, '/api/state');
|
||||
assert.deepEqual(rolledBack.route.localRules.custom, routed.state.route.localRules.custom);
|
||||
assert.equal(rolledBack.connection.process, 'running');
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
|
||||
revision = rolledBack.revision;
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
|
||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||
if (process.argv[2] === 'check') {
|
||||
require('node:fs').unlinkSync(process.argv[1]);
|
||||
@@ -253,6 +317,7 @@ if (process.argv[2] === 'check') {
|
||||
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.custom, routed.state.route.localRules.custom);
|
||||
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
|
||||
|
||||
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
||||
|
||||
@@ -33,9 +33,10 @@ test('a failure before rename preserves the last successful file', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy state migrates to schema v1 and keeps a backup', (t) => {
|
||||
test('schema v1 state migrates to the current schema and keeps a backup', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
schemaVersion: 1,
|
||||
revision: 7,
|
||||
selectedTag: 'nl',
|
||||
servers: [{ tag: 'nl' }],
|
||||
@@ -49,9 +50,9 @@ test('legacy state migrates to schema v1 and keeps a backup', (t) => {
|
||||
|
||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.equal(migrated.appliedTag, 'nl');
|
||||
assert.equal(store.migration.fromVersion, 0);
|
||||
assert.equal(store.migration.fromVersion, 1);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
||||
@@ -63,11 +64,11 @@ test('corrupt JSON is preserved and replaced with an explicit recovery state', (
|
||||
});
|
||||
const recovered = store.read();
|
||||
|
||||
assert.equal(recovered.schemaVersion, 1);
|
||||
assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.equal(recovered.revision, 0);
|
||||
assert.equal(store.recovery.kind, 'corrupt-json');
|
||||
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('concurrent updates are serialized without lost values', async (t) => {
|
||||
|
||||
Reference in New Issue
Block a user