264 lines
12 KiB
TypeScript
264 lines
12 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 { EgressProxySecretMaterial, EgressProxySubscriptionDiagnostics, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, 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;
|
|
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 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,
|
|
};
|
|
}
|