chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
public sealed class EnrollRequest
|
||||
{
|
||||
[JsonPropertyName("join_token")]
|
||||
public string JoinToken { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>身份公钥(SPKI,base64)。</summary>
|
||||
[JsonPropertyName("pubkey")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
/// <summary>面向对端节点的 TLS 证书指纹(SHA-256,base64)。</summary>
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string TlsSpki { get; set; } = "";
|
||||
|
||||
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
|
||||
[JsonPropertyName("proof")]
|
||||
public string Proof { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("node_version")]
|
||||
public string NodeVersion { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("meta")]
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EnrollResponse
|
||||
{
|
||||
[JsonPropertyName("node_id")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
/// <summary>pending | active</summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>RFC 9457 problem+json,带 <c>code</c> 扩展字段。</summary>
|
||||
public sealed class FleetProblem
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "about:blank";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EnrollRequest))]
|
||||
[JsonSerializable(typeof(EnrollResponse))]
|
||||
[JsonSerializable(typeof(FleetProblem))]
|
||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
public static class FleetEnrollmentEndpoints
|
||||
{
|
||||
private const int MaxBodyBytes = 64 * 1024;
|
||||
|
||||
/// <summary>注册端点:<c>POST {path}</c>,使用加入令牌认证。需要在 DI 中注册 <see cref="FleetEnrollmentService"/>。</summary>
|
||||
public static IEndpointRouteBuilder MapFleetEnrollment(this IEndpointRouteBuilder app, string path)
|
||||
{
|
||||
app.MapPost(path, HandleAsync)
|
||||
.WithName("fleet_enroll")
|
||||
.WithTags("agent")
|
||||
.WithSummary("Enroll a node with a join token")
|
||||
.DisableAntiforgery();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||
{
|
||||
EnrollRequest? request;
|
||||
try
|
||||
{
|
||||
request = await ReadBodyAsync(http, http.RequestAborted);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid");
|
||||
}
|
||||
|
||||
Result<EnrollResponse> result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted);
|
||||
if (result.TryGet(out EnrollResponse? response, out FleetError? error))
|
||||
{
|
||||
return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
int status = error.Code == FleetErrors.JoinTokenInvalid ? StatusCodes.Status401Unauthorized
|
||||
: error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone
|
||||
: error.Code == FleetErrors.ProofInvalid ? StatusCodes.Status403Forbidden
|
||||
: error.Code == FleetErrors.NameConflict ? StatusCodes.Status409Conflict
|
||||
: error.Code == FleetErrors.InvalidParams ? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
return Problem(status, error.Code, error.Message);
|
||||
}
|
||||
|
||||
private static async Task<EnrollRequest?> ReadBodyAsync(HttpContext http, CancellationToken ct)
|
||||
{
|
||||
if (http.Request.ContentLength > MaxBodyBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int read;
|
||||
while ((read = await http.Request.Body.ReadAsync(chunk, ct)) > 0)
|
||||
{
|
||||
buffer.Write(chunk, 0, read);
|
||||
if (buffer.Length > MaxBodyBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(buffer.ToArray(), EnrollmentJsonContext.Default.EnrollRequest);
|
||||
}
|
||||
|
||||
private static IResult Problem(int status, ErrorCode code, string detail) =>
|
||||
Results.Json(
|
||||
new FleetProblem { Title = ((HttpStatusCode)status).ToString(), Status = status, Code = code.Value, Detail = detail },
|
||||
EnrollmentJsonContext.Default.FleetProblem,
|
||||
contentType: "application/problem+json",
|
||||
statusCode: status);
|
||||
}
|
||||
|
||||
public static class FleetEnrollmentServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFleetEnrollment(this IServiceCollection services) =>
|
||||
services.AddSingleton<FleetEnrollmentService>();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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 FleetServerOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_hooks = hooks;
|
||||
_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.Proof, out byte[] proof))
|
||||
{
|
||||
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var node = new NewNode(
|
||||
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
|
||||
request.Name,
|
||||
status,
|
||||
publicKey,
|
||||
tlsSpki,
|
||||
token.Labels,
|
||||
request.Meta?.GetRawText(),
|
||||
request.NodeVersion,
|
||||
nowMs);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user