Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
148 lines
6.0 KiB
C#
148 lines
6.0 KiB
C#
using System.Text.RegularExpressions;
|
|
using Fleet.Core;
|
|
using Fleet.Identity;
|
|
|
|
namespace Fleet.Server;
|
|
|
|
/// <summary>注册流程:校验令牌 → 校验持有证明 → 创建节点 → 触发产品挂钩。与 HTTP 无关,便于测试。</summary>
|
|
public sealed partial class FleetEnrollmentService
|
|
{
|
|
private readonly IFleetStore _store;
|
|
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
|
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
|
private readonly FleetServerOptions _options;
|
|
private readonly TimeProvider _time;
|
|
|
|
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
|
|
{
|
|
_store = store;
|
|
_hooks = hooks;
|
|
_trust = trust;
|
|
_options = options;
|
|
_time = time;
|
|
}
|
|
|
|
public async ValueTask<Result<EnrollResponse>> EnrollAsync(EnrollRequest request, string? remoteIp, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrEmpty(request.JoinToken) || !NamePattern().IsMatch(request.Name))
|
|
{
|
|
return Result<EnrollResponse>.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<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed");
|
|
}
|
|
|
|
// 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的
|
|
if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki))
|
|
{
|
|
return Result<EnrollResponse>.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<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
|
}
|
|
|
|
if (token.ExpiresAtMs <= nowMs)
|
|
{
|
|
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
|
}
|
|
|
|
if (!NodeIdentity.Verify(publicKey, proof, SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), request.PublicKey))
|
|
{
|
|
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
|
|
}
|
|
|
|
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
|
|
{
|
|
return Result<EnrollResponse>.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<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
|
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
|
{
|
|
return Result<EnrollResponse>.Fail(error);
|
|
}
|
|
|
|
var context = new EnrollmentContext(remoteIp, request.Meta);
|
|
foreach (IEnrollmentHook hook in _hooks)
|
|
{
|
|
await hook.OnEnrolledAsync(record, context, ct);
|
|
}
|
|
|
|
return Result<EnrollResponse>.Ok(new EnrollResponse
|
|
{
|
|
NodeId = record.Id.Value,
|
|
Status = record.Status == NodeStatus.Active ? "active" : "pending",
|
|
});
|
|
}
|
|
|
|
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
|
|
public async ValueTask<Result<TrustBundle>> 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<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
|
}
|
|
|
|
if (token.ExpiresAtMs <= nowMs)
|
|
{
|
|
return Result<TrustBundle>.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<TrustBundle>.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();
|
|
}
|