fix: installer checks containers only; the panel reads signed manifests from the registry when it has none; unsigned-updater nodes are labeled from the panel's manifests; sign node release images; release 0.3.0 [CI]
ci/woodpecker/push/main-amd64-build Pipeline was canceled
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/gitea-release unknown status

This commit is contained in:
justme committed 2026-10-08 15:59:28 +08:00
1 parent 4a1e48db20
commit 879d1c8593
15 files changed
+372 -18

No files matched your search

@@ -22,6 +22,7 @@ public sealed class BinaryBindingTests : IDisposable
private readonly ECDsa _ci = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly ECDsa _other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly MemoryStore _store = new();
private readonly MemorySettings _settings = new();
private readonly NodeRecord _agent = Node(AgentIds.Prefix + "_test1");
public void Dispose()
@@ -35,7 +36,7 @@ public sealed class BinaryBindingTests : IDisposable
private static NodeRecord Node(string id) => new(new NodeId(id), "n", NodeStatus.Active, [1], [2], [], null, null, 0, null);
private BinaryBindingService Service(string policy = "enforce") =>
new(_store, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System);
new(_store, _settings, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System);
private sealed class TestKey(ECDsa key) : IReleaseKey
{
@@ -208,6 +209,86 @@ public sealed class BinaryBindingTests : IDisposable
Assert.True((await service.CheckAsync(second, Evidence(H('b')), default)).Accept);
}
/// <summary>面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。</summary>
private void PanelHolds(string component, long seq, string[] binaries, ECDsa? signer = null)
{
string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries)}\"}}";
_settings.Values["release." + component + ".manifest"] = manifest;
_settings.Values["release." + component + ".signature"] = Sign(signer ?? _ci, manifest);
}
[Fact]
public async Task ANodeWithoutAnUpdater_IsMarkedSigned_WhenThePanelHoldsAManifestThatListsItsBinary()
{
// 一键容器安装的节点没有宿主机更新器,握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份
PanelHolds("agent", seq: 100, [H('a')]);
BinaryBindingService service = Service();
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
Assert.True(bound.Signed);
Assert.Equal(100, bound.Seq);
}
[Fact]
public async Task TheManifestThePanelHolds_NeverRefusesANodeAtFirstBinding_OnlyLabelsIt()
{
// 老一点的官方版本、自己构建的版本:不在面板手里最新的清单里,但没有证据说它不是官方的,所以不拒绝,只是不标成已签名
PanelHolds("agent", seq: 100, [H('b')]);
BinaryBindingService service = Service();
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
Assert.False(bound.Signed);
Assert.Equal(0, bound.Seq);
}
[Fact]
public async Task AManifestThePanelHoldsThatIsSignedByAnotherKey_ProvesNothing()
{
PanelHolds("agent", seq: 100, [H('a')], signer: _other);
BinaryBindingService service = Service();
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed);
}
[Fact]
public async Task AnUpgradeOfANodeWithoutAnUpdater_IsFollowedFromTheManifestThePanelHolds()
{
PanelHolds("agent", seq: 100, [H('a')]);
BinaryBindingService service = Service();
await service.CheckAsync(_agent, Evidence(H('a')), default);
// 之后发布了新版,面板登记了新的清单;节点换成新二进制重连
PanelHolds("agent", seq: 200, [H('b')]);
Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
Assert.Equal(200, (await _store.GetAsync(_agent.Id.Value, default))!.Seq);
// 没人签过的二进制仍然被拒绝
Assert.False((await service.CheckAsync(_agent, Evidence(H('c')), default)).Accept);
}
private sealed class MemorySettings : ISettingsStore
{
public Dictionary<string, string> Values { get; } = [];
public ValueTask<string?> GetAsync(string key, CancellationToken ct) => new(Values.GetValueOrDefault(key));
public ValueTask SetAsync(string key, string value, CancellationToken ct)
{
Values[key] = value;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct) => new(Values);
public ValueTask<long> IncrementAsync(string key, CancellationToken ct) => new(0);
}
private sealed class MemoryStore : INodeBindingStore
{
private readonly Dictionary<string, NodeBinding> _rows = [];
@@ -91,6 +91,9 @@ public sealed class OwnerEndToEndTests : IDisposable
string script = await anonymous.GetStringAsync(path);
Assert.Contains("MOSHIRO_ALLOW_NO_OWNER", script, StringComparison.Ordinal);
Assert.Contains("this node has no owner", script, StringComparison.Ordinal);
// 只按容器判断「已经装过」:podman 的 inspect 不分类型,本机残留的旧镜像会被误当成已安装的容器
Assert.Contains("container inspect", script, StringComparison.Ordinal);
Assert.DoesNotContain("inspect \"$container\"", script.Replace("container inspect", ""), StringComparison.Ordinal);
}
}
}
@@ -0,0 +1,123 @@
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 = "";
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"));
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 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)
{
// 系统临时目录里的残留
}
}
}
}