Files
MoShiro/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs
T
justme 636a784043
ci/woodpecker/push/main-amd64-build Pipeline is running
fix: the panel polls the registry for its own and the nodes' releases [CI]
Registering a manifest with the panel is best effort in CI (an unreachable
panel only warns, the pipeline stays green). The Backend update notice then
never appeared, because the host updater only reads the mailbox the panel
writes. A background poller now fetches the signed manifests of the agent, the
probe and the backend itself from the registry every five minutes, verifies the
release signature and registers them, writing the backend one into the host
updater's mailbox.
2026-10-08 19:09:21 +08:00

180 lines
9.6 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 面板没有登记过清单时(清过库、CI 登记那一步网络失败),直接从镜像仓库里匿名读取签名清单;取回来的要用 CI 的发布公钥验过才采用,
/// 所以仓库(或者路上的人)给一份伪造的,面板拒绝,安装脚本也就得不到它。
/// </summary>
public sealed class ReleaseFeedTests : IAsyncLifetime
{
private WebApplication _registry = null!;
private string _registryUrl = "";
private string _label = "";
private string _backendLabel = "";
private string _backendSignature = "";
public async ValueTask InitializeAsync()
{
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(["--urls", "http://127.0.0.1:0"]);
_registry = builder.Build();
const string ConfigDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000001";
_registry.MapGet("/v2/moshiro-dev/moshiro-agent-manifest/manifests/dev", () => Results.Text(new JsonObject { ["schemaVersion"] = 2, ["config"] = new JsonObject { ["digest"] = ConfigDigest } }.ToJsonString(), "application/vnd.oci.image.manifest.v1+json"));
_registry.MapGet("/v2/moshiro-dev/moshiro-agent-manifest/blobs/" + ConfigDigest, () => Results.Text(
new JsonObject { ["config"] = new JsonObject { ["Labels"] = new JsonObject { ["org.moshiro.manifest"] = _label, ["org.moshiro.signature"] = "c2ln" } } }.ToJsonString(), "application/json"));
const string BackendConfigDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000002";
_registry.MapGet("/v2/moshiro-dev/moshiro-backend-manifest/manifests/dev", () => Results.Text(new JsonObject { ["schemaVersion"] = 2, ["config"] = new JsonObject { ["digest"] = BackendConfigDigest } }.ToJsonString(), "application/vnd.oci.image.manifest.v1+json"));
_registry.MapGet("/v2/moshiro-dev/moshiro-backend-manifest/blobs/" + BackendConfigDigest, () => Results.Text(
new JsonObject { ["config"] = new JsonObject { ["Labels"] = new JsonObject { ["org.moshiro.manifest"] = _backendLabel, ["org.moshiro.signature"] = _backendSignature } } }.ToJsonString(), "application/json"));
await _registry.StartAsync();
_registryUrl = _registry.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
}
public async ValueTask DisposeAsync()
{
await _registry.StopAsync();
await _registry.DisposeAsync();
}
[Fact]
public async Task TheManifestAndSignature_AreReadFromTheLabelsOfTheSmallImage()
{
string manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}";
_label = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest));
using var feed = new ReleaseFeed(new BackendOptions { NodeImageRegistry = _registryUrl });
(string Manifest, string Signature)? fetched = await feed.FetchAsync("agent", "dev", default);
Assert.Equal(manifest, fetched!.Value.Manifest);
Assert.Equal("c2ln", fetched.Value.Signature);
}
[Theory]
[InlineData("probe", "dev")] // 仓库里没有这个组件的清单
[InlineData("agent", "release")] // 没有这个通道
public async Task NothingThere_MeansNull_NotAnException(string component, string channel)
{
using var feed = new ReleaseFeed(new BackendOptions { NodeImageRegistry = _registryUrl });
Assert.Null(await feed.FetchAsync(component, channel, default));
}
[Fact]
public async Task AnUnreachableOrEmptyRegistry_MeansNull()
{
using var unreachable = new ReleaseFeed(new BackendOptions { NodeImageRegistry = "http://127.0.0.1:1" });
using var empty = new ReleaseFeed(new BackendOptions { NodeImageRegistry = "" });
Assert.Null(await unreachable.FetchAsync("agent", "dev", default));
Assert.Null(await empty.FetchAsync("agent", "dev", default));
}
[Fact]
public async Task ThePanelFindsItsOwnNewRelease_InTheRegistry_AndLeavesItForTheHostUpdater()
{
// CI 把清单登记给面板那一步失败了(只警告):面板自己去仓库取,验过签就登记,并写进宿主机更新器的信箱
long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds();
string manifest = $$"""{"schema":1,"component":"backend","channel":"dev","image":"registry.example/moshiro-dev/moshiro-backend","digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","tag":"dev-new","version":"0.1.1","seq":{{seq}}}""";
_backendLabel = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest));
_backendSignature = TestReleaseKey.Sign(manifest);
string directory = Path.Combine(Path.GetTempPath(), "moshiro-feed-" + Guid.NewGuid().ToString("N"));
await using WebApplication panel = BackendHost.Build(
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
options =>
{
options.DataDirectory = directory;
options.Pbkdf2Iterations = 1000;
options.SecureCookies = false;
options.NodeImageRegistry = _registryUrl;
options.UpdateChannel = "dev";
options.GeoLookupUrl = string.Empty;
options.RequireOwner = false;
},
configureServices: services => services.AddSingleton<IReleaseKey>(TestReleaseKey.Instance));
await panel.StartAsync();
try
{
ReleaseService releases = panel.Services.GetRequiredService<ReleaseService>();
Assert.Null(await releases.GetBackendAsync("dev", default));
ReleaseOffer? offer = await releases.GetOrFetchAsync("backend", default);
Assert.Equal(seq, offer!.Seq);
Assert.Equal(seq, (await releases.GetBackendAsync("dev", default))!.Seq);
Assert.Equal(manifest, await File.ReadAllTextAsync(Path.Combine(directory, "update", "offer.manifest")));
}
finally
{
await panel.StopAsync();
await panel.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// 系统临时目录里的残留
}
}
}
[Fact]
public async Task AForgedManifestInTheRegistry_IsNeverServedToTheInstaller()
{
// 起一个指向这个假仓库的后端:仓库里是一份没有 CI 签名的清单,安装脚本向面板要清单时必须拿不到
string directory = Path.Combine(Path.GetTempPath(), "moshiro-feed-" + Guid.NewGuid().ToString("N"));
_label = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"schema\":1,\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"evil/agent\",\"digest\":\"sha256:" + new string('a', 64) + "\",\"tag\":\"x\",\"version\":\"9\",\"seq\":999999999999}"));
await using WebApplication panel = BackendHost.Build(
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
options =>
{
options.DataDirectory = directory;
options.Pbkdf2Iterations = 1000;
options.SecureCookies = false;
options.ClaimCode = "FEED-TEST-CODE";
options.NodeImageRegistry = _registryUrl;
options.UpdateChannel = "dev";
options.GeoLookupUrl = string.Empty;
options.RequireOwner = false;
});
await panel.StartAsync();
try
{
using var http = new HttpClient { BaseAddress = new Uri(panel.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
using HttpResponseMessage setup = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = "FEED-TEST-CODE", ["username"] = "owner", ["password"] = "correct horse battery", ["return_session"] = true }));
Assert.Equal(HttpStatusCode.Created, setup.StatusCode);
http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", JsonNode.Parse(await setup.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
using HttpResponseMessage created = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
string code = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using HttpResponseMessage manifest = await http.GetAsync("/install/manifest?code=" + Uri.EscapeDataString(code));
Assert.Equal(HttpStatusCode.NotFound, manifest.StatusCode); // 没有有效的 CI 签名,面板不采用
}
finally
{
await panel.StopAsync();
await panel.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// 系统临时目录里的残留
}
}
}
}