Files
pikasTech-unidesk/scripts/src/platform-infra/config.ts
T
2026-06-26 10:32:43 +00:00

531 lines
34 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. config module for scripts/src/platform-infra.ts.
// Moved mechanically from scripts/src/platform-infra.ts:454-936 for #903.
import { createHash, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import { resolveEgressProxySourceRef } from "../egress-proxy-sources";
import { startJob } from "../jobs";
import type { RenderedCliResult } from "../output";
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service";
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { secretRoot } from "./apply-script";
import { configPath } from "./entry";
import { normalizePublicBaseUrl, validateHostOrIp, validateHostname, validateKubernetesResourceName, validatePort, validateProxyName, validateProxyUrl } from "./manifest";
export function defaultSub2ApiTargetId(): string {
return readSub2ApiConfig().defaults.targetId;
}
export function validateOptions(args: string[], booleanOptions: Set<string>): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--target") {
index += 1;
continue;
}
if (arg.startsWith("--target=") && booleanOptions.has("--target")) continue;
if (booleanOptions.has(arg)) continue;
throw new Error(`unsupported option: ${arg}`);
}
}
export function readSub2ApiConfig(): Sub2ApiConfig {
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configPath} must contain a YAML object`);
const root = parsed as Record<string, unknown>;
const version = integerField(root, "version", "");
if (version !== 1) throw new Error(`${configPath}.version must be 1`);
const kind = stringField(root, "kind", "");
if (kind !== "platform-infra-sub2api") throw new Error(`${configPath}.kind must be platform-infra-sub2api`);
const metadata = objectField(root, "metadata", "");
const relatedIssues = integerArrayField(metadata, "relatedIssues", "metadata");
const defaults = parseDefaults(root);
const image = (parsed as { image?: unknown }).image;
if (typeof image !== "object" || image === null || Array.isArray(image)) throw new Error(`${configPath}.image must be an object`);
const record = image as Record<string, unknown>;
const repository = stringField(record, "repository", "image");
const tag = stringField(record, "tag", "image");
const pullPolicy = stringField(record, "pullPolicy", "image");
if (pullPolicy !== "Always" && pullPolicy !== "IfNotPresent" && pullPolicy !== "Never") throw new Error(`${configPath}.image.pullPolicy must be Always, IfNotPresent, or Never`);
if (!isImageRepository(repository)) throw new Error(`${configPath}.image.repository has an unsupported format`);
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.image.tag has an unsupported format`);
const dependencyImages = parseDependencyImages(root);
const security = objectField(parsed as Record<string, unknown>, "security", "");
const urlAllowlist = objectField(security, "urlAllowlist", "security");
const enabled = booleanField(urlAllowlist, "enabled", "security.urlAllowlist");
const allowInsecureHttp = booleanField(urlAllowlist, "allowInsecureHttp", "security.urlAllowlist");
const allowPrivateHosts = booleanField(urlAllowlist, "allowPrivateHosts", "security.urlAllowlist");
const upstreamHosts = stringArrayField(urlAllowlist, "upstreamHosts", "security.urlAllowlist");
const targets = parseTargets(root, defaults.targetId);
const runtime = parseRuntime(root);
return {
version,
kind,
metadata: {
id: stringField(metadata, "id", "metadata"),
owner: stringField(metadata, "owner", "metadata"),
relatedIssues,
},
defaults,
image: { repository, tag, pullPolicy },
dependencyImages,
security: {
urlAllowlist: {
enabled,
allowInsecureHttp,
allowPrivateHosts,
upstreamHosts,
},
},
targets,
runtime,
};
}
export function parseDefaults(root: Record<string, unknown>): Sub2ApiDefaults {
const defaults = objectField(root, "defaults", "");
const targetId = stringField(defaults, "targetId", "defaults");
if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error(`${configPath}.defaults.targetId must be a simple target id`);
const cleanup = objectField(defaults, "cleanup", "defaults");
const externalDbState = objectField(cleanup, "externalDbState", "defaults.cleanup");
const redisPersistentState = objectField(cleanup, "redisPersistentState", "defaults.cleanup");
const publicExposure = objectField(cleanup, "publicExposure", "defaults.cleanup");
const egressProxy = objectField(cleanup, "egressProxy", "defaults.cleanup");
const parsed: Sub2ApiDefaults = {
targetId,
cleanup: {
externalDbState: {
postgresStatefulSetName: stringField(externalDbState, "postgresStatefulSetName", "defaults.cleanup.externalDbState"),
postgresServiceName: stringField(externalDbState, "postgresServiceName", "defaults.cleanup.externalDbState"),
postgresPvcName: stringField(externalDbState, "postgresPvcName", "defaults.cleanup.externalDbState"),
appDataPvcName: stringField(externalDbState, "appDataPvcName", "defaults.cleanup.externalDbState"),
},
redisPersistentState: {
pvcName: stringField(redisPersistentState, "pvcName", "defaults.cleanup.redisPersistentState"),
},
publicExposure: {
deploymentName: stringField(publicExposure, "deploymentName", "defaults.cleanup.publicExposure"),
configMapName: stringField(publicExposure, "configMapName", "defaults.cleanup.publicExposure"),
secretName: stringField(publicExposure, "secretName", "defaults.cleanup.publicExposure"),
},
egressProxy: {
deploymentName: stringField(egressProxy, "deploymentName", "defaults.cleanup.egressProxy"),
serviceName: stringField(egressProxy, "serviceName", "defaults.cleanup.egressProxy"),
secretName: stringField(egressProxy, "secretName", "defaults.cleanup.egressProxy"),
},
},
};
const resourceNames = {
...parsed.cleanup.externalDbState,
redisPersistentPvcName: parsed.cleanup.redisPersistentState.pvcName,
publicExposureDeploymentName: parsed.cleanup.publicExposure.deploymentName,
publicExposureConfigMapName: parsed.cleanup.publicExposure.configMapName,
publicExposureSecretName: parsed.cleanup.publicExposure.secretName,
egressProxyDeploymentName: parsed.cleanup.egressProxy.deploymentName,
egressProxyServiceName: parsed.cleanup.egressProxy.serviceName,
egressProxySecretName: parsed.cleanup.egressProxy.secretName,
};
for (const [key, value] of Object.entries(resourceNames)) {
validateKubernetesResourceName(value, `defaults.cleanup.${key}`);
}
return parsed;
}
export function parseDependencyImages(root: Record<string, unknown>): Sub2ApiConfig["dependencyImages"] {
const value = root.dependencyImages;
if (value === undefined) throw new Error(`${configPath}.dependencyImages must be an object`);
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.dependencyImages must be an object`);
const record = value as Record<string, unknown>;
const postgres = stringField(record, "postgres", "dependencyImages");
const redis = stringField(record, "redis", "dependencyImages");
if (!isImageReference(postgres)) throw new Error(`${configPath}.dependencyImages.postgres has an unsupported format`);
if (!isImageReference(redis)) throw new Error(`${configPath}.dependencyImages.redis has an unsupported format`);
return { postgres, redis };
}
export function parseTargets(root: Record<string, unknown>, defaultTargetId: string): Sub2ApiTargetConfig[] {
const value = root.targets;
if (value === undefined) throw new Error(`${configPath}.targets must be an array`);
if (!Array.isArray(value)) throw new Error(`${configPath}.targets must be an array`);
const targets = value.map((item, index) => {
if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error(`${configPath}.targets[${index}] must be an object`);
const record = item as Record<string, unknown>;
const path = `targets[${index}]`;
const id = stringField(record, "id", path);
const route = stringField(record, "route", path);
const targetNamespace = stringField(record, "namespace", path);
const role = stringField(record, "role", path);
const enabled = booleanField(record, "enabled", path);
const databaseMode = enumField(record, "databaseMode", path, ["bundled", "external-pending", "external-active"] as const);
const redisMode = enumField(record, "redisMode", path, ["bundled-persistent", "local-ephemeral"] as const);
const appReplicas = integerField(record, "appReplicas", path);
const redisReplicas = integerField(record, "redisReplicas", path);
const image = targetImageOverride(record, path);
const dependencyImages = targetDependencyImageOverride(record, path);
const publicExposure = parsePublicExposureConfig(record.publicExposure, path);
const egressProxy = parseEgressProxyConfig(record.egressProxy, path);
if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`);
if (!/^[A-Za-z0-9:_./-]+$/u.test(route)) throw new Error(`${configPath}.${path}.route has an unsupported format`);
if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`);
if (appReplicas < 0) throw new Error(`${configPath}.${path}.appReplicas must be >= 0`);
if (redisReplicas < 0) throw new Error(`${configPath}.${path}.redisReplicas must be >= 0`);
return { id, route, namespace: targetNamespace, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, publicExposure, egressProxy };
});
const ids = new Set<string>();
for (const target of targets) {
if (ids.has(target.id)) throw new Error(`${configPath}.targets contains duplicate id ${target.id}`);
ids.add(target.id);
}
if (!ids.has(defaultTargetId)) throw new Error(`${configPath}.targets must include defaults.targetId ${defaultTargetId}`);
return targets;
}
export function targetImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["image"]> {
if (record.image === undefined) return {};
const image = objectField(record, "image", path);
const override: Partial<Sub2ApiConfig["image"]> = {};
if (image.repository !== undefined) {
const repository = stringField(image, "repository", `${path}.image`);
if (!isImageRepository(repository)) throw new Error(`${configPath}.${path}.image.repository has an unsupported format`);
override.repository = repository;
}
if (image.tag !== undefined) {
const tag = stringField(image, "tag", `${path}.image`);
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.${path}.image.tag has an unsupported format`);
override.tag = tag;
}
if (image.pullPolicy !== undefined) {
override.pullPolicy = enumField(image, "pullPolicy", `${path}.image`, ["Always", "IfNotPresent", "Never"] as const);
}
return override;
}
export function targetDependencyImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["dependencyImages"]> {
if (record.dependencyImages === undefined) return {};
const dependencyImages = objectField(record, "dependencyImages", path);
const override: Partial<Sub2ApiConfig["dependencyImages"]> = {};
if (dependencyImages.postgres !== undefined) {
const postgres = stringField(dependencyImages, "postgres", `${path}.dependencyImages`);
if (!isImageReference(postgres)) throw new Error(`${configPath}.${path}.dependencyImages.postgres has an unsupported format`);
override.postgres = postgres;
}
if (dependencyImages.redis !== undefined) {
const redis = stringField(dependencyImages, "redis", `${path}.dependencyImages`);
if (!isImageReference(redis)) throw new Error(`${configPath}.${path}.dependencyImages.redis has an unsupported format`);
override.redis = redis;
}
return override;
}
export function parsePublicExposureConfig(value: unknown, path: string): Sub2ApiPublicExposureConfig | null {
if (value === undefined || value === null) return null;
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
const record = value as Record<string, unknown>;
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
const frpcRaw = objectField(record, "frpc", `${path}.publicExposure`);
const pk01Raw = objectField(record, "pk01", `${path}.publicExposure`);
const hostname = stringField(dnsRaw, "hostname", `${path}.publicExposure.dns`);
const expectedA = stringField(dnsRaw, "expectedA", `${path}.publicExposure.dns`);
const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`);
const exposure: Sub2ApiPublicExposureConfig = {
enabled,
publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`),
dns: {
hostname,
expectedA,
resolvers,
},
frpc: {
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
secretKey: stringField(frpcRaw, "secretKey", `${path}.publicExposure.frpc`),
image: stringField(frpcRaw, "image", `${path}.publicExposure.frpc`),
serverAddr: stringField(frpcRaw, "serverAddr", `${path}.publicExposure.frpc`),
serverPort: integerField(frpcRaw, "serverPort", `${path}.publicExposure.frpc`),
proxyName: stringField(frpcRaw, "proxyName", `${path}.publicExposure.frpc`),
remotePort: integerField(frpcRaw, "remotePort", `${path}.publicExposure.frpc`),
localIP: stringField(frpcRaw, "localIP", `${path}.publicExposure.frpc`),
localPort: integerField(frpcRaw, "localPort", `${path}.publicExposure.frpc`),
tokenSourceRef: stringField(frpcRaw, "tokenSourceRef", `${path}.publicExposure.frpc`),
tokenSourceKey: stringField(frpcRaw, "tokenSourceKey", `${path}.publicExposure.frpc`),
},
pk01: {
route: stringField(pk01Raw, "route", `${path}.publicExposure.pk01`),
caddyBinaryPath: stringField(pk01Raw, "caddyBinaryPath", `${path}.publicExposure.pk01`),
caddyDownloadUrl: stringField(pk01Raw, "caddyDownloadUrl", `${path}.publicExposure.pk01`),
caddyDownloadProxyUrl: pk01Raw.caddyDownloadProxyUrl === undefined ? null : stringField(pk01Raw, "caddyDownloadProxyUrl", `${path}.publicExposure.pk01`),
caddyConfigPath: stringField(pk01Raw, "caddyConfigPath", `${path}.publicExposure.pk01`),
caddyServiceName: stringField(pk01Raw, "caddyServiceName", `${path}.publicExposure.pk01`),
caddyStorageDir: stringField(pk01Raw, "caddyStorageDir", `${path}.publicExposure.pk01`),
caddyEmail: stringField(pk01Raw, "caddyEmail", `${path}.publicExposure.pk01`),
pikanodeRoot: stringField(pk01Raw, "pikanodeRoot", `${path}.publicExposure.pk01`),
pikanodeContainerName: stringField(pk01Raw, "pikanodeContainerName", `${path}.publicExposure.pk01`),
pikanodeImage: stringField(pk01Raw, "pikanodeImage", `${path}.publicExposure.pk01`),
pikanodeHttpHostPort: integerField(pk01Raw, "pikanodeHttpHostPort", `${path}.publicExposure.pk01`),
responseHeaderTimeoutSeconds: integerField(pk01Raw, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`),
},
};
validatePublicExposureConfig(exposure, path);
return exposure;
}
export function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProxyConfig | null {
if (value === undefined || value === null) return null;
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`);
const record = value as Record<string, unknown>;
const sourceConfigRef = optionalConfigStringField(record, "sourceConfigRef", `${path}.egressProxy`);
const source = sourceConfigRef === null ? null : resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.${path}.egressProxy.sourceConfigRef`);
const sourceType = source?.sourceType ?? enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] as const);
assertOptionalMatch(record, "sourceType", sourceType, `${path}.egressProxy`);
const preferredOutbound = sourceType === "subscription-url" || record.preferredOutbound !== undefined
? source?.preferredOutbound ?? enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const)
: null;
if (source !== null && source.preferredOutbound !== null && record.preferredOutbound !== undefined) assertOptionalMatch(record, "preferredOutbound", source.preferredOutbound, `${path}.egressProxy`);
const masterShadowsocks = sourceType === "master-shadowsocks"
? source?.masterShadowsocks ?? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`)
: null;
const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const);
const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`);
const proxy: Sub2ApiEgressProxyConfig = {
enabled: booleanField(record, "enabled", `${path}.egressProxy`),
deploymentName: stringField(record, "deploymentName", `${path}.egressProxy`),
serviceName: stringField(record, "serviceName", `${path}.egressProxy`),
secretName: stringField(record, "secretName", `${path}.egressProxy`),
secretKey: stringField(record, "secretKey", `${path}.egressProxy`),
image: stringField(record, "image", `${path}.egressProxy`),
imagePullPolicy,
listenPort: integerField(record, "listenPort", `${path}.egressProxy`),
sourceConfigRef,
sourceFingerprint: source?.fingerprint ?? null,
sourceRef: source?.sourceRef ?? stringField(record, "sourceRef", `${path}.egressProxy`),
sourceKey: source?.sourceKey ?? stringField(record, "sourceKey", `${path}.egressProxy`),
sourceType,
preferredOutbound,
masterShadowsocks,
noProxy,
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`),
healthProbeUrl: source?.healthProbeUrl ?? stringField(record, "healthProbeUrl", `${path}.egressProxy`),
};
if (source !== null) {
assertOptionalMatch(record, "sourceRef", source.sourceRef, `${path}.egressProxy`);
assertOptionalMatch(record, "sourceKey", source.sourceKey, `${path}.egressProxy`);
assertOptionalMatch(record, "healthProbeUrl", source.healthProbeUrl, `${path}.egressProxy`);
}
validateEgressProxyConfig(proxy, path);
return proxy;
}
export function parseMasterShadowsocksConfig(value: unknown, path: string): Sub2ApiMasterShadowsocksConfig {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.${path} must be an object`);
const record = value as Record<string, unknown>;
return {
serverHost: stringField(record, "serverHost", path),
serverPort: integerField(record, "serverPort", path),
method: stringField(record, "method", path),
};
}
export function parseRuntime(root: Record<string, unknown>): Sub2ApiConfig["runtime"] {
const value = root.runtime;
if (value === undefined) throw new Error(`${configPath}.runtime must be an object`);
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.runtime must be an object`);
const runtime = value as Record<string, unknown>;
const database = objectField(runtime, "database", "runtime");
const databaseMode = enumField(database, "mode", "runtime.database", ["external"] as const);
const sourceRef = stringField(database, "sourceRef", "runtime.database");
const sourceKeysRaw = objectField(database, "sourceKeys", "runtime.database");
const sourceKeys = {
user: stringField(sourceKeysRaw, "user", "runtime.database.sourceKeys"),
password: stringField(sourceKeysRaw, "password", "runtime.database.sourceKeys"),
dbName: stringField(sourceKeysRaw, "dbName", "runtime.database.sourceKeys"),
};
const secret = stringField(database, "secretName", "runtime.database");
const passwordKey = stringField(database, "passwordKey", "runtime.database");
const host = stringField(database, "host", "runtime.database");
const port = integerField(database, "port", "runtime.database");
const user = stringField(database, "user", "runtime.database");
const dbName = stringField(database, "dbName", "runtime.database");
const sslMode = stringField(database, "sslMode", "runtime.database");
const pendingAllowed = booleanField(database, "pendingAllowed", "runtime.database");
if (!isKubernetesName(secret)) throw new Error(`${configPath}.runtime.database.secretName must be a Kubernetes Secret name`);
if (!/^[A-Za-z0-9_./-]+$/u.test(sourceRef)) throw new Error(`${configPath}.runtime.database.sourceRef has an unsupported format`);
for (const [key, value] of Object.entries(sourceKeys)) {
if (!/^[A-Z0-9_]+$/u.test(value)) throw new Error(`${configPath}.runtime.database.sourceKeys.${key} must be an env key`);
}
if (!/^[A-Z0-9_]+$/u.test(passwordKey)) throw new Error(`${configPath}.runtime.database.passwordKey must be an env key`);
if (!/^[A-Za-z0-9._-]+$/u.test(host)) throw new Error(`${configPath}.runtime.database.host has an unsupported format`);
if (!/^[A-Za-z0-9_][-A-Za-z0-9_]*$/u.test(user)) throw new Error(`${configPath}.runtime.database.user has an unsupported format`);
if (!/^[A-Za-z0-9_][-A-Za-z0-9_]*$/u.test(dbName)) throw new Error(`${configPath}.runtime.database.dbName has an unsupported format`);
if (!/^[A-Za-z0-9_-]+$/u.test(sslMode)) throw new Error(`${configPath}.runtime.database.sslMode has an unsupported format`);
if (port < 1 || port > 65535) throw new Error(`${configPath}.runtime.database.port must be a TCP port`);
if (databaseMode !== "external") throw new Error(`${configPath}.runtime.database.mode must be external`);
const redis = objectField(runtime, "redis", "runtime");
const redisServiceName = stringField(redis, "serviceName", "runtime.redis");
const redisPersistence = booleanField(redis, "persistence", "runtime.redis");
if (!isKubernetesName(redisServiceName)) throw new Error(`${configPath}.runtime.redis.serviceName must be a Kubernetes Service name`);
const secretsRaw = objectField(runtime, "secrets", "runtime");
const secretRoot = stringField(secretsRaw, "root", "runtime.secrets");
const appSourceRef = stringField(secretsRaw, "appSourceRef", "runtime.secrets");
if (!/^[A-Za-z0-9_./-]+$/u.test(secretRoot)) throw new Error(`${configPath}.runtime.secrets.root has an unsupported format`);
if (!/^[A-Za-z0-9_./-]+$/u.test(appSourceRef)) throw new Error(`${configPath}.runtime.secrets.appSourceRef has an unsupported format`);
const appData = objectField(runtime, "appData", "runtime");
const appDataMode = enumField(appData, "mode", "runtime.appData", ["persistent-pvc", "empty-dir"] as const);
const sentinel = objectField(runtime, "sentinel", "runtime");
const sentinelMode = enumField(sentinel, "mode", "runtime.sentinel", ["singleton"] as const);
const enabledOnTargets = stringArrayField(sentinel, "enabledOnTargets", "runtime.sentinel");
if (sentinelMode !== "singleton") throw new Error(`${configPath}.runtime.sentinel.mode must be singleton`);
return {
database: { mode: "external", sourceRef, sourceKeys, secretName: secret, passwordKey, host, port, user, dbName, sslMode, pendingAllowed },
secrets: { root: secretRoot, appSourceRef },
redis: { serviceName: redisServiceName, persistence: redisPersistence },
appData: { mode: appDataMode },
sentinel: { mode: "singleton", enabledOnTargets },
};
}
export function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${fieldLabel(path, key)} must be a non-empty string`);
return value;
}
export function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
const value = obj[key];
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${fieldLabel(path, key)} must be an object`);
return value as Record<string, unknown>;
}
export function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
return yamlBooleanField(obj, key, configPath, path);
}
export function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
throw new Error(`${fieldLabel(path, key)} must be an array of non-empty strings`);
}
return value.map((item) => item.trim());
}
function optionalConfigStringField(obj: Record<string, unknown>, key: string, path: string): string | null {
const value = obj[key];
if (value === undefined || value === null) return null;
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${fieldLabel(path, key)} must be a non-empty string when set`);
return value.trim();
}
function assertOptionalMatch(obj: Record<string, unknown>, key: string, expected: string | null, path: string): void {
if (obj[key] === undefined || obj[key] === null) return;
const actual = stringField(obj, key, path);
if (actual !== expected) throw new Error(`${fieldLabel(path, key)} must match sourceConfigRef value ${expected}`);
}
export function enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number] {
const value = stringField(obj, key, path);
if (!(values as readonly string[]).includes(value)) throw new Error(`${fieldLabel(path, key)} must be one of ${values.join(", ")}`);
return value as T[number];
}
export function integerField(obj: Record<string, unknown>, key: string, path: string): number {
return yamlIntegerField(obj, key, configPath, path);
}
export function integerArrayField(obj: Record<string, unknown>, key: string, path: string): number[] {
const value = obj[key];
if (!Array.isArray(value) || !value.every((item) => typeof item === "number" && Number.isInteger(item))) {
throw new Error(`${fieldLabel(path, key)} must be an array of integers`);
}
return value;
}
export function fieldLabel(path: string, key: string): string {
return yamlFieldLabel(configPath, path, key);
}
export function isKubernetesName(value: string): boolean {
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value);
}
export function isImageRepository(value: string): boolean {
return /^[A-Za-z0-9._:/-]+$/u.test(value) && !value.includes("..") && !value.includes("@") && !value.endsWith(":");
}
export function isImageReference(value: string): boolean {
return /^[A-Za-z0-9._:/-]+$/u.test(value) && value.includes(":") && !value.includes("..");
}
export function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig, path: string): void {
const url = new URL(config.publicBaseUrl);
if (url.protocol !== "https:") throw new Error(`${configPath}.${path}.publicExposure.publicBaseUrl must use https://`);
if (url.hostname !== config.dns.hostname) throw new Error(`${configPath}.${path}.publicExposure.publicBaseUrl hostname must match publicExposure.dns.hostname`);
validateHostname(config.dns.hostname, `${path}.publicExposure.dns.hostname`);
if (!/^[0-9.]+$/u.test(config.dns.expectedA)) throw new Error(`${configPath}.${path}.publicExposure.dns.expectedA must be an IPv4 address`);
for (const resolver of config.dns.resolvers) {
if (!/^[0-9.]+$/u.test(resolver)) throw new Error(`${configPath}.${path}.publicExposure.dns.resolvers entries must be IPv4 resolvers`);
}
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);
if (config.pk01.caddyDownloadProxyUrl !== null) validateProxyUrl(config.pk01.caddyDownloadProxyUrl, `${path}.publicExposure.pk01.caddyDownloadProxyUrl`);
if (!config.pk01.caddyConfigPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyConfigPath must be absolute`);
validateProxyName(config.pk01.caddyServiceName, `${path}.publicExposure.pk01.caddyServiceName`);
if (!config.pk01.caddyStorageDir.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyStorageDir must be absolute`);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(config.pk01.caddyEmail)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyEmail must be an email address`);
if (!config.pk01.pikanodeRoot.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.pikanodeRoot must be absolute`);
validateProxyName(config.pk01.pikanodeContainerName, `${path}.publicExposure.pk01.pikanodeContainerName`);
if (!isImageRepository(config.pk01.pikanodeImage) && !isImageReference(config.pk01.pikanodeImage)) throw new Error(`${configPath}.${path}.publicExposure.pk01.pikanodeImage has an unsupported format`);
validatePort(config.pk01.pikanodeHttpHostPort, `${path}.publicExposure.pk01.pikanodeHttpHostPort`);
if (config.pk01.responseHeaderTimeoutSeconds < 1) throw new Error(`${configPath}.${path}.publicExposure.pk01.responseHeaderTimeoutSeconds must be >= 1`);
}
export function validateEgressProxyConfig(config: Sub2ApiEgressProxyConfig, path: string): void {
validateKubernetesResourceName(config.deploymentName, `${path}.egressProxy.deploymentName`);
validateKubernetesResourceName(config.serviceName, `${path}.egressProxy.serviceName`);
validateKubernetesResourceName(config.secretName, `${path}.egressProxy.secretName`);
if (config.secretKey !== "config.json") throw new Error(`${configPath}.${path}.egressProxy.secretKey must be config.json`);
if (!isImageReference(config.image)) throw new Error(`${configPath}.${path}.egressProxy.image has an unsupported format`);
validatePort(config.listenPort, `${path}.egressProxy.listenPort`);
if (config.sourceConfigRef !== null && (!config.sourceConfigRef.startsWith("config/") || !config.sourceConfigRef.includes("#sources.") || config.sourceConfigRef.includes(".."))) {
throw new Error(`${configPath}.${path}.egressProxy.sourceConfigRef must reference config/*.yaml#sources.<id>`);
}
if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.egressProxy.sourceKey must be an env key`);
if (config.sourceType === "subscription-url" && config.preferredOutbound === null) {
throw new Error(`${configPath}.${path}.egressProxy.preferredOutbound is required for sourceType=subscription-url`);
}
if (config.sourceType === "master-shadowsocks") {
if (config.masterShadowsocks === null) throw new Error(`${configPath}.${path}.egressProxy.masterShadowsocks is required for sourceType=master-shadowsocks`);
validateHostOrIp(config.masterShadowsocks.serverHost, `${path}.egressProxy.masterShadowsocks.serverHost`);
validatePort(config.masterShadowsocks.serverPort, `${path}.egressProxy.masterShadowsocks.serverPort`);
if (!/^[A-Za-z0-9-]+$/u.test(config.masterShadowsocks.method)) throw new Error(`${configPath}.${path}.egressProxy.masterShadowsocks.method has an unsupported format`);
}
for (const entry of config.noProxy) {
if (!/^[A-Za-z0-9.:_/*-]+$/u.test(entry)) throw new Error(`${configPath}.${path}.egressProxy.noProxy contains an unsupported entry`);
}
const url = new URL(config.healthProbeUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path}.egressProxy.healthProbeUrl must use http:// or https://`);
}