feat!: probes generate their own keys and enroll with a join token; node and peer keys are separate [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 16:57:34 +08:00
1 parent 1ff847e45a
commit a9f562441f
51 files changed
+505 -749

No files matched your search

@@ -29,9 +29,11 @@ public static class EnrollmentClient
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();
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
@@ -42,6 +44,7 @@ public static class EnrollmentClient
["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,
@@ -331,6 +331,15 @@ public sealed class LoadedNodeIdentity : IDisposable
public X509Certificate2 Certificate { get; }
/// <summary>
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
/// 各自只用于自己的用途(一把泄露不影响另一把)。
/// </summary>
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
private NodeIdentity? _peerIdentity;
public static LoadedNodeIdentity Load(string directory)
{
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
@@ -347,6 +356,7 @@ public sealed class LoadedNodeIdentity : IDisposable
public void Dispose()
{
Identity.Dispose();
_peerIdentity?.Dispose();
Certificate.Dispose();
}
}
@@ -22,7 +22,7 @@ public static class NodeCertificate
return certificate.Export(X509ContentType.Pfx);
}
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable);
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
@@ -5,6 +5,9 @@ public sealed class FleetServerOptions
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20;
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
@@ -31,7 +31,9 @@ public sealed record NewNode(
IReadOnlyList<string> Labels,
string? MetaJson,
string? NodeVersion,
long NowMs);
long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null);
public sealed record JoinTokenRecord(
string Id,
@@ -43,6 +45,7 @@ public sealed record JoinTokenRecord(
int Uses,
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs);
long CreatedAtMs,
string Kind = "");
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -19,6 +19,13 @@ public sealed class EnrollRequest
[JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = "";
/// <summary>
/// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 <see cref="TlsSpki"/> 对得上。节点用这把密钥做对等面的签名认证
/// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。
/// </summary>
[JsonPropertyName("tls_pubkey")]
public string TlsPublicKey { get; set; } = "";
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
[JsonPropertyName("proof")]
public string Proof { get; set; } = "";
@@ -29,9 +29,16 @@ public sealed partial class FleetEnrollmentService
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 or proof is malformed");
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);
@@ -61,8 +68,9 @@ public sealed partial class FleetEnrollmentService
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(_options.NodeIdPrefix, _time)),
new NodeId(Ulid.NewPrefixed(prefix, _time)),
request.Name,
status,
publicKey,
@@ -70,7 +78,9 @@ public sealed partial class FleetEnrollmentService
token.Labels,
request.Meta?.GetRawText(),
request.NodeVersion,
nowMs);
nowMs,
token.Kind,
tlsPublicKey);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
+7 -205
View File
@@ -1928,7 +1928,7 @@
"tags": [
"admin-join-tokens"
],
"summary": "Create a join token; the plaintext is returned only once",
"summary": "Create a join token for an agent or a probe (role); the plaintext is returned only once",
"operationId": "join_tokens_create",
"requestBody": {
"content": {
@@ -2079,81 +2079,6 @@
}
}
}
},
"post": {
"tags": [
"admin-probe"
],
"summary": "Create a probe identity; the private key is returned only once",
"operationId": "probes_create",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/CreateProbeRequest"
}
]
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProbeIdentityDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/api/admin/probes/{id}": {
@@ -2353,77 +2278,6 @@
}
}
},
"/api/admin/probes/{id}/rotate": {
"post": {
"tags": [
"admin-probe"
],
"summary": "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once",
"operationId": "probes_rotate",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProbeIdentityDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/api/admin/probes/{id}/revoke": {
"post": {
"tags": [
@@ -5007,16 +4861,8 @@
"string"
],
"format": "int32"
}
}
},
"CreateProbeRequest": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
},
"role": {
"type": [
"null",
"string"
@@ -5822,6 +5668,10 @@
},
"created_at": {
"format": "date-time"
},
"role": {
"type": "string",
"default": "agent"
}
}
},
@@ -7191,54 +7041,6 @@
}
}
},
"ProbeIdentityDto": {
"required": [
"probe_id",
"private_key",
"server",
"limits"
],
"type": "object",
"properties": {
"probe_id": {
"type": "string"
},
"private_key": {
"type": "string"
},
"server": {
"type": "string"
},
"limits": {
"$ref": "#/components/schemas/ProbeLimitsDto"
}
}
},
"ProbeLimitsDto": {
"required": [
"max_test_mbps",
"max_total_pps"
],
"type": "object",
"properties": {
"max_test_mbps": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
},
"max_total_pps": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
}
}
},
"ProblemDto": {
"required": [
"type",
+8 -2
View File
@@ -11,7 +11,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
└─────Agent 列表、状态(Fleet 长连接)───────────┘
```
Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
@@ -34,7 +34,13 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行:
在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
```sh
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
```
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
```sh
moshiro-probe --config /etc/moshiro/probe.json
+6 -6
View File
@@ -23,20 +23,20 @@ public sealed class ProbeApp : IDisposable
private readonly INodeLog _log;
private readonly FleetLink _link;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
var options = new FleetLinkOptions
{
Server = new Uri(file.Server),
Identity = identity,
NodeId = new NodeId(file.ProbeId),
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
+14 -11
View File
@@ -4,21 +4,13 @@ using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
/// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("probe_id")]
public string ProbeId { get; set; } = "";
/// <summary>PKCS#8,base64。</summary>
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("fleet")]
public ProbeFleetSection Fleet { get; set; } = new();
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
[JsonPropertyName("restart_at")]
@@ -28,6 +20,17 @@ public sealed class ProbeFile
public ProbeLimits Limits { get; set; } = new();
}
public sealed class ProbeFleetSection
{
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
}
public sealed class KnownAgent
{
[JsonPropertyName("ip_version")]
+13 -10
View File
@@ -21,17 +21,25 @@ for (int i = 0; i < args.Length - 1; i++)
}
ProbeFile file;
LoadedNodeIdentity identity;
RestartController restart;
try
{
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0 || !Uri.TryCreate(file.Server, UriKind.Absolute, out _))
if (!Uri.TryCreate(file.Fleet.Server, UriKind.Absolute, out _))
{
throw new InvalidOperationException("probe.json needs probe_id, private_key and server (download it from the panel: Probes → Create)");
throw new InvalidOperationException("probe.json needs fleet.server (the panel address)");
}
identity = LoadedNodeIdentity.Load(file.Fleet.IdentityDir);
if (!identity.NodeId.Value.StartsWith(MoShiro.Protocol.ProbeId.Prefix, StringComparison.Ordinal))
{
identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException)
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", ex.Message));
return 1;
@@ -39,15 +47,10 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using var restartScope = restart;
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
try
using (identity)
{
using var app = new ProbeApp(file, directory, time, log, restart);
using var app = new ProbeApp(file, identity, directory, time, log, restart);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
}
catch (Exception ex) when (ex is FormatException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", "private_key is not a valid PKCS#8 key: " + ex.Message));
return 1;
}
return 0;
@@ -40,7 +40,7 @@ public static class AgentEndpoints
group.MapGet("/join-tokens", ListTokensAsync)
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
group.MapPost("/join-tokens", CreateTokenAsync)
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
@@ -48,16 +48,10 @@ public static class AgentEndpoints
group.MapGet("/probes", ListProbesAsync)
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
group.MapPost("/probes", CreateProbeAsync)
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
group.MapGet("/probes/{id}", GetProbeAsync)
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
group.MapPatch("/probes/{id}", PatchProbeAsync)
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
@@ -122,7 +116,7 @@ public static class AgentEndpoints
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
Result<CreatedJoinToken> result = await tokens.CreateAsync(
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted);
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted);
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
{
return Problems.From(http, error);
@@ -150,19 +144,6 @@ public static class AgentEndpoints
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, CreateProbeRequest? request, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
await probes.GetAsync(id, ct) is { } view
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
@@ -179,18 +160,6 @@ public static class AgentEndpoints
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
}
private static async Task<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, string id, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
{
Result<ProbeView> result = await probes.RevokeAsync(id, ct);
@@ -213,10 +182,4 @@ public static class AgentEndpoints
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent");
}
/// <summary>Probe 要连的面板地址:配置的公开地址,没有就用这次请求的地址。</summary>
private static string PanelUrl(HttpContext http, BackendOptions options) => (options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}").TrimEnd('/');
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
new(file.ProbeId.Value, file.PrivateKey, file.Server, new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
}
@@ -154,7 +154,7 @@ public static class Mappers
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs));
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind));
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
@@ -48,9 +48,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt);
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
@@ -72,13 +73,6 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, string Server, ProbeLimitsDto Limits);
public sealed record CreateProbeRequest(string? Name);
public sealed record PatchProbeRequest(string? Name);
public sealed record TaskDto(
@@ -56,8 +56,6 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
[JsonSerializable(typeof(CreateJoinTokenRequest))]
[JsonSerializable(typeof(CreatedJoinTokenDto))]
[JsonSerializable(typeof(ProbeIdentityDto))]
[JsonSerializable(typeof(CreateProbeRequest))]
[JsonSerializable(typeof(PatchProbeRequest))]
[JsonSerializable(typeof(ProbeDto))]
[JsonSerializable(typeof(PageDto<HostItemDto>))]
@@ -2,7 +2,8 @@ using Fleet.Core;
namespace MoShiro.Backend.Application;
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
@@ -12,6 +13,14 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService
{
public const string AgentRole = "agent";
public const string ProbeRole = "probe";
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
private readonly IJoinTokenStore _store;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
@@ -26,6 +35,11 @@ public sealed class JoinTokenService
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (request.Role is not (AgentRole or ProbeRole))
{
errors["role"] = "must be agent or probe";
}
if (request.MaxUses is < 1 or > 1000)
{
errors["max_uses"] = "must be between 1 and 1000";
@@ -54,13 +68,18 @@ public sealed class JoinTokenService
string plaintext = TokenHash.NewToken("jt");
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove,
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh
string shell = request.Role == ProbeRole
? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name <node-name>"
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
string powershell = request.Role == ProbeRole
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
}
@@ -20,6 +20,13 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
{
if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
// Probe:新的对等面公钥要进每个 Agent 的名单,Probe 自己也要拿到 Agent 列表
await _service.AfterAgentSetChangedAsync(ct);
return;
}
AgentEnrollMeta meta = new();
if (context.ProductMeta is { } raw)
{
@@ -1,17 +1,10 @@
using System.Text.RegularExpressions;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥;<c>Server</c> 是 Probe 要连的面板地址。</summary>
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits);
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
@@ -21,8 +14,9 @@ public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<Pro
}
/// <summary>
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。
/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。
/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。
/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。
/// 一条线路是一对(Probe,Agent)。
/// </summary>
public sealed partial class ProbeService
@@ -46,26 +40,6 @@ public sealed partial class ProbeService
_time = time;
}
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, string server, CancellationToken ct)
{
string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim();
if (!NamePattern().IsMatch(finalName))
{
return ValidationError.Fail<ProbeIdentityFile>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
}
if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase)))
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.NameConflict, $"a probe named {finalName} already exists");
}
using NodeIdentity identity = NodeIdentity.Generate();
var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
await _probes.SaveAsync(probe, ct);
_gateway.NotifyAllTaskSetsChanged();
return Result<ProbeIdentityFile>.Ok(BuildFile(probe, identity, server));
}
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
{
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
@@ -98,27 +72,6 @@ public sealed partial class ProbeService
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
}
/// <summary>换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。</summary>
public async ValueTask<Result<ProbeIdentityFile>> RotateAsync(string id, string server, CancellationToken ct)
{
if (await _probes.GetAsync(id, ct) is not { } probe)
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
}
if (probe.RevokedAtMs is not null)
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead");
}
using NodeIdentity identity = NodeIdentity.Generate();
ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki };
await _probes.SaveAsync(updated, ct);
_gateway.NotifyAllTaskSetsChanged();
await DisconnectEverywhereAsync(id, ct);
return Result<ProbeIdentityFile>.Ok(BuildFile(updated, identity, server));
}
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
{
@@ -164,14 +117,6 @@ public sealed partial class ProbeService
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
}
private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct)
{
foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId))
{
await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct);
}
}
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
{
var connections = new List<ProbeConnection>();
@@ -187,9 +132,6 @@ public sealed partial class ProbeService
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
}
private static ProbeIdentityFile BuildFile(ProbeRecord probe, NodeIdentity identity, string server) =>
new(new ProbeId(probe.Id), Convert.ToBase64String(identity.ExportPkcs8()), server, new ProbeLimits(MaxTestMbps: 100, MaxTotalPps: 20_000));
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
private static partial Regex NamePattern();
}
@@ -64,6 +64,11 @@ public sealed record JoinTokenInfo(
int Uses,
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs);
long CreatedAtMs,
string Kind = "");
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null);
/// <summary>
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 <c>peers</c> 里放的是后者。
/// </summary>
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null);
@@ -51,7 +51,7 @@ public interface IGeoLookup
public interface IJoinTokenStore
{
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
@@ -60,9 +60,9 @@ public sealed class TaskPlanner : IDisposable
});
}
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && links.IsLinked(p.Id, agentId.Value)))
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
{
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) });
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
}
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
@@ -18,7 +18,11 @@ public static class GatewayServiceCollectionExtensions
services.AddSingleton<IAgentGateway, FleetGateway>();
services.AddHostedService<ConnectionShutdown>();
services.AddFleetEnrollment();
services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix)
services.AddFleetServer(options =>
{
options.NodeIdPrefix = AgentIds.Prefix;
options.NodeIdPrefixes[JoinTokenService.ProbeRole] = ProbeId.Prefix;
})
.AddState<TaskSetStateProvider>()
.AddState<ProbeAgentsStateProvider>()
.AddInbox<RawChunkInbox>()
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
internal static class Schema
{
public const int CurrentVersion = 7;
public const int CurrentVersion = 8;
public static void Migrate(SqliteConnection connection)
{
@@ -110,6 +110,16 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 7");
transaction.Commit();
}
if (version < 8)
{
// Probe 用加入令牌注册:令牌带种类;Probe 有两把独立的密钥,对等面公钥单独存
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE join_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''");
connection.Exec("ALTER TABLE probes ADD COLUMN peer_pubkey BLOB");
connection.Exec("PRAGMA user_version = 8");
transaction.Commit();
}
}
/// <summary>
@@ -56,4 +56,6 @@ internal static class Sql
public static string? NullableString(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
public static byte[] Bytes(this SqliteDataReader reader, int ordinal) => (byte[])reader.GetValue(ordinal);
public static byte[]? NullableBytes(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : (byte[])reader.GetValue(ordinal);
}
@@ -138,8 +138,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
byte[] hash = tokenSha256.ToArray();
return _db.ReadAsync(
c => c.QueryOne(
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9)),
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h",
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10)),
("@h", hash)),
ct);
}
@@ -154,6 +154,11 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
public ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct) =>
_db.WriteAsync(c =>
{
if (node.Kind == JoinTokenService.ProbeRole)
{
return CreateProbe(c, node);
}
if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id))
{
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a node with this name already exists");
@@ -166,6 +171,25 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
}, ct);
/// <summary>Probe 用加入令牌注册:直接进 probes 表,有令牌就算批准(Probe 没有 pending 状态);两把公钥都是它自己生成的。</summary>
private static Result<NodeRecord> CreateProbe(SqliteConnection c, NewNode node)
{
if (node.TlsPublicKey is not { Length: > 0 } peerKey)
{
return Result<NodeRecord>.Fail(FleetErrors.InvalidParams, "a probe needs its peer key");
}
if (c.Scalar<long>("SELECT COUNT(*) FROM probes WHERE name = @name COLLATE NOCASE", ("@name", node.Name)) > 0)
{
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a probe with this name already exists");
}
c.Exec(
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at) VALUES (@id, @name, @pubkey, @peer, @now)",
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs));
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
}
/// <summary>Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。</summary>
async ValueTask<NodeRecord?> IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) =>
await GetAsync(id, ct) is { } agent
@@ -15,21 +15,21 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db = db;
}
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) =>
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) =>
_db.WriteAsync(c =>
{
c.Exec(
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, max_uses, expires_at, created_at) VALUES (@id, @h, @note, @labels, @auto, @max, @exp, @now)",
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0),
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now)",
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs));
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs);
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind);
}, ct);
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
c => c.Query(
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at FROM join_tokens ORDER BY created_at DESC, id",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8))),
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens ORDER BY created_at DESC, id",
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9))),
ct);
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) =>
@@ -50,12 +50,12 @@ public sealed class SqliteProbeStore : IProbeStore
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5))),
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6))),
ct);
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5)), ("@id", id)),
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6)), ("@id", id)),
ct);
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
@@ -71,9 +71,9 @@ public sealed class SqliteProbeStore : IProbeStore
_db.ExecuteAsync(c =>
{
c.Exec(
"INSERT INTO probes (id, name, pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @at, @revoked) " +
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, pubkey = excluded.pubkey, created_at = excluded.created_at, revoked_at = excluded.revoked_at",
("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs));
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @peer, @at, @revoked) " +
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, pubkey = excluded.pubkey, peer_pubkey = excluded.peer_pubkey, created_at = excluded.created_at, revoked_at = excluded.revoked_at",
("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@peer", probe.PeerPublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs));
}, ct);
}
@@ -63,18 +63,19 @@ public sealed class AgentHarness : IAsyncDisposable
return harness;
}
/// <summary>在后端生成 Probe 身份(私钥只返回一次),并构造连到这个 Agent 的假 Probe。</summary>
/// <summary>注册一个 Probe(密钥由它自己生成),并构造连到这个 Agent 的假 Probe:对等面用它的对等面密钥。</summary>
public async Task<ProbeSimulator> CreateProbeAsync()
{
JsonNode identity = await Backend.CreateProbeAsync();
NodeIdentity key = NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>()));
using EnrolledProbe probe = await Backend.CreateProbeAsync();
// 假 Probe 要在注册目录被释放之后继续用这把密钥:导出再导入一份
NodeIdentity key = NodeIdentity.FromPkcs8(probe.Identity.PeerIdentity.ExportPkcs8());
string publicKey = Convert.ToBase64String(key.PublicKeySpki);
// 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝)
await BackendFixture.EventuallyAsync(
() => Task.FromResult(App.IsPeerKnown(identity["probe_id"]!.GetValue<string>(), publicKey)),
because: "the agent should learn the probe's key");
return new ProbeSimulator(identity["probe_id"]!.GetValue<string>(), key);
() => Task.FromResult(App.IsPeerKnown(probe.ProbeId, publicKey)),
because: "the agent should learn the probe's peer key");
return new ProbeSimulator(probe.ProbeId, key);
}
public async Task<ProbeSimulator> ConnectProbeAsync()
@@ -37,9 +37,15 @@ public sealed class AotSmokeTests
using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json"));
settingsResponse.EnsureSuccessStatusCode();
using HttpResponseMessage probeResponse = await http.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "aot-probe-" + Guid.NewGuid().ToString("N")[..6] }));
probeResponse.EnsureSuccessStatusCode();
string probeId = JsonNode.Parse(await probeResponse.Content.ReadAsStringAsync())!["probe_id"]!.GetValue<string>();
// Probe:probe 角色的加入令牌 → 节点自己生成密钥并注册
using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 }));
probeJoin.EnsureSuccessStatusCode();
string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using var probeHttp = new HttpClient();
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString());
string probeId = probeEnrolled.Value.NodeId;
// Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
@@ -53,13 +53,26 @@ public class BackendFixture : IAsyncLifetime
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
/// <summary>在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。</summary>
public async Task<JsonNode> CreateProbeAsync(string? name = null)
/// <summary>
/// 注册一个新的 Probe:和真实部署一样,先在管理端创建 <c>probe</c> 角色的加入令牌,再由节点自己生成密钥、用 <c>fleet-enroll</c> 同样的流程注册。
/// 返回的身份目录里有两把密钥:连面板的 <c>Identity</c> 和对等面的 <c>PeerIdentity</c>。调用方负责释放。
/// </summary>
public async Task<EnrolledProbe> CreateProbeAsync(string? name = null)
{
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/probes", Json(new JsonObject { ["name"] = name ?? UniqueName("probe") }));
string body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, body);
return JsonNode.Parse(body)!;
string token = await CreateJoinTokenAsync(role: "probe");
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N"));
using var http = new HttpClient();
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default);
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
return new EnrolledProbe(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory));
}
/// <summary>注册一个 Probe,只要它的 id(密钥用完即释放)。</summary>
public async Task<string> CreateProbeIdAsync(string? name = null)
{
using EnrolledProbe probe = await CreateProbeAsync(name);
return probe.ProbeId;
}
public async ValueTask InitializeAsync()
@@ -68,7 +81,10 @@ public class BackendFixture : IAsyncLifetime
await StartAsync();
_adminToken = await CreateAdminTokenAsync();
CreateClients();
DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue<string>();
using (EnrolledProbe probe = await CreateProbeAsync("default"))
{
DefaultProbeId = probe.ProbeId;
}
}
public async ValueTask DisposeAsync()
@@ -123,9 +139,9 @@ public class BackendFixture : IAsyncLifetime
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
public async Task<string> CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1)
public async Task<string> CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1, string role = "agent")
{
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours }));
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["role"] = role, ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours }));
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
@@ -235,3 +251,9 @@ public sealed class StrictLoginFixture : BackendFixture
{
protected override int LoginAttemptsPerMinute => 10;
}
/// <summary>注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。</summary>
public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
{
public void Dispose() => Identity.Dispose();
}
@@ -93,6 +93,31 @@ public sealed class EnrollmentAndTaskSetTests
Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
}
[Fact]
public async Task Enrollment_RejectsATlsPublicKeyThatDoesNotMatchItsFingerprint_AndAnUnknownRole()
{
string token = await _backend.CreateJoinTokenAsync(role: "probe");
using var mine = Fleet.Identity.NodeIdentity.Generate();
using var tls = Fleet.Identity.NodeIdentity.Generate();
using var impostor = Fleet.Identity.NodeIdentity.Generate();
string pubkey = Convert.ToBase64String(mine.PublicKeySpki);
string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
// 指纹是 tls 的,公钥却换成了别的:Agent 将来按指纹校验证书,两者必须对得上
using HttpResponseMessage response = await PostEnrollAsync(new JsonObject
{
["join_token"] = token,
["name"] = _backend.UniqueName("tls"),
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(tls.PublicKeySpki)),
["tls_pubkey"] = Convert.ToBase64String(impostor.PublicKeySpki),
["proof"] = Convert.ToBase64String(mine.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey)),
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using HttpResponseMessage badRole = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "root" }));
Assert.True(badRole.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity);
}
[Fact]
public async Task Enrollment_RejectsAProofSignedByAnotherKey()
{
@@ -107,7 +132,8 @@ public sealed class EnrollmentAndTaskSetTests
["join_token"] = token,
["name"] = _backend.UniqueName("forged"),
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(new byte[32]),
["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(mine.PublicKeySpki)),
["tls_pubkey"] = pubkey,
["proof"] = forged,
});
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
@@ -327,47 +353,52 @@ public sealed class EnrollmentAndTaskSetTests
}
[Fact]
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList()
public async Task ProbeLifecycle_EnrollRenameRevokeDelete_IsReflectedInEveryAgentsPeerList()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
JsonNode identity = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string id = identity["probe_id"]!.GetValue<string>();
using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>()));
Assert.True(Uri.TryCreate(identity["server"]!.GetValue<string>(), UriKind.Absolute, out _)); // Probe 直接连面板:probe.json 里是面板的地址
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key1.PublicKeySpki)));
// 创建 Probe 就是创建一个 probe 角色的加入令牌;密钥由节点自己生成,Backend 从不接触私钥
string name = _backend.UniqueName("home");
using EnrolledProbe probe = await _backend.CreateProbeAsync(name);
string id = probe.ProbeId;
Assert.StartsWith(ProbeId.Prefix, id, StringComparison.Ordinal);
string peerKey = Convert.ToBase64String(probe.Identity.PeerIdentity.PublicKeySpki);
string backendKey = Convert.ToBase64String(probe.Identity.Identity.PublicKeySpki);
Assert.NotEqual(backendKey, peerKey); // 连面板的和连 Agent 的是两把独立的密钥
// The panel lists it; two probes can coexist and a duplicate name is refused
// Agent 的名单里放的是对等面的公钥,不是连面板的那把
TaskSet withProbe = await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == peerKey));
Assert.DoesNotContain(withProbe.Peers, p => p.PublicKey == backendKey);
// 令牌带角色;面板能列出这个 Probe;重名的注册被拒绝
JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
Assert.Contains(tokens["items"]!.AsArray(), t => t!["role"]!.GetValue<string>() == "probe");
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue<string>();
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = name }));
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
string duplicateToken = await _backend.CreateJoinTokenAsync(role: "probe");
using var http = new HttpClient();
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
Assert.False(duplicate.IsSuccess);
Assert.Equal("fleet_name_conflict", duplicate.Error.Code.Value);
// Rename
// 创建身份和轮换密钥的接口已经没有了:换密钥就是吊销之后重新注册
using HttpResponseMessage oldCreate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "x" }));
Assert.True(oldCreate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
using HttpResponseMessage oldRotate = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
Assert.True(oldRotate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
// 改名
using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" }));
Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>());
// Rotate: same id, new key; the old key leaves every agent's list
using HttpResponseMessage rotated = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
JsonNode regenerated = JsonNode.Parse(await rotated.Content.ReadAsStringAsync())!;
Assert.Equal(id, regenerated["probe_id"]!.GetValue<string>());
Assert.NotEqual(identity["private_key"]!.GetValue<string>(), regenerated["private_key"]!.GetValue<string>());
using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue<string>()));
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key2.PublicKeySpki)));
// Revoke: gone from the peer list, kept in the panel as revoked; it cannot be rotated any more
// 吊销:从名单里消失,面板里留着记录
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null);
Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>());
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id));
using HttpResponseMessage rotateRevoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
Assert.Equal(HttpStatusCode.Conflict, rotateRevoked.StatusCode);
// Delete
// 删除
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id);
@@ -30,7 +30,7 @@ public sealed class LinkTests
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>();
string probe = await _backend.CreateProbeIdAsync();
string agentId = agent.NodeId.Value;
try
{
@@ -72,7 +72,7 @@ public sealed class LinkTests
public async Task Links_RefuseUnknownNodes()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>();
string probe = await _backend.CreateProbeIdAsync();
using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false);
Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode);
using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false);
@@ -5,16 +5,18 @@ using MoShiro.Probe;
namespace MoShiro.Backend.Tests;
/// <summary>进程内的真实 Probe:用后端生成的身份文件(和面板下载的 probe.json 同一份)启动 <see cref="ProbeApp"/>。</summary>
/// <summary>进程内的真实 Probe:用加入令牌注册出来的身份(节点自己生成的两把密钥)启动 <see cref="ProbeApp"/>。</summary>
public sealed class ProbeHarness : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
private readonly string _directory;
private readonly Task _run;
private readonly EnrolledProbe _enrolled;
private ProbeHarness(string directory, ProbeApp app)
private ProbeHarness(string directory, ProbeApp app, EnrolledProbe enrolled)
{
_directory = directory;
_enrolled = enrolled;
App = app;
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
}
@@ -25,21 +27,19 @@ public sealed class ProbeHarness : IAsyncDisposable
public string ProbeId { get; init; } = "";
/// <summary>向后端申请身份(<c>POST /api/admin/probe/identity</c>),写成 <c>probe.json</c> 并启动。</summary>
/// <summary>用加入令牌注册(节点自己生成密钥),把面板地址和身份目录写进 <c>probe.json</c>,然后启动。</summary>
public static async Task<ProbeHarness> StartAsync(BackendFixture backend, Action<ProbeFile>? configure = null)
{
JsonNode created = await backend.CreateProbeAsync();
string json = created.ToJsonString();
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(directory);
EnrolledProbe enrolled = await backend.CreateProbeAsync();
string directory = enrolled.Directory;
string path = Path.Combine(directory, "probe.json");
await File.WriteAllTextAsync(path, json, Encoding.UTF8);
var written = new ProbeFile { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory } };
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(written, ProbeConfigContext.Default.ProbeFile), Encoding.UTF8);
ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile);
configure?.Invoke(file);
var app = new ProbeApp(file, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out));
return new ProbeHarness(directory, app) { ProbeId = created["probe_id"]!.GetValue<string>() };
var app = new ProbeApp(file, enrolled.Identity, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out));
return new ProbeHarness(directory, app, enrolled) { ProbeId = enrolled.ProbeId };
}
public async ValueTask DisposeAsync()
@@ -55,6 +55,7 @@ public sealed class ProbeHarness : IAsyncDisposable
}
App.Dispose();
_enrolled.Dispose();
_stop.Dispose();
try
{
@@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable
var tokens = new SqliteJoinTokenStore(_db);
var fleet = new SqliteAgentStore(_db);
byte[] hash = TokenHash.Sha256("jt_race");
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default);
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default);
Assert.Equal(0, created.Uses);
bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default))));
@@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable
Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default));
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, 5, expiresAtMs: 5_000, nowMs: 1_000, default);
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, default);
Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
@@ -80,12 +80,14 @@ public sealed class AgentSimulator : IAsyncDisposable
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken));
string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey));
using NodeIdentity tls = NodeIdentity.Generate();
var body = new JsonObject
{
["join_token"] = joinToken,
["name"] = name,
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
["tls_spki"] = Convert.ToBase64String(NodeIdentity.Fingerprint(tls.PublicKeySpki)),
["tls_pubkey"] = Convert.ToBase64String(tls.PublicKeySpki),
["proof"] = proof,
["node_version"] = "0.0.1-sim",
["meta"] = new JsonObject