using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Fleet.Core; using Fleet.Hosting; using Fleet.Identity; namespace Fleet.Enrollment.Client; [JsonSerializable(typeof(TrustBundle))] internal sealed partial class TrustBundleContext : JsonSerializerContext; public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false); /// /// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具): /// 生成身份密钥和 TLS 证书 → 用身份私钥签名持有证明 → 调用注册端点 → 写入身份目录。 /// 注册逻辑只存在于这里,常驻的业务进程不包含注册代码,也不使用 HttpClient。 /// public static class EnrollmentClient { public static async Task> EnrollAsync( HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, string? seed = null, string? genesis = null, string role = "agent") { string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); if (File.Exists(nodeFile)) { return Result.Fail(FleetErrors.NameConflict, $"{nodeFile} already exists: this node is already enrolled (remove the directory to enroll again)"); } using NodeIdentity identity = NodeIdentity.Generate(); byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time); byte[] spki; byte[] tlsPublicKey; using (var certificate = NodeCertificate.LoadPfx(pfx)) { spki = NodeCertificate.SpkiFingerprint(certificate); tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo(); } // 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的, // 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。 Binding? binding = null; string? bindingStatement = null; string? bindingSignature = null; TrustBundle? bundle = null; if (seed is not null || genesis is not null) { Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted = await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct); if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError)) { return Result.Fail(trustError); } bundle = value.Bundle; binding = value.Binding; bindingStatement = value.Statement; bindingSignature = value.Signature; } string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken))); var body = new JsonObject { ["join_token"] = joinToken, ["name"] = name, ["pubkey"] = pubkey, ["tls_spki"] = Convert.ToBase64String(spki), ["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey), ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["node_version"] = nodeVersion, ["meta"] = productMeta, ["binding"] = bindingStatement, ["binding_signature"] = bindingSignature, }; using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll"), content, ct); string text = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { string code = "fleet_internal"; string detail = text; try { JsonNode? problem = JsonNode.Parse(text); code = problem?["code"]?.GetValue() ?? code; detail = problem?["detail"]?.GetValue() ?? detail; } catch (JsonException) { // 不是 problem+json:原样返回 } return Result.Fail(new ErrorCode(code), $"{(int)response.StatusCode}: {detail}"); } JsonNode result = JsonNode.Parse(text)!; string nodeId = result["node_id"]!.GetValue(); var store = new ConfigStore(identityDirectory); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.TlsFileName, pfx); if (bundle is not null && binding is not null) { TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! }); } // node.json 最后写:它存在就表示注册完整完成 store.Write( LoadedNodeIdentity.NodeFileName, JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null)); } private static async Task> EstablishTrustAsync( HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct) { static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); if (seedText is null || genesis is null) { return Refuse("--seed and --genesis go together (the panel's install command has both)"); } if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32) { return Refuse("the code seed is malformed"); } if (OperatorTrust.AuthToken(seed) != joinToken) { return Refuse("the join token is not derived from the code seed"); } using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json"); using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct); if (!response.IsSuccessStatusCode) { return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); } TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle); if (bundle?.Grant is null || bundle.Chain.Count == 0) { return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator"); } OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]); if (current is null) { return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)"); } Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement); long now = time.GetUtcNow().ToUnixTimeMilliseconds(); if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current)) { return Refuse("the grant is not signed by your operator passkey"); } if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs) { return Refuse("the grant is not for this code, this role, or this time"); } using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); if (codeKey is null) { return Refuse("the code seed does not match the grant's code key"); } var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now); string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement); return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature)); } }