503 lines
32 KiB
TypeScript
503 lines
32 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 { 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 sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] as const);
|
|
const preferredOutbound = sourceType === "subscription-url" || record.preferredOutbound !== undefined
|
|
? enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const)
|
|
: null;
|
|
const masterShadowsocks = sourceType === "master-shadowsocks"
|
|
? 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`),
|
|
sourceRef: stringField(record, "sourceRef", `${path}.egressProxy`),
|
|
sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`),
|
|
sourceType,
|
|
preferredOutbound,
|
|
masterShadowsocks,
|
|
noProxy,
|
|
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
|
|
applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`),
|
|
healthProbeUrl: stringField(record, "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());
|
|
}
|
|
|
|
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 (!/^[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://`);
|
|
}
|