feat: the one-line installer verifies the CI-signed release manifest and pulls the image by digest [CI]
This commit is contained in:
1 parent
a19e6548d3
commit
310b2b5b19
8 files changed
+227
-8
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -9,12 +9,22 @@ namespace MoShiro.Backend.Api;
|
||||
/// </summary>
|
||||
public static class InstallScripts
|
||||
{
|
||||
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本用它验发布清单的签名。</summary>
|
||||
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)
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<IResult> 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<IResult> InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
|
||||
@@ -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<string>();
|
||||
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>();
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user