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