From 71c5d1168c2034ea0ceee5b8bb05aeb7cd0bcae0 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 23:44:38 +0000 Subject: [PATCH] feat: declare D601 v03 public exposure --- config/hwlab-node-lanes.yaml | 34 ++- scripts/src/hwlab-node-lanes.ts | 78 +++++ scripts/src/hwlab-node.ts | 484 +++++++++++++++++++++++++++++++- 3 files changed, 592 insertions(+), 4 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 13b5ba44..271c40f9 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -133,8 +133,38 @@ lanes: - source: fatedier/frpc:v0.68.1 target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 public: - webUrl: http://74.48.78.17:20666 - apiUrl: http://74.48.78.17:20667 + webUrl: https://v03.hwpod.com + apiUrl: https://v03.hwpod.com + publicExposure: + mode: pk01-caddy-frp + publicBaseUrl: https://v03.hwpod.com + hostname: v03.hwpod.com + expectedA: 82.156.23.220 + frpc: + serverAddr: 82.156.23.220 + serverPort: 22000 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + secretName: hwlab-v03-frpc-secrets + secretKey: frpc.toml + tokenKey: token + webProxy: + name: hwlab-d601-v03-cloud-web + remotePort: 22096 + localIP: hwlab-cloud-web.hwlab-v03.svc.cluster.local + localPort: 8080 + apiProxy: + name: hwlab-d601-v03-edge-proxy + remotePort: 22095 + localIP: hwlab-edge-proxy.hwlab-v03.svc.cluster.local + localPort: 6667 + caddy: + route: PK01 + configPath: /etc/caddy/Caddyfile + serviceName: caddy + email: ops@pikapython.com + tls: internal + responseHeaderTimeoutSeconds: 600 externalPostgres: provider: PK01 configRef: config/platform-db/postgres-pk01.yaml diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index dc9080fd..3fcdb5a9 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -90,6 +90,36 @@ export interface HwlabRuntimeImageRewriteSpec { readonly target: string; } +export interface HwlabRuntimePublicExposureFrpcProxySpec { + readonly name: string; + readonly localIP: string; + readonly localPort: number; + readonly remotePort: number; +} + +export interface HwlabRuntimePublicExposureSpec { + readonly enabled: boolean; + readonly mode: "pk01-caddy-frp"; + readonly publicBaseUrl: string; + readonly hostname: string; + readonly expectedA: string; + readonly serverAddr: string; + readonly serverPort: number; + readonly tokenSourceRef: string; + readonly tokenSourceKey: string; + readonly secretName: string; + readonly secretKey: string; + readonly tokenKey: string; + readonly caddyRoute: string; + readonly caddyConfigPath: string; + readonly caddyServiceName: string; + readonly caddyEmail: string; + readonly caddyTls: "auto" | "internal"; + readonly responseHeaderTimeoutSeconds: number; + readonly webProxy: HwlabRuntimePublicExposureFrpcProxySpec; + readonly apiProxy: HwlabRuntimePublicExposureFrpcProxySpec; +} + export interface HwlabRuntimeLaneSpec { readonly lane: HwlabRuntimeLane; readonly nodeId: string; @@ -127,6 +157,7 @@ export interface HwlabRuntimeLaneSpec { readonly stepEnv: Record; readonly buildkit?: HwlabRuntimeBuildkitSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; + readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; readonly networkProfileId: string; @@ -166,6 +197,7 @@ interface HwlabLaneConfig { readonly stepEnv: Record; readonly buildkit?: HwlabRuntimeBuildkitSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; + readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; } @@ -365,6 +397,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa stepEnv: optionalStringRecord(raw.stepEnv, `lanes.${id}.stepEnv`), buildkit: buildkitConfig(raw.buildkit, `lanes.${id}.buildkit`), externalPostgres: externalPostgresConfig(raw.externalPostgres, `lanes.${id}.externalPostgres`), + publicExposure: publicExposureConfig(raw.publicExposure, `lanes.${id}.publicExposure`), observability: observabilityConfig(raw.observability, `lanes.${id}.observability`), runtimeImageRewrites: runtimeImageRewritesConfig(raw.runtimeImageRewrites, `lanes.${id}.runtimeImageRewrites`), }; @@ -382,6 +415,7 @@ function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record< stepEnv: mergeOptionalRecord(baseRaw.stepEnv, targetRaw.stepEnv) ?? {}, buildkit: mergeOptionalRecord(baseRaw.buildkit, targetRaw.buildkit), externalPostgres: mergeOptionalRecord(baseRaw.externalPostgres, targetRaw.externalPostgres), + publicExposure: mergeOptionalRecord(baseRaw.publicExposure, targetRaw.publicExposure), observability: mergeOptionalRecord(baseRaw.observability, targetRaw.observability), }; delete merged.targets; @@ -427,6 +461,49 @@ function externalPostgresConfig(value: unknown, path: string): HwlabRuntimeExter }; } +function publicExposureProxyConfig(value: unknown, path: string): HwlabRuntimePublicExposureFrpcProxySpec { + const raw = asRecord(value, path); + return { + name: stringField(raw, "name", path), + localIP: stringField(raw, "localIP", path), + localPort: numberField(raw, "localPort", path), + remotePort: numberField(raw, "remotePort", path), + }; +} + +function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicExposureSpec | null { + if (value === undefined) return null; + const raw = asRecord(value, path); + const mode = stringField(raw, "mode", path); + if (mode !== "pk01-caddy-frp") throw new Error(`${path}.mode must be pk01-caddy-frp`); + const frpc = asRecord(raw.frpc, `${path}.frpc`); + const caddy = asRecord(raw.caddy, `${path}.caddy`); + const caddyTls = optionalStringField(caddy, "tls", `${path}.caddy`) ?? "auto"; + if (caddyTls !== "auto" && caddyTls !== "internal") throw new Error(`${path}.caddy.tls must be auto or internal`); + return { + enabled: true, + mode, + publicBaseUrl: stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, ""), + hostname: stringField(raw, "hostname", path), + expectedA: stringField(raw, "expectedA", path), + serverAddr: stringField(frpc, "serverAddr", `${path}.frpc`), + serverPort: numberField(frpc, "serverPort", `${path}.frpc`), + tokenSourceRef: stringField(frpc, "tokenSourceRef", `${path}.frpc`), + tokenSourceKey: stringField(frpc, "tokenSourceKey", `${path}.frpc`), + secretName: stringField(frpc, "secretName", `${path}.frpc`), + secretKey: optionalStringField(frpc, "secretKey", `${path}.frpc`) ?? "frpc.toml", + tokenKey: optionalStringField(frpc, "tokenKey", `${path}.frpc`) ?? "token", + caddyRoute: stringField(caddy, "route", `${path}.caddy`), + caddyConfigPath: stringField(caddy, "configPath", `${path}.caddy`), + caddyServiceName: stringField(caddy, "serviceName", `${path}.caddy`), + caddyEmail: stringField(caddy, "email", `${path}.caddy`), + caddyTls, + responseHeaderTimeoutSeconds: numberField(caddy, "responseHeaderTimeoutSeconds", `${path}.caddy`), + webProxy: publicExposureProxyConfig(frpc.webProxy, `${path}.frpc.webProxy`), + apiProxy: publicExposureProxyConfig(frpc.apiProxy, `${path}.frpc.apiProxy`), + }; +} + function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservabilitySpec { const raw = asRecord(value, path); return { @@ -527,6 +604,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { stepEnv: config.stepEnv, ...(config.buildkit === undefined ? {} : { buildkit: config.buildkit }), ...(config.externalPostgres === undefined ? {} : { externalPostgres: config.externalPostgres }), + publicExposure: config.publicExposure, observability: config.observability, runtimeImageRewrites: config.runtimeImageRewrites, networkProfileId: networkProfile.id, diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index b5ae2168..890dd679 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { repoRoot, rootPath, type Config } from "./config"; @@ -5,7 +6,7 @@ import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; import { runHwlabG14Command } from "./hwlab-g14"; import { hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "./hwlab-node-control-plane"; -import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; +import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimePublicExposureSpec } from "./hwlab-node-lanes"; type SecretAction = "status" | "ensure" | "cleanup-owned-postgres" | "cleanup-obsolete"; type SecretPreset = "openfga" | "master-server-admin-api-key" | "bootstrap-admin" | "code-agent-provider" | "cloud-api-db" | "owned-postgres-cleanup" | "obsolete-secret-cleanup"; @@ -31,6 +32,16 @@ interface NodeSecretOptions { timeoutSeconds: number; } +interface NodePublicExposureOptions { + action: "public-exposure"; + node: string; + lane: HwlabRuntimeLane; + dryRun: boolean; + confirm: boolean; + timeoutSeconds: number; + spec: HwlabRuntimeLaneSpec; +} + interface RuntimeSecretSpec { node: string; lane: string; @@ -116,6 +127,8 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes control-plane sync --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes control-plane public-exposure --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes control-plane public-exposure --node D601 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes control-plane runtime-migration --node D601 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane runtime-migration --node D601 --lane v03 --confirm", @@ -139,6 +152,17 @@ export function hwlabNodeHelp(): Record { async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise> { const scoped = parseNodeScopedDelegatedOptions(domain, args); const defaultSpec = hwlabRuntimeLaneSpec(scoped.lane); + if (domain === "control-plane" && scoped.action === "public-exposure") { + return runNodePublicExposure({ + action: "public-exposure", + node: scoped.node, + lane: scoped.lane, + dryRun: scoped.dryRun || !scoped.confirm, + confirm: scoped.confirm, + timeoutSeconds: scoped.timeoutSeconds, + spec: scoped.spec, + }); + } if (domain === "control-plane" && scoped.action === "plan") { return nodeRuntimeControlPlanePlan(scoped); } @@ -262,6 +286,7 @@ function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record env && env.name === name);", + " if (!item) { item = { name }; container.env.push(item); }", + " const nextValueFrom = { secretKeyRef: { name: secretName, key: secretKey } };", + " const changed = item.value !== undefined || JSON.stringify(item.valueFrom || {}) !== JSON.stringify(nextValueFrom);", + " delete item.value;", + " item.valueFrom = nextValueFrom;", + " return changed;", + "}", + "function patchPublicExposure() {", + " const exposure = overlay.publicExposure;", + " if (!exposure || !exposure.enabled) return { configured: false, changed: false };", + " const file = path.join(runtimePath, 'node-frpc.yaml');", + " if (!fs.existsSync(file)) {", + " console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'node-frpc-yaml-missing', filePath: file, hostname: exposure.hostname }));", + " process.exit(49);", + " }", + " const docs = readYamlDocuments(file);", + " const configName = String(overlay.runtimeNamespace) + '-frpc-config';", + " const deploymentName = String(overlay.runtimeNamespace) + '-frpc';", + " const configKey = String(exposure.secretKey || 'frpc.toml');", + " const tokenKey = String(exposure.tokenKey || 'token');", + " const toml = renderPublicExposureFrpcToml(exposure);", + " let changed = false;", + " let foundConfigMap = false;", + " let foundDeployment = false;", + " for (const doc of docs) {", + " for (const item of listItems(doc).filter(Boolean)) {", + " if (!isObject(item)) continue;", + " item.metadata = item.metadata || {};", + " if (item.kind === 'ConfigMap' && item.metadata.name === configName) {", + " foundConfigMap = true;", + " item.data = item.data || {};", + " if (item.data[configKey] !== toml) { item.data[configKey] = toml; changed = true; }", + " }", + " if (item.kind === 'Deployment' && item.metadata.name === deploymentName) {", + " foundDeployment = true;", + " const podSpec = podSpecFor(item);", + " for (const container of Array.isArray(podSpec && podSpec.containers) ? podSpec.containers : []) {", + " if (!isObject(container)) continue;", + " if (container.name === 'frpc' || String(container.image || '').includes('frpc')) changed = setEnvFromSecret(container, 'HWLAB_FRP_TOKEN', exposure.secretName, tokenKey) || changed;", + " }", + " }", + " }", + " }", + " if (!foundConfigMap || !foundDeployment) {", + " console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'frpc-resource-missing', filePath: file, configName, deploymentName, foundConfigMap, foundDeployment }));", + " process.exit(50);", + " }", + " if (changed) writeYamlDocuments(file, docs);", + " console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: true, applied: true, changed, filePath: file, hostname: exposure.hostname, serverAddr: exposure.serverAddr, serverPort: exposure.serverPort, webProxy: exposure.webProxy.name, apiProxy: exposure.apiProxy.name, configSha256: crypto.createHash('sha256').update(toml).digest('hex') }));", + " return { configured: true, changed, foundConfigMap, foundDeployment };", + "}", "const kustomizationChanged = patchKustomization();", "const runtimeWorkloadsChanged = patchRuntimeWorkloads();", "const externalPostgresChanged = patchExternalPostgres();", "const healthContractChanged = patchHealthContract();", - "console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-postprocess', ok: true, runtimePath, sourcePath, pathRelocated: sourcePath !== runtimePath, observabilityPrometheusOperator: overlay.observability ? overlay.observability.prometheusOperator : null, runtimeImageRewriteCount: (overlay.runtimeImageRewrites || []).length, kustomizationChanged, observabilityWorkloadsChanged: runtimeWorkloadsChanged.observabilityChanged, startupProbeChanged: runtimeWorkloadsChanged.startupProbeChanged, runtimeImageRewriteChanged: runtimeWorkloadsChanged.imageRewriteChanged, gitReadUrlChanged: runtimeWorkloadsChanged.gitReadUrlChanged, publicEndpointChanged: runtimeWorkloadsChanged.publicEndpointChanged, dbSslModeChanged: runtimeWorkloadsChanged.dbSslModeChanged, externalPostgresChanged, healthContractChanged }));", + "const publicExposureChanged = patchPublicExposure();", + "console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-postprocess', ok: true, runtimePath, sourcePath, pathRelocated: sourcePath !== runtimePath, observabilityPrometheusOperator: overlay.observability ? overlay.observability.prometheusOperator : null, runtimeImageRewriteCount: (overlay.runtimeImageRewrites || []).length, kustomizationChanged, observabilityWorkloadsChanged: runtimeWorkloadsChanged.observabilityChanged, startupProbeChanged: runtimeWorkloadsChanged.startupProbeChanged, runtimeImageRewriteChanged: runtimeWorkloadsChanged.imageRewriteChanged, gitReadUrlChanged: runtimeWorkloadsChanged.gitReadUrlChanged, publicEndpointChanged: runtimeWorkloadsChanged.publicEndpointChanged, dbSslModeChanged: runtimeWorkloadsChanged.dbSslModeChanged, externalPostgresChanged, healthContractChanged, publicExposureChanged }));", "NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS`;", "}", "function runtimeGitopsVerifyScript() {", @@ -1830,6 +1938,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " runtimePath: overlay.runtimePath,", " runtimeNamespace: overlay.runtimeNamespace,", " externalPostgres: overlay.externalPostgres,", + " publicExposure: overlay.publicExposure,", " observability: overlay.observability,", " runtimeImageRewrites: overlay.runtimeImageRewrites,", " gitReadUrl: overlay.gitReadUrl,", @@ -1860,6 +1969,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " return files;", "}", "function readYamlDocuments(file) { return YAML.parseAllDocuments(fs.readFileSync(file, 'utf8')).map((document) => document.toJS()).filter((doc) => doc !== null); }", + "function allItemsFromFile(file) { return readYamlDocuments(file).flatMap((doc) => listItems(doc).filter(Boolean)); }", "function podSpecFor(item) {", " if (!isObject(item) || !isObject(item.spec)) return null;", " if (item.kind === 'Pod') return item.spec;", @@ -1944,6 +2054,32 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " if (String(endpointAddress) !== String(pg.endpointAddress)) fail('external-postgres-address-mismatch', { endpointAddress, expectedEndpointAddress: pg.endpointAddress });", " checks.push('external-postgres-bridge');", "}", + "const exposure = overlay.publicExposure;", + "if (exposure && exposure.enabled) {", + " const file = path.join(runtimePath, 'node-frpc.yaml');", + " if (!fs.existsSync(file)) fail('public-exposure-frpc-missing');", + " const items = allItemsFromFile(file);", + " const configName = String(overlay.runtimeNamespace) + '-frpc-config';", + " const deploymentName = String(overlay.runtimeNamespace) + '-frpc';", + " const configKey = String(exposure.secretKey || 'frpc.toml');", + " const tokenKey = String(exposure.tokenKey || 'token');", + " const configMap = items.find((item) => item && item.kind === 'ConfigMap' && item.metadata && item.metadata.name === configName);", + " const deployment = items.find((item) => item && item.kind === 'Deployment' && item.metadata && item.metadata.name === deploymentName);", + " if (!configMap) fail('public-exposure-frpc-configmap-missing', { expected: configName });", + " if (!deployment) fail('public-exposure-frpc-deployment-missing', { expected: deploymentName });", + " const toml = configMap.data && configMap.data[configKey];", + " if (typeof toml !== 'string') fail('public-exposure-frpc-config-missing', { expectedKey: configKey });", + " for (const expected of [String(exposure.serverAddr), String(exposure.serverPort), String(exposure.webProxy.name), String(exposure.webProxy.remotePort), String(exposure.apiProxy.name), String(exposure.apiProxy.remotePort), 'HWLAB_FRP_TOKEN']) {", + " if (!toml.includes(expected)) fail('public-exposure-frpc-config-mismatch', { expected });", + " }", + " const podSpec = podSpecFor(deployment);", + " const containers = Array.isArray(podSpec && podSpec.containers) ? podSpec.containers : [];", + " const frpc = containers.find((container) => container && (container.name === 'frpc' || String(container.image || '').includes('frpc')));", + " const env = frpc && Array.isArray(frpc.env) ? frpc.env.find((item) => item && item.name === 'HWLAB_FRP_TOKEN') : null;", + " const secretRef = env && env.valueFrom && env.valueFrom.secretKeyRef;", + " if (!secretRef || secretRef.name !== exposure.secretName || secretRef.key !== tokenKey) fail('public-exposure-frpc-token-env-mismatch', { expectedSecret: exposure.secretName, expectedKey: tokenKey });", + " checks.push('public-exposure-frpc');", + "}", "console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-verify', ok: true, runtimePath, checks }));", "NODE_UNIDESK_RUNTIME_GITOPS_VERIFY`;", "}", @@ -2176,6 +2312,20 @@ function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record { + return { + mode: exposure.mode, + publicBaseUrl: exposure.publicBaseUrl, + hostname: exposure.hostname, + expectedA: exposure.expectedA, + frpc: { + serverAddr: exposure.serverAddr, + serverPort: exposure.serverPort, + tokenSourceRef: exposure.tokenSourceRef, + tokenSourceKey: exposure.tokenSourceKey, + secretName: exposure.secretName, + secretKey: exposure.secretKey, + tokenKey: exposure.tokenKey, + webProxy: exposure.webProxy, + apiProxy: exposure.apiProxy, + }, + caddy: { + route: exposure.caddyRoute, + configPath: exposure.caddyConfigPath, + serviceName: exposure.caddyServiceName, + tls: exposure.caddyTls, + responseHeaderTimeoutSeconds: exposure.responseHeaderTimeoutSeconds, + }, + }; +} + +function runNodePublicExposure(options: NodePublicExposureOptions): Record { + const exposure = options.spec.publicExposure; + if (exposure === null || !exposure.enabled) { + return { + ok: false, + command: "hwlab nodes control-plane public-exposure", + node: options.node, + lane: options.lane, + configPath: hwlabRuntimeLaneConfigPath(), + error: "publicExposure is not configured for this node/lane target", + mutation: false, + }; + } + const source = readPublicExposureTokenSource(exposure); + if (!source.ok) { + return { + ok: false, + command: "hwlab nodes control-plane public-exposure", + node: options.node, + lane: options.lane, + mode: options.dryRun ? "dry-run" : "confirmed", + configPath: hwlabRuntimeLaneConfigPath(), + publicExposure: publicExposureSummary(exposure), + source, + mutation: false, + valuesRedacted: true, + next: { fixSecretSource: `create .state/secrets/${exposure.tokenSourceRef} with ${exposure.tokenSourceKey}=` }, + }; + } + const secretResult = runTransScript(options.node, publicExposureSecretScript(options, exposure), source.value ?? "", options.timeoutSeconds); + const secretFields = keyValueLinesFromText(statusText(secretResult)); + const caddyResult = runTransHostScript(exposure.caddyRoute, publicExposureCaddyScript(options, exposure), "", options.timeoutSeconds); + const caddyFields = keyValueLinesFromText(statusText(caddyResult)); + const secretOk = options.dryRun ? secretResult.exitCode === 0 : secretResult.exitCode === 0 && secretFields.afterSecretExists === "yes"; + const ok = secretOk + && caddyResult.exitCode === 0 + && caddyFields.afterBlockPresent === "yes" + && caddyFields.validateExitCode === "0" + && (options.dryRun || caddyFields.active === "active"); + return { + ok, + command: "hwlab nodes control-plane public-exposure", + node: options.node, + lane: options.lane, + mode: options.dryRun ? "dry-run" : "confirmed", + mutation: !options.dryRun && (secretFields.mutation === "true" || caddyFields.mutation === "true"), + configPath: hwlabRuntimeLaneConfigPath(), + publicExposure: publicExposureSummary(exposure), + source: { + ok: source.ok, + path: source.path, + key: exposure.tokenSourceKey, + bytes: source.value?.length ?? 0, + fingerprint: source.fingerprint, + valueRedacted: true, + }, + secret: publicExposureSecretStatus(secretFields, secretResult), + caddy: publicExposureCaddyStatus(caddyFields, caddyResult), + valuesRedacted: true, + next: ok + ? { + triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`, + status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`, + } + : { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` }, + }; +} + +function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } { + const checkedPaths = publicExposureTokenSourcePaths(exposure); + const path = checkedPaths.find((candidate) => existsSync(candidate)) ?? checkedPaths[0] ?? join(repoRoot, ".state", "secrets", exposure.tokenSourceRef); + if (!existsSync(path)) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-source-missing" }; + const values = parseEnvFile(readFileSync(path, "utf8")); + const value = values[exposure.tokenSourceKey]; + if (value === undefined || value.length === 0) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-key-missing" }; + return { + ok: true, + path, + checkedPaths, + key: exposure.tokenSourceKey, + value, + fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`, + }; +} + +function publicExposureTokenSourcePaths(exposure: HwlabRuntimePublicExposureSpec): string[] { + const paths = [join(repoRoot, ".state", "secrets", exposure.tokenSourceRef)]; + const marker = "/.worktree/"; + const index = repoRoot.indexOf(marker); + if (index >= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", exposure.tokenSourceRef)); + return [...new Set(paths)]; +} + +function publicExposureSecretStatus(fields: Record, result: CommandResult): Record { + const dryRun = fields.dryRun === "true"; + return { + ok: result.exitCode === 0 && (dryRun || fields.afterSecretExists === "yes"), + dryRun, + mutation: fields.mutation === "true", + namespace: fields.namespace || null, + secret: fields.secret || null, + beforeExists: fields.beforeSecretExists === "yes", + afterExists: fields.afterSecretExists === "yes", + tokenBytes: numericField(fields.tokenBytes), + applyExitCode: numericField(fields.applyExitCode), + rolloutRestartExitCode: numericField(fields.rolloutRestartExitCode), + rolloutStatusExitCode: numericField(fields.rolloutStatusExitCode), + exitCode: result.exitCode, + stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000), + }; +} + +function publicExposureCaddyStatus(fields: Record, result: CommandResult): Record { + return { + ok: result.exitCode === 0 && fields.afterBlockPresent === "yes" && fields.validateExitCode === "0", + dryRun: fields.dryRun === "true", + mutation: fields.mutation === "true", + hostname: fields.hostname || null, + configPath: fields.configPath || null, + beforeBlockPresent: fields.beforeBlockPresent === "yes", + afterBlockPresent: fields.afterBlockPresent === "yes", + validateMode: fields.validateMode || null, + validateExitCode: numericField(fields.validateExitCode), + reloadExitCode: numericField(fields.reloadExitCode), + active: fields.active || null, + localWebStatus: fields.localWebStatus || null, + localApiStatus: fields.localApiStatus || null, + validateErrorPreview: fields.validateErrorPreview || null, + localWebErrorPreview: fields.localWebErrorPreview || null, + localApiErrorPreview: fields.localApiErrorPreview || null, + exitCode: result.exitCode, + stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000), + }; +} + +function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string { + return [ + "set +e", + `namespace=${shellQuote(options.spec.runtimeNamespace)}`, + `secret=${shellQuote(exposure.secretName)}`, + `token_key=${shellQuote(exposure.tokenKey)}`, + `dry_run=${shellQuote(options.dryRun ? "true" : "false")}`, + "token=$(cat)", + "before_secret_exists=no", + "kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && before_secret_exists=yes", + "token_bytes=$(printf '%s' \"$token\" | wc -c | tr -d ' ')", + "apply_exit=", + "rollout_restart_exit=", + "rollout_status_exit=", + "mutation=false", + "if [ \"$dry_run\" = false ]; then", + " tmp=$(mktemp /tmp/hwlab-public-frpc-secret.XXXXXX.yaml)", + " token_b64=$(printf '%s' \"$token\" | base64 | tr -d '\\n')", + " cat >\"$tmp\" </tmp/hwlab-public-frpc-secret.apply.out 2>/tmp/hwlab-public-frpc-secret.apply.err", + " apply_exit=$?", + " rm -f \"$tmp\"", + " if [ \"$apply_exit\" = 0 ]; then mutation=true; fi", + " kubectl -n \"$namespace\" rollout restart deploy/hwlab-v03-frpc >/tmp/hwlab-public-frpc-rollout-restart.out 2>/tmp/hwlab-public-frpc-rollout-restart.err", + " rollout_restart_exit=$?", + " if [ \"$rollout_restart_exit\" = 0 ]; then", + " kubectl -n \"$namespace\" rollout status deploy/hwlab-v03-frpc --timeout=60s >/tmp/hwlab-public-frpc-rollout-status.out 2>/tmp/hwlab-public-frpc-rollout-status.err", + " rollout_status_exit=$?", + " fi", + "fi", + "after_secret_exists=no", + "kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && after_secret_exists=yes", + "printf 'namespace\\t%s\\n' \"$namespace\"", + "printf 'secret\\t%s\\n' \"$secret\"", + "printf 'dryRun\\t%s\\n' \"$dry_run\"", + "printf 'mutation\\t%s\\n' \"$mutation\"", + "printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"", + "printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"", + "printf 'tokenBytes\\t%s\\n' \"$token_bytes\"", + "printf 'applyExitCode\\t%s\\n' \"$apply_exit\"", + "printf 'rolloutRestartExitCode\\t%s\\n' \"$rollout_restart_exit\"", + "printf 'rolloutStatusExitCode\\t%s\\n' \"$rollout_status_exit\"", + "if [ \"$dry_run\" = true ]; then exit 0; fi", + "[ \"$after_secret_exists\" = yes ]", + ].join("\n"); +} + +function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string { + const blockB64 = Buffer.from(publicExposureCaddyBlock(exposure), "utf8").toString("base64"); + const marker = `unidesk managed ${exposure.hostname}`; + return [ + "set +e", + `dry_run=${shellQuote(options.dryRun ? "true" : "false")}`, + `hostname=${shellQuote(exposure.hostname)}`, + `config_path=${shellQuote(exposure.caddyConfigPath)}`, + `service=${shellQuote(exposure.caddyServiceName)}`, + `marker=${shellQuote(marker)}`, + `block_b64=${shellQuote(blockB64)}`, + `expected_a=${shellQuote(exposure.expectedA)}`, + "rm -f /tmp/hwlab-public-caddy-validate.out /tmp/hwlab-public-caddy-validate.err /tmp/hwlab-public-caddy-web.err /tmp/hwlab-public-caddy-api.err", + "tmp=$(mktemp -d)", + "block=\"$tmp/block\"", + "next=\"$tmp/Caddyfile\"", + "printf '%s' \"$block_b64\" | base64 -d >\"$block\"", + "before_present=no", + "if [ -f \"$config_path\" ] && grep -Fq \"# BEGIN $marker\" \"$config_path\"; then before_present=yes; fi", + "if [ -f \"$config_path\" ]; then cp \"$config_path\" \"$next\"; else : >\"$next\"; fi", + "python3 - \"$next\" \"$block\" \"$marker\" <<'PY'", + "import pathlib, sys", + "config = pathlib.Path(sys.argv[1])", + "block = pathlib.Path(sys.argv[2]).read_text(encoding='utf-8')", + "marker = sys.argv[3]", + "text = config.read_text(encoding='utf-8') if config.exists() else ''", + "begin = f'# BEGIN {marker}'", + "end = f'# END {marker}'", + "managed = f'{begin}\\n{block.rstrip()}\\n{end}\\n'", + "if begin in text and end in text:", + " start = text.index(begin)", + " stop = text.index(end, start) + len(end)", + " text = text[:start].rstrip() + '\\n\\n' + managed.rstrip() + '\\n' + text[stop:].lstrip('\\n')", + "else:", + " text = text.rstrip() + '\\n\\n' + managed", + "config.write_text(text, encoding='utf-8')", + "PY", + "validate_exit=1", + "validate_mode=validate", + "if [ \"$dry_run\" = true ]; then", + " validate_mode=adapt", + " caddy adapt --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err", + "else", + " sudo caddy validate --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err", + "fi", + "validate_exit=$?", + "reload_exit=", + "mutation=false", + "if [ \"$dry_run\" = false ] && [ \"$validate_exit\" = 0 ]; then", + " sudo install -m 0644 \"$next\" \"$config_path\" >/tmp/hwlab-public-caddy-install.out 2>/tmp/hwlab-public-caddy-install.err", + " install_exit=$?", + " if [ \"$install_exit\" = 0 ]; then", + " mutation=true", + " sudo systemctl reload \"$service\" >/tmp/hwlab-public-caddy-reload.out 2>/tmp/hwlab-public-caddy-reload.err || sudo systemctl restart \"$service\" >>/tmp/hwlab-public-caddy-reload.out 2>>/tmp/hwlab-public-caddy-reload.err", + " reload_exit=$?", + " else", + " reload_exit=$install_exit", + " fi", + "fi", + "active=$(systemctl is-active \"$service\" 2>/dev/null || true)", + "after_present=no", + "if [ \"$dry_run\" = true ]; then grep -Fq \"# BEGIN $marker\" \"$next\" && after_present=yes; else grep -Fq \"# BEGIN $marker\" \"$config_path\" && after_present=yes; fi", + "local_web_status=", + "local_api_status=", + "if [ \"$dry_run\" = false ]; then", + " local_web_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-web.err || true)", + " local_api_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/health/live\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-api.err || true)", + "fi", + "validate_err_preview=$([ -f /tmp/hwlab-public-caddy-validate.err ] && tr '\\n' ';' /dev/null | cut -c1-1000 || true)", + "local_web_err_preview=$([ -f /tmp/hwlab-public-caddy-web.err ] && tr '\\n' ';' /dev/null | cut -c1-1000 || true)", + "local_api_err_preview=$([ -f /tmp/hwlab-public-caddy-api.err ] && tr '\\n' ';' /dev/null | cut -c1-1000 || true)", + "printf 'hostname\\t%s\\n' \"$hostname\"", + "printf 'expectedA\\t%s\\n' \"$expected_a\"", + "printf 'configPath\\t%s\\n' \"$config_path\"", + "printf 'dryRun\\t%s\\n' \"$dry_run\"", + "printf 'mutation\\t%s\\n' \"$mutation\"", + "printf 'beforeBlockPresent\\t%s\\n' \"$before_present\"", + "printf 'afterBlockPresent\\t%s\\n' \"$after_present\"", + "printf 'validateMode\\t%s\\n' \"$validate_mode\"", + "printf 'validateExitCode\\t%s\\n' \"$validate_exit\"", + "printf 'reloadExitCode\\t%s\\n' \"$reload_exit\"", + "printf 'active\\t%s\\n' \"$active\"", + "printf 'localWebStatus\\t%s\\n' \"$local_web_status\"", + "printf 'localApiStatus\\t%s\\n' \"$local_api_status\"", + "printf 'validateErrorPreview\\t%s\\n' \"$validate_err_preview\"", + "printf 'localWebErrorPreview\\t%s\\n' \"$local_web_err_preview\"", + "printf 'localApiErrorPreview\\t%s\\n' \"$local_api_err_preview\"", + "rm -rf \"$tmp\"", + "[ \"$validate_exit\" = 0 ] && [ \"$after_present\" = yes ]", + ].join("\n"); +} + +function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string { + const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : ""; + return `${exposure.hostname} { +${tlsLines} @api path /health* /api* /v1* /openapi* /docs* /swagger* + reverse_proxy @api 127.0.0.1:${exposure.apiProxy.remotePort} { + transport http { + response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s + } + } + reverse_proxy 127.0.0.1:${exposure.webProxy.remotePort} { + transport http { + response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s + } + } +}`; +} + function runTransScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult { return runCommand([transPath(), `${node}:k3s`, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 }); }