using Fleet.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// /// 信任根(docs/security/access.md 第 8 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; /// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 /// public static class TrustEndpoints { private const string Tag = "admin-trust"; public static void Map(RouteGroupBuilder guarded) { guarded.MapGet("/trust", StateAsync) .Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag) .Returns().ProducesProblems(401, 403) .Requires(Capability.FleetRead); guarded.MapPost("/trust", CommitAsync) .Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Requires(Capability.AccountsManage); } private static async Task StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options) { TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted); http.Response.Headers.CacheControl = "no-store"; string sh = InstallScripts.Sha256(InstallScripts.Sh); string ps1 = InstallScripts.Sha256(InstallScripts.Ps1); return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto); } /// 面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。 private static async Task MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct) { foreach (string component in new[] { ReleaseService.AgentComponent }) { if (await releases.GetAsync(component, ct) is not { } offer) { continue; } using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest); if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1)) { return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal); } } return null; } private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options) { PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); Result result = await trust.CommitAsync(signed, context, http.RequestAborted); if (!result.TryGet(out TrustState? state, out FleetError? error)) { return Problems.From(http, error); } await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点 http.SetDetail($"operator set v{state.Version}"); return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); } }