diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 2f35505a..a5e87c5a 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -357,6 +357,12 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - worker agent 只在 YAML 派生摘要变化或 service 未运行时安装/恢复,不得在每次集群 apply 中先卸载再重装。 - `Type=notify` agent 的 `activating` 且 MainPID 存在属于运行中;节点 Ready 时不得仅因 systemd 未进入 `active` 而重复重装。 - worker 必须先由集群入口按 `hostProxyConfigRef` 部署并验证 proxy client,再启动 agent;containerd 通过该 proxy 直接访问上游 registry,禁止用公共 registry mirror 代替出网链路。 + - 集群自有 artifact registry 的异地拉取是明确例外: + - 内部 publish endpoint 保持唯一写入 authority; + - 公网 HTTPS 只允许鉴权 `GET`/`HEAD`,不得成为 push、cache export 或删除入口; + - worker 保持既有镜像引用,由集群 owning YAML 渲染 endpoint 与 SecretRef; + - registry 大流量不得经过 WireGuard/Flannel,也不得使用 CDN 或公共镜像站; + - 验收统一走 `platform-infra k3s-cluster registry-smoke`,不手工 patch Caddy 或 containerd。 - worker 安装使用短启动并由 control-plane Node Ready 轮询收口,禁止让 `trans`/SSH 长挂等待 systemd notify。 - worker 加入集群不改变 PaC consumer 的 source commit authority,也不授权人工 PipelineRun、Argo sync 或业务 rollout。 - 长命令用异步 job 或短轮询;不要长时间挂住 trans/ssh。 diff --git a/.agents/skills/unidesk-ymalops/SKILL.md b/.agents/skills/unidesk-ymalops/SKILL.md index 8aaac9be..435659b0 100644 --- a/.agents/skills/unidesk-ymalops/SKILL.md +++ b/.agents/skills/unidesk-ymalops/SKILL.md @@ -93,6 +93,12 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/ - worker host proxy 归 `config/platform-infra/host-proxy.yaml`;集群 apply 必须先确认 proxy client 连接声明的 `vpn-server` 并通过外网 probe,再启动 agent。 - worker proxy 前必须先收敛 WireGuard;配置一致时不重启,只有 unit 未运行或握手过期且隧道探针失败时才允许恢复 worker 接口。 - containerd 通过 host proxy 直接访问公共 registry,禁止为公共依赖新增 registry mirror;集群内 artifact registry 映射不属于公共镜像代理。 + - 集群自有 artifact registry 可由同一 owning YAML 声明公网可信 TLS 的 pull-only endpoint: + - 内部 endpoint 继续拥有 push; + - public-edge 只允许鉴权 `GET`/`HEAD`; + - containerd 凭据与 Caddy hash 必须来自同一 SecretRef; + - 镜像层绕开 WireGuard/Flannel 和 CDN; + - registry 配置变化不得触发 worker agent 卸载重装。 - 既有 control-plane 的 k3s renderer 通过 `clusterConfigRef` 消费集群网络参数,不得由集群 CLI 再写第二个 systemd drop-in。 - 无固定公网地址 worker 使用宿主内网 SSH 作为部署控制通道并主动连接 WireGuard endpoint,不使用 FRP 代替三层集群网络。 - control-plane drop-in 只走 `hwlab nodes control-plane infra k3s-config` 窄域: diff --git a/config/platform-infra/k3s-clusters.yaml b/config/platform-infra/k3s-clusters.yaml index 76b8c415..6474e57d 100644 --- a/config/platform-infra/k3s-clusters.yaml +++ b/config/platform-infra/k3s-clusters.yaml @@ -46,6 +46,9 @@ targets: workerPrivateKey: D601_VM_WIREGUARD_PRIVATE_KEY workerPublicKey: D601_VM_WIREGUARD_PUBLIC_KEY agentToken: K3S_AGENT_TOKEN + registryUsername: ARTIFACT_REGISTRY_PULL_USERNAME + registryPassword: ARTIFACT_REGISTRY_PULL_PASSWORD + registryPasswordHash: ARTIFACT_REGISTRY_PULL_PASSWORD_HASH k3s: serverUrl: https://10.89.0.1:6443 agentTokenFile: /etc/unidesk/k3s-agent-token @@ -53,7 +56,32 @@ targets: maxPods: 500 registryMirror: source: 127.0.0.1:5000 - endpoint: http://10.43.249.83:5000 + endpoint: https://registry.hwpod.com + auth: + sourceRef: /root/.unidesk/.state/secrets/platform-infra/k3s-clusters/nc01-d601.env + usernameKey: ARTIFACT_REGISTRY_PULL_USERNAME + passwordKey: ARTIFACT_REGISTRY_PULL_PASSWORD + passwordHashKey: ARTIFACT_REGISTRY_PULL_PASSWORD_HASH + publicExposure: + enabled: true + publicBaseUrl: https://registry.hwpod.com + upstream: http://127.0.0.1:5000 + healthPath: /v2/ + healthExpectedStatuses: + - 401 + dns: + hostname: registry.hwpod.com + expectedA: 152.53.229.148 + accessPolicy: + allowedMethods: + - GET + - HEAD + registryPullSmoke: + image: 127.0.0.1:5000/hwlab/argocd:v3.4.2 + expectedDigest: sha256:aac65b69f9d95d409de358ae18d6b00a859b6f047f939588e595d9c0c576ecd2 + expectedBytes: 195443636 + concurrentPulls: 2 + timeoutSeconds: 900 serviceLb: enabledNodeLabel: svccontroller.k3s.cattle.io/enablelb enabledNode: v2202607378382480008 diff --git a/config/platform-infra/public-edge.yaml b/config/platform-infra/public-edge.yaml index cd48e55d..4f6369e4 100644 --- a/config/platform-infra/public-edge.yaml +++ b/config/platform-infra/public-edge.yaml @@ -68,6 +68,9 @@ targets: - 10.43.0.10 responseHeaderTimeoutSeconds: 60 sites: + - id: artifact-registry + configRef: config/platform-infra/k3s-clusters.yaml + path: targets.nc01-d601.k3s.registryMirror - id: pikaoa-development configRef: config/pikaoa.yaml path: developmentRuntime.targets.NC01 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 4a79ba14..b9b0ffd0 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -172,6 +172,36 @@ bun scripts/cli.ts platform-infra public-edge status --target NC01 ``` +### 异地节点只读 Artifact Registry + +- NC01 集群内 registry 的内部 endpoint 是唯一写入 authority: + - CI、BuildKit 和 publish 继续使用内部 `127.0.0.1:5000` 语义; + - 公网 `registry.hwpod.com` 只用于异地 worker 拉取,不接受 push 或删除。 +- 公网入口归共享 public-edge 管理: + - hostname、DNS、upstream、允许方法和鉴权 SecretRef 来自集群 owning YAML; + - Caddy 只允许 `GET`、`HEAD`,其余方法返回拒绝; + - Basic Auth 只消费 SecretRef 中的用户名与 bcrypt hash,不把值写入 Git、计划输出或 Caddyfile; + - 可信 TLS 由 public-edge 管理,镜像层不得经过 CDN 代理。 +- worker containerd 归集群 CLI 管理: + - 既有 `127.0.0.1:5000/...` 镜像引用保持不变; + - `registries.yaml` 把该引用映射到 YAML 声明的公网 HTTPS endpoint; + - 用户名和密码只从同一 SecretRef 渲染; + - registry 配置单独变化时只重启 agent 使配置生效,不卸载或重装 k3s。 +- 受控入口: + + ```bash + bun scripts/cli.ts platform-infra k3s-cluster registry-credential-init --target nc01-d601 --confirm + bun scripts/cli.ts platform-infra k3s-cluster apply --target nc01-d601 --confirm + bun scripts/cli.ts platform-infra k3s-cluster registry-smoke --target nc01-d601 --confirm + ``` + +- `registry-smoke` 必须验证: + - 未鉴权拒绝、有效凭据、manifest digest、blob range 和写方法拒绝; + - YAML 选定的真实镜像冷拉取、并发拉取和重复拉取; + - 拉取期间 Kubernetes API 连通,前后 control-plane 与 worker 均为 Ready; + - Provider 控制通道能返回结构化终态; + - 结束后不保留临时 Kubernetes workload。 + ## Gitea 与 Pipelines-as-Code 边界 - Gitea mirror 与 Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务。`config/platform-infra/gitea.yaml` 和 `config/platform-infra/pipelines-as-code.yaml` 分别拥有 mirror/webhook 与 PaC Repository/consumer 配置;repo URL、snapshot、webhook、public exposure、FRP/Caddy port、token sourceRef 和 PaC params 不得隐藏在代码特例中。 diff --git a/project-management/PJ2026-01/specs/PJ2026-01060314-hyperv-ubuntu-vm.md b/project-management/PJ2026-01/specs/PJ2026-01060314-hyperv-ubuntu-vm.md index 72da2ee1..7e2552a6 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060314-hyperv-ubuntu-vm.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060314-hyperv-ubuntu-vm.md @@ -84,4 +84,10 @@ Provider TCP 数据池恢复必须满足: - WireGuard 私钥和 k3s agent token 只能来自 YAML `sourceRef`,不得从运行面反解或打印。 - k3s 节点地址、Flannel 接口、API TLS SAN、集群内 artifact registry 映射和 ServiceLB 节点选择必须由 owning YAML 渲染。 - worker 必须先受控部署 host proxy client 并连接 YAML 引用的 `vpn-server`,再启动 agent;containerd 直接通过该 proxy 访问公共 registry,不得依赖公共镜像站或新增 registry mirror。 +- 集群自有 artifact registry 可以通过公网可信 TLS 向异地 worker 提供鉴权只读拉取: + - 内部 CI/publish 继续使用集群内写入 endpoint,公网入口不得成为第二个 push authority。 + - 公网边缘只允许 Registry V2 拉取所需的 `GET` 与 `HEAD`,其他方法必须在反向代理层拒绝。 + - endpoint、DNS、上游、允许方法、凭据 `sourceRef`、镜像摘要和拉取验收参数必须来自集群 owning YAML。 + - worker containerd 继续消费既有镜像引用,只把该内部引用映射到声明的公网 HTTPS endpoint。 + - artifact 大文件不得经过 WireGuard、Flannel/VXLAN 或受传输时长和对象大小限制的 CDN。 - 宿主内网 SSH 可以作为 worker 的稳定部署控制通道,provider route 只作为部署后验收入口。 diff --git a/scripts/src/platform-infra-k3s-cluster-config.ts b/scripts/src/platform-infra-k3s-cluster-config.ts index 1dec9947..62e80702 100644 --- a/scripts/src/platform-infra-k3s-cluster-config.ts +++ b/scripts/src/platform-infra-k3s-cluster-config.ts @@ -51,13 +51,32 @@ export interface K3sClusterTarget { workerPrivateKey: string; workerPublicKey: string; agentToken: string; + registryUsername: string; + registryPassword: string; + registryPasswordHash: string; }; k3s: { serverUrl: string; agentTokenFile: string; flannelInterface: string; maxPods: number; - registryMirror: { source: string; endpoint: string }; + registryMirror: { + source: string; + endpoint: string; + auth: { + sourceRef: string; + usernameKey: string; + passwordKey: string; + passwordHashKey: string; + }; + }; + registryPullSmoke: { + image: string; + expectedDigest: string; + expectedBytes: number; + concurrentPulls: number; + timeoutSeconds: number; + }; serviceLb: { enabledNodeLabel: string; enabledNode: string }; workerDns: { namespace: string; @@ -93,6 +112,8 @@ export function readK3sClusterConfig(targetId?: string): { defaultTargetId: stri const secrets = record(raw.secrets, `targets.${id}.secrets`); const k3s = record(raw.k3s, `targets.${id}.k3s`); const mirror = record(k3s.registryMirror, `targets.${id}.k3s.registryMirror`); + const mirrorAuth = record(mirror.auth, `targets.${id}.k3s.registryMirror.auth`); + const registryPullSmoke = record(k3s.registryPullSmoke, `targets.${id}.k3s.registryPullSmoke`); const serviceLb = record(k3s.serviceLb, `targets.${id}.k3s.serviceLb`); const workerDns = record(k3s.workerDns, `targets.${id}.k3s.workerDns`); const smoke = record(raw.smoke, `targets.${id}.smoke`); @@ -151,13 +172,32 @@ export function readK3sClusterConfig(targetId?: string): { defaultTargetId: stri workerPrivateKey: text(secrets, "workerPrivateKey", `targets.${id}.secrets`), workerPublicKey: text(secrets, "workerPublicKey", `targets.${id}.secrets`), agentToken: text(secrets, "agentToken", `targets.${id}.secrets`), + registryUsername: text(secrets, "registryUsername", `targets.${id}.secrets`), + registryPassword: text(secrets, "registryPassword", `targets.${id}.secrets`), + registryPasswordHash: text(secrets, "registryPasswordHash", `targets.${id}.secrets`), }, k3s: { serverUrl: text(k3s, "serverUrl", `targets.${id}.k3s`), agentTokenFile: text(k3s, "agentTokenFile", `targets.${id}.k3s`), flannelInterface: text(k3s, "flannelInterface", `targets.${id}.k3s`), maxPods: integer(k3s, "maxPods", `targets.${id}.k3s`), - registryMirror: { source: text(mirror, "source", "registryMirror"), endpoint: text(mirror, "endpoint", "registryMirror") }, + registryMirror: { + source: text(mirror, "source", "registryMirror"), + endpoint: httpsUrl(mirror, "endpoint", "registryMirror"), + auth: { + sourceRef: text(mirrorAuth, "sourceRef", "registryMirror.auth"), + usernameKey: text(mirrorAuth, "usernameKey", "registryMirror.auth"), + passwordKey: text(mirrorAuth, "passwordKey", "registryMirror.auth"), + passwordHashKey: text(mirrorAuth, "passwordHashKey", "registryMirror.auth"), + }, + }, + registryPullSmoke: { + image: text(registryPullSmoke, "image", "registryPullSmoke"), + expectedDigest: sha256Digest(registryPullSmoke, "expectedDigest", "registryPullSmoke"), + expectedBytes: integer(registryPullSmoke, "expectedBytes", "registryPullSmoke"), + concurrentPulls: integer(registryPullSmoke, "concurrentPulls", "registryPullSmoke"), + timeoutSeconds: integer(registryPullSmoke, "timeoutSeconds", "registryPullSmoke"), + }, serviceLb: { enabledNodeLabel: text(serviceLb, "enabledNodeLabel", "serviceLb"), enabledNode: text(serviceLb, "enabledNode", "serviceLb") }, workerDns: { namespace: kubernetesName(workerDns, "namespace", "workerDns"), @@ -181,6 +221,16 @@ export function readK3sClusterConfig(targetId?: string): { defaultTargetId: stri if (target.k3s.flannelInterface !== target.wireguard.interfaceName) throw new Error(`targets.${id}.k3s.flannelInterface must match wireguard.interfaceName`); if (target.k3s.serviceLb.enabledNode !== target.controlPlane.nodeName) throw new Error(`targets.${id}.k3s.serviceLb.enabledNode must match controlPlane.nodeName`); if (!target.tcpTuning.sysctlPath.startsWith("/etc/sysctl.d/")) throw new Error(`targets.${id}.tcpTuning.sysctlPath must stay under /etc/sysctl.d`); + if (target.k3s.registryMirror.auth.sourceRef !== target.secrets.sourceRef) { + throw new Error(`targets.${id}.k3s.registryMirror.auth.sourceRef must match targets.${id}.secrets.sourceRef`); + } + if ( + target.k3s.registryMirror.auth.usernameKey !== target.secrets.registryUsername + || target.k3s.registryMirror.auth.passwordKey !== target.secrets.registryPassword + || target.k3s.registryMirror.auth.passwordHashKey !== target.secrets.registryPasswordHash + ) { + throw new Error(`targets.${id}.k3s.registryMirror.auth keys must match targets.${id}.secrets registry keys`); + } return { defaultTargetId, target }; } @@ -227,6 +277,21 @@ function integer(value: Record, key: string, label: string): nu return Number(result); } +function httpsUrl(value: Record, key: string, label: string): string { + const result = text(value, key, label); + const parsed = new URL(result); + if (parsed.protocol !== "https:" || parsed.username.length > 0 || parsed.password.length > 0) { + throw new Error(`${label}.${key} must be an HTTPS URL without embedded credentials`); + } + return result; +} + +function sha256Digest(value: Record, key: string, label: string): string { + const result = text(value, key, label); + if (!/^sha256:[0-9a-f]{64}$/u.test(result)) throw new Error(`${label}.${key} must be a sha256 digest`); + return result; +} + function enumText(value: Record, key: string, label: string, allowed: readonly T[]): T { const result = text(value, key, label); if (!allowed.includes(result as T)) throw new Error(`${label}.${key} must be one of ${allowed.join(", ")}`); diff --git a/scripts/src/platform-infra-k3s-cluster.ts b/scripts/src/platform-infra-k3s-cluster.ts index 2fb2b106..1ece9128 100644 --- a/scripts/src/platform-infra-k3s-cluster.ts +++ b/scripts/src/platform-infra-k3s-cluster.ts @@ -1,5 +1,5 @@ import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; @@ -13,7 +13,7 @@ import { } from "./platform-infra-host-proxy"; import { address, K3S_CLUSTER_CONFIG_LABEL, readK3sClusterConfig, type K3sClusterTarget } from "./platform-infra-k3s-cluster-config"; -type Action = "plan" | "secret-init" | "apply" | "status" | "smoke"; +type Action = "plan" | "secret-init" | "registry-credential-init" | "apply" | "status" | "smoke" | "registry-smoke"; interface Options { action: Action; @@ -27,6 +27,9 @@ interface SecretMaterial { workerPrivate: string; workerPublic: string; agentToken: string; + registryUsername: string; + registryPassword: string; + registryPasswordHash: string; fingerprint: string; } @@ -35,16 +38,18 @@ export async function runPlatformInfraK3sClusterCommand(_config: UniDeskConfig, const { target } = readK3sClusterConfig(options.targetId); if (options.action === "plan") return plan(target); if (options.action === "secret-init") return secretInit(target, options.confirm); + if (options.action === "registry-credential-init") return registryCredentialInit(target, options.confirm); if (options.action === "status") return status(target); if (options.action === "smoke") return smoke(target, options.confirm); + if (options.action === "registry-smoke") return registrySmoke(target, options.confirm); if (!options.confirm) return { ...plan(target), mode: "dry-run", mutation: false }; return apply(target); } function parseOptions(args: string[]): Options { const action = args[0] ?? "plan"; - if (action !== "plan" && action !== "secret-init" && action !== "apply" && action !== "status" && action !== "smoke") { - throw new Error("platform-infra k3s-cluster usage: plan|secret-init|apply|status|smoke [--target ] [--confirm]"); + if (action !== "plan" && action !== "secret-init" && action !== "registry-credential-init" && action !== "apply" && action !== "status" && action !== "smoke" && action !== "registry-smoke") { + throw new Error("platform-infra k3s-cluster usage: plan|secret-init|registry-credential-init|apply|status|smoke|registry-smoke [--target ] [--confirm]"); } return { action, targetId: option(args, "--target") ?? undefined, confirm: args.includes("--confirm") }; } @@ -72,15 +77,18 @@ function plan(target: K3sClusterTarget): Record { serverUrl: target.k3s.serverUrl, agentTokenFile: target.k3s.agentTokenFile, registryMirror: target.k3s.registryMirror, + registryPullSmoke: target.k3s.registryPullSmoke, serviceLb: target.k3s.serviceLb, workerDns: target.k3s.workerDns, }, secret: secretSummary(target), next: { secretInit: `bun scripts/cli.ts platform-infra k3s-cluster secret-init --target ${target.id} --confirm`, + registryCredentialInit: `bun scripts/cli.ts platform-infra k3s-cluster registry-credential-init --target ${target.id} --confirm`, apply: `bun scripts/cli.ts platform-infra k3s-cluster apply --target ${target.id} --confirm`, status: `bun scripts/cli.ts platform-infra k3s-cluster status --target ${target.id}`, smoke: `bun scripts/cli.ts platform-infra k3s-cluster smoke --target ${target.id} --confirm`, + registrySmoke: `bun scripts/cli.ts platform-infra k3s-cluster registry-smoke --target ${target.id} --confirm`, }, }; } @@ -91,12 +99,16 @@ function secretInit(target: K3sClusterTarget, confirm: boolean): Record = { [target.secrets.controlPlanePrivateKey]: control.privateKey, [target.secrets.controlPlanePublicKey]: control.publicKey, [target.secrets.workerPrivateKey]: worker.privateKey, [target.secrets.workerPublicKey]: worker.publicKey, [target.secrets.agentToken]: randomBytes(32).toString("base64url"), + [target.secrets.registryUsername]: registry.username, + [target.secrets.registryPassword]: registry.password, + [target.secrets.registryPasswordHash]: registry.passwordHash, }; mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n") + "\n", { encoding: "utf8", mode: 0o600 }); @@ -104,6 +116,44 @@ function secretInit(target: K3sClusterTarget, confirm: boolean): Record { + const path = target.secrets.sourceRef; + const keys = [target.secrets.registryUsername, target.secrets.registryPassword, target.secrets.registryPasswordHash]; + if (!existsSync(path)) throw new Error(`${path} is missing; run platform-infra k3s-cluster secret-init --confirm`); + const values = parseEnvFile(readFileSync(path, "utf8")); + const present = keys.filter((key) => typeof values[key] === "string" && values[key]!.length > 0); + if (present.length === keys.length) { + return { ok: true, mutation: false, mode: "already-present", targetId: target.id, secret: secretSummary(target), valuesPrinted: false }; + } + if (present.length > 0) throw new Error(`${path} contains a partial artifact registry credential; refusing to overwrite`); + if (!confirm) { + return { ok: true, mutation: false, mode: "dry-run", targetId: target.id, sourceRef: path, keys, valuesPrinted: false }; + } + const credential = createRegistryCredential(); + appendFileSync(path, [ + `${target.secrets.registryUsername}=${credential.username}`, + `${target.secrets.registryPassword}=${credential.password}`, + `${target.secrets.registryPasswordHash}=${credential.passwordHash}`, + "", + ].join("\n"), { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); + return { ok: true, mutation: true, targetId: target.id, secret: secretSummary(target), valuesPrinted: false }; +} + +function createRegistryCredential(): { username: string; password: string; passwordHash: string } { + const username = "unidesk-pull"; + const password = randomBytes(32).toString("base64url"); + const result = runCommand( + ["docker", "run", "--rm", "-i", "caddy:2.10.2-alpine", "sh", "-c", 'IFS= read -r password; caddy hash-password --algorithm bcrypt --plaintext "$password"'], + rootPath(), + { input: `${password}\n`, timeoutMs: 60_000 }, + ); + if (result.exitCode !== 0 || !/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/u.test(result.stdout.trim())) { + throw new Error("failed to generate artifact registry bcrypt credential"); + } + return { username, password, passwordHash: result.stdout.trim() }; +} + function apply(target: K3sClusterTarget): Record { const secret = readSecrets(target); const workerNetwork = runWorker(target, workerWireguardApplyScript(target, secret), false, 120_000); @@ -237,6 +287,175 @@ function smoke(target: K3sClusterTarget, confirm: boolean): Record { + const smoke = target.k3s.registryPullSmoke; + if (!confirm) { + return { + ok: true, + mutation: false, + mode: "dry-run", + targetId: target.id, + workerNode: target.worker.nodeName, + registry: { ...target.k3s.registryMirror, auth: { ...target.k3s.registryMirror.auth, valuesPrinted: false } }, + smoke, + valuesPrinted: false, + }; + } + const secret = readSecrets(target); + const before = runControlPlane(target, nodeReadinessScript(target), 30_000); + requireSuccess(before, "registry-smoke-node-readiness-before"); + const worker = runWorker(target, registrySmokeScript(target, secret), true, (smoke.timeoutSeconds + 120) * 1000); + const after = runControlPlane(target, nodeReadinessScript(target), 30_000); + const checks = parseFirstJson(worker.stdout); + const beforeChecks = parseFirstJson(before.stdout); + const afterChecks = parseFirstJson(after.stdout); + const ok = worker.exitCode === 0 + && beforeChecks.ok === true + && after.exitCode === 0 + && afterChecks.ok === true + && checks.ok === true; + return { + ok, + mutation: true, + targetId: target.id, + workerNode: target.worker.nodeName, + registry: { + source: target.k3s.registryMirror.source, + endpoint: target.k3s.registryMirror.endpoint, + authSourceRef: target.k3s.registryMirror.auth.sourceRef, + valuesPrinted: false, + }, + smoke, + checks, + clusterBefore: beforeChecks, + clusterAfter: afterChecks, + providerControlChannelReturned: worker.exitCode === 0, + result: { worker: compact(worker), before: compact(before), after: compact(after) }, + valuesPrinted: false, + }; +} + +function nodeReadinessScript(target: K3sClusterTarget): string { + return `set -eu +cp_ready=$(/usr/local/bin/kubectl get node ${shQuote(target.controlPlane.nodeName)} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}') +worker_ready=$(/usr/local/bin/kubectl get node ${shQuote(target.worker.nodeName)} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}') +python3 - "$cp_ready" "$worker_ready" <<'PY' +import json, sys +cp, worker = sys.argv[1:] +print(json.dumps({"ok": cp == "True" and worker == "True", "controlPlaneReady": cp == "True", "workerReady": worker == "True"}, separators=(",", ":"))) +PY +`; +} + +function registrySmokeScript(target: K3sClusterTarget, secret: SecretMaterial): string { + const smoke = target.k3s.registryPullSmoke; + const endpoint = new URL(target.k3s.registryMirror.endpoint); + const imagePath = smoke.image.slice(target.k3s.registryMirror.source.length + 1); + const separator = imagePath.lastIndexOf(":"); + if (separator < 1) throw new Error(`registry smoke image must use a tag: ${smoke.image}`); + const repository = imagePath.slice(0, separator); + const tag = imagePath.slice(separator + 1); + return `set -eu +tmp="$(mktemp -d)" +monitor_pid="" +cleanup() { + touch "$tmp/stop" + if [ -n "$monitor_pid" ]; then wait "$monitor_pid" 2>/dev/null || true; fi + rm -rf "$tmp" +} +trap cleanup EXIT +cat >"$tmp/netrc" <<'EOF' +machine ${endpoint.hostname} +login ${secret.registryUsername} +password ${secret.registryPassword} +EOF +chmod 0600 "$tmp/netrc" +base=${shQuote(target.k3s.registryMirror.endpoint)} +unauth_status="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 20 "$base/v2/" || true)" +auth_status="$(curl -sS --netrc-file "$tmp/netrc" -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 20 "$base/v2/" || true)" +manifest_headers="$tmp/manifest.headers" +curl -fsS --netrc-file "$tmp/netrc" -D "$manifest_headers" -o "$tmp/manifest.json" -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' "$base/v2/${repository}/manifests/${tag}" +manifest_digest="$(tr -d '\\r' <"$manifest_headers" | awk 'tolower($1)=="docker-content-digest:" {print $2}' | tail -1)" +layer_digest="$(python3 - "$tmp/manifest.json" <<'PY' +import json, sys +manifest = json.load(open(sys.argv[1], encoding="utf-8")) +print(manifest["layers"][0]["digest"]) +PY +)" +range_status="$(curl -sS --netrc-file "$tmp/netrc" -H 'Range: bytes=0-1023' -o "$tmp/range.bin" -w '%{http_code}' "$base/v2/${repository}/blobs/$layer_digest" || true)" +range_bytes="$(wc -c <"$tmp/range.bin" | tr -d ' ')" +write_status="$(curl -sS --netrc-file "$tmp/netrc" -X POST -o /dev/null -w '%{http_code}' "$base/v2/unidesk-registry-smoke/blobs/uploads/" || true)" +( + : >"$tmp/api.failures" + while [ ! -f "$tmp/stop" ]; do + nc -z -w 3 ${address(target.wireguard.controlPlaneAddress)} 6443 || printf x >>"$tmp/api.failures" + sleep 2 + done +) & +monitor_pid=$! +k3s crictl rmi ${shQuote(smoke.image)} >/dev/null 2>&1 || true +cold_start="$(date +%s%3N)" +k3s crictl pull ${shQuote(smoke.image)} >"$tmp/cold.log" 2>&1 +cold_end="$(date +%s%3N)" +k3s crictl inspecti ${shQuote(smoke.image)} >"$tmp/inspect.json" +grep -F ${shQuote(smoke.expectedDigest)} "$tmp/inspect.json" >/dev/null +k3s crictl rmi ${shQuote(smoke.image)} >/dev/null 2>&1 || true +concurrent_start="$(date +%s%3N)" +pids="" +for index in $(seq 1 ${smoke.concurrentPulls}); do + k3s crictl pull ${shQuote(smoke.image)} >"$tmp/concurrent-$index.log" 2>&1 & + pids="$pids $!" +done +concurrent_ok=true +for pid in $pids; do wait "$pid" || concurrent_ok=false; done +concurrent_end="$(date +%s%3N)" +hot_start="$(date +%s%3N)" +k3s crictl pull ${shQuote(smoke.image)} >"$tmp/hot.log" 2>&1 +hot_end="$(date +%s%3N)" +touch "$tmp/stop" +wait "$monitor_pid" 2>/dev/null || true +monitor_pid="" +api_failures="$(wc -c <"$tmp/api.failures" | tr -d ' ')" +python3 - "$unauth_status" "$auth_status" "$manifest_digest" "$range_status" "$range_bytes" "$write_status" "$cold_start" "$cold_end" "$concurrent_start" "$concurrent_end" "$hot_start" "$hot_end" "$concurrent_ok" "$api_failures" <<'PY' +import json, sys +(unauth, auth, digest, range_status, range_bytes, write_status, cold_start, cold_end, + concurrent_start, concurrent_end, hot_start, hot_end, concurrent_ok, api_failures) = sys.argv[1:] +cold_ms = int(cold_end) - int(cold_start) +concurrent_ms = int(concurrent_end) - int(concurrent_start) +hot_ms = int(hot_end) - int(hot_start) +expected_bytes = ${smoke.expectedBytes} +throughput = None if cold_ms <= 0 else round(expected_bytes / (cold_ms / 1000), 2) +checks = { + "unauthenticatedRejected": unauth == "401", + "authenticatedV2": auth == "200", + "manifestDigest": digest == ${JSON.stringify(smoke.expectedDigest)}, + "blobRange": range_status == "206" and int(range_bytes or 0) == 1024, + "writesRejected": write_status in {"403", "405"}, + "coldPull": cold_ms > 0, + "concurrentPulls": concurrent_ok == "true", + "repeatPull": hot_ms >= 0, + "apiStable": int(api_failures) == 0, +} +print(json.dumps({ + "ok": all(checks.values()), + "checks": checks, + "unauthenticatedStatus": unauth, + "authenticatedStatus": auth, + "manifestDigest": digest, + "rangeStatus": range_status, + "rangeBytes": int(range_bytes or 0), + "writeStatus": write_status, + "coldPullDurationMs": cold_ms, + "coldPullBytesPerSecond": throughput, + "concurrentPullDurationMs": concurrent_ms, + "hotPullDurationMs": hot_ms, + "apiProbeFailures": int(api_failures), + "valuesPrinted": False, +}, separators=(",", ":"))) +PY +`; +} + function reconcileWorkerDns(target: K3sClusterTarget): Record { const before = workerDnsStatus(target); const mutation = before.data.configurationCurrent !== true; @@ -556,7 +775,6 @@ function workerApplyScript(target: K3sClusterTarget, secret: SecretMaterial): st const desiredHash = createHash("sha256").update(JSON.stringify({ version: target.worker.k3sVersion, exec, - registryMirror: target.k3s.registryMirror, agentToken: secret.agentToken, })).digest("hex"); return `set -eu @@ -586,8 +804,14 @@ mirrors: "${target.k3s.registryMirror.source}": endpoint: - "${target.k3s.registryMirror.endpoint}" +configs: + "${new URL(target.k3s.registryMirror.endpoint).host}": + auth: + username: ${JSON.stringify(secret.registryUsername)} + password: ${JSON.stringify(secret.registryPassword)} EOF wg_changed=false +registry_changed=false if ! cmp -s "$wg_candidate" /etc/wireguard/${wg.interfaceName}.conf 2>/dev/null; then install -m 0600 "$wg_candidate" /etc/wireguard/${wg.interfaceName}.conf wg_changed=true @@ -597,6 +821,7 @@ if ! cmp -s "$token_candidate" ${shQuote(target.k3s.agentTokenFile)} 2>/dev/null fi if ! cmp -s "$registry_candidate" /etc/rancher/k3s/registries.yaml 2>/dev/null; then install -m 0600 "$registry_candidate" /etc/rancher/k3s/registries.yaml + registry_changed=true fi systemctl enable wg-quick@${wg.interfaceName} >/dev/null 2>&1 || true if [ "$wg_changed" = true ]; then @@ -610,7 +835,10 @@ desired_hash_file=/etc/unidesk/k3s-agent-desired.sha256 current_hash=$(cat "$desired_hash_file" 2>/dev/null || true) agent_state="$(systemctl show k3s-agent -p ActiveState --value 2>/dev/null || true)" agent_pid="$(systemctl show k3s-agent -p MainPID --value 2>/dev/null || true)" -if [ "$current_hash" = "$desired_hash" ] && { [ "$agent_state" = active ] || [ "$agent_state" = activating ]; } && [ "\${agent_pid:-0}" -gt 0 ]; then exit 0; fi +if [ "$current_hash" = "$desired_hash" ] && { [ "$agent_state" = active ] || [ "$agent_state" = activating ]; } && [ "\${agent_pid:-0}" -gt 0 ]; then + if [ "$registry_changed" = true ]; then systemctl restart --no-block k3s-agent; fi + exit 0 +fi if [ -x /usr/local/bin/k3s-uninstall.sh ]; then /usr/local/bin/k3s-uninstall.sh; fi if [ -x /usr/local/bin/k3s-agent-uninstall.sh ]; then /usr/local/bin/k3s-agent-uninstall.sh; fi install -d -m 700 /etc/rancher/k3s @@ -848,9 +1076,9 @@ ${bundle.applyScript} function readSecrets(target: K3sClusterTarget): SecretMaterial { if (!existsSync(target.secrets.sourceRef)) throw new Error(`${target.secrets.sourceRef} is missing; run platform-infra k3s-cluster secret-init --confirm`); const values = parseEnvFile(readFileSync(target.secrets.sourceRef, "utf8")); - const required = (key: string) => { + const required = (key: string, minimumLength = 32) => { const value = values[key]; - if (typeof value !== "string" || value.length < 32) throw new Error(`${target.secrets.sourceRef}.${key} is missing or invalid`); + if (typeof value !== "string" || value.length < minimumLength) throw new Error(`${target.secrets.sourceRef}.${key} is missing or invalid`); return value; }; const material = { @@ -859,6 +1087,9 @@ function readSecrets(target: K3sClusterTarget): SecretMaterial { workerPrivate: required(target.secrets.workerPrivateKey), workerPublic: required(target.secrets.workerPublicKey), agentToken: required(target.secrets.agentToken), + registryUsername: required(target.secrets.registryUsername, 1), + registryPassword: required(target.secrets.registryPassword), + registryPasswordHash: required(target.secrets.registryPasswordHash), }; return { ...material, fingerprint: hash(Object.entries(material).sort().map(([key, value]) => `${key}=${value}`).join("\n")) }; } diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 944fca53..11d8738d 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -67,6 +67,15 @@ export interface ResolvedSite extends SiteReference { upstreamTlsServerName?: string; healthPath: string; expectedA: string; + healthExpectedStatuses?: number[]; + accessPolicy?: { + allowedMethods: string[]; + basicAuth: { + sourceRef: string; + usernameKey: string; + passwordHashKey: string; + }; + }; routes?: ResolvedSiteRoute[]; } @@ -334,11 +343,23 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult { throw new Error(`${label}.upstreamTlsServerName 仅允许 HTTPS upstream`); } const healthPath = resolveHealthPath(owner, exposure, label); + const healthExpectedStatuses = resolveHealthExpectedStatuses(exposure, label); + const accessPolicy = resolveAccessPolicy(owner, exposure, label); const routes = resolveSiteRoutes(exposure, label); const previous = hostnames.get(hostname); if (previous !== undefined) throw new Error(`hostname 与 site ${previous} 重复:${hostname}`); hostnames.set(hostname, reference.id); - sites.push({ ...reference, hostname, upstream, upstreamTlsServerName, healthPath, expectedA, ...(routes === undefined ? {} : { routes }) }); + sites.push({ + ...reference, + hostname, + upstream, + upstreamTlsServerName, + healthPath, + expectedA, + ...(healthExpectedStatuses === undefined ? {} : { healthExpectedStatuses }), + ...(accessPolicy === undefined ? {} : { accessPolicy }), + ...(routes === undefined ? {} : { routes }), + }); } catch (error) { unresolved.push({ ...reference, code: "config-ref-unresolved", detail: error instanceof Error ? error.message : String(error) }); } @@ -346,6 +367,40 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult { return { sites, unresolved }; } +function resolveHealthExpectedStatuses(exposure: Record, label: string): number[] | undefined { + if (exposure.healthExpectedStatuses === undefined) return undefined; + const statuses = array(exposure.healthExpectedStatuses, `${label}.healthExpectedStatuses`).map((value, index) => { + if (!Number.isInteger(value) || Number(value) < 100 || Number(value) > 599) { + throw new Error(`${label}.healthExpectedStatuses[${index}] 必须是 HTTP status`); + } + return Number(value); + }); + if (statuses.length === 0) throw new Error(`${label}.healthExpectedStatuses 不能为空`); + return [...new Set(statuses)]; +} + +function resolveAccessPolicy( + owner: Record, + exposure: Record, + label: string, +): ResolvedSite["accessPolicy"] { + if (exposure.accessPolicy === undefined) return undefined; + const policy = record(exposure.accessPolicy, `${label}.accessPolicy`); + const allowedMethods = stringArray(policy.allowedMethods, `${label}.accessPolicy.allowedMethods`); + if (allowedMethods.length === 0 || allowedMethods.some((method) => !/^[A-Z]+$/u.test(method))) { + throw new Error(`${label}.accessPolicy.allowedMethods 必须是非空大写 HTTP method 列表`); + } + const auth = record(owner.auth, `${label}.auth`); + return { + allowedMethods: [...new Set(allowedMethods)], + basicAuth: { + sourceRef: absolutePath(auth.sourceRef, `${label}.auth.sourceRef`), + usernameKey: envName(auth.usernameKey, `${label}.auth.usernameKey`), + passwordHashKey: envName(auth.passwordHashKey, `${label}.auth.passwordHashKey`), + }, + }; +} + function readSiteOwner(reference: SiteReference, ownerPath: string): Record { if (!isAbsolute(reference.configRef)) { if (reference.sourceCommit !== undefined) throw new Error(`仓库内 configRef 不接受 sourceCommit:${reference.configRef}`); @@ -650,7 +705,9 @@ export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: Resol .map((site) => renderManagedBlock(`public-edge-site:${site.id}`, renderSiteBlock(site, target.runtime.responseHeaderTimeoutSeconds))) .join("\n"); const dnsServers = target.runtime.dnsServers.map((server) => ` - ${server}`).join("\n"); - const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n dns:\n${dnsServers}\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`; + const envFiles = [...new Set(sites.flatMap((site) => site.accessPolicy === undefined ? [] : [site.accessPolicy.basicAuth.sourceRef]))]; + const envFileBlock = envFiles.length === 0 ? "" : ` env_file:\n${envFiles.map((path) => ` - ${path}`).join("\n")}\n`; + const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n${envFileBlock} dns:\n${dnsServers}\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`; return { caddyfile: `${global}\n${blocks}`, compose }; } @@ -660,6 +717,11 @@ function renderManagedBlock(owner: string, body: string): string { function renderSiteBlock(site: ResolvedSite, defaultTimeoutSeconds: number): string { if (site.routes === undefined) { + if (site.accessPolicy !== undefined) { + const deniedMethods = site.accessPolicy.allowedMethods.join(" "); + const auth = site.accessPolicy.basicAuth; + return `${site.hostname} \{\n\troute \{\n\t\t@denied not method ${deniedMethods}\n\t\trespond @denied 405\n\t\tbasic_auth \{\n\t\t\t{$${auth.usernameKey}} {$${auth.passwordHashKey}}\n\t\t\}\n${renderReverseProxy(site.upstream, defaultTimeoutSeconds, site.upstreamTlsServerName, 2)}\n\t\}\n\}\n`; + } return `${site.hostname} \{\n${renderReverseProxy(site.upstream, defaultTimeoutSeconds, site.upstreamTlsServerName, 1)}\n\}\n`; } const routes = site.routes.map((route) => { @@ -700,7 +762,9 @@ export function applyScript( const desiredCaddyFingerprint = sha256Fingerprint(artifacts.caddyfile); const desiredComposeFingerprint = sha256Fingerprint(artifacts.compose); const dnsItems = sites.map((site) => shQuote(`${site.hostname}|${site.expectedA}`)).join(" "); - const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" "); + const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}|${site.healthExpectedStatuses?.join(",") ?? "2xx3xx"}`)).join(" "); + const authFiles = [...new Set(sites.flatMap((site) => site.accessPolicy === undefined ? [] : [site.accessPolicy.basicAuth.sourceRef]))]; + const caddyEnvArgs = authFiles.map((path) => `--env-file ${shQuote(path)}`).join(" "); return `set -u mkdir -p "$(dirname ${shQuote(target.runtime.lockPath)})" exec 9>${shQuote(target.runtime.lockPath)} @@ -717,6 +781,7 @@ desired_caddy="$tmp/Caddyfile.desired" current_caddy="$tmp/Caddyfile.current" current_caddy_source="empty" mkdir -p ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.dataDir)} ${shQuote(target.runtime.configDir)} "$(dirname ${shQuote(target.runtime.statePath)})" +${authFiles.map((path) => `[ -s ${shQuote(path)} ] || { printf '%s\\n' '{"ok":false,"mutation":false,"error":"public-edge-auth-source-missing","sourceRef":${JSON.stringify(path)},"valuesPrinted":false}'; exit 1; }`).join("\n")} printf '%s' ${shQuote(ancestry)} | base64 -d >"$ancestry_file" printf '%s' ${shQuote(caddyfile)} | base64 -d >"$desired_caddy" printf '%s' ${shQuote(compose)} | base64 -d >"$compose_candidate" @@ -815,8 +880,8 @@ for item in ${dnsItems}; do fi done compose_rc=0; docker compose -f "$compose_candidate" config >/dev/null 2>"$tmp/compose.err" || compose_rc=$? -adapt_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err" || adapt_rc=$? -validate_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err" || validate_rc=$? +adapt_rc=0; docker run --rm ${caddyEnvArgs} -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err" || adapt_rc=$? +validate_rc=0; docker run --rm ${caddyEnvArgs} -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err" || validate_rc=$? if [ "$listeners_rc" -ne 0 ] || [ "$udp_rc" -ne 0 ] || [ "$compose_rc" -ne 0 ] || [ "$adapt_rc" -ne 0 ] || [ "$validate_rc" -ne 0 ]; then python3 - "$listeners_rc" "$udp_rc" "$dns_rc" "$compose_rc" "$adapt_rc" "$validate_rc" <<'PY' import json, sys @@ -918,10 +983,14 @@ probe_index=0 for item in ${probeItems}; do probe_index=$((probe_index + 1)) ( - host="${"${item%%|*}"}"; path="${"${item#*|}"}"; path="/${"${path#/}"}" - if ! curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>"$tmp/probe-$probe_index.err"; then + host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="${"${rest%%|*}"}"; expected="${"${rest#*|}"}"; path="/${"${path#/}"}" + status="$(curl --noproxy '*' -sS -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 10 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" 2>"$tmp/probe-$probe_index.err" || true)" + case "$expected" in + 2xx3xx) printf '%s' "$status" | grep -Eq '^[23][0-9][0-9]$' ;; + *) printf ',%s,' "$expected" | grep -Fq ",$status," ;; + esac || { printf '%s\t%s\\n' "$host" "$path" >"$tmp/probe-failure-$probe_index.tsv" - fi + } ) & done wait || true @@ -977,11 +1046,16 @@ export function renderPublicEdgeStatusScript( hostname: site.hostname, path: site.healthPath, }), "utf8").toString("base64"); + const expected = site.healthExpectedStatuses; + const statusCheck = expected === undefined + ? `printf '%s' "$status" | grep -Eq '^[23][0-9][0-9]$'` + : `printf ',${expected.join(",")},' | grep -Fq ",$status,"`; return `( if ! getent ahostsv4 ${shQuote(site.hostname)} | awk '{print $1}' | grep -Fxq ${shQuote(site.expectedA)}; then printf '%s' ${shQuote(dnsFailure)} | base64 -d >"$dns_failures_dir/${failureFile}" fi - if ! curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve ${shQuote(`${site.hostname}:${target.listener.httpsPort}:127.0.0.1`)} ${shQuote(`https://${site.hostname}${site.healthPath}`)} >/dev/null; then + status="$(curl --noproxy '*' -sS -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 10 --resolve ${shQuote(`${site.hostname}:${target.listener.httpsPort}:127.0.0.1`)} ${shQuote(`https://${site.hostname}${site.healthPath}`)} || true)" + if ! ${statusCheck}; then printf '%s' ${shQuote(siteFailure)} | base64 -d >"$site_failures_dir/${failureFile}" fi ) &`; @@ -1200,6 +1274,12 @@ function absolutePath(value: unknown, path: string): string { return result; } +function envName(value: unknown, path: string): string { + const parsed = string(value, path); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(parsed)) throw inputError(`${path} 必须是大写环境变量名`, "invalid-env-name", path); + return parsed; +} + function yamlConfigPath(value: unknown, path: string): string { const result = string(value, path); if (result.includes("..") || !result.endsWith(".yaml")) throw new Error(`${path} 必须是 YAML 路径`);