// 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): 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; 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; 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, "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): 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): 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; 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, 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; 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(); 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, path: string): Partial { if (record.image === undefined) return {}; const image = objectField(record, "image", path); const override: Partial = {}; 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, path: string): Partial { if (record.dependencyImages === undefined) return {}; const dependencyImages = objectField(record, "dependencyImages", path); const override: Partial = {}; 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; 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; 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; return { serverHost: stringField(record, "serverHost", path), serverPort: integerField(record, "serverPort", path), method: stringField(record, "method", path), }; } export function parseRuntime(root: Record): 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; 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, 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, key: string, path: string): Record { 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; } export function booleanField(obj: Record, key: string, path: string): boolean { return yamlBooleanField(obj, key, configPath, path); } export function stringArrayField(obj: Record, 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, 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, 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(obj: Record, 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, key: string, path: string): number { return yamlIntegerField(obj, key, configPath, path); } export function integerArrayField(obj: Record, 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.`); } 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://`); }