145 lines
7.0 KiB
C#
145 lines
7.0 KiB
C#
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);
|
||
}
|
||
}
|