From a9f562441f4d4e96869cd516a0c4acfa8ee59635 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 16:57:34 +0800 Subject: [PATCH] feat!: probes generate their own keys and enroll with a join token; node and peer keys are separate [CI] --- .../EnrollmentClient.cs | 3 + backend/fleet/src/Fleet.Hosting/Hosting.cs | 10 + .../src/Fleet.Identity/NodeCertificate.cs | 2 +- .../FleetServerOptions.cs | 3 + .../Fleet.Server.Abstractions/NodeRecords.cs | 7 +- .../EnrollmentModels.cs | 7 + .../FleetEnrollmentService.cs | 16 +- backend/openapi/v1.json | 212 +----------------- backend/probe/README.md | 10 +- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 12 +- .../probe/src/MoShiro.Probe/ProbeConfig.cs | 25 ++- backend/probe/src/MoShiro.Probe/Program.cs | 23 +- .../Admin/AgentEndpoints.cs | 41 +--- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 +- .../Contracts/AdminDtos.cs | 12 +- .../Contracts/ApiJsonContext.cs | 2 - .../Admin/JoinTokenService.cs | 27 ++- .../Agents/MoShiroFleetHooks.cs | 7 + .../Agents/ProbeService.cs | 64 +----- .../Models/AgentModels.cs | 9 +- .../Ports/StorePorts.cs | 2 +- .../Tasks/TaskPlanner.cs | 4 +- .../GatewayServiceCollectionExtensions.cs | 6 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 12 +- .../src/MoShiro.Backend.Storage.Sqlite/Sql.cs | 2 + .../SqliteAgentStore.cs | 28 ++- .../SqliteConfigStores.cs | 22 +- .../MoShiro.Backend.Tests/AgentHarness.cs | 13 +- .../MoShiro.Backend.Tests/AotSmokeTests.cs | 12 +- .../MoShiro.Backend.Tests/BackendFixture.cs | 40 +++- .../EnrollmentAndTaskSetTests.cs | 85 ++++--- .../tests/MoShiro.Backend.Tests/LinkTests.cs | 4 +- .../MoShiro.Backend.Tests/ProbeHarness.cs | 23 +- .../MoShiro.Backend.Tests/StorageTests.cs | 4 +- .../MoShiro.TestSupport/AgentSimulator.cs | 4 +- ci/package-cross-platform.ps1 | 8 +- ci/release-node-images.sh | 8 +- docs/api.md | 7 +- docs/deployment.md | 12 +- docs/enrollment.md | 16 +- docs/security.md | 2 +- packaging/container/Probe.Containerfile | 1 + .../container/Probe.NativeBuild.Containerfile | 14 +- packaging/container/deploy-node.sh | 30 +-- packaging/install.sh | 63 +++--- packaging/openwrt/moshiro-probe.init | 2 +- scripts/smoke-linux.sh | 8 +- scripts/smoke-systemd.sh | 4 +- web/admin/src/pages/Probes.tsx | 165 ++++++++------ web/shared/api/schema.ts | 158 +------------ web/shared/api/types.ts | 1 - 51 files changed, 505 insertions(+), 749 deletions(-) diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index e73bb55..726d27c 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -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, diff --git a/backend/fleet/src/Fleet.Hosting/Hosting.cs b/backend/fleet/src/Fleet.Hosting/Hosting.cs index ded35be..c9b7697 100644 --- a/backend/fleet/src/Fleet.Hosting/Hosting.cs +++ b/backend/fleet/src/Fleet.Hosting/Hosting.cs @@ -331,6 +331,15 @@ public sealed class LoadedNodeIdentity : IDisposable public X509Certificate2 Certificate { get; } + /// + /// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 是两把独立的密钥, + /// 各自只用于自己的用途(一把泄露不影响另一把)。 + /// + 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(); } } diff --git a/backend/fleet/src/Fleet.Identity/NodeCertificate.cs b/backend/fleet/src/Fleet.Identity/NodeCertificate.cs index dae7288..c478a58 100644 --- a/backend/fleet/src/Fleet.Identity/NodeCertificate.cs +++ b/backend/fleet/src/Fleet.Identity/NodeCertificate.cs @@ -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); /// 证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。 public static byte[] SpkiFingerprint(X509Certificate2 certificate) => diff --git a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs index 2020147..4239a9e 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs @@ -5,6 +5,9 @@ public sealed class FleetServerOptions /// 节点 ID 的类型前缀,MoShiro 使用 agt。 public string NodeIdPrefix { get; set; } = "node"; + /// 加入令牌的「种类」对应的节点 ID 前缀(例如 probe → prb);没有列出的种类用 。 + public Dictionary NodeIdPrefixes { get; } = new(StringComparer.Ordinal); + public int MaxFrameBytes { get; set; } = 16 << 20; public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index e8d8427..e792a2f 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -31,7 +31,9 @@ public sealed record NewNode( IReadOnlyList 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 Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs index c079eea..de87ec2 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -19,6 +19,13 @@ public sealed class EnrollRequest [JsonPropertyName("tls_spki")] public string TlsSpki { get; set; } = ""; + /// + /// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 对得上。节点用这把密钥做对等面的签名认证 + /// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。 + /// + [JsonPropertyName("tls_pubkey")] + public string TlsPublicKey { get; set; } = ""; + /// 持有证明:对 fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey} 的签名(base64)。 [JsonPropertyName("proof")] public string Proof { get; set; } = ""; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index ca59cc4..64f03a6 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -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.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed"); + 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); @@ -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 created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 87bf05e..06c33ba 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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", diff --git a/backend/probe/README.md b/backend/probe/README.md index 0b72774..09dc3b9 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -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:`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。 @@ -34,7 +34,13 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r -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 diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 1ac464e..1dc5fca 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -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) + /// 注册时由节点自己生成:Identity 用来连面板,PeerIdentity 是另一把独立的密钥,只用来连 Agent。 + 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), diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 5a3bc19..53c697c 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -4,21 +4,13 @@ using MoShiro.Protocol; namespace MoShiro.Probe; /// -/// probe.json:面板 POST /api/admin/probe/identity 生成的文件(只能下载一次)。 +/// probe.json:本地配置,和 Agent 一样由 fleet-enroll 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。 /// limits 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。 /// public sealed class ProbeFile { - [JsonPropertyName("probe_id")] - public string ProbeId { get; set; } = ""; - - /// PKCS#8,base64。 - [JsonPropertyName("private_key")] - public string PrivateKey { get; set; } = ""; - - /// 面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。 - [JsonPropertyName("server")] - public string Server { get; set; } = ""; + [JsonPropertyName("fleet")] + public ProbeFleetSection Fleet { get; set; } = new(); /// 每天定时重启一次,例如 04:00+08:00(不写偏移按 UTC);不写就不重启。环境变量 MOSHIRO_RESTART_AT 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。 [JsonPropertyName("restart_at")] @@ -28,6 +20,17 @@ public sealed class ProbeFile public ProbeLimits Limits { get; set; } = new(); } +public sealed class ProbeFleetSection +{ + /// 面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。 + [JsonPropertyName("server")] + public string Server { get; set; } = ""; + + /// fleet-enroll 写出的身份目录:node.json、node.key(连面板的密钥)、tls.pfx(对等面的密钥)。 + [JsonPropertyName("identity_dir")] + public string IdentityDir { get; set; } = "/etc/moshiro/probe-id"; +} + public sealed class KnownAgent { [JsonPropertyName("ip_version")] diff --git a/backend/probe/src/MoShiro.Probe/Program.cs b/backend/probe/src/MoShiro.Probe/Program.cs index f463996..6f375b3 100644 --- a/backend/probe/src/MoShiro.Probe/Program.cs +++ b/backend/probe/src/MoShiro.Probe/Program.cs @@ -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; diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 5aa566e..6eada2e 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -40,7 +40,7 @@ public static class AgentEndpoints group.MapGet("/join-tokens", ListTokensAsync) .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().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(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>().ProducesProblems(401, 403); - group.MapPost("/probes", CreateProbeAsync) - .Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); group.MapGet("/probes/{id}", GetProbeAsync) .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404); group.MapPatch("/probes/{id}", PatchProbeAsync) .Api("probes_update", "Rename a probe", ProbeTag).Returns().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().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().ProducesProblems(401, 403, 404); @@ -122,7 +116,7 @@ public static class AgentEndpoints { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; Result 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 ListProbesAsync(ProbeService probes, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto); - private static async Task CreateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, CreateProbeRequest? request, CancellationToken ct) - { - Result 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 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 RotateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, string id, CancellationToken ct) - { - Result 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 RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) { Result 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"); } - - /// Probe 要连的面板地址:配置的公开地址,没有就用这次请求的地址。 - 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)); } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index acd06f8..f53902c 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -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)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index d4bb481..f341544 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -48,9 +48,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo /// merge-patch:缺省或为 null 的字段不修改。 public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null); -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt); +/// agent 或 probe:用这个令牌注册出来的是哪种节点。 +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent"); -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); /// 令牌明文只在创建的响应里出现一次。 public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); @@ -72,13 +73,6 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList Items); public sealed record SetLinkRequest(bool? Enabled); -public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps); - -/// probe.json:含私钥,只在生成时返回一次。 -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( diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index beb9ee0..53f9803 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -56,8 +56,6 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateJoinTokenRequest))] [JsonSerializable(typeof(CreatedJoinTokenDto))] -[JsonSerializable(typeof(ProbeIdentityDto))] -[JsonSerializable(typeof(CreateProbeRequest))] [JsonSerializable(typeof(PatchProbeRequest))] [JsonSerializable(typeof(ProbeDto))] [JsonSerializable(typeof(PageDto))] diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index 382267e..73a7158 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -2,7 +2,8 @@ using Fleet.Core; namespace MoShiro.Backend.Application; -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); +/// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole); /// 各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。 public sealed record InstallCommands(string Linux, string Macos, string Windows); @@ -12,6 +13,14 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService { + public const string AgentRole = "agent"; + public const string ProbeRole = "probe"; + + /// 令牌里存的「种类」:空表示 Agent,其余就是角色名。 + 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> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct) { var errors = new Dictionary(); + 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 "; + // Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh + string shell = request.Role == ProbeRole + ? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name " + : $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴 - string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name \""; + string powershell = request.Role == ProbeRole + ? $"fleet-enroll --server {baseUrl} --join {plaintext} --name --identity-dir " + : $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name \""; return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell))); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index 7c51ac3..b406ff2 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -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) { diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index 5647b65..4f87aea 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -1,17 +1,10 @@ using System.Text.RegularExpressions; using Fleet.Core; -using Fleet.Identity; using Fleet.Server; using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// 本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。 -public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps); - -/// probe.json:只在生成(或轮换密钥)时返回一次,含私钥;Server 是 Probe 要连的面板地址。 -public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits); - /// 某个 Probe 现在连着的一个 Agent(Agent 上报)。 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 -/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。 -/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(peers);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。 +/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。 +/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(peers);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。 +/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。 /// 一条线路是一对(Probe,Agent)。 /// public sealed partial class ProbeService @@ -46,26 +40,6 @@ public sealed partial class ProbeService _time = time; } - public async ValueTask> CreateAsync(string? name, string server, CancellationToken ct) - { - string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim(); - if (!NamePattern().IsMatch(finalName)) - { - return ValidationError.Fail("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.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.Ok(BuildFile(probe, identity, server)); - } - public async ValueTask> ListAsync(CancellationToken ct) { IReadOnlyList probes = await _probes.ListAsync(ct); @@ -98,27 +72,6 @@ public sealed partial class ProbeService return Result.Ok((await GetAsync(id, ct))!); } - /// 换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。 - public async ValueTask> RotateAsync(string id, string server, CancellationToken ct) - { - if (await _probes.GetAsync(id, ct) is not { } probe) - { - return Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found"); - } - - if (probe.RevokedAtMs is not null) - { - return Result.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.Ok(BuildFile(updated, identity, server)); - } - /// 吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。 public async ValueTask> 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 agents) { var connections = new List(); @@ -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(); } diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 1b6153f..f146fec 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -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); +/// +/// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; +/// Agent 的 peers 里放的是后者。 +/// +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 13a75b2..0ed3d13 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -51,7 +51,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs index 8a21a96..58cdf6c 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -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))); diff --git a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs index 18cc74f..54c28fb 100644 --- a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs @@ -18,7 +18,11 @@ public static class GatewayServiceCollectionExtensions services.AddSingleton(); services.AddHostedService(); services.AddFleetEnrollment(); - services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix) + services.AddFleetServer(options => + { + options.NodeIdPrefix = AgentIds.Prefix; + options.NodeIdPrefixes[JoinTokenService.ProbeRole] = ProbeId.Prefix; + }) .AddState() .AddState() .AddInbox() diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 453018b..a2b48d4 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 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(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Sql.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Sql.cs index d6751c1..ebf46e2 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Sql.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Sql.cs @@ -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); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index c35f7e6..1b04b82 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -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> 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.Fail(FleetErrors.NameConflict, "a node with this name already exists"); @@ -166,6 +171,25 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); + /// Probe 用加入令牌注册:直接进 probes 表,有令牌就算批准(Probe 没有 pending 状态);两把公钥都是它自己生成的。 + private static Result CreateProbe(SqliteConnection c, NewNode node) + { + if (node.TlsPublicKey is not { Length: > 0 } peerKey) + { + return Result.Fail(FleetErrors.InvalidParams, "a probe needs its peer key"); + } + + if (c.Scalar("SELECT COUNT(*) FROM probes WHERE name = @name COLLATE NOCASE", ("@name", node.Name)) > 0) + { + return Result.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.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); + } + /// Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。 async ValueTask IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => await GetAsync(id, ct) is { } agent diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 900d43a..63a8bda 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,21 +15,21 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList 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> ListAsync(CancellationToken ct) => _db.ReadAsync>( 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 RevokeAsync(string id, long nowMs, CancellationToken ct) => @@ -50,12 +50,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - 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 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); } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index e4318ab..5ba8869 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -63,18 +63,19 @@ public sealed class AgentHarness : IAsyncDisposable return harness; } - /// 在后端生成 Probe 身份(私钥只返回一次),并构造连到这个 Agent 的假 Probe。 + /// 注册一个 Probe(密钥由它自己生成),并构造连到这个 Agent 的假 Probe:对等面用它的对等面密钥。 public async Task CreateProbeAsync() { - JsonNode identity = await Backend.CreateProbeAsync(); - NodeIdentity key = NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue())); + 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(), publicKey)), - because: "the agent should learn the probe's key"); - return new ProbeSimulator(identity["probe_id"]!.GetValue(), 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 ConnectProbeAsync() diff --git a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs index 86204ec..708f428 100644 --- a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs @@ -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(); + // 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(); + using var probeHttp = new HttpClient(); + Fleet.Core.Result 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 })); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index 95f4397..e0c8675 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -53,13 +53,26 @@ public class BackendFixture : IAsyncLifetime public string Line(string agentId) => $"{agentId}~{DefaultProbeId}"; - /// 在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。 - public async Task CreateProbeAsync(string? name = null) + /// + /// 注册一个新的 Probe:和真实部署一样,先在管理端创建 probe 角色的加入令牌,再由节点自己生成密钥、用 fleet-enroll 同样的流程注册。 + /// 返回的身份目录里有两把密钥:连面板的 Identity 和对等面的 PeerIdentity。调用方负责释放。 + /// + public async Task 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 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)); + } + + /// 注册一个 Probe,只要它的 id(密钥用完即释放)。 + public async Task 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(); + 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 CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1) + public async Task 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(); } @@ -235,3 +251,9 @@ public sealed class StrictLoginFixture : BackendFixture { protected override int LoginAttemptsPerMinute => 10; } + +/// 注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。 +public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable +{ + public void Dispose() => Identity.Dispose(); +} diff --git a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs index b6c2213..f6134b6 100644 --- a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs @@ -93,6 +93,31 @@ public sealed class EnrollmentAndTaskSetTests Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == 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(); - using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue())); - Assert.True(Uri.TryCreate(identity["server"]!.GetValue(), 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() == "probe"); JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes"); Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue() == id && !p["revoked"]!.GetValue()); - string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue(); - 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 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()); - // 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()); - Assert.NotEqual(identity["private_key"]!.GetValue(), regenerated["private_key"]!.GetValue()); - using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue())); - 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()); 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); diff --git a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs index 15b2020..fdd0450 100644 --- a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs @@ -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 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 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); diff --git a/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs b/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs index e10cbcb..40e55ad 100644 --- a/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs @@ -5,16 +5,18 @@ using MoShiro.Probe; namespace MoShiro.Backend.Tests; -/// 进程内的真实 Probe:用后端生成的身份文件(和面板下载的 probe.json 同一份)启动 。 +/// 进程内的真实 Probe:用加入令牌注册出来的身份(节点自己生成的两把密钥)启动 。 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; } = ""; - /// 向后端申请身份(POST /api/admin/probe/identity),写成 probe.json 并启动。 + /// 用加入令牌注册(节点自己生成密钥),把面板地址和身份目录写进 probe.json,然后启动。 public static async Task StartAsync(BackendFixture backend, Action? 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() }; + 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 { diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index ec98bcf..fa02e7f 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -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)); diff --git a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs index 13ffdf0..4fa21d5 100644 --- a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs @@ -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 diff --git a/ci/package-cross-platform.ps1 b/ci/package-cross-platform.ps1 index 71327a4..fba5216 100644 --- a/ci/package-cross-platform.ps1 +++ b/ci/package-cross-platform.ps1 @@ -66,11 +66,9 @@ try { Copy-Item -LiteralPath (Join-Path $root "backend\$role\README.md") -Destination $stage $executables = @("moshiro-$role$extension") - if ($role -eq 'agent') { - $enrollName = if ($target.Windows) { 'fleet-enroll.exe' } else { 'fleet-enroll' } - Copy-Item -LiteralPath (Join-Path $enrollDirectory $enrollName) -Destination $stage - $executables += $enrollName - } + $enrollName = if ($target.Windows) { 'fleet-enroll.exe' } else { 'fleet-enroll' } + Copy-Item -LiteralPath (Join-Path $enrollDirectory $enrollName) -Destination $stage + $executables += $enrollName if ($target.Os -eq 'linux') { Copy-Item -LiteralPath (Join-Path $root 'packaging/install.sh') -Destination $stage $executables += 'install.sh' diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index e6c1cd9..0280285 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -66,12 +66,8 @@ build_arch_image() { curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \ "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$filename" -o "$archive" mkdir -p "$context/app" "$context/config" - if [[ "$component" == agent ]]; then - tar -xzf "$archive" -C "$work" moshiro-agent fleet-enroll - install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll" - else - tar -xzf "$archive" -C "$work" "moshiro-$component" - fi + tar -xzf "$archive" -C "$work" "moshiro-$component" fleet-enroll + install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll" install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component" : > "$context/config/.keep" diff --git a/docs/api.md b/docs/api.md index d21a034..91a8113 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,7 +37,7 @@ ├─ auth/{login|logout|me} ├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover} ├─ join-tokens -├─ probes probes/{id} probes/{id}/{rotate|revoke} Probe 身份(后端生成、吊销、删除) +├─ probes probes/{id} probes/{id}/revoke Probe(用 probe 角色的加入令牌注册、吊销、删除) ├─ links links/{probe_id}/{agent_id} 线路开关:哪些(Probe,Agent)连成线路(自动连接 + 手动选择) ├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务 ├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel @@ -126,11 +126,10 @@ | PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` | | POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) | | DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 | -| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次) | +| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次);`role` 是 `agent`(默认)或 `probe`,决定用它注册出来的是哪种节点 | | DELETE | `/api/admin/join-tokens/{id}` | 吊销 | -| GET/POST | `/api/admin/probes` | 列出所有 Probe(含它现在连着哪些 Agent:版本、远端 IP、起始时间);创建 Probe 身份,返回 `probe.json`(含私钥,仅此一次) | +| GET | `/api/admin/probes` | 列出所有 Probe(含它现在连着哪些 Agent:版本、远端 IP、起始时间);Probe 由 `role=probe` 的加入令牌注册,密钥由它自己生成 | | GET/PATCH/DELETE | `/api/admin/probes/{id}` | 详情、改名、删除(历史数据按保留策略自然过期) | -| POST | `/api/admin/probes/{id}/rotate` | 换新密钥(id 不变):旧密钥立即失效,所有 Agent 断开它的会话;返回新的 `probe.json`(仅此一次) | | POST | `/api/admin/probes/{id}/revoke` | 吊销:公钥从所有 Agent 的名单里移除,Agent 立即断开它;记录和历史保留 | | POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` | | GET/POST | `/api/admin/tasks` | 列表和创建:line 模板(不带 `agent_id`)、单个 Agent 的覆盖(带 `agent_id` + `base_task_id`)、target 任务 | diff --git a/docs/deployment.md b/docs/deployment.md index 33ae25a..df5d447 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -102,8 +102,8 @@ Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库 # Agent(服务器):注册并启动 ./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1 -# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次) -./install.sh probe --probe-json ./probe.json +# Probe(家宽网络里的机器或 OpenWrt 路由器):用面板里 probe 角色的加入令牌注册,密钥由它自己生成 +./install.sh probe --server https://panel.example.com --join <加入令牌> --name home-1 ./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册) ``` @@ -174,7 +174,7 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 - **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。 - **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。 -- **Probe 直连面板**:`probe.json` 里的 `server` 就是面板地址(取自 `MoShiro__PublicUrl`,没配就用创建它时的请求地址),Probe 用它连面板,Agent 列表由面板下发;先注册还是先创建 Probe 都可以。 +- **Probe 直连面板**:`probe.json` 里的 `fleet.server` 就是面板地址,Probe 用它连面板,Agent 列表由面板下发;密钥由 Probe 注册时自己生成,身份目录(`node.json`、`node.key`、`tls.pfx`)在 `fleet.identity_dir`。 - **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。 - 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 - 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。 @@ -212,7 +212,7 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 ```sh # 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https:// --join --name -sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --probe-json ./probe.json +sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https:// --join --name # 之后更新节点:不带参数 sudo sh deploy-node.sh agent ``` @@ -230,7 +230,7 @@ JSON podman compose up -d ``` -Probe 在 `packaging/compose/probe` 下,将面板下载的身份文件通过标准输入导入 volume:`podman compose run --rm -T --entrypoint /bin/sh probe -c 'umask 077; cat > /etc/moshiro/probe.json' < probe.json`,再 `podman compose up -d`。私钥权限为 600。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册或轮换密钥。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。 +Probe 在 `packaging/compose/probe` 下,和 Agent 一样先用 `fleet-enroll` 注册:`podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll probe --server https:// --join --name --identity-dir /etc/moshiro/probe-id`,再写 `probe.json`(`{"fleet":{"server":"https://","identity_dir":"/etc/moshiro/probe-id"}}`),然后 `podman compose up -d`。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册。要换密钥就在面板吊销再重新注册。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。 纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro--rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/.image`;rootful Podman 使用 `moshiro--container.service`,Docker 使用 `moshiro--docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now ` 或 `sudo systemctl enable --now `。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。 @@ -243,7 +243,7 @@ Backend 用根目录的 `compose.yaml`(见上文「Backend 容器」),数 Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。 - **在管理端控制**:系统 → 设置 →「定时重启」,开关、时刻和时区(默认**开**,北京时间 04:30)。保存后马上通知所有在线节点,新连上的节点会补发。 -- **节点本地覆盖**:节点本地写了就以本地为准(面板的设置对它不生效)。`agent.json` 或 `probe.json` 里加 `"restart_at": "04:30+08:00"`,或者设置环境变量 `MOSHIRO_RESTART_AT`(优先)。写法 `HH:mm`(按 UTC)、`HH:mmZ` 或 `HH:mm±hh:mm`;写错了启动时就会报错。从面板重新下载 `probe.json` 会丢掉手写的 `restart_at`,所以 Probe 的容器推荐用环境变量。 +- **节点本地覆盖**:节点本地写了就以本地为准(面板的设置对它不生效)。`agent.json` 或 `probe.json` 里加 `"restart_at": "04:30+08:00"`,或者设置环境变量 `MOSHIRO_RESTART_AT`(优先)。写法 `HH:mm`(按 UTC)、`HH:mmZ` 或 `HH:mm±hh:mm`;写错了启动时就会报错。Probe 的容器同样可以用环境变量。 - 启动后不满 10 分钟不会触发,所以重启之后不会立刻又重启一次。日志里有 `restart scheduled at=… source=panel|local` 和 `stopping result=scheduled_restart`。 - Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。 diff --git a/docs/enrollment.md b/docs/enrollment.md index 70922f5..5f4cf8a 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -7,7 +7,7 @@ | 主体 | 凭据 | 生成方 | Backend 保存 | | --- | --- | --- | --- | | Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 | -| Probe | Fleet 节点身份(同时用于连面板和连 Agent) | 面板生成 `probe.json`(可以有多个 Probe,每个独立吊销) | 公钥 | +| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 | | 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 | | API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 | | 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** | @@ -41,10 +41,12 @@ install.sh(只做安装,不包含任何远程控制功能;容器节点的 Probe 直接连面板(Fleet 长连接,身份是下面生成的密钥),Agent 列表由面板作为期望状态下发;测量数据仍然只走 Probe → Agent。 ```text -面板:POST /api/admin/probes {name} - └─ 生成 Fleet 节点身份,返回 probe.json(只能下载一次;轮换密钥 /rotate 会使旧密钥失效,吊销 /revoke 会让面板和所有 Agent 断开它) - { probe_id, private_key, server: "https://panel...", - limits: { max_test_mbps, max_total_pps } } ← 本地硬上限,面板无法修改;在路由器上建议调低 +面板:POST /api/admin/join-tokens {role: "probe"} → 一次性加入令牌 jt_… +Probe 机器:fleet-enroll --server … --join jt_… --name home --identity-dir … + ├─ 本地生成两把密钥:node.key(连面板认证)和 tls.pfx 里的密钥(连 Agent 对端认证);面板从不接触私钥 + ├─ 注册请求带两把公钥(pubkey、tls_pubkey)和持有证明;面板据此写 probes 记录,ID 前缀 prb_ + └─ 吊销 /revoke 会让面板和所有 Agent 断开它;换密钥 = 吊销后重新注册(没有 rotate) +probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← limits 是本地硬上限,面板无法修改;在路由器上建议调低 Probe 启动 ├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息 @@ -87,8 +89,8 @@ Probe 启动 | GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) | | GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 | | POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 | -| GET/POST/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 身份的创建、列表、改名、删除 | -| POST | `/api/admin/probes/{id}/rotate`、`/revoke` | 轮换密钥、吊销 | +| GET/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 | +| POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) | | POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe | | GET | `/install.sh`、`/install.ps1` | 安装脚本 | diff --git a/docs/security.md b/docs/security.md index 6593da7..9e58c73 100644 --- a/docs/security.md +++ b/docs/security.md @@ -263,7 +263,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ```text 零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏 - 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 破坏性:旧 probe.json 失效 + 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 破坏性:旧 Probe 被拒绝 三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册 四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名 diff --git a/packaging/container/Probe.Containerfile b/packaging/container/Probe.Containerfile index 8ca719e..8ade640 100644 --- a/packaging/container/Probe.Containerfile +++ b/packaging/container/Probe.Containerfile @@ -7,6 +7,7 @@ LABEL org.opencontainers.image.title="MoShiro Probe" \ org.opencontainers.image.version="$APP_VERSION" COPY --chown=1654:1654 app/moshiro-probe /usr/local/bin/moshiro-probe +COPY --chown=1654:1654 app/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ USER 1654:1654 diff --git a/packaging/container/Probe.NativeBuild.Containerfile b/packaging/container/Probe.NativeBuild.Containerfile index 387e83a..e68781b 100644 --- a/packaging/container/Probe.NativeBuild.Containerfile +++ b/packaging/container/Probe.NativeBuild.Containerfile @@ -3,7 +3,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build WORKDIR /src/backend COPY restore/backend/ ./ # restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 -RUN dotnet restore probe/src/MoShiro.Probe -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts +RUN dotnet restore probe/src/MoShiro.Probe -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts \ + && dotnet restore fleet/tools/fleet-enroll -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts COPY source/backend/ ./ # 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) @@ -14,10 +15,14 @@ ENV MOSHIRO_TAG=$MOSHIRO_TAG MOSHIRO_COMMIT=$MOSHIRO_COMMIT MOSHIRO_BUILT_AT=$MO RUN dotnet publish probe/src/MoShiro.Probe -m:3 -c Release -r linux-musl-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ - -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/probe + -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/probe \ + && dotnet publish fleet/tools/fleet-enroll -m:3 -c Release -r linux-musl-x64 \ + --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ + -p:UseAppHost=true -p:StripSymbols=true \ + -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/enroll # 原生程序有几 MB;几十 KB 的是只会去找 .dll 的启动器 -RUN test -x /out/probe/moshiro-probe \ - && test "$(stat -c%s /out/probe/moshiro-probe)" -gt 2000000 +RUN test -x /out/probe/moshiro-probe && test -x /out/enroll/fleet-enroll \ + && test "$(stat -c%s /out/probe/moshiro-probe)" -gt 2000000 && test "$(stat -c%s /out/enroll/fleet-enroll)" -gt 2000000 FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine @@ -28,6 +33,7 @@ LABEL org.opencontainers.image.title="MoShiro Probe" \ org.opencontainers.image.version="$APP_VERSION" COPY --chown=1654:1654 --from=build /out/probe/moshiro-probe /usr/local/bin/moshiro-probe +COPY --chown=1654:1654 --from=build /out/enroll/fleet-enroll /usr/local/bin/fleet-enroll COPY --chown=1654:1654 config/ /etc/moshiro/ USER 1654:1654 diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh index 57f1c8e..f78f7c6 100644 --- a/packaging/container/deploy-node.sh +++ b/packaging/container/deploy-node.sh @@ -3,7 +3,7 @@ # 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。 # # 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1 -# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --probe-json ./probe.json +# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1 # 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置) # # 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。 @@ -12,13 +12,13 @@ set -eu usage() { echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2 - echo " [--server URL --join TOKEN --name NAME] [--probe-json FILE] [--policy auto|manual] [--authfile F]" >&2 + echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2 exit 2 } ROLE="${1:-}"; shift || true case "$ROLE" in agent|probe) ;; *) usage ;; esac -ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; PROBE_JSON=""; POLICY=""; AUTHFILE="" +ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE="" while [ $# -gt 0 ]; do case "$1" in --engine) ENGINE="$2"; shift 2 ;; @@ -28,7 +28,6 @@ while [ $# -gt 0 ]; do --server) SERVER="$2"; shift 2 ;; --join) JOIN="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;; - --probe-json) PROBE_JSON="$2"; shift 2 ;; --policy) POLICY="$2"; shift 2 ;; --authfile) AUTHFILE="$2"; shift 2 ;; *) usage ;; @@ -82,13 +81,14 @@ in_volume() { } in_volume 'mkdir -p /etc/moshiro/update' &2; exit 1; } + "$ENGINE" run --rm --pull=never -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \ + --server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR" +fi if [ "$ROLE" = agent ]; then - if ! in_volume 'test -f /etc/moshiro/agent-id/node.json' &2; exit 1; } - "$ENGINE" run --rm --pull=never -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \ - --server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir /etc/moshiro/agent-id - fi if ! in_volume 'test -f /etc/moshiro/agent.json' &2; exit 1; } printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}\n' "$SERVER" \ @@ -100,11 +100,11 @@ if [ "$ROLE" = agent ]; then echo "config: listen.bind 0.0.0.0 -> ::" fi else - if [ -n "$PROBE_JSON" ]; then - in_volume 'umask 077; cat > /etc/moshiro/probe.json' < "$PROBE_JSON" - echo "config: imported probe.json" - elif ! in_volume 'test -f /etc/moshiro/probe.json' &2; exit 1 + if ! in_volume 'test -f /etc/moshiro/probe.json' &2; exit 1; } + printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/probe-id"}}\n' "$SERVER" \ + | in_volume 'umask 077; cat > /etc/moshiro/probe.json' + echo "config: created probe.json" fi fi diff --git a/packaging/install.sh b/packaging/install.sh index 3df42a8..371f975 100644 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -3,7 +3,7 @@ # POSIX sh: runs on Debian/Ubuntu/Alpine/OpenWrt (busybox). # # ./install.sh agent --server https://panel.example.com --join --name hk-1 -# ./install.sh probe [--probe-json /path/to/probe.json] +# ./install.sh probe --server https://panel --join jt_xxx --name home-1 # ./install.sh uninstall agent|probe [--purge] # # Binaries go to /usr/lib/moshiro (root-owned: the collector cannot rewrite its own executable); @@ -62,27 +62,34 @@ install_binary() { put 0755 "$HERE/$src" "$PREFIX/$src" } -write_agent_config() { - cat >"$CONFIG/agent.json" <"$CONFIG/agent.json" <"$CONFIG/probe.json" < --join --name --identity-dir $CONFIG (as the '$USER_NAME' user)" - echo " write $CONFIG/agent.json (see README.md), then: systemctl enable --now moshiro-agent" - else - echo " save the panel's probe identity (POST /api/admin/probe/identity) as $CONFIG/probe.json (mode 0600, owner $USER_NAME)" - echo " then: systemctl enable --now moshiro-probe (OpenWrt: /etc/init.d/moshiro-probe enable && /etc/init.d/moshiro-probe start)" - fi + echo " $PREFIX/fleet-enroll --server --join --name --identity-dir $CONFIG (as the '$USER_NAME' user)" + echo " write $CONFIG/$role.json (see README.md), then: systemctl enable --now moshiro-$role" + [ "$role" = agent ] || echo " (OpenWrt: /etc/init.d/moshiro-probe enable && /etc/init.d/moshiro-probe start)" fi } @@ -166,7 +161,7 @@ uninstall() { rm -f /etc/init.d/moshiro-probe /lib/upgrade/keep.d/moshiro ;; esac rm -f "$PREFIX/moshiro-$role" - if [ "$role" = agent ]; then rm -f "$PREFIX/fleet-enroll"; fi + rm -f "$PREFIX/fleet-enroll" rmdir "$PREFIX" 2>/dev/null || true if [ "$purge" -eq 1 ]; then rm -rf "$CONFIG" diff --git a/packaging/openwrt/moshiro-probe.init b/packaging/openwrt/moshiro-probe.init index be5cddf..0979539 100644 --- a/packaging/openwrt/moshiro-probe.init +++ b/packaging/openwrt/moshiro-probe.init @@ -10,7 +10,7 @@ CONF=/etc/moshiro/probe.json start_service() { [ -f "$CONF" ] || { - logger -t moshiro-probe "$CONF not found: download it from the panel (POST /api/admin/probe/identity)" + logger -t moshiro-probe "$CONF not found: enroll first (install.sh probe --server URL --join TOKEN --name NAME)" return 1 } procd_open_instance diff --git a/scripts/smoke-linux.sh b/scripts/smoke-linux.sh index d026590..e03c8c6 100644 --- a/scripts/smoke-linux.sh +++ b/scripts/smoke-linux.sh @@ -36,6 +36,7 @@ csrf=$(echo "$login" | tail -1 | jq -r .csrf_token) token=$(curl -fs -X POST "$base/api/admin/api-tokens" -H "Cookie: $cookie" -H "X-CSRF-Token: $csrf" -H 'Content-Type: application/json' -d '{"name":"smoke","scope":"admin"}' | jq -r .plaintext) auth="Authorization: Bearer $token" join=$(curl -fs -X POST "$base/api/admin/join-tokens" -H "$auth" -H 'Content-Type: application/json' -d '{"auto_approve":true,"max_uses":1,"expires_in_hours":1}' | jq -r .plaintext) +probe_join=$(curl -fs -X POST "$base/api/admin/join-tokens" -H "$auth" -H 'Content-Type: application/json' -d '{"role":"probe","max_uses":1,"expires_in_hours":1}' | jq -r .plaintext) if [ "$mode" = install ]; then # 安装到 /usr/lib/moshiro 和 /etc/moshiro,以 moshiro 用户运行(没有 systemd:自己启动) @@ -46,8 +47,7 @@ if [ "$mode" = install ]; then run_as /usr/lib/moshiro/moshiro-agent --config /etc/moshiro/agent.json >"$work/agent.log" 2>&1 & agent_pid=$! sleep 3 - curl -fs -X POST "$base/api/admin/probe/identity" -H "$auth" -H 'Content-Type: application/json' -d '{}' >"$work/probe/probe.json" - (cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --probe-json "$work/probe/probe.json" --no-start) + (cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --server "$base" --join "$probe_join" --name smoke-probe --no-start) run_as /usr/lib/moshiro/moshiro-probe --config /etc/moshiro/probe.json >"$work/probe.log" 2>&1 & probe_pid=$! else @@ -57,7 +57,9 @@ else "$pub/agent/$rid/moshiro-agent" --config "$work/agent.json" >"$work/agent.log" 2>&1 & agent_pid=$! sleep 3 - curl -fs -X POST "$base/api/admin/probe/identity" -H "$auth" -H 'Content-Type: application/json' -d '{}' >"$work/probe/probe.json" + mkdir -p "$work/probe-id" + "$pub/fleet-enroll/$rid/fleet-enroll" --server "$base" --join "$probe_join" --name smoke-probe --identity-dir "$work/probe-id" + printf '{"fleet":{"server":"%s","identity_dir":"%s"}}' "$base" "$work/probe-id" >"$work/probe/probe.json" "$pub/probe/$rid/moshiro-probe" --config "$work/probe/probe.json" >"$work/probe.log" 2>&1 & probe_pid=$! fi diff --git a/scripts/smoke-systemd.sh b/scripts/smoke-systemd.sh index 65c222a..3ee744a 100644 --- a/scripts/smoke-systemd.sh +++ b/scripts/smoke-systemd.sh @@ -17,8 +17,8 @@ join=$(curl -fs -X POST $base/api/admin/join-tokens -H "$auth" -H 'Content-Type: mkdir -p /tmp/pkg; for r in agent probe; do tar -C /tmp/pkg -xzf /art/packages/moshiro-$r-*-linux-x64.tar.gz; done (cd /tmp/pkg/moshiro-agent-* && ./install.sh agent --server $base --join "$join" --name sysd-agent) sleep 3 -curl -fs -X POST $base/api/admin/probe/identity -H "$auth" -H 'Content-Type: application/json' -d '{}' >/tmp/probe.json -(cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --probe-json /tmp/probe.json) +probe_join=$(curl -fs -X POST $base/api/admin/join-tokens -H "$auth" -H 'Content-Type: application/json' -d '{"role":"probe","max_uses":1,"expires_in_hours":1}' | jq -r .plaintext) +(cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --server $base --join "$probe_join" --name sysd-probe) sleep 25 systemctl is-active moshiro-agent moshiro-probe echo "agents: $(curl -fs $base/api/admin/agents -H "$auth" | jq -c '[.items[] | {name, status, probe_connected}]')" diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 772a215..a8d617f 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -1,13 +1,13 @@ -import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react' +import { Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react' import { useState } from 'react' import { Link } from 'react-router-dom' import { api } from '@shared/api/client' -import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' +import type { CreatedJoinTokenDto, LinkDto, LinkMatrixDto, Page, ProbeDto } from '@shared/api/types' import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { IPV6_STATES } from '@shared/ipVersions' -import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' +import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, guessPlatform, Spinner, useAction } from '@shared/ui' /** * 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的; @@ -86,39 +86,96 @@ function LinkMatrix() { ) } -/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ -function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) { - const json = JSON.stringify(identity, null, 2) +/** + * 创建 Probe 和创建 Agent 一样:生成一个一次性的加入令牌,在家宽机器上运行命令。 + * 密钥由 Probe 自己生成,面板从头到尾不接触私钥;想换密钥就吊销之后重新注册。 + */ +function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const { busy, run } = useAction() + const [note, setNote] = useState('') + const [hours, setHours] = useState(24) + const [created, setCreated] = useState() + + if (created) { + return ( + + 完成 + + } + > +
+
令牌明文只显示这一次,请现在就复制。
+ + + + +
{created.plaintext}
+
+ +
+
+

一次性令牌,{fmtTime(created.token.expires_at)} 过期。

+
+
+ ) + } + return ( - 我已保存 - + <> + + + } > -
-
私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。
-
- - +

+ 添加 Probe 就是创建一个一次性的加入令牌:在家宽机器上运行命令,Probe 会用它注册并出现在列表里。 +

+
+
+ + setNote(e.target.value)} placeholder="上海电信家宽" /> +
- -
{`moshiro-probe --config /etc/moshiro/probe.json`}
+ + setHours(Number(e.target.value))} /> -
- - 查看内容 - -
-            {json}
-          
-
) @@ -159,7 +216,7 @@ function NameDialog({ title, initial, onClose, onSubmit }: { title: string; init ) } -type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto } +type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto } export function Probes() { const probes = useQuery(() => api.get>('/api/admin/probes'), [], 8000) @@ -168,13 +225,11 @@ export function Probes() { const [creating, setCreating] = useState(false) const [renaming, setRenaming] = useState() const [pending, setPending] = useState() - const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>() const list = probes.data?.items ?? [] const confirmText = { - rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' }, - revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' }, + revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复;要换密钥就吊销之后重新添加一个 Probe。' }, delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' }, } @@ -186,7 +241,7 @@ export function Probes() { {probes.loading && !probes.data ? : null} @@ -197,7 +252,7 @@ export function Probes() { {probes.data && list.length === 0 ? (
- 还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。 + 还没有 Probe。点击右上角「添加 Probe」,在家宽机器上运行命令即可注册。
) : null} {list.map((p) => ( @@ -221,16 +276,10 @@ export function Probes() { 改名 {!p.revoked ? ( - <> - - - + ) : null}