diff --git a/.woodpecker/gitea-release.yml b/.woodpecker/gitea-release.yml
index 322c7ce..0f59e12 100644
--- a/.woodpecker/gitea-release.yml
+++ b/.woodpecker/gitea-release.yml
@@ -27,6 +27,9 @@ steps:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
+ RELEASE_API_URL: https://mo.oeo.one
+ RELEASE_SIGNING_KEY:
+ from_secret: release_signing_key
commands:
- bash ci/release-node-images.sh
depends_on: []
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index c120fbd..ed79b1b 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -13,7 +13,7 @@
true
$(MSBuildThisFileDirectory)artifacts
true
- 0.2.0
+ 0.3.0
false
diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
index 5799778..603775d 100644
--- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
+++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs
@@ -42,7 +42,7 @@ public static class OpsEndpoints
{
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);
+ ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted);
if (offer is null)
{
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
diff --git a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs
index ceaf08c..55eca2e 100644
--- a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs
+++ b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs
@@ -29,12 +29,14 @@ public sealed class BinaryBindingService : IBinaryAttestor
public const string Off = "off";
private readonly INodeBindingStore _store;
+ private readonly ISettingsStore _settings;
private readonly IReleaseKey _key;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
- public BinaryBindingService(INodeBindingStore store, IReleaseKey key, BackendOptions options, TimeProvider time)
+ public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
{
+ _settings = settings;
_store = store;
_key = key;
_options = options;
@@ -72,18 +74,22 @@ public sealed class BinaryBindingService : IBinaryAttestor
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
}
- Official? official = VerifyManifest(evidence, component);
+ // 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
+ // 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
+ Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
+ bool carried = official is not null;
+ official ??= await StoredOfficialAsync(component, ct);
bool listed = official is not null && official.Binaries.Contains(hash);
if (binding is null)
{
- if (official is not null && !listed)
+ if (carried && !listed)
{
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
}
- await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official?.Seq ?? 0, listed, now, null, null), ct);
+ await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
return new BinaryDecision(true);
}
@@ -120,10 +126,13 @@ public sealed class BinaryBindingService : IBinaryAttestor
private sealed record Official(long Seq, HashSet Binaries);
/// 用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。
- private Official? VerifyManifest(BinaryEvidence evidence, string component)
+ private async ValueTask StoredOfficialAsync(string component, CancellationToken ct) =>
+ VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
+
+ private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
{
- if (evidence.Manifest is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
- || evidence.Signature is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
+ if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
+ || signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
{
return null;
diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
index d680d0e..c5bb491 100644
--- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs
@@ -39,6 +39,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseFeed.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseFeed.cs
new file mode 100644
index 0000000..b4f5394
--- /dev/null
+++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseFeed.cs
@@ -0,0 +1,90 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace MoShiro.Backend.Application;
+
+///
+/// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <命名空间>/moshiro-<组件>-manifest:<通道> 这个没有文件层的小镜像的标签里
+/// (org.moshiro.manifest 是 base64 的清单,org.moshiro.signature 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败),
+/// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。
+///
+public sealed partial class ReleaseFeed : IDisposable
+{
+ private const int MaxBytes = 1 << 20;
+
+ private readonly BackendOptions _options;
+ private readonly HttpClient _http;
+
+ public ReleaseFeed(BackendOptions options)
+ {
+ _options = options;
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ }
+
+ public void Dispose() => _http.Dispose();
+
+ /// 取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。
+ public async ValueTask<(string Manifest, string Signature)?> FetchAsync(string component, string channel, CancellationToken ct)
+ {
+ string registry = (_options.NodeImageRegistry ?? "").Trim().TrimEnd('/');
+ if (registry.Length == 0)
+ {
+ return null;
+ }
+
+ string baseUrl = registry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || registry.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? registry : "https://" + registry;
+ string repository = $"{(channel == "dev" ? "moshiro-dev" : "moshiro")}/moshiro-{component}-manifest";
+ try
+ {
+ string manifestJson = await GetAsync($"{baseUrl}/v2/{repository}/manifests/{channel}", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", ct);
+ using JsonDocument manifest = JsonDocument.Parse(manifestJson);
+ string? digest = manifest.RootElement.GetProperty("config").GetProperty("digest").GetString();
+ if (digest is null || !Digest().IsMatch(digest))
+ {
+ return null;
+ }
+
+ string configJson = await GetAsync($"{baseUrl}/v2/{repository}/blobs/{digest}", "application/json", ct);
+ using JsonDocument config = JsonDocument.Parse(configJson);
+ JsonElement labels = config.RootElement.GetProperty("config").GetProperty("Labels");
+ string? encoded = labels.GetProperty("org.moshiro.manifest").GetString();
+ string? signature = labels.GetProperty("org.moshiro.signature").GetString();
+ return encoded is null || signature is null ? null : (Encoding.UTF8.GetString(Convert.FromBase64String(encoded)), signature);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
+ {
+ return null;
+ }
+ }
+
+ private async Task GetAsync(string url, string accept, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.TryAddWithoutValidation("Accept", accept);
+ using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+ if (response.Content.Headers.ContentLength is > MaxBytes)
+ {
+ throw new InvalidOperationException("the registry response is too large");
+ }
+
+ await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
+ using var limited = new MemoryStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = await stream.ReadAsync(buffer, ct)) > 0)
+ {
+ limited.Write(buffer, 0, read);
+ if (limited.Length > MaxBytes)
+ {
+ throw new InvalidOperationException("the registry response is too large");
+ }
+ }
+
+ return Encoding.UTF8.GetString(limited.ToArray());
+ }
+
+ [GeneratedRegex("^sha256:[0-9a-f]{64}$")]
+ private static partial Regex Digest();
+}
diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs
index 75658c6..41ebd5f 100644
--- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs
+++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs
@@ -26,10 +26,15 @@ public sealed partial class ReleaseService : IDisposable
private readonly IAgentGateway _gateway;
private readonly IBackendUpdateFiles _backendFiles;
private readonly BackendOptions _options;
+ private readonly IReleaseKey _key;
+ private readonly ReleaseFeed _feed;
+ private readonly ConcurrentDictionary _feedChecked = new();
private readonly ConcurrentDictionary _status = new();
- public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options)
+ public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, IReleaseKey key, ReleaseFeed feed)
{
+ _key = key;
+ _feed = feed;
_settings = settings;
_gateway = gateway;
_backendFiles = backendFiles;
@@ -84,6 +89,33 @@ public sealed partial class ReleaseService : IDisposable
}
}
+ ///
+ /// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的:
+ /// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。
+ ///
+ public async ValueTask GetOrFetchAsync(string component, CancellationToken ct)
+ {
+ ReleaseOffer? stored = await GetAsync(component, ct);
+ long now = Environment.TickCount64;
+ long wait = stored is null ? 30_000 : 600_000; // 没有就 30 秒试一次,已经有了就隔 10 分钟看一眼有没有更新的
+ if (_feedChecked.TryGetValue(component, out long last) && now - last < wait)
+ {
+ return stored;
+ }
+
+ _feedChecked[component] = now;
+ if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && ReleaseSignature.Verify(_key.Pem, fetched.Manifest, fetched.Signature))
+ {
+ Result published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
+ if (published.IsSuccess)
+ {
+ return published.Value;
+ }
+ }
+
+ return stored;
+ }
+
public async ValueTask GetAsync(string component, CancellationToken ct)
{
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
@@ -150,7 +182,7 @@ public sealed partial class ReleaseService : IDisposable
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
: null;
- private static string Key(string component, string part) => $"release.{component}.{part}";
+ internal static string Key(string component, string part) => $"release.{component}.{part}";
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
{
diff --git a/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs
index f5b034f..5febd21 100644
--- a/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs
@@ -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);
}
+ /// 面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。
+ 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 Values { get; } = [];
+
+ public ValueTask 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> GetAllAsync(CancellationToken ct) => new(Values);
+
+ public ValueTask IncrementAsync(string key, CancellationToken ct) => new(0);
+ }
+
private sealed class MemoryStore : INodeBindingStore
{
private readonly Dictionary _rows = [];
diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
index f8e2024..393b8c5 100644
--- a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
+++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs
@@ -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);
}
}
}
diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs
new file mode 100644
index 0000000..cead4f0
--- /dev/null
+++ b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs
@@ -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;
+
+///
+/// 面板没有登记过清单时(清过库、CI 登记那一步网络失败),直接从镜像仓库里匿名读取签名清单;取回来的要用 CI 的发布公钥验过才采用,
+/// 所以仓库(或者路上的人)给一份伪造的,面板拒绝,安装脚本也就得不到它。
+///
+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().Features.Get()!.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().Features.Get()!.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());
+ 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();
+
+ 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)
+ {
+ // 系统临时目录里的残留
+ }
+ }
+ }
+}
diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh
index debb513..547a87a 100644
--- a/ci/release-node-images.sh
+++ b/ci/release-node-images.sh
@@ -67,6 +67,8 @@ build_arch_image() {
"$gitea_base/api/packages/justme/generic/moshiro-node/$version/$filename" -o "$archive"
mkdir -p "$context/app" "$context/config"
tar -xzf "$archive" -C "$work" "moshiro-$component" fleet-enroll
+ # 镜像里装的就是这个文件:它的哈希写进签名的发布清单(binaries),节点握手时上报正在运行的二进制的哈希,面板据此认「官方二进制」
+ sha256sum "$work/moshiro-$component" | cut -d' ' -f1 >> "$work/$component.hashes"
install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll"
install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component"
: > "$context/config/.keep"
@@ -98,7 +100,7 @@ publish_index() {
buildah manifest create "$manifest" >/dev/null
buildah manifest add "$manifest" "docker://$image:$version-amd64" >/dev/null
buildah manifest add "$manifest" "docker://$image:$version-arm64" >/dev/null
- buildah manifest push --all --format oci "$manifest" "docker://$image:$version" >/dev/null
+ buildah manifest push --all --format oci --digestfile "$work/$component.digest" "$manifest" "docker://$image:$version" >/dev/null
if [[ "$version" != *-* ]]; then
buildah manifest push --all --format oci "$manifest" "docker://$image:latest" >/dev/null
fi
@@ -110,7 +112,14 @@ publish_index() {
}
for component in agent probe; do
+ rm -f "$work/$component.hashes"
build_arch_image "$component" amd64 musl-amd64
build_arch_image "$component" arm64 musl-arm64
publish_index "$component"
+ # 发布清单:给多架构镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:release),面板在线时再登记一份加速。
+ # 没有配置签名密钥(例如 fork)就只发镜像、不发清单。
+ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
+ digest="$(cat "$work/$component.digest")"
+ RELEASE_BINARIES="$(paste -sd, "$work/$component.hashes")" bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release"
+ fi
done
diff --git a/docs/deployment.md b/docs/deployment.md
index 731074c..a340b6e 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -335,6 +335,9 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给
```
- **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。
+- **面板自己取**:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议**匿名**读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。仓库要匿名可读(`moshiro/**`、`moshiro-dev/**`)。
+- **没有宿主机更新器的节点**(一键容器安装、二进制安装)握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份把节点的二进制标成「已签名」;不在清单里的只是不标「已签名」,不会因此被拒绝。
+- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,清单里带两个架构的二进制哈希,放到 `moshiro/…-manifest:release`。
- **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。
- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。
- 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。
diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1
index 05b2803..a288911 100644
--- a/packaging/install/install.ps1
+++ b/packaging/install/install.ps1
@@ -64,7 +64,7 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
$container = "moshiro-$role"
$volume = "moshiro-$role-config"
- docker inspect $container *> $null
+ docker container inspect $container *> $null
if ($LASTEXITCODE -eq 0) {
docker start $container | Out-Null
Write-Host "$container is already installed and running."
@@ -134,7 +134,7 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
# 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好
for ($i = 0; $i -lt 30; $i++) {
- $running = docker inspect -f '{{.State.Running}}' $container 2>$null
+ $running = docker container inspect -f '{{.State.Running}}' $container 2>$null
$logs = (docker logs $container 2>&1) -join "`n"
if ($logs -match 'link up') {
Write-Host "done: $role '$name' is online. It shows up in the panel now."
diff --git a/packaging/install/install.sh b/packaging/install/install.sh
index 26584fb..a98ab9f 100644
--- a/packaging/install/install.sh
+++ b/packaging/install/install.sh
@@ -54,7 +54,7 @@ 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 2>&1 /dev/null /dev/null /dev/null &1