Files
MoShiro/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs
T

145 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
/// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
/// 生成身份密钥和 TLS 证书 → 用身份私钥签名持有证明 → 调用注册端点 → 写入身份目录。
/// 注册逻辑只存在于这里,常驻的业务进程不包含注册代码,也不使用 HttpClient。
/// </summary>
public static class EnrollmentClient
{
public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
string? genesis = null, IReadOnlyList<string>? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
{
return Result<EnrollmentResult>.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)和属主(--controllers):先取操作员集合链,从固定的创世验起,
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
if (genesis is not null)
{
if (controllers is not { Count: > 0 })
{
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
}
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
return Result<EnrollmentResult>.Fail(trustError);
}
}
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,
};
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<string>() ?? code;
detail = problem?["detail"]?.GetValue<string>() ?? detail;
}
catch (JsonException)
{
// 不是 problem+json:原样返回
}
return Result<EnrollmentResult>.Fail(new ErrorCode(code), $"{(int)response.StatusCode}: {detail}");
}
JsonNode result = JsonNode.Parse(text)!;
string nodeId = result["node_id"]!.GetValue<string>();
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
if (bundle is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// 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<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
}
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
{
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
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>.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 is null || bundle.Chain.Count == 0)
{
return Refuse("the panel has no operator trust root set up");
}
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)");
}
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
return Refuse("an owner passkey in the install command is not in the operator set");
}
return Result<TrustBundle>.Ok(bundle);
}
}