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); });