Merge pull request #346 from pikasTech/ymalops-round3
ymal-first Round 3:收敛 Sub2API Secret material adapter
This commit is contained in:
@@ -6,6 +6,8 @@ import { rootPath } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "./pk01-caddy";
|
||||
import { prepareFrpcSecret } from "./platform-infra-public-service";
|
||||
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const defaultTargetId = "D601";
|
||||
@@ -15,7 +17,6 @@ const fieldManager = "unidesk-platform-infra";
|
||||
const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml");
|
||||
const configPath = rootPath("config", "platform-infra", "sub2api.yaml");
|
||||
const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml");
|
||||
const repoRoot = rootPath();
|
||||
const secretName = "sub2api-secrets";
|
||||
const sub2apiCaddyManagedMarker = "sub2api";
|
||||
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
|
||||
@@ -1890,11 +1891,12 @@ function escapeRegExp(value: string): string {
|
||||
function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalActiveSecretMaterial {
|
||||
const database = sub2api.runtime.database;
|
||||
const root = secretRoot(sub2api);
|
||||
const dbSourcePath = join(root, database.sourceRef);
|
||||
if (!existsSync(dbSourcePath)) {
|
||||
throw new Error(`external-active requires ${redactRepoPath(dbSourcePath)}; run platform-db postgres apply/status first to materialize the PK01 DB credential source`);
|
||||
}
|
||||
const dbValues = parseEnvFile(readFileSync(dbSourcePath, "utf8"));
|
||||
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);
|
||||
@@ -1925,11 +1927,11 @@ function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalActiveSecr
|
||||
if (action !== "none") writeEnvFile(appSourcePath, { ...existing, ...values });
|
||||
return {
|
||||
sourceRef: database.sourceRef,
|
||||
sourcePath: redactRepoPath(dbSourcePath),
|
||||
sourcePath: dbSource.sourcePathRedacted,
|
||||
appSourceRef: sub2api.runtime.secrets.appSourceRef,
|
||||
appSourcePath: redactRepoPath(appSourcePath),
|
||||
action,
|
||||
fingerprint: fingerprintValues(values, [...requiredSecretKeys]),
|
||||
fingerprint: fingerprintSecretValues(values, [...requiredSecretKeys]),
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -1937,41 +1939,28 @@ function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalActiveSecr
|
||||
function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): PublicExposureSecretMaterial | null {
|
||||
const exposure = target.publicExposure;
|
||||
if (exposure === null || !exposure.enabled) return null;
|
||||
const sourcePath = join(secretRoot(sub2api), exposure.frpc.tokenSourceRef);
|
||||
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`);
|
||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
||||
const token = requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
|
||||
const frpcToml = [
|
||||
`serverAddr = "${exposure.frpc.serverAddr}"`,
|
||||
`serverPort = ${exposure.frpc.serverPort}`,
|
||||
`loginFailExit = true`,
|
||||
`auth.token = "${escapeTomlString(token)}"`,
|
||||
"",
|
||||
"[[proxies]]",
|
||||
`name = "${exposure.frpc.proxyName}"`,
|
||||
`type = "tcp"`,
|
||||
`localIP = "${exposure.frpc.localIP}"`,
|
||||
`localPort = ${exposure.frpc.localPort}`,
|
||||
`remotePort = ${exposure.frpc.remotePort}`,
|
||||
"",
|
||||
].join("\n");
|
||||
return {
|
||||
sourceRef: exposure.frpc.tokenSourceRef,
|
||||
sourcePath: redactRepoPath(sourcePath),
|
||||
secretName: exposure.frpc.secretName,
|
||||
secretKey: exposure.frpc.secretKey,
|
||||
fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]),
|
||||
frpcToml,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): EgressProxySecretMaterial | null {
|
||||
const proxy = target.egressProxy;
|
||||
if (proxy === null || !proxy.enabled) return null;
|
||||
const sourcePath = join(secretRoot(sub2api), proxy.sourceRef);
|
||||
if (!existsSync(sourcePath)) throw new Error(`egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`);
|
||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
||||
const source = readEnvSourceFile({
|
||||
root: secretRoot(sub2api),
|
||||
sourceRef: proxy.sourceRef,
|
||||
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`,
|
||||
});
|
||||
const values = source.values;
|
||||
const subscriptionUrl = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
|
||||
const subscription = fetchSubscription(subscriptionUrl);
|
||||
const decoded = decodeSubscription(subscription.body);
|
||||
@@ -1982,10 +1971,10 @@ function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
|
||||
const noProxy = proxy.noProxy.join(",");
|
||||
return {
|
||||
sourceRef: proxy.sourceRef,
|
||||
sourcePath: redactRepoPath(sourcePath),
|
||||
sourcePath: source.sourcePathRedacted,
|
||||
secretName: proxy.secretName,
|
||||
secretKey: proxy.secretKey,
|
||||
fingerprint: fingerprintValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
|
||||
fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
|
||||
subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"),
|
||||
selectedOutbound: selected.kind,
|
||||
configJson,
|
||||
@@ -2151,10 +2140,6 @@ function redactSubscriptionUri(uri: string): string {
|
||||
return uri.replace(/\/\/[^@]+@/u, "//<redacted>@").replace(/password=[^&#]+/giu, "password=<redacted>").replace(/pbk=[^&#]+/giu, "pbk=<redacted>");
|
||||
}
|
||||
|
||||
function escapeTomlString(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise<Record<string, unknown>> {
|
||||
const exposure = target.publicExposure;
|
||||
if (exposure === null || !exposure.enabled) return { ok: true, action: "not-enabled" };
|
||||
@@ -2641,33 +2626,6 @@ function secretRoot(sub2api: Sub2ApiConfig): string {
|
||||
return isAbsolute(root) ? root : rootPath(root);
|
||||
}
|
||||
|
||||
function requiredEnvValue(values: Record<string, string>, key: string, sourceRef: string): string {
|
||||
const value = values[key];
|
||||
if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvFile(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
||||
result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value: string): string {
|
||||
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function writeEnvFile(path: string, values: Record<string, string>): void {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const lines = Object.keys(values)
|
||||
@@ -2682,21 +2640,6 @@ function quoteEnv(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function fingerprintValues(values: Record<string, string>, keys: string[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const key of keys) {
|
||||
hash.update(key);
|
||||
hash.update("\0");
|
||||
hash.update(values[key] ?? "");
|
||||
hash.update("\0");
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
function redactRepoPath(path: string): string {
|
||||
return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path;
|
||||
}
|
||||
|
||||
function dryRunScript(yaml: string, target: Sub2ApiTargetConfig): string {
|
||||
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user