using System.Text.RegularExpressions; using Fleet.Core; using Fleet.Identity; namespace Fleet.Server; /// 注册流程:校验令牌 → 校验持有证明 → 创建节点 → 触发产品挂钩。与 HTTP 无关,便于测试。 public sealed partial class FleetEnrollmentService { private readonly IFleetStore _store; private readonly IEnumerable _hooks; private readonly IEnumerable _trust; private readonly FleetServerOptions _options; private readonly TimeProvider _time; public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, IEnumerable trust, FleetServerOptions options, TimeProvider time) { _store = store; _hooks = hooks; _trust = trust; _options = options; _time = time; } public async ValueTask> EnrollAsync(EnrollRequest request, string? remoteIp, CancellationToken ct) { if (string.IsNullOrEmpty(request.JoinToken) || !NamePattern().IsMatch(request.Name)) { return Result.Fail(FleetErrors.InvalidParams, "join_token and a valid name are required"); } if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey) || !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32 || !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey) || !TryDecode(request.Proof, out byte[] proof)) { return Result.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed"); } // 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的 if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki)) { return Result.Fail(FleetErrors.InvalidParams, "tls_spki does not match tls_pubkey"); } byte[] tokenHash = TokenHash.Sha256(request.JoinToken); JoinTokenRecord? token = await _store.FindJoinTokenAsync(tokenHash, ct); long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); } if (token.ExpiresAtMs <= nowMs) { return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); } if (!NodeIdentity.Verify(publicKey, proof, SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), request.PublicKey)) { return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify"); } if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct)) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); } // 同名节点已存在时进入 pending,由管理员选择“接管”或“作为新节点”。 bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null; NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending; string prefix = token.Kind.Length > 0 && _options.NodeIdPrefixes.TryGetValue(token.Kind, out string? kindPrefix) ? kindPrefix : _options.NodeIdPrefix; var node = new NewNode( new NodeId(Ulid.NewPrefixed(prefix, _time)), request.Name, status, publicKey, tlsSpki, token.Labels, request.Meta?.GetRawText(), request.NodeVersion, nowMs, token.Kind, tlsPublicKey, token.CreatedBy); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) { return Result.Fail(error); } var context = new EnrollmentContext(remoteIp, request.Meta); foreach (IEnrollmentHook hook in _hooks) { await hook.OnEnrolledAsync(record, context, ct); } return Result.Ok(new EnrollResponse { NodeId = record.Id.Value, Status = record.Status == NodeStatus.Active ? "active" : "pending", }); } /// 节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。 public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct) { JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct); long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); } if (token.ExpiresAtMs <= nowMs) { return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); } var bundle = new TrustBundle(); foreach (IOperatorTrustSource source in _trust) { bundle.Chain.AddRange(await source.ChainAsync(ct)); } return Result.Ok(bundle); } private static bool TryDecode(string text, out byte[] data) { try { data = Convert.FromBase64String(text); return data.Length > 0; } catch (FormatException) { data = []; return false; } } [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] private static partial Regex NamePattern(); }