Files
harbor-net/src/server/gatewayNativeRuntime.ts
T

164 lines
5.8 KiB
TypeScript

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