From 310b2b5b1996fa67ab88de518a38f7dedbdf7093 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:01:45 +0800 Subject: [PATCH] feat: the one-line installer verifies the CI-signed release manifest and pulls the image by digest [CI] --- backend/openapi/v1.json | 66 +++++++++++++++++++ .../src/MoShiro.Backend.Api/InstallScripts.cs | 10 +++ .../MoShiro.Backend.Api.csproj | 1 + .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 21 ++++++ .../MoShiro.Backend.Tests/InstallTests.cs | 31 +++++++++ docs/deployment.md | 2 + packaging/install/install.ps1 | 56 ++++++++++++++++ packaging/install/install.sh | 48 +++++++++++--- 8 files changed, 227 insertions(+), 8 deletions(-) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 5b9262f..524b140 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4847,6 +4847,72 @@ } } }, + "/install/manifest": { + "get": { + "tags": [ + "ops" + ], + "summary": "The signed release manifest of the component an install code is for (exact signed bytes)", + "operationId": "install_manifest", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/install/manifest.sig": { + "get": { + "tags": [ + "ops" + ], + "summary": "Base64 DER ECDSA P-256 signature of the manifest", + "operationId": "install_manifest_signature", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/install.ps1": { "get": { "tags": [ diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index 35d427f..1969b20 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -9,12 +9,22 @@ namespace MoShiro.Backend.Api; /// public static class InstallScripts { + /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本用它验发布清单的签名。 + public static string ReleaseKey() + { + using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem") + ?? throw new InvalidOperationException("the embedded release key is missing"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + public static string Render(string resource, InstallTarget target) { using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource) ?? throw new InvalidOperationException($"the embedded resource {resource} is missing"); using var reader = new StreamReader(stream); return reader.ReadToEnd() + .Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal) .Replace("@@ROLE@@", target.Role, StringComparison.Ordinal) .Replace("@@SERVER@@", target.Server, StringComparison.Ordinal) .Replace("@@CODE@@", target.Code, StringComparison.Ordinal) diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index fdf3d8b..3ef7001 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -9,5 +9,6 @@ + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index f098c99..801f51a 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -26,11 +26,32 @@ public static class OpsEndpoints .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404); app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) .ExcludeFromDescription(); + // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 + app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) + .Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404); + app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) + .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain")) .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); } + private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) + { + string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); + ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted); + if (offer is null) + { + return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); + } + + http.Response.Headers.CacheControl = "no-store"; + return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8"); + } + private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index ddcb629..8d38ada 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -56,6 +56,37 @@ public sealed class InstallTests Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); } + [Fact] + public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode() + { + string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; + using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" })); + Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync()); + + string code = (await CreateAsync("probe"))["plaintext"]!.GetValue(); + using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); + Assert.Equal(HttpStatusCode.OK, served.StatusCode); + Assert.Equal("no-store", served.Headers.CacheControl?.ToString()); + string body = await served.Content.ReadAsStringAsync(); + Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 + using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code); + Assert.Equal(HttpStatusCode.OK, signature.StatusCode); + Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync())); + + Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode); + } + + [Fact] + public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest() + { + string code = (await CreateAsync("agent"))["plaintext"]!.GetValue(); + string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code); + Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal); + Assert.DoesNotContain("@@", script, StringComparison.Ordinal); + Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal); + } + [Fact] public async Task Codes_RecordWhoCreatedThem() { diff --git a/docs/deployment.md b/docs/deployment.md index 6726c11..1693e8c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -307,6 +307,8 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +一行安装会验证镜像:脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`),签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 才会按标签安装。脚本本身的完整性(哈希核对)见 [access.md](access.md) 第 15 节。 + ## Backend 签名更新 Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index 5c43dea..b3a662c 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -1,13 +1,41 @@ # MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: # irm 'https://panel/install.ps1?code=jt_…' | iex +# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。面板还没有签名的清单时,设 $env:MOSHIRO_ALLOW_UNVERIFIED='1' 才会按标签安装。 # 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。 # 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。 +function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) { + # 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s + $b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join '' + $spki = [Convert]::FromBase64String($b64.Trim()) + $xy = $spki[($spki.Length - 64)..($spki.Length - 1)] + $blob = [byte[]]@(0x45, 0x43, 0x53, 0x31, 0x20, 0, 0, 0) + $xy + $i = 2 + if ($derSignature[1] -band 0x80) { $i = 2 + ($derSignature[1] -band 0x7f) } + $parts = @() + for ($n = 0; $n -lt 2; $n++) { + $len = $derSignature[$i + 1] + $part = [byte[]]$derSignature[($i + 2)..($i + 1 + $len)] + while ($part.Length -gt 32 -and $part[0] -eq 0) { $part = [byte[]]$part[1..($part.Length - 1)] } + if ($part.Length -gt 32) { return $false } + $parts += , ([byte[]](New-Object byte[] (32 - $part.Length)) + $part) + $i += 2 + $len + } + $key = [Security.Cryptography.CngKey]::Import($blob, [Security.Cryptography.CngKeyBlobFormat]::EccPublicBlob) + try { + $ecdsa = New-Object Security.Cryptography.ECDsaCng($key) + return $ecdsa.VerifyData($data, [byte[]]($parts[0] + $parts[1]), [Security.Cryptography.HashAlgorithmName]::SHA256) + } finally { $key.Dispose() } +} + function Install-MoShiro { $ErrorActionPreference = 'Stop' $role = '@@ROLE@@' $server = '@@SERVER@@' $code = '@@CODE@@' $image = '@@IMAGE@@' + $releaseKey = @' +@@RELEASE_KEY@@ +'@ $name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME } $name = $name -replace '[^A-Za-z0-9._-]', '-' @@ -34,6 +62,34 @@ function Install-MoShiro { return } + # 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装 + $client = New-Object System.Net.WebClient + $manifest = $null + try { + $manifest = $client.DownloadData("$server/install/manifest?code=$code") + $signature = [Convert]::FromBase64String(($client.DownloadString("$server/install/manifest.sig?code=$code")).Trim()) + } catch { + $manifest = $null + } + if ($null -eq $manifest) { + if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1') { + Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. Run again with `$env:MOSHIRO_ALLOW_UNVERIFIED='1' to install $image without verification" + return + } + Write-Host "warning: installing $image without signature verification" + } else { + if (-not (Test-MoShiroSignature $manifest $signature $releaseKey)) { + Write-Error 'the release signature does not verify against the built-in release key; refusing to install' + return + } + $release = [Text.Encoding]::UTF8.GetString($manifest) | ConvertFrom-Json + if ($release.component -ne $role) { Write-Error "the signed release is for '$($release.component)', not '$role'"; return } + if ($release.digest -notmatch '^sha256:[0-9a-f]{64}$') { Write-Error 'the signed release has a malformed digest'; return } + if ($release.image -notmatch '^[A-Za-z0-9._/:-]+$') { Write-Error 'the signed release has a malformed image name'; return } + $image = "$($release.image)@$($release.digest)" + Write-Host "verified release $($release.version) ($($release.tag)): $image" + } + Write-Host "pulling $image" docker pull $image | Out-Null if ($LASTEXITCODE -ne 0) { diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 1a012ac..04d3271 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -2,13 +2,16 @@ # MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好: # curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh) # 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。 -# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、 +# MOSHIRO_ALLOW_UNVERIFIED=1(面板还没有已签名的发布清单时,不验签、按标签安装;默认拒绝)。 +# 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。 set -eu ROLE='@@ROLE@@' SERVER='@@SERVER@@' CODE='@@CODE@@' IMAGE='@@IMAGE@@' +RELEASE_KEY='@@RELEASE_KEY@@' say() { printf '%s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } @@ -39,6 +42,42 @@ volume="moshiro-$ROLE-config" linux=0 [ "$(uname -s)" = Linux ] && linux=1 +if [ "${MOSHIRO_DRY_RUN:-}" != 1 ] && eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null /dev/null /dev/null 2>&1 || die "openssl is required to verify the release signature" + printf '%s\n' "$RELEASE_KEY" > "$tmp/key.pem" + openssl base64 -d -A -in "$tmp/sig.b64" -out "$tmp/sig.bin" 2>/dev/null || die "the release signature is not valid base64" + openssl dgst -sha256 -verify "$tmp/key.pem" -signature "$tmp/sig.bin" "$tmp/manifest" >/dev/null 2>&1 \ + || die "the release signature does not verify against the built-in release key; refusing to install" + field() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" "$tmp/manifest" | head -n 1; } + [ "$(field component)" = "$ROLE" ] || die "the signed release is for '$(field component)', not '$ROLE'" + repo="$(field image)" + digest="$(field digest)" + case "$digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) die "the signed release has a malformed digest" ;; esac + case "$repo" in ''|*[!A-Za-z0-9._/:-]*) die "the signed release has a malformed image name" ;; esac + IMAGE="$repo@$digest" + say "verified release $(field version) ($(field tag)): $IMAGE" +} +resolve_image + restart=unless-stopped [ "$engine" = podman ] && restart=always set -- run -d --name "$container" --restart "$restart" \ @@ -64,13 +103,6 @@ if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then exit 0 fi -if eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null