Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface MaterializeOptions {
|
||||
apiPort: number;
|
||||
secretPath: string;
|
||||
runtimeConfigPath: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function privateWrite(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
||||
let descriptor: number | null = null;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, JSON.stringify(value));
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
const status = fs.lstatSync(filePath);
|
||||
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
|
||||
throw new Error('private runtime config is not a regular 0600 file');
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== null) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openSecret(secretPath: string) {
|
||||
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
|
||||
try {
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
descriptor: fs.openSync(
|
||||
secretPath,
|
||||
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||
0o600,
|
||||
),
|
||||
created: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureGatewayNativeApiSecret(secretPath: string) {
|
||||
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||
const { descriptor, created } = openSecret(secretPath);
|
||||
try {
|
||||
const secret = created
|
||||
? crypto.randomBytes(32).toString('hex')
|
||||
: fs.readFileSync(descriptor, 'utf8');
|
||||
if (created) {
|
||||
fs.writeFileSync(descriptor, secret);
|
||||
fs.fsyncSync(descriptor);
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(secret)) {
|
||||
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
|
||||
}
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
const linked = fs.lstatSync(secretPath);
|
||||
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|
||||
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|
||||
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
|
||||
throw new Error('native API secret is not a regular 0600 file');
|
||||
}
|
||||
return secret;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function withoutApiServices(config: unknown) {
|
||||
const safe = structuredClone(record(config));
|
||||
const services = Array.isArray(safe.services)
|
||||
? safe.services.filter((service) => record(service).type !== 'api')
|
||||
: [];
|
||||
if (services.length) safe.services = services;
|
||||
else delete safe.services;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
|
||||
privateWrite(runtimeConfigPath, withoutApiServices(config));
|
||||
return { configPath: runtimeConfigPath, secret: null, warning: null };
|
||||
}
|
||||
|
||||
function withAuthenticatedApi(config: unknown, secret: string) {
|
||||
const materialized = structuredClone(record(config));
|
||||
const services = Array.isArray(materialized.services) ? materialized.services : [];
|
||||
materialized.services = services.map((service) => (
|
||||
record(service).type === 'api'
|
||||
? { ...record(service), secret }
|
||||
: service
|
||||
));
|
||||
return materialized;
|
||||
}
|
||||
|
||||
function validateApiService(config: unknown, apiPort: number) {
|
||||
const configuredServices = record(config).services;
|
||||
const services = Array.isArray(configuredServices) ? configuredServices : [];
|
||||
const apiServices = services.map(record).filter(({ type }) => type === 'api');
|
||||
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
|
||||
const [service] = apiServices;
|
||||
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|
||||
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
|
||||
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeGatewayNativeConfig(
|
||||
config: unknown,
|
||||
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
|
||||
) {
|
||||
let warning: string | null = null;
|
||||
try {
|
||||
validateApiService(config, apiPort);
|
||||
const secret = ensureGatewayNativeApiSecret(secretPath);
|
||||
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
|
||||
return { configPath: runtimeConfigPath, secret, warning };
|
||||
} catch (error) {
|
||||
warning = `Native traffic API disabled: ${message(error)}`;
|
||||
}
|
||||
|
||||
const safeConfig = withoutApiServices(config);
|
||||
try {
|
||||
privateWrite(runtimeConfigPath, safeConfig);
|
||||
return { configPath: runtimeConfigPath, secret: null, warning };
|
||||
} catch (error) {
|
||||
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
|
||||
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
|
||||
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
|
||||
privateWrite(fallbackPath, safeConfig);
|
||||
return { configPath: fallbackPath, secret: null, warning };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user