Merge pull request #348 from pikasTech/ymalops-round4
ymal-first Round 4:收敛 public exposure helper
This commit is contained in:
@@ -4,16 +4,26 @@ import { isAbsolute } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
import { applyPk01CaddyBlock, prepareFrpcSecret, shQuote, type FrpcSecretMaterial } from "./platform-infra-public-service";
|
||||
import {
|
||||
applyPk01CaddyBlock,
|
||||
capture,
|
||||
compactCapture,
|
||||
dryRunManifestScript,
|
||||
parseJsonOutput,
|
||||
prepareFrpcSecret,
|
||||
publicHttpProbe,
|
||||
publicServicePolicyChecks,
|
||||
redactText,
|
||||
renderFrpcManifest,
|
||||
shQuote,
|
||||
type FrpcSecretMaterial,
|
||||
} from "./platform-infra-public-service";
|
||||
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const configFile = rootPath("config", "platform-infra", "langbot.yaml");
|
||||
const serviceName = "langbot";
|
||||
const pluginRuntimeServiceName = "langbot-plugin-runtime";
|
||||
const fieldManager = "unidesk-platform-langbot";
|
||||
const caddyManagedStart = "# BEGIN unidesk managed langbot";
|
||||
const caddyManagedEnd = "# END unidesk managed langbot";
|
||||
|
||||
interface LangBotConfig {
|
||||
version: number;
|
||||
@@ -452,7 +462,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
||||
};
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const result = await capture(config, target.route, ["script"], dryRunScript(yaml, target));
|
||||
const result = await capture(config, target.route, ["script"], dryRunManifestScript({ yaml, target, fieldManager, manifestName: serviceName }));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
@@ -476,7 +486,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
||||
const confirmedYaml = renderManifest(langbot, target, secretMaterial.fingerprint);
|
||||
const result = await capture(config, target.route, ["script"], applyScript(confirmedYaml, langbot, target, secretMaterial, frpcSecret));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure, { start: caddyManagedStart, end: caddyManagedEnd });
|
||||
const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure);
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && caddy.ok === true,
|
||||
action: "platform-infra-langbot-apply",
|
||||
@@ -533,7 +543,7 @@ async function validate(config: UniDeskConfig, options: CommonOptions): Promise<
|
||||
const target = resolveTarget(langbot, options.targetId);
|
||||
const k8s = await capture(config, target.route, ["script"], validateScript(target));
|
||||
const k8sParsed = parseJsonOutput(k8s.stdout);
|
||||
const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/api/v1/system/info", null);
|
||||
const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/api/v1/system/info");
|
||||
return {
|
||||
ok: k8s.exitCode === 0 && boolField(k8sParsed, "ok", false) && publicProbe.ok,
|
||||
action: "platform-infra-langbot-validate",
|
||||
@@ -620,7 +630,7 @@ function query(options: QueryOptions): Record<string, unknown> {
|
||||
const langbot = readLangBotConfig();
|
||||
const target = resolveTarget(langbot, options.targetId);
|
||||
const secret = prepareSecretMaterial(langbot);
|
||||
const probe = publicHttpProbe(target.publicExposure.publicBaseUrl, options.path, secret.values.apiKey);
|
||||
const probe = publicHttpProbe(target.publicExposure.publicBaseUrl, options.path, { headers: [`X-API-Key: ${secret.values.apiKey}`] });
|
||||
return {
|
||||
ok: probe.ok,
|
||||
action: "platform-infra-langbot-query",
|
||||
@@ -908,77 +918,13 @@ ${renderFrpcManifest(target)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFrpcManifest(target: LangBotTarget): string {
|
||||
const exposure = target.publicExposure;
|
||||
if (!exposure.enabled) return "";
|
||||
return `---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${exposure.frpc.deploymentName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
unidesk.ai/runtime-node: ${target.id}
|
||||
unidesk.ai/public-hostname: ${exposure.dns.hostname}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
|
||||
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
|
||||
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
|
||||
spec:
|
||||
containers:
|
||||
- name: frpc
|
||||
image: ${exposure.frpc.image}
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -c
|
||||
- /etc/frp/frpc.toml
|
||||
volumeMounts:
|
||||
- name: frpc-config
|
||||
mountPath: /etc/frp/frpc.toml
|
||||
subPath: ${exposure.frpc.secretKey}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: frpc-config
|
||||
secret:
|
||||
secretName: ${exposure.frpc.secretName}
|
||||
`;
|
||||
}
|
||||
|
||||
function policyChecks(yaml: string, target: LangBotTarget): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "LangBot public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress." },
|
||||
{ name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." },
|
||||
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
|
||||
{ name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." },
|
||||
{ name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." },
|
||||
...publicServicePolicyChecks(yaml, target, "LangBot"),
|
||||
{ name: "no-box-docker-socket", ok: !/docker\.sock|langbot-box/u.test(yaml), detail: "LangBot Box is disabled by default and Docker socket is not mounted." },
|
||||
{ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` },
|
||||
];
|
||||
}
|
||||
|
||||
function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean {
|
||||
return yaml.split(/^---\s*$/mu).some((document) => /^\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));
|
||||
}
|
||||
|
||||
export function prepareLangBotSecretMaterial(): SecretMaterial {
|
||||
return prepareSecretMaterial(readLangBotConfig());
|
||||
}
|
||||
@@ -1051,47 +997,6 @@ export function readLangBotSecretMaterial(): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function dryRunScript(yaml: string, target: LangBotTarget): string {
|
||||
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$tmp/langbot.k8s.yaml"
|
||||
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
||||
kubectl apply --dry-run=client -f "$manifest" >"$tmp/client.out" 2>"$tmp/client.err"
|
||||
client_rc=$?
|
||||
if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then
|
||||
kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >"$tmp/server.out" 2>"$tmp/server.err"
|
||||
server_rc=$?
|
||||
server_disposition=executed
|
||||
else
|
||||
: >"$tmp/server.err"
|
||||
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet' >"$tmp/server.out"
|
||||
server_rc=0
|
||||
server_disposition=skipped-namespace-missing
|
||||
fi
|
||||
python3 - "$client_rc" "$server_rc" "$server_disposition" "$tmp/client.out" "$tmp/client.err" "$tmp/server.out" "$tmp/server.err" <<'PY'
|
||||
import json, sys
|
||||
client_rc, server_rc = int(sys.argv[1]), int(sys.argv[2])
|
||||
def text(path):
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
payload = {
|
||||
"ok": client_rc == 0 and server_rc == 0,
|
||||
"target": "${target.id}",
|
||||
"namespace": "${target.namespace}",
|
||||
"clientDryRun": {"exitCode": client_rc, "stdout": text(sys.argv[4])[-4000:], "stderr": text(sys.argv[5])[-4000:]},
|
||||
"serverDryRun": {"exitCode": server_rc, "disposition": sys.argv[3], "stdout": text(sys.argv[6])[-4000:], "stderr": text(sys.argv[7])[-4000:]},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function applyScript(yaml: string, langbot: LangBotConfig, target: LangBotTarget, secret: SecretMaterial, frpc: FrpcSecretMaterial): string {
|
||||
const encodedManifest = Buffer.from(yaml, "utf8").toString("base64");
|
||||
const dbPasswordB64 = Buffer.from(secret.values.dbPassword, "utf8").toString("base64");
|
||||
@@ -1452,30 +1357,6 @@ PY
|
||||
`;
|
||||
}
|
||||
|
||||
function publicHttpProbe(baseUrl: string, path: string, apiKey: string | null): Record<string, unknown> {
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
|
||||
const args = ["-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}"];
|
||||
if (apiKey !== null) args.unshift("-H", `X-API-Key: ${apiKey}`);
|
||||
args.push(url);
|
||||
const result = Bun.spawnSync(["curl", ...args], { stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const stderr = new TextDecoder().decode(result.stderr);
|
||||
const lines = stdout.split(/\r?\n/u);
|
||||
const statusText = lines.pop() ?? "";
|
||||
const status = Number(statusText);
|
||||
const body = lines.join("\n");
|
||||
return {
|
||||
ok: result.exitCode === 0 && Number.isInteger(status) && status >= 200 && status < 500,
|
||||
url,
|
||||
status: Number.isInteger(status) ? status : null,
|
||||
bodyBytes: Buffer.byteLength(body, "utf8"),
|
||||
bodyPreview: redactText(body).slice(0, 2000),
|
||||
stderrTail: redactText(stderr).slice(-2000),
|
||||
apiKeyUsed: apiKey !== null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function postgresConninfo(langbot: LangBotConfig, secret: SecretMaterial): string {
|
||||
const db = langbot.runtime.database;
|
||||
return `host=${db.host} port=${db.port} user=${db.user} dbname=${db.dbName} sslmode=${db.sslMode} connect_timeout=10`;
|
||||
@@ -1546,10 +1427,6 @@ function secretSummary(secret: SecretMaterial, frpc: FrpcSecretMaterial): Record
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise<SshCaptureResult> {
|
||||
return await runSshCommandCapture(config, route, args, stdin);
|
||||
}
|
||||
|
||||
function secretRoot(langbot: LangBotConfig): string {
|
||||
const root = langbot.runtime.secrets.root;
|
||||
return isAbsolute(root) ? root : rootPath(root);
|
||||
@@ -1559,47 +1436,10 @@ function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
||||
const full = options.full ?? false;
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
||||
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
||||
stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "",
|
||||
stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function redactText(text: string): string {
|
||||
return text
|
||||
.replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_<redacted>")
|
||||
.replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1<redacted>@")
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
|
||||
.replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
|
||||
}
|
||||
|
||||
function boolField(value: Record<string, unknown> | null, key: string, fallback: boolean): boolean {
|
||||
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
||||
return value as Record<string, unknown>;
|
||||
|
||||
@@ -26,8 +26,6 @@ const configFile = rootPath("config", "platform-infra", "n8n.yaml");
|
||||
const configLabel = "config/platform-infra/n8n.yaml";
|
||||
const serviceName = "n8n";
|
||||
const fieldManager = "unidesk-platform-n8n";
|
||||
const caddyManagedStart = "# BEGIN unidesk managed n8n";
|
||||
const caddyManagedEnd = "# END unidesk managed n8n";
|
||||
|
||||
interface N8nConfig {
|
||||
version: number;
|
||||
@@ -292,7 +290,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
||||
const confirmedYaml = renderManifest(n8n, target, secretMaterial.fingerprint);
|
||||
const result = await capture(config, target.route, ["script"], applyScript(confirmedYaml, n8n, target, secretMaterial, frpcSecret));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure, { start: caddyManagedStart, end: caddyManagedEnd });
|
||||
const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure);
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && caddy.ok === true,
|
||||
action: "platform-infra-n8n-apply",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { applyPk01CaddyManagedBlock } from "./pk01-caddy";
|
||||
import { applyPk01CaddyManagedBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
export interface PublicServiceExposure {
|
||||
@@ -56,7 +56,7 @@ export async function applyPk01CaddyBlock(
|
||||
config: UniDeskConfig,
|
||||
serviceId: string,
|
||||
exposure: PublicServiceExposure,
|
||||
markers: { start: string; end: string },
|
||||
markers: { start: string; end: string } = caddyManagedBlockMarkers(serviceId),
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await applyPk01CaddyManagedBlock(config, serviceId, exposure, markers);
|
||||
}
|
||||
@@ -227,9 +227,15 @@ export function compactCapture(result: SshCaptureResult, options: { full?: boole
|
||||
};
|
||||
}
|
||||
|
||||
export function publicHttpProbe(baseUrl: string, path: string): Record<string, unknown> {
|
||||
export function publicHttpProbe(baseUrl: string, path: string, options: { headers?: string[] } = {}): Record<string, unknown> {
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
|
||||
const result = Bun.spawnSync(["curl", "-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}", url], { stdout: "pipe", stderr: "pipe" });
|
||||
const args = ["-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}"];
|
||||
for (const header of options.headers ?? []) {
|
||||
args.unshift(header);
|
||||
args.unshift("-H");
|
||||
}
|
||||
args.push(url);
|
||||
const result = Bun.spawnSync(["curl", ...args], { stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
const stderr = new TextDecoder().decode(result.stderr);
|
||||
const lines = stdout.split(/\r?\n/u);
|
||||
@@ -241,16 +247,19 @@ export function publicHttpProbe(baseUrl: string, path: string): Record<string, u
|
||||
url,
|
||||
status: Number.isInteger(status) ? status : null,
|
||||
bodyBytes: Buffer.byteLength(body, "utf8"),
|
||||
bodyPreview: body.slice(0, 2000),
|
||||
bodyPreview: redactText(body).slice(0, 2000),
|
||||
stderrTail: redactText(stderr).slice(-2000),
|
||||
headersUsed: options.headers?.length ?? 0,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactText(text: string): string {
|
||||
return text
|
||||
.replace(/postgres(?:ql)?:\/\/[^@\s]+@/giu, "postgresql://<redacted>@")
|
||||
.replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2<redacted>");
|
||||
.replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_<redacted>")
|
||||
.replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1<redacted>@")
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
|
||||
.replace(/(["']?(?:N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API[_-]?KEY|APIKEY|JWT[_-]?SECRET|DATABASE[_-]?URL)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
|
||||
}
|
||||
|
||||
export function fingerprintValues(values: Record<string, string>, keys: string[]): string {
|
||||
|
||||
Reference in New Issue
Block a user