73 lines
3.9 KiB
C#
73 lines
3.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
|
|
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
|
|
/// </summary>
|
|
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<TrustStateDto>().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<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
|
.Requires(Capability.AccountsManage);
|
|
}
|
|
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
|
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
|
{
|
|
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
|
{
|
|
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<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
|
{
|
|
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
|
|
Result<TrustState> 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);
|
|
}
|
|
}
|