164 lines
5.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|