Files
pikasTech-unidesk/scripts/src/platform-infra/secrets-and-egress.ts
T
2026-06-30 10:38:03 +00:00

323 lines
15 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. secrets-and-egress module for scripts/src/platform-infra.ts.
// Moved mechanically from scripts/src/platform-infra.ts:1974-2225 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 { AccountLocalProxySecretMaterial, EgressProxySecretMaterial, EgressProxySubscriptionDiagnostics, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiAccountLocalProxyConfig, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import { secretRoot, writeEnvFile } from "./apply-script";
import { configPath, requiredSecretKeys } from "./entry";
import { analyzeEgressProxySubscription, renderSingBoxConfig, renderSingBoxMasterShadowsocksConfig } from "./pk01-public-exposure";
export function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean {
return yaml.split(/^---\s*$/mu).some((document) => {
return /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document)
&& /^\s*name:\s*allow-all\s*$/mu.test(document)
&& new RegExp(`^\\s*namespace:\\s*${escapeRegExp(namespaceName)}\\s*$`, "mu").test(document)
&& /^\s*podSelector:\s*\{\}\s*$/mu.test(document)
&& /^\s*-\s*Ingress\s*$/mu.test(document)
&& /^\s*-\s*Egress\s*$/mu.test(document)
&& /^\s*ingress:\s*\n\s*-\s*\{\}\s*$/mu.test(document)
&& /^\s*egress:\s*\n\s*-\s*\{\}\s*$/mu.test(document);
});
}
export function hasDeploymentReplicas(yaml: string, name: string, replicas: number): boolean {
return yaml.split(/^---\s*$/mu).some((document) => {
return /^\s*kind:\s*Deployment\s*$/mu.test(document)
&& new RegExp(`^\\s*name:\\s*${escapeRegExp(name)}\\s*$`, "mu").test(document)
&& new RegExp(`^\\s*replicas:\\s*${replicas}\\s*$`, "mu").test(document);
});
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalActiveSecretMaterial {
const database = sub2api.runtime.database;
const root = secretRoot(sub2api);
const dbSource = readEnvSourceFile({
root,
sourceRef: database.sourceRef,
missingMessage: (sourcePath) => `external-active requires ${redactRepoPath(sourcePath)}; run platform-db postgres apply/status first to materialize the PK01 DB credential source`,
});
const dbValues = dbSource.values;
const dbUser = requiredEnvValue(dbValues, database.sourceKeys.user, database.sourceRef);
const dbPassword = requiredEnvValue(dbValues, database.sourceKeys.password, database.sourceRef);
const dbName = requiredEnvValue(dbValues, database.sourceKeys.dbName, database.sourceRef);
if (dbUser !== database.user) throw new Error(`${database.sourceRef}.${database.sourceKeys.user} does not match ${configPath}.runtime.database.user`);
if (dbName !== database.dbName) throw new Error(`${database.sourceRef}.${database.sourceKeys.dbName} does not match ${configPath}.runtime.database.dbName`);
const appSourcePath = join(root, sub2api.runtime.secrets.appSourceRef);
const existedBefore = existsSync(appSourcePath);
const existing = existedBefore ? parseEnvFile(readFileSync(appSourcePath, "utf8")) : {};
const next = { ...existing };
next.POSTGRES_PASSWORD = dbPassword;
if (next.ADMIN_PASSWORD === undefined || next.ADMIN_PASSWORD.length === 0) next.ADMIN_PASSWORD = randomBytes(16).toString("hex");
if (next.JWT_SECRET === undefined || next.JWT_SECRET.length === 0) next.JWT_SECRET = randomBytes(32).toString("hex");
if (next.TOTP_ENCRYPTION_KEY === undefined || next.TOTP_ENCRYPTION_KEY.length === 0) next.TOTP_ENCRYPTION_KEY = randomBytes(32).toString("hex");
const values: Record<typeof requiredSecretKeys[number], string> = {
POSTGRES_PASSWORD: next.POSTGRES_PASSWORD,
ADMIN_PASSWORD: next.ADMIN_PASSWORD,
JWT_SECRET: next.JWT_SECRET,
TOTP_ENCRYPTION_KEY: next.TOTP_ENCRYPTION_KEY,
};
const missing = requiredSecretKeys.filter((key) => values[key].length === 0);
if (missing.length > 0) throw new Error(`external-active app secret source is missing ${missing.join(", ")}`);
const action = !existedBefore
? "create"
: requiredSecretKeys.some((key) => existing[key] !== values[key])
? "update"
: "none";
if (action !== "none") writeEnvFile(appSourcePath, { ...existing, ...values });
return {
sourceRef: database.sourceRef,
sourcePath: dbSource.sourcePathRedacted,
appSourceRef: sub2api.runtime.secrets.appSourceRef,
appSourcePath: redactRepoPath(appSourcePath),
action,
fingerprint: fingerprintSecretValues(values, [...requiredSecretKeys]),
values,
};
}
export function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): PublicExposureSecretMaterial | null {
const exposure = target.publicExposure;
if (exposure === null || !exposure.enabled) return null;
if (exposure.mode === "pk01-local") return null;
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return prepareFrpcSecret({
secretRoot: secretRoot(sub2api),
exposure,
sourcePathRedactor: redactRepoPath,
parseEnvFile,
requiredEnvValue,
readTextFile: (sourcePath) => {
if (!existsSync(sourcePath)) throw new Error(`publicExposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}; copy the PK01 frps token into the local secret source first`);
return readTextFile(sourcePath);
},
});
}
export function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): EgressProxySecretMaterial | null {
const proxy = target.egressProxy;
if (proxy === null || !proxy.enabled) return null;
const source = readEnvSourceFile({
root: secretRoot(sub2api),
sourceRef: proxy.sourceRef,
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
});
const values = source.values;
if (proxy.sourceType === "master-shadowsocks") {
const password = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
const configJson = renderSingBoxMasterShadowsocksConfig(proxy, password);
const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`;
const noProxy = proxy.noProxy.join(",");
return {
sourceRef: proxy.sourceRef,
sourcePath: source.sourcePathRedacted,
secretName: proxy.secretName,
secretKey: proxy.secretKey,
fingerprint: fingerprintSecretValues({ password, configJson }, ["password", "configJson"]),
sourceBytes: Buffer.byteLength(password, "utf8"),
selectedOutbound: "shadowsocks",
sourceDiagnostics: masterShadowsocksDiagnostics(proxy),
configJson,
proxyUrl,
noProxy,
valuesPrinted: false,
};
}
if (proxy.preferredOutbound === null) throw new Error(`egressProxy preferredOutbound is required for sourceType=${proxy.sourceType}`);
const subscriptionUrl = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
const subscription = fetchSubscription(subscriptionUrl);
const decoded = decodeSubscription(subscription.body);
const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean);
const analyzed = analyzeEgressProxySubscription(nodes, proxy.preferredOutbound);
if (analyzed.selected === null) throw new Error(analyzed.diagnostics.error ?? `egressProxy preferredOutbound=${proxy.preferredOutbound} is absent from subscription; no fallback outbound was selected`);
const selected = analyzed.selected;
const configJson = renderSingBoxConfig(selected, proxy);
const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`;
const noProxy = proxy.noProxy.join(",");
return {
sourceRef: proxy.sourceRef,
sourcePath: source.sourcePathRedacted,
secretName: proxy.secretName,
secretKey: proxy.secretKey,
fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
sourceBytes: Buffer.byteLength(subscription.body, "utf8"),
selectedOutbound: selected.kind,
sourceDiagnostics: analyzed.diagnostics,
configJson,
proxyUrl,
noProxy,
valuesPrinted: false,
};
}
export function prepareAccountLocalProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): AccountLocalProxySecretMaterial | null {
const proxy = target.accountLocalProxy;
if (proxy === null || !proxy.enabled) return null;
const source = readEnvSourceFile({
root: secretRoot(sub2api),
sourceRef: proxy.sourceRef,
missingMessage: (sourcePath) => `accountLocalProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
});
const password = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef);
const configJson = renderAccountLocalProxyConfig(proxy, password);
return {
sourceRef: proxy.sourceRef,
sourcePath: source.sourcePathRedacted,
secretName: proxy.secretName,
secretKey: proxy.secretKey,
fingerprint: fingerprintSecretValues({ password, configJson }, ["password", "configJson"]),
sourceBytes: Buffer.byteLength(password, "utf8"),
selectedOutbound: "shadowsocks",
configJson,
proxyUrl: `http://${proxy.listenHost}:${proxy.listenPort}`,
valuesPrinted: false,
};
}
export function renderAccountLocalProxyConfig(proxy: Sub2ApiAccountLocalProxyConfig, password: string): string {
return `${JSON.stringify({
log: { level: "info", timestamp: true },
inbounds: [
{
type: "mixed",
tag: "account-local",
listen: proxy.listenHost,
listen_port: proxy.listenPort,
},
],
outbounds: [
{
type: "shadowsocks",
tag: "master-vpn",
server: proxy.masterShadowsocks.serverHost,
server_port: proxy.masterShadowsocks.serverPort,
method: proxy.masterShadowsocks.method,
password,
},
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
],
route: {
rules: [
{ ip_is_private: true, outbound: "direct" },
{ domain_suffix: ["cluster.local", "svc"], outbound: "direct" },
],
final: "master-vpn",
},
}, null, 2)}\n`;
}
export function fetchSubscription(subscriptionUrl: string): { body: string } {
const shellUrl = shQuote(subscriptionUrl);
const command = `curl -fsSL --connect-timeout 10 --max-time 30 ${shellUrl}`;
const proc = Bun.spawnSync(["bash", "-lc", command], { stdout: "pipe", stderr: "pipe" });
if (proc.exitCode !== 0) {
const stderr = new TextDecoder().decode(proc.stderr).slice(-500);
throw new Error(`failed to fetch master VPN subscription: exit=${proc.exitCode} stderr=${stderr}`);
}
const body = new TextDecoder().decode(proc.stdout).trim();
if (body.length === 0) throw new Error("master VPN subscription is empty");
return { body };
}
export function decodeSubscription(raw: string): string {
if (raw.includes("://")) return raw;
const normalized = raw.replace(/\s+/gu, "");
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
try {
return Buffer.from(`${normalized}${padding}`, "base64").toString("utf8");
} catch (error) {
throw new Error(`master VPN subscription is neither URI list nor base64 URI list: ${String(error)}`);
}
}
export function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics {
try {
if (proxy.sourceType === "master-shadowsocks") return masterShadowsocksDiagnostics(proxy);
const source = readEnvSourceFile({
root: secretRoot(sub2api),
sourceRef: proxy.sourceRef,
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
});
if (proxy.preferredOutbound === null) throw new Error(`egressProxy preferredOutbound is required for sourceType=${proxy.sourceType}`);
const subscriptionUrl = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef);
const subscription = fetchSubscription(subscriptionUrl);
const decoded = decodeSubscription(subscription.body);
const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean);
return analyzeEgressProxySubscription(nodes, proxy.preferredOutbound).diagnostics;
} catch (error) {
return {
ok: false,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
candidateCount: 0,
supportedCount: 0,
unsupportedCount: 0,
byKind: {},
selectedOutbound: null,
selectedFingerprint: null,
candidates: [],
error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
valuesPrinted: false,
};
}
}
export function masterShadowsocksDiagnostics(proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics {
const shadowsocks = proxy.masterShadowsocks;
if (shadowsocks === null) {
return {
ok: false,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
candidateCount: 0,
supportedCount: 0,
unsupportedCount: 0,
byKind: {},
selectedOutbound: null,
selectedFingerprint: null,
candidates: [],
error: "masterShadowsocks is missing",
valuesPrinted: false,
};
}
const fingerprint = fingerprintSecretValues({ outbound: `${shadowsocks.serverHost}:${shadowsocks.serverPort}:${shadowsocks.method}` }, ["outbound"]);
return {
ok: true,
sourceType: "master-shadowsocks",
preferredOutbound: null,
candidateCount: 1,
supportedCount: 1,
unsupportedCount: 0,
byKind: { shadowsocks: 1 },
selectedOutbound: "shadowsocks",
selectedFingerprint: fingerprint,
candidates: [
{
sourceLine: 1,
kind: "shadowsocks",
fingerprint,
paramKeys: ["serverHost", "serverPort", "method"],
selected: true,
},
],
valuesPrinted: false,
};
}