diff --git a/.agents/skills/unidesk-ops/SKILL.md b/.agents/skills/unidesk-ops/SKILL.md index 4dbf9cb2..c9417dba 100644 --- a/.agents/skills/unidesk-ops/SKILL.md +++ b/.agents/skills/unidesk-ops/SKILL.md @@ -63,7 +63,7 @@ D601/D518 结果必须分表记录:`STATE`、Job/run、duration、`APK/NPM/GO/ - 集群入口经 Windows 宿主内网 SSH 分发静态客户端到 guest。 - Windows staging 工件按 YAML URL 获取并校验 SHA-256,Provider/trans 不搬运大二进制。 - 启动 `k3s-agent` 前必须验证外网 probe。 - - WireGuard 已建立的 worker 通过隧道地址连接 control-plane `vpn-server`,不为 proxy 单独开放公网端口。 + - WireGuard 只承载集群三层网络;NAT 或链路不稳定的 worker proxy 消费 `vpn-server` 输出的 Hysteria2 client YAML。 - containerd 直接通过 host proxy 访问上游 registry,不使用公共镜像站。 `sub2api-egress-proxy` 的持久化配置从 `config/platform-infra/sub2api.yaml` 渲染,运行面变更必须通过受控 CLI 落地。不要把一次性 `kubectl patch` 留作最终状态。 diff --git a/config/platform-infra/host-proxy.yaml b/config/platform-infra/host-proxy.yaml index ddca9ddc..4c13856d 100644 --- a/config/platform-infra/host-proxy.yaml +++ b/config/platform-infra/host-proxy.yaml @@ -67,6 +67,22 @@ sources: binarySha256: 84b23a2304a6c4efa41dc027a78b7226a94ef6c63566b56a5a2721bdfe473446 podAccess: enabled: false + managed-vpn-server-hysteria: + extends: templates.sources.singBoxHostClient + sourceType: vpn-server-hysteria-managed + benchmarkRef: local-vpn-server + sourceConfigRef: /root/vpn-server/output/client.yaml + client: + version: v1.12.0 + upstreamUrl: https://github.com/SagerNet/sing-box/releases/download/v1.12.0/sing-box-1.12.0-linux-amd64.tar.gz + archiveCachePath: /root/.unidesk/.state/artifacts/platform-infra/sing-box-1.12.0-linux-amd64.tar.gz + archiveSha256: f59b1253ae0143997cb46915af30d14a33431dcbd7e39edacfcde8d73050faaf + archiveInstallPath: /var/lib/unidesk/artifacts/sing-box-1.12.0-linux-amd64.tar.gz + binaryMember: sing-box-1.12.0-linux-amd64/sing-box + binaryCachePath: /root/.unidesk/.state/artifacts/platform-infra/sing-box-1.12.0 + binarySha256: 84b23a2304a6c4efa41dc027a78b7226a94ef6c63566b56a5a2721bdfe473446 + podAccess: + enabled: false jd01-real-deps-master-shadowsocks: extends: templates.sources.singBoxHostClient benchmarkRef: "pikasTech/unidesk#1110" @@ -100,7 +116,7 @@ targets: D601-VM: extends: templates.targets.hostProxyClient route: D601-VM - sourceRef: sources.wireguard-worker-vpn-server-shadowsocks + sourceRef: sources.managed-vpn-server-hysteria files: k3sSystemdDropIn: /etc/systemd/system/k3s-agent.service.d/10-unidesk-proxy.conf env: diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 1891a8c1..41e6decb 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -24,7 +24,7 @@ - 集群 apply 先经宿主内网 SSH 分发零依赖 proxy client,确认它连接声明的 `vpn-server` 且外网 probe 成功,再启动或恢复 `k3s-agent`。 - 集群 apply 在 proxy 前先收敛 worker WireGuard;内容一致时不强写,只有 unit 未运行或握手过期且隧道探针失败时才恢复 worker 接口。 - 大型 proxy 工件由 Windows 宿主按 YAML `upstreamUrl` 获取并校验 SHA-256,Provider/trans 只承载控制命令,不作为大二进制数据面。 - - WireGuard 已建立的 worker 应通过隧道地址连接 control-plane 上的 `vpn-server`,不要求为 proxy 单独开放公网端口。 + - WireGuard 只承载集群三层网络;NAT 或链路不稳定的 worker proxy 使用 `vpn-server` 输出的 Hysteria2 client YAML,不把应用出网 TCP 再套入 WireGuard TCP 路径。 - proxy client 协议实现版本由 host-proxy YAML 声明,并与所连接的 `vpn-server` 协议实现版本保持一致。 - control-plane 节点配置只组合 `hwlab nodes control-plane infra k3s-config plan|status|apply`: - 该窄域只比较并安装 owning YAML 渲染的 k3s systemd drop-in; diff --git a/scripts/src/platform-infra-host-proxy.ts b/scripts/src/platform-infra-host-proxy.ts index 9e9af03a..bfb301e9 100644 --- a/scripts/src/platform-infra-host-proxy.ts +++ b/scripts/src/platform-infra-host-proxy.ts @@ -43,7 +43,7 @@ interface HostProxyServer { interface HostProxySource { id: string; - sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks" | "vpn-server-hysteria-existing"; + sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks" | "vpn-server-hysteria-existing" | "vpn-server-hysteria-managed"; serverRef: string; benchmarkRef: string; implementationRef: string; @@ -52,6 +52,15 @@ interface HostProxySource { sourceKey: string; sourceFingerprint: string; masterShadowsocks: MasterShadowsocksSourceSpec | null; + managedHysteria: { + server: string; + serverPort: number; + auth: string; + serverName: string; + insecure: boolean; + obfsType: string; + obfsPassword: string; + } | null; client: HostProxyClient; proxyUrl: string; externalProbeUrl: string; @@ -294,8 +303,9 @@ function parseServer(raw: Record, label: string): HostProxyServ } function parseSource(id: string, raw: Record): HostProxySource { - const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks", "vpn-server-hysteria-existing"] as const); + const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks", "vpn-server-hysteria-existing", "vpn-server-hysteria-managed"] as const); if (sourceType === "vpn-server-hysteria-existing") return parseExistingHysteriaSource(id, raw, sourceType); + if (sourceType === "vpn-server-hysteria-managed") return parseManagedHysteriaSource(id, raw, sourceType); const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`); const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.sources.${id}.sourceConfigRef`); if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) { @@ -316,6 +326,7 @@ function parseSource(id: string, raw: Record): HostProxySource sourceKey: resolved.sourceKey, sourceFingerprint: resolved.fingerprint, masterShadowsocks: resolved.masterShadowsocks, + managedHysteria: null, client, proxyUrl, externalProbeUrl: urlField(raw, "externalProbeUrl", `${configPath}.sources.${id}`), @@ -337,6 +348,49 @@ function parseSource(id: string, raw: Record): HostProxySource }; } +function parseManagedHysteriaSource(id: string, raw: Record, sourceType: HostProxySource["sourceType"]): HostProxySource { + const label = `${configPath}.sources.${id}`; + const sourceConfigRef = absolutePathField(raw, "sourceConfigRef", label); + const sourceRaw = record(Bun.YAML.parse(readFileSync(sourceConfigRef, "utf8")), sourceConfigRef); + const [server, portText] = stringField(sourceRaw, "server", sourceConfigRef).split(":"); + const tls = record(sourceRaw.tls, `${sourceConfigRef}.tls`); + const obfs = record(sourceRaw.obfs, `${sourceConfigRef}.obfs`); + const salamander = record(obfs.salamander, `${sourceConfigRef}.obfs.salamander`); + const client = managedClientSpec(record(raw.client, `${label}.client`), `${label}.client`); + const proxyUrl = urlField(raw, "proxyUrl", label); + const expectedProxyUrl = `http://${client.listenHost}:${client.listenPort}`; + if (proxyUrl !== expectedProxyUrl) throw new Error(`${label}.proxyUrl must be ${expectedProxyUrl}`); + const managedHysteria = { + server: server ?? "", + serverPort: Number(portText), + auth: stringField(sourceRaw, "auth", sourceConfigRef), + serverName: stringField(tls, "sni", `${sourceConfigRef}.tls`), + insecure: booleanField(tls, "insecure", `${sourceConfigRef}.tls`), + obfsType: stringField(obfs, "type", `${sourceConfigRef}.obfs`), + obfsPassword: stringField(salamander, "password", `${sourceConfigRef}.obfs.salamander`), + }; + if (!managedHysteria.server || !Number.isInteger(managedHysteria.serverPort)) throw new Error(`${sourceConfigRef}.server must be host:port`); + const source = { + id, + sourceType, + serverRef: stringField(raw, "serverRef", label), + benchmarkRef: stringField(raw, "benchmarkRef", label), + implementationRef: stringField(raw, "implementationRef", label), + sourceConfigRef, + sourceRef: sourceConfigRef, + sourceKey: "auth,obfs.salamander.password", + sourceFingerprint: fingerprint({ sourceConfigRef, managedHysteria }), + masterShadowsocks: null, + managedHysteria, + client, + proxyUrl, + externalProbeUrl: urlField(raw, "externalProbeUrl", label), + expectedServer: `${managedHysteria.server}:${managedHysteria.serverPort}`, + fingerprint: "", + }; + return { ...source, fingerprint: fingerprint({ sourceType, sourceConfigRef, sourceFingerprint: source.sourceFingerprint, client, proxyUrl }) }; +} + function parseExistingHysteriaSource(id: string, raw: Record, sourceType: HostProxySource["sourceType"]): HostProxySource { const client = existingHysteriaClientSpec(record(raw.client, `${configPath}.sources.${id}.client`), `${configPath}.sources.${id}.client`); const proxyUrl = urlField(raw, "proxyUrl", `${configPath}.sources.${id}`); @@ -355,6 +409,7 @@ function parseExistingHysteriaSource(id: string, raw: Record, s sourceKey: "", sourceFingerprint: fingerprint({ sourceConfigRef, expectedServer }), masterShadowsocks: null, + managedHysteria: null, client, proxyUrl, externalProbeUrl: urlField(raw, "externalProbeUrl", `${configPath}.sources.${id}`), @@ -625,6 +680,16 @@ function skippedMasterProxyServer(source: HostProxySource, mode: "apply" | "stat } function proxySecretMaterial(server: HostProxyServer, source: HostProxySource): HostProxySecretMaterial { + if (source.managedHysteria !== null) { + const clientConfigJson = renderSingBoxClientConfig(source, ""); + return { + serverConfigJson: "{}\n", + clientConfigJson, + sourcePath: source.sourceConfigRef, + fingerprint: fingerprintSecretValues({ clientConfigJson }, ["clientConfigJson"]), + valuesPrinted: false, + }; + } if (source.masterShadowsocks === null) throw new Error(`${configPath}.sources.${source.id} does not render managed shadowsocks secret material`); const envSource = readEnvSourceFile({ root: rootPath(".state", "secrets"), @@ -655,7 +720,7 @@ function renderMasterShadowsocksServerConfig(server: HostProxyServer, password: } function renderSingBoxClientConfig(source: HostProxySource, password: string): string { - if (source.masterShadowsocks === null || source.client.mode !== "trans-static-binary") { + if ((source.masterShadowsocks === null && source.managedHysteria === null) || source.client.mode !== "trans-static-binary") { throw new Error(`${configPath}.sources.${source.id} cannot render sing-box client config`); } const client = source.client; @@ -670,13 +735,21 @@ function renderSingBoxClientConfig(source: HostProxySource, password: string): s experimental: { clash_api: { external_controller: client.clashApiListen } }, inbounds, outbounds: [ - { + source.managedHysteria === null ? { type: "shadowsocks", tag: "master-vpn", server: source.masterShadowsocks.serverHost, server_port: source.masterShadowsocks.serverPort, method: source.masterShadowsocks.method, password, + } : { + type: "hysteria2", + tag: "master-vpn", + server: source.managedHysteria.server, + server_port: source.managedHysteria.serverPort, + password: source.managedHysteria.auth, + obfs: { type: source.managedHysteria.obfsType, password: source.managedHysteria.obfsPassword }, + tls: { enabled: true, server_name: source.managedHysteria.serverName, insecure: source.managedHysteria.insecure }, }, { type: "direct", tag: "direct" }, { type: "block", tag: "block" },