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

@@ -0,0 +1,90 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace MoShiro.Backend.Application;
/// <summary>
/// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <c>&lt;命名空间&gt;/moshiro-&lt;组件&gt;-manifest:&lt;通道&gt;</c> 这个没有文件层的小镜像的标签里
/// (<c>org.moshiro.manifest</c> 是 base64 的清单,<c>org.moshiro.signature</c> 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败),
/// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。
/// </summary>
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();
/// <summary>取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。</summary>
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<string> 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();
}
@@ -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<string, long> _feedChecked = new();
private readonly ConcurrentDictionary<string, UpdateStatus> _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
}
}
/// <summary>
/// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的:
/// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。
/// </summary>
public async ValueTask<ReleaseOffer?> 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<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
if (published.IsSuccess)
{
return published.Value;
}
}
return stored;
}
public async ValueTask<ReleaseOffer?> 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)
{