Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
ensureGatewayNativeApiSecret,
|
||||
materializeGatewayNativeConfig,
|
||||
} from '../../dist/server/gatewayNativeRuntime.js';
|
||||
import { createSingboxRuntime } from '../../dist/server/singboxRuntime.js';
|
||||
|
||||
const apiService = {
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
};
|
||||
|
||||
function mode(filePath) {
|
||||
return fs.statSync(filePath).mode & 0o777;
|
||||
}
|
||||
|
||||
async function waitForJson(filePath) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
test('Gateway native materialization keeps a stable 0600 secret out of shared config', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const secretPath = path.join(directory, 'api.secret');
|
||||
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||
const config = { services: [apiService], inbounds: [], outbounds: [] };
|
||||
|
||||
const first = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath,
|
||||
runtimeConfigPath,
|
||||
});
|
||||
const second = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath,
|
||||
runtimeConfigPath,
|
||||
});
|
||||
|
||||
assert.match(first.secret, /^[0-9a-f]{64}$/);
|
||||
assert.equal(second.secret, first.secret);
|
||||
assert.equal(first.warning, null);
|
||||
assert.equal(mode(secretPath), 0o600);
|
||||
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||
assert.equal(JSON.stringify(config).includes(first.secret), false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(runtimeConfigPath, 'utf8')).services[0].secret, first.secret);
|
||||
});
|
||||
|
||||
test('Gateway native secret rejects symlinks and repairs regular-file permissions', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-mode-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const regularPath = path.join(directory, 'regular.secret');
|
||||
fs.writeFileSync(regularPath, 'a'.repeat(64), { mode: 0o644 });
|
||||
assert.equal(ensureGatewayNativeApiSecret(regularPath), 'a'.repeat(64));
|
||||
assert.equal(mode(regularPath), 0o600);
|
||||
|
||||
const linkPath = path.join(directory, 'linked.secret');
|
||||
fs.symlinkSync(regularPath, linkPath);
|
||||
assert.throws(() => ensureGatewayNativeApiSecret(linkPath));
|
||||
});
|
||||
|
||||
test('materialization strips every API service and returns a warning on unsafe input', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-safe-config-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const config = {
|
||||
services: [
|
||||
apiService,
|
||||
{ type: 'api', listen: '0.0.0.0', listen_port: 19092, dashboard: false },
|
||||
{ type: 'resolved' },
|
||||
],
|
||||
};
|
||||
const result = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath: path.join(directory, 'api.secret'),
|
||||
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||
});
|
||||
const runtimeConfig = JSON.parse(fs.readFileSync(result.configPath, 'utf8'));
|
||||
|
||||
assert.equal(result.secret, null);
|
||||
assert.match(result.warning, /expected exactly one native API service/);
|
||||
assert.deepEqual(runtimeConfig.services, [{ type: 'resolved' }]);
|
||||
});
|
||||
|
||||
test('runtime starts the VPN-safe config and reports native materialization warnings', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-runtime-'));
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
const configPath = path.join(directory, 'shared.json');
|
||||
const capturedPath = path.join(directory, 'captured.json');
|
||||
fs.mkdirSync(binDirectory);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
services: [{ ...apiService, listen: '0.0.0.0' }],
|
||||
inbounds: [],
|
||||
outbounds: [],
|
||||
}));
|
||||
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||
const runtime = createSingboxRuntime({
|
||||
configPath,
|
||||
nativeApi: {
|
||||
apiPort: 19091,
|
||||
secretPath: path.join(directory, 'api.secret'),
|
||||
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||
},
|
||||
});
|
||||
t.after(async () => {
|
||||
await runtime.stop();
|
||||
process.env.PATH = previousPath;
|
||||
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const checked = runtime.checkConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
||||
assert.match(checked.warning, /must be unauthenticated base config/);
|
||||
const state = await runtime.apply();
|
||||
assert.equal(state.running, true);
|
||||
assert.match(state.nativeApiWarning, /must be unauthenticated base config/);
|
||||
assert.equal(runtime.nativeApiSecret, null);
|
||||
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||
});
|
||||
|
||||
test('snapshot runtime strips a native API left by the previous mode before starting sing-box', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-snapshot-runtime-'));
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
const configPath = path.join(directory, 'shared.json');
|
||||
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||
const capturedPath = path.join(directory, 'captured.json');
|
||||
fs.mkdirSync(binDirectory);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
services: [apiService],
|
||||
inbounds: [],
|
||||
outbounds: [],
|
||||
}));
|
||||
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||
const runtime = createSingboxRuntime({ configPath, gatewayRuntimeConfigPath: runtimeConfigPath });
|
||||
t.after(async () => {
|
||||
await runtime.stop();
|
||||
process.env.PATH = previousPath;
|
||||
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const state = await runtime.apply();
|
||||
assert.equal(state.running, true);
|
||||
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user