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