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]
This commit is contained in:
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><命名空间>/moshiro-<组件>-manifest:<通道></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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user