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

+3
View File
@@ -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: []
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.2.0</Version>
<Version>0.3.0</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
@@ -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");
@@ -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<string> Binaries);
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
private Official? VerifyManifest(BinaryEvidence evidence, string component)
private async ValueTask<Official?> 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;
@@ -39,6 +39,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AuditService>();
services.AddSingleton<ServerIdentityService>();
services.AddSingleton<SetupService>();
services.AddSingleton<ReleaseFeed>();
services.AddSingleton<BinaryBindingService>();
services.AddSingleton<NodeOwnerService>();
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
@@ -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)
{
@@ -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)
{
// 系统临时目录里的残留
}
}
}
}
+10 -1
View File
@@ -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
+3
View File
@@ -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、换钥、仓库不可用回退、首次部署的验签)。
+2 -2
View File
@@ -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."
+2 -2
View File
@@ -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; then
if [ "${MOSHIRO_DRY_RUN:-}" != 1 ] && eng container inspect "$container" >/dev/null 2>&1 </dev/null; then
eng start "$container" >/dev/null </dev/null
say "$container is already installed and running."
say "reinstall: $engine rm -f $container && $engine volume rm $volume (then run the install command again)"
@@ -141,7 +141,7 @@ fi
# 等注册完成:第一次启动会用上面的令牌注册,成功之后才算装好
i=0
while [ "$i" -lt 30 ]; do
state="$(eng inspect -f '{{.State.Running}}' "$container" 2>/dev/null </dev/null || echo false)"
state="$(eng container inspect -f '{{.State.Running}}' "$container" 2>/dev/null </dev/null || echo false)"
logs="$(eng logs "$container" 2>&1 </dev/null || true)"
case "$logs" in
*"link up"*) say "done: $ROLE '$name' is online. It shows up in the panel now."; say "logs: $engine logs -f $container"; exit 0 ;;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "moshiro-web",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"workspaces": [
"display",
"admin"