ci: sync main into dev [CI SKIP]

This commit is contained in:
justme committed 2026-10-07 16:58:59 +08:00
commit 7ee860cb27
51 files changed
+505 -749

No files matched your search

@@ -29,9 +29,11 @@ public static class EnrollmentClient
using NodeIdentity identity = NodeIdentity.Generate(); using NodeIdentity identity = NodeIdentity.Generate();
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time); byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
byte[] spki; byte[] spki;
byte[] tlsPublicKey;
using (var certificate = NodeCertificate.LoadPfx(pfx)) using (var certificate = NodeCertificate.LoadPfx(pfx))
{ {
spki = NodeCertificate.SpkiFingerprint(certificate); spki = NodeCertificate.SpkiFingerprint(certificate);
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
} }
string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
@@ -42,6 +44,7 @@ public static class EnrollmentClient
["name"] = name, ["name"] = name,
["pubkey"] = pubkey, ["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(spki), ["tls_spki"] = Convert.ToBase64String(spki),
["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey),
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion, ["node_version"] = nodeVersion,
["meta"] = productMeta, ["meta"] = productMeta,
@@ -331,6 +331,15 @@ public sealed class LoadedNodeIdentity : IDisposable
public X509Certificate2 Certificate { get; } public X509Certificate2 Certificate { get; }
/// <summary>
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
/// 各自只用于自己的用途(一把泄露不影响另一把)。
/// </summary>
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
private NodeIdentity? _peerIdentity;
public static LoadedNodeIdentity Load(string directory) public static LoadedNodeIdentity Load(string directory)
{ {
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName))) 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() public void Dispose()
{ {
Identity.Dispose(); Identity.Dispose();
_peerIdentity?.Dispose();
Certificate.Dispose(); Certificate.Dispose();
} }
} }
@@ -22,7 +22,7 @@ public static class NodeCertificate
return certificate.Export(X509ContentType.Pfx); return certificate.Export(X509ContentType.Pfx);
} }
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null); public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable);
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary> /// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
public static byte[] SpkiFingerprint(X509Certificate2 certificate) => public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
@@ -5,6 +5,9 @@ public sealed class FleetServerOptions
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary> /// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node"; public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20; public int MaxFrameBytes { get; set; } = 16 << 20;
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
@@ -31,7 +31,9 @@ public sealed record NewNode(
IReadOnlyList<string> Labels, IReadOnlyList<string> Labels,
string? MetaJson, string? MetaJson,
string? NodeVersion, string? NodeVersion,
long NowMs); long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null);
public sealed record JoinTokenRecord( public sealed record JoinTokenRecord(
string Id, string Id,
@@ -43,6 +45,7 @@ public sealed record JoinTokenRecord(
int Uses, int Uses,
long ExpiresAtMs, long ExpiresAtMs,
long? RevokedAtMs, long? RevokedAtMs,
long CreatedAtMs); long CreatedAtMs,
string Kind = "");
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -19,6 +19,13 @@ public sealed class EnrollRequest
[JsonPropertyName("tls_spki")] [JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = ""; public string TlsSpki { get; set; } = "";
/// <summary>
/// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 <see cref="TlsSpki"/> 对得上。节点用这把密钥做对等面的签名认证
/// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。
/// </summary>
[JsonPropertyName("tls_pubkey")]
public string TlsPublicKey { get; set; } = "";
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary> /// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
[JsonPropertyName("proof")] [JsonPropertyName("proof")]
public string Proof { get; set; } = ""; public string Proof { get; set; } = "";
@@ -29,9 +29,16 @@ public sealed partial class FleetEnrollmentService
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey) if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32 || !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|| !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey)
|| !TryDecode(request.Proof, out byte[] proof)) || !TryDecode(request.Proof, out byte[] proof))
{ {
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed"); return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed");
}
// 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的
if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "tls_spki does not match tls_pubkey");
} }
byte[] tokenHash = TokenHash.Sha256(request.JoinToken); 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; bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending; 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( var node = new NewNode(
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)), new NodeId(Ulid.NewPrefixed(prefix, _time)),
request.Name, request.Name,
status, status,
publicKey, publicKey,
@@ -70,7 +78,9 @@ public sealed partial class FleetEnrollmentService
token.Labels, token.Labels,
request.Meta?.GetRawText(), request.Meta?.GetRawText(),
request.NodeVersion, request.NodeVersion,
nowMs); nowMs,
token.Kind,
tlsPublicKey);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct); Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error)) if (!created.TryGet(out NodeRecord? record, out FleetError? error))
+7 -205
View File
@@ -1928,7 +1928,7 @@
"tags": [ "tags": [
"admin-join-tokens" "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", "operationId": "join_tokens_create",
"requestBody": { "requestBody": {
"content": { "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}": { "/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": { "/api/admin/probes/{id}/revoke": {
"post": { "post": {
"tags": [ "tags": [
@@ -5007,16 +4861,8 @@
"string" "string"
], ],
"format": "int32" "format": "int32"
} },
} "role": {
},
"CreateProbeRequest": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"type": [ "type": [
"null", "null",
"string" "string"
@@ -5822,6 +5668,10 @@
}, },
"created_at": { "created_at": {
"format": "date-time" "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": { "ProblemDto": {
"required": [ "required": [
"type", "type",
+8 -2
View File
@@ -11,7 +11,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
└─────Agent 列表、状态(Fleet 长连接)───────────┘ └─────Agent 列表、状态(Fleet 长连接)───────────┘
``` ```
Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。 Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。 容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
@@ -34,7 +34,13 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
## 配置与运行 ## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行: 在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
```sh
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
```
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
```sh ```sh
moshiro-probe --config /etc/moshiro/probe.json moshiro-probe --config /etc/moshiro/probe.json
+6 -6
View File
@@ -23,20 +23,20 @@ public sealed class ProbeApp : IDisposable
private readonly INodeLog _log; private readonly INodeLog _log;
private readonly FleetLink _link; private readonly FleetLink _link;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) /// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{ {
_log = log; _log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log); var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey)); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release(); directory.Changed += () => _changed.Release();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log); var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
var options = new FleetLinkOptions var options = new FleetLinkOptions
{ {
Server = new Uri(file.Server), Server = new Uri(file.Fleet.Server),
Identity = identity, Identity = identity.Identity,
NodeId = new NodeId(file.ProbeId), NodeId = identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base], 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), HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
+14 -11
View File
@@ -4,21 +4,13 @@ using MoShiro.Protocol;
namespace MoShiro.Probe; namespace MoShiro.Probe;
/// <summary> /// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。 /// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。 /// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary> /// </summary>
public sealed class ProbeFile public sealed class ProbeFile
{ {
[JsonPropertyName("probe_id")] [JsonPropertyName("fleet")]
public string ProbeId { get; set; } = ""; public ProbeFleetSection Fleet { get; set; } = new();
/// <summary>PKCS#8,base64。</summary>
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary> /// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
[JsonPropertyName("restart_at")] [JsonPropertyName("restart_at")]
@@ -28,6 +20,17 @@ public sealed class ProbeFile
public ProbeLimits Limits { get; set; } = new(); public ProbeLimits Limits { get; set; } = new();
} }
public sealed class ProbeFleetSection
{
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
}
public sealed class KnownAgent public sealed class KnownAgent
{ {
[JsonPropertyName("ip_version")] [JsonPropertyName("ip_version")]
+13 -10
View File
@@ -21,17 +21,25 @@ for (int i = 0; i < args.Length - 1; i++)
} }
ProbeFile file; ProbeFile file;
LoadedNodeIdentity identity;
RestartController restart; RestartController restart;
try try
{ {
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile); file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt)); 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)); log.Error("startup failed", ("err", ex.Message));
return 1; return 1;
@@ -39,15 +47,10 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using var restartScope = restart; using var restartScope = restart;
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!; 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); 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; return 0;
@@ -40,7 +40,7 @@ public static class AgentEndpoints
group.MapGet("/join-tokens", ListTokensAsync) group.MapGet("/join-tokens", ListTokensAsync)
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403); .Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
group.MapPost("/join-tokens", CreateTokenAsync) group.MapPost("/join-tokens", CreateTokenAsync)
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag) .Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); .Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) .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) group.MapGet("/probes", ListProbesAsync)
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403); .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
group.MapPost("/probes", CreateProbeAsync)
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
group.MapGet("/probes/{id}", GetProbeAsync) group.MapGet("/probes/{id}", GetProbeAsync)
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404); .Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
group.MapPatch("/probes/{id}", PatchProbeAsync) group.MapPatch("/probes/{id}", PatchProbeAsync)
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409); .Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) 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) .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
.Returns<ProbeDto>().ProducesProblems(401, 403, 404); .Returns<ProbeDto>().ProducesProblems(401, 403, 404);
@@ -122,7 +116,7 @@ public static class AgentEndpoints
{ {
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
Result<CreatedJoinToken> result = await tokens.CreateAsync( Result<CreatedJoinToken> result = await tokens.CreateAsync(
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted); new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted);
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
{ {
return Problems.From(http, error); return Problems.From(http, error);
@@ -150,19 +144,6 @@ public static class AgentEndpoints
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) => private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto); TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, CreateProbeRequest? request, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) => private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
await probes.GetAsync(id, ct) is { } view await probes.GetAsync(id, ct) is { } view
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) ? 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); return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
} }
private static async Task<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, string id, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
{ {
Result<ProbeView> result = await probes.RevokeAsync(id, ct); Result<ProbeView> result = await probes.RevokeAsync(id, ct);
@@ -213,10 +182,4 @@ public static class AgentEndpoints
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent"); return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent");
} }
/// <summary>Probe 要连的面板地址:配置的公开地址,没有就用这次请求的地址。</summary>
private static string PanelUrl(HttpContext http, BackendOptions options) => (options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}").TrimEnd('/');
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
new(file.ProbeId.Value, file.PrivateKey, file.Server, new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
} }
@@ -154,7 +154,7 @@ public static class Mappers
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); 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) => 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)); public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
@@ -48,9 +48,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary> /// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null); public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt); /// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent");
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
/// <summary>令牌明文只在创建的响应里出现一次。</summary> /// <summary>令牌明文只在创建的响应里出现一次。</summary>
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
@@ -72,13 +73,6 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled); public sealed record SetLinkRequest(bool? Enabled);
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, string Server, ProbeLimitsDto Limits);
public sealed record CreateProbeRequest(string? Name);
public sealed record PatchProbeRequest(string? Name); public sealed record PatchProbeRequest(string? Name);
public sealed record TaskDto( public sealed record TaskDto(
@@ -56,8 +56,6 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PageDto<JoinTokenDto>))] [JsonSerializable(typeof(PageDto<JoinTokenDto>))]
[JsonSerializable(typeof(CreateJoinTokenRequest))] [JsonSerializable(typeof(CreateJoinTokenRequest))]
[JsonSerializable(typeof(CreatedJoinTokenDto))] [JsonSerializable(typeof(CreatedJoinTokenDto))]
[JsonSerializable(typeof(ProbeIdentityDto))]
[JsonSerializable(typeof(CreateProbeRequest))]
[JsonSerializable(typeof(PatchProbeRequest))] [JsonSerializable(typeof(PatchProbeRequest))]
[JsonSerializable(typeof(ProbeDto))] [JsonSerializable(typeof(ProbeDto))]
[JsonSerializable(typeof(PageDto<HostItemDto>))] [JsonSerializable(typeof(PageDto<HostItemDto>))]
@@ -2,7 +2,8 @@ using Fleet.Core;
namespace MoShiro.Backend.Application; namespace MoShiro.Backend.Application;
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); /// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary> /// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows); public sealed record InstallCommands(string Linux, string Macos, string Windows);
@@ -12,6 +13,14 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary> /// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService public sealed class JoinTokenService
{ {
public const string AgentRole = "agent";
public const string ProbeRole = "probe";
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
private readonly IJoinTokenStore _store; private readonly IJoinTokenStore _store;
private readonly BackendOptions _options; private readonly BackendOptions _options;
private readonly TimeProvider _time; private readonly TimeProvider _time;
@@ -26,6 +35,11 @@ public sealed class JoinTokenService
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct) public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
{ {
var errors = new Dictionary<string, string>(); var errors = new Dictionary<string, string>();
if (request.Role is not (AgentRole or ProbeRole))
{
errors["role"] = "must be agent or probe";
}
if (request.MaxUses is < 1 or > 1000) if (request.MaxUses is < 1 or > 1000)
{ {
errors["max_uses"] = "must be between 1 and 1000"; errors["max_uses"] = "must be between 1 and 1000";
@@ -54,13 +68,18 @@ public sealed class JoinTokenService
string plaintext = TokenHash.NewToken("jt"); string plaintext = TokenHash.NewToken("jt");
long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync( 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); request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>"; // Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh
string shell = request.Role == ProbeRole
? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name <node-name>"
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴 // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\""; string powershell = request.Role == ProbeRole
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell))); return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
} }
@@ -20,6 +20,13 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct) 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(); AgentEnrollMeta meta = new();
if (context.ProductMeta is { } raw) if (context.ProductMeta is { } raw)
{ {
@@ -1,17 +1,10 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Fleet.Core; using Fleet.Core;
using Fleet.Identity;
using Fleet.Server; using Fleet.Server;
using MoShiro.Protocol; using MoShiro.Protocol;
namespace MoShiro.Backend.Application; namespace MoShiro.Backend.Application;
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥;<c>Server</c> 是 Probe 要连的面板地址。</summary>
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits);
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary> /// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
@@ -21,8 +14,9 @@ public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<Pro
} }
/// <summary> /// <summary>
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。 /// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。 /// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。
/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。
/// 一条线路是一对(Probe,Agent)。 /// 一条线路是一对(Probe,Agent)。
/// </summary> /// </summary>
public sealed partial class ProbeService public sealed partial class ProbeService
@@ -46,26 +40,6 @@ public sealed partial class ProbeService
_time = time; _time = time;
} }
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, string server, CancellationToken ct)
{
string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim();
if (!NamePattern().IsMatch(finalName))
{
return ValidationError.Fail<ProbeIdentityFile>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
}
if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase)))
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.NameConflict, $"a probe named {finalName} already exists");
}
using NodeIdentity identity = NodeIdentity.Generate();
var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
await _probes.SaveAsync(probe, ct);
_gateway.NotifyAllTaskSetsChanged();
return Result<ProbeIdentityFile>.Ok(BuildFile(probe, identity, server));
}
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct) public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
{ {
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct); IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
@@ -98,27 +72,6 @@ public sealed partial class ProbeService
return Result<ProbeView>.Ok((await GetAsync(id, ct))!); return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
} }
/// <summary>换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。</summary>
public async ValueTask<Result<ProbeIdentityFile>> RotateAsync(string id, string server, CancellationToken ct)
{
if (await _probes.GetAsync(id, ct) is not { } probe)
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
}
if (probe.RevokedAtMs is not null)
{
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead");
}
using NodeIdentity identity = NodeIdentity.Generate();
ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki };
await _probes.SaveAsync(updated, ct);
_gateway.NotifyAllTaskSetsChanged();
await DisconnectEverywhereAsync(id, ct);
return Result<ProbeIdentityFile>.Ok(BuildFile(updated, identity, server));
}
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary> /// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct) public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
{ {
@@ -164,14 +117,6 @@ public sealed partial class ProbeService
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct); return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
} }
private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct)
{
foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId))
{
await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct);
}
}
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents) private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
{ {
var connections = new List<ProbeConnection>(); var connections = new List<ProbeConnection>();
@@ -187,9 +132,6 @@ public sealed partial class ProbeService
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]); 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}$")] [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
private static partial Regex NamePattern(); private static partial Regex NamePattern();
} }
@@ -64,6 +64,11 @@ public sealed record JoinTokenInfo(
int Uses, int Uses,
long ExpiresAtMs, long ExpiresAtMs,
long? RevokedAtMs, long? RevokedAtMs,
long CreatedAtMs); long CreatedAtMs,
string Kind = "");
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null); /// <summary>
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 <c>peers</c> 里放的是后者。
/// </summary>
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null);
@@ -51,7 +51,7 @@ public interface IGeoLookup
public interface IJoinTokenStore public interface IJoinTokenStore
{ {
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct); ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
@@ -60,9 +60,9 @@ public sealed class TaskPlanner : IDisposable
}); });
} }
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && links.IsLinked(p.Id, agentId.Value))) foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
{ {
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) }); document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
} }
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet))); string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
@@ -18,7 +18,11 @@ public static class GatewayServiceCollectionExtensions
services.AddSingleton<IAgentGateway, FleetGateway>(); services.AddSingleton<IAgentGateway, FleetGateway>();
services.AddHostedService<ConnectionShutdown>(); services.AddHostedService<ConnectionShutdown>();
services.AddFleetEnrollment(); services.AddFleetEnrollment();
services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix) services.AddFleetServer(options =>
{
options.NodeIdPrefix = AgentIds.Prefix;
options.NodeIdPrefixes[JoinTokenService.ProbeRole] = ProbeId.Prefix;
})
.AddState<TaskSetStateProvider>() .AddState<TaskSetStateProvider>()
.AddState<ProbeAgentsStateProvider>() .AddState<ProbeAgentsStateProvider>()
.AddInbox<RawChunkInbox>() .AddInbox<RawChunkInbox>()
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary> /// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
internal static class Schema internal static class Schema
{ {
public const int CurrentVersion = 7; public const int CurrentVersion = 8;
public static void Migrate(SqliteConnection connection) public static void Migrate(SqliteConnection connection)
{ {
@@ -110,6 +110,16 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 7"); connection.Exec("PRAGMA user_version = 7");
transaction.Commit(); transaction.Commit();
} }
if (version < 8)
{
// Probe 用加入令牌注册:令牌带种类;Probe 有两把独立的密钥,对等面公钥单独存
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE join_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''");
connection.Exec("ALTER TABLE probes ADD COLUMN peer_pubkey BLOB");
connection.Exec("PRAGMA user_version = 8");
transaction.Commit();
}
} }
/// <summary> /// <summary>
@@ -56,4 +56,6 @@ internal static class Sql
public static string? NullableString(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); public static 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[] Bytes(this SqliteDataReader reader, int ordinal) => (byte[])reader.GetValue(ordinal);
public static byte[]? NullableBytes(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : (byte[])reader.GetValue(ordinal);
} }
@@ -138,8 +138,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
byte[] hash = tokenSha256.ToArray(); byte[] hash = tokenSha256.ToArray();
return _db.ReadAsync( return _db.ReadAsync(
c => c.QueryOne( 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", "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 => 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)), ("@h", hash)),
ct); ct);
} }
@@ -154,6 +154,11 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
public ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct) => public ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct) =>
_db.WriteAsync(c => _db.WriteAsync(c =>
{ {
if (node.Kind == JoinTokenService.ProbeRole)
{
return CreateProbe(c, node);
}
if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id)) if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id))
{ {
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a node with this name already exists"); return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a node with this name already exists");
@@ -166,6 +171,25 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
}, ct); }, ct);
/// <summary>Probe 用加入令牌注册:直接进 probes 表,有令牌就算批准(Probe 没有 pending 状态);两把公钥都是它自己生成的。</summary>
private static Result<NodeRecord> CreateProbe(SqliteConnection c, NewNode node)
{
if (node.TlsPublicKey is not { Length: > 0 } peerKey)
{
return Result<NodeRecord>.Fail(FleetErrors.InvalidParams, "a probe needs its peer key");
}
if (c.Scalar<long>("SELECT COUNT(*) FROM probes WHERE name = @name COLLATE NOCASE", ("@name", node.Name)) > 0)
{
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a probe with this name already exists");
}
c.Exec(
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at) VALUES (@id, @name, @pubkey, @peer, @now)",
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs));
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
}
/// <summary>Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。</summary> /// <summary>Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。</summary>
async ValueTask<NodeRecord?> IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => async ValueTask<NodeRecord?> IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) =>
await GetAsync(id, ct) is { } agent await GetAsync(id, ct) is { } agent
@@ -15,21 +15,21 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db = db; _db = db;
} }
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) => public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) =>
_db.WriteAsync(c => _db.WriteAsync(c =>
{ {
c.Exec( 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)", "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), ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs)); ("@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); }, ct);
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>( _db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
c => c.Query( 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", "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 => 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); ct);
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) => public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) =>
@@ -50,12 +50,12 @@ public sealed class SqliteProbeStore : IProbeStore
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) => public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<ProbeRecord>>( _db.ReadAsync<IReadOnlyList<ProbeRecord>>(
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5))), c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6))),
ct); ct);
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) => public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync( _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); ct);
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
@@ -71,9 +71,9 @@ public sealed class SqliteProbeStore : IProbeStore
_db.ExecuteAsync(c => _db.ExecuteAsync(c =>
{ {
c.Exec( c.Exec(
"INSERT INTO probes (id, name, pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @at, @revoked) " + "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, created_at = excluded.created_at, revoked_at = excluded.revoked_at", "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), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs)); ("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@peer", probe.PeerPublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs));
}, ct); }, ct);
} }
@@ -63,18 +63,19 @@ public sealed class AgentHarness : IAsyncDisposable
return harness; return harness;
} }
/// <summary>在后端生成 Probe 身份(私钥只返回一次),并构造连到这个 Agent 的假 Probe。</summary> /// <summary>注册一个 Probe(密钥由它自己生成),并构造连到这个 Agent 的假 Probe:对等面用它的对等面密钥。</summary>
public async Task<ProbeSimulator> CreateProbeAsync() public async Task<ProbeSimulator> CreateProbeAsync()
{ {
JsonNode identity = await Backend.CreateProbeAsync(); using EnrolledProbe probe = await Backend.CreateProbeAsync();
NodeIdentity key = NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>())); // 假 Probe 要在注册目录被释放之后继续用这把密钥:导出再导入一份
NodeIdentity key = NodeIdentity.FromPkcs8(probe.Identity.PeerIdentity.ExportPkcs8());
string publicKey = Convert.ToBase64String(key.PublicKeySpki); string publicKey = Convert.ToBase64String(key.PublicKeySpki);
// 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝) // 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝)
await BackendFixture.EventuallyAsync( await BackendFixture.EventuallyAsync(
() => Task.FromResult(App.IsPeerKnown(identity["probe_id"]!.GetValue<string>(), publicKey)), () => Task.FromResult(App.IsPeerKnown(probe.ProbeId, publicKey)),
because: "the agent should learn the probe's key"); because: "the agent should learn the probe's peer key");
return new ProbeSimulator(identity["probe_id"]!.GetValue<string>(), key); return new ProbeSimulator(probe.ProbeId, key);
} }
public async Task<ProbeSimulator> ConnectProbeAsync() public async Task<ProbeSimulator> ConnectProbeAsync()
@@ -37,9 +37,15 @@ public sealed class AotSmokeTests
using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json"));
settingsResponse.EnsureSuccessStatusCode(); settingsResponse.EnsureSuccessStatusCode();
using HttpResponseMessage probeResponse = await http.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "aot-probe-" + Guid.NewGuid().ToString("N")[..6] })); // Probe:probe 角色的加入令牌 → 节点自己生成密钥并注册
probeResponse.EnsureSuccessStatusCode(); using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 }));
string probeId = JsonNode.Parse(await probeResponse.Content.ReadAsStringAsync())!["probe_id"]!.GetValue<string>(); probeJoin.EnsureSuccessStatusCode();
string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using var probeHttp = new HttpClient();
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString());
string probeId = probeEnrolled.Value.NodeId;
// Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态 // Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 })); using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
@@ -53,13 +53,26 @@ public class BackendFixture : IAsyncLifetime
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}"; public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
/// <summary>在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。</summary> /// <summary>
public async Task<JsonNode> CreateProbeAsync(string? name = null) /// 注册一个新的 Probe:和真实部署一样,先在管理端创建 <c>probe</c> 角色的加入令牌,再由节点自己生成密钥、用 <c>fleet-enroll</c> 同样的流程注册。
/// 返回的身份目录里有两把密钥:连面板的 <c>Identity</c> 和对等面的 <c>PeerIdentity</c>。调用方负责释放。
/// </summary>
public async Task<EnrolledProbe> CreateProbeAsync(string? name = null)
{ {
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/probes", Json(new JsonObject { ["name"] = name ?? UniqueName("probe") })); string token = await CreateJoinTokenAsync(role: "probe");
string body = await response.Content.ReadAsStringAsync(); string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N"));
Assert.True(response.IsSuccessStatusCode, body); using var http = new HttpClient();
return JsonNode.Parse(body)!; Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default);
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
return new EnrolledProbe(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory));
}
/// <summary>注册一个 Probe,只要它的 id(密钥用完即释放)。</summary>
public async Task<string> CreateProbeIdAsync(string? name = null)
{
using EnrolledProbe probe = await CreateProbeAsync(name);
return probe.ProbeId;
} }
public async ValueTask InitializeAsync() public async ValueTask InitializeAsync()
@@ -68,7 +81,10 @@ public class BackendFixture : IAsyncLifetime
await StartAsync(); await StartAsync();
_adminToken = await CreateAdminTokenAsync(); _adminToken = await CreateAdminTokenAsync();
CreateClients(); CreateClients();
DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue<string>(); using (EnrolledProbe probe = await CreateProbeAsync("default"))
{
DefaultProbeId = probe.ProbeId;
}
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -123,9 +139,9 @@ public class BackendFixture : IAsyncLifetime
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}"; public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
public async Task<string> CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1) public async Task<string> CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1, string role = "agent")
{ {
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours })); using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["role"] = role, ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours }));
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>(); return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
} }
@@ -235,3 +251,9 @@ public sealed class StrictLoginFixture : BackendFixture
{ {
protected override int LoginAttemptsPerMinute => 10; protected override int LoginAttemptsPerMinute => 10;
} }
/// <summary>注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。</summary>
public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
{
public void Dispose() => Identity.Dispose();
}
@@ -93,6 +93,31 @@ public sealed class EnrollmentAndTaskSetTests
Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id); Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
} }
[Fact]
public async Task Enrollment_RejectsATlsPublicKeyThatDoesNotMatchItsFingerprint_AndAnUnknownRole()
{
string token = await _backend.CreateJoinTokenAsync(role: "probe");
using var mine = Fleet.Identity.NodeIdentity.Generate();
using var tls = Fleet.Identity.NodeIdentity.Generate();
using var impostor = Fleet.Identity.NodeIdentity.Generate();
string pubkey = Convert.ToBase64String(mine.PublicKeySpki);
string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
// 指纹是 tls 的,公钥却换成了别的:Agent 将来按指纹校验证书,两者必须对得上
using HttpResponseMessage response = await PostEnrollAsync(new JsonObject
{
["join_token"] = token,
["name"] = _backend.UniqueName("tls"),
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(tls.PublicKeySpki)),
["tls_pubkey"] = Convert.ToBase64String(impostor.PublicKeySpki),
["proof"] = Convert.ToBase64String(mine.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey)),
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using HttpResponseMessage badRole = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "root" }));
Assert.True(badRole.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity);
}
[Fact] [Fact]
public async Task Enrollment_RejectsAProofSignedByAnotherKey() public async Task Enrollment_RejectsAProofSignedByAnotherKey()
{ {
@@ -107,7 +132,8 @@ public sealed class EnrollmentAndTaskSetTests
["join_token"] = token, ["join_token"] = token,
["name"] = _backend.UniqueName("forged"), ["name"] = _backend.UniqueName("forged"),
["pubkey"] = pubkey, ["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(new byte[32]), ["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(mine.PublicKeySpki)),
["tls_pubkey"] = pubkey,
["proof"] = forged, ["proof"] = forged,
}); });
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
@@ -327,47 +353,52 @@ public sealed class EnrollmentAndTaskSetTests
} }
[Fact] [Fact]
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList() public async Task ProbeLifecycle_EnrollRenameRevokeDelete_IsReflectedInEveryAgentsPeerList()
{ {
await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); 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") })); // 创建 Probe 就是创建一个 probe 角色的加入令牌;密钥由节点自己生成,Backend 从不接触私钥
Assert.Equal(HttpStatusCode.Created, created.StatusCode); string name = _backend.UniqueName("home");
Assert.Equal("no-store", created.Headers.CacheControl!.ToString()); using EnrolledProbe probe = await _backend.CreateProbeAsync(name);
JsonNode identity = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; string id = probe.ProbeId;
string id = identity["probe_id"]!.GetValue<string>(); Assert.StartsWith(ProbeId.Prefix, id, StringComparison.Ordinal);
using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>())); string peerKey = Convert.ToBase64String(probe.Identity.PeerIdentity.PublicKeySpki);
Assert.True(Uri.TryCreate(identity["server"]!.GetValue<string>(), UriKind.Absolute, out _)); // Probe 直接连面板:probe.json 里是面板的地址 string backendKey = Convert.ToBase64String(probe.Identity.Identity.PublicKeySpki);
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key1.PublicKeySpki))); Assert.NotEqual(backendKey, peerKey); // 连面板的和连 Agent 的是两把独立的密钥
// The panel lists it; two probes can coexist and a duplicate name is refused // Agent 的名单里放的是对等面的公钥,不是连面板的那把
TaskSet withProbe = await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == peerKey));
Assert.DoesNotContain(withProbe.Peers, p => p.PublicKey == backendKey);
// 令牌带角色;面板能列出这个 Probe;重名的注册被拒绝
JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
Assert.Contains(tokens["items"]!.AsArray(), t => t!["role"]!.GetValue<string>() == "probe");
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes"); JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>()); Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue<string>(); string duplicateToken = await _backend.CreateJoinTokenAsync(role: "probe");
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = name })); using var http = new HttpClient();
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
Assert.False(duplicate.IsSuccess);
Assert.Equal("fleet_name_conflict", duplicate.Error.Code.Value);
// Rename // 创建身份和轮换密钥的接口已经没有了:换密钥就是吊销之后重新注册
using HttpResponseMessage oldCreate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "x" }));
Assert.True(oldCreate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
using HttpResponseMessage oldRotate = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
Assert.True(oldRotate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
// 改名
using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" })); using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" }));
Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>()); Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>());
// Rotate: same id, new key; the old key leaves every agent's list // 吊销:从名单里消失,面板里留着记录
using HttpResponseMessage rotated = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
JsonNode regenerated = JsonNode.Parse(await rotated.Content.ReadAsStringAsync())!;
Assert.Equal(id, regenerated["probe_id"]!.GetValue<string>());
Assert.NotEqual(identity["private_key"]!.GetValue<string>(), regenerated["private_key"]!.GetValue<string>());
using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue<string>()));
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key2.PublicKeySpki)));
// Revoke: gone from the peer list, kept in the panel as revoked; it cannot be rotated any more
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null); using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null);
Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>()); Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>());
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id)); 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); using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id); using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id);
@@ -30,7 +30,7 @@ public sealed class LinkTests
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin() public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
{ {
await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>(); string probe = await _backend.CreateProbeIdAsync();
string agentId = agent.NodeId.Value; string agentId = agent.NodeId.Value;
try try
{ {
@@ -72,7 +72,7 @@ public sealed class LinkTests
public async Task Links_RefuseUnknownNodes() public async Task Links_RefuseUnknownNodes()
{ {
await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>(); string probe = await _backend.CreateProbeIdAsync();
using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false); using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false);
Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode); Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode);
using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false); using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false);
@@ -5,16 +5,18 @@ using MoShiro.Probe;
namespace MoShiro.Backend.Tests; namespace MoShiro.Backend.Tests;
/// <summary>进程内的真实 Probe:用后端生成的身份文件(和面板下载的 probe.json 同一份)启动 <see cref="ProbeApp"/>。</summary> /// <summary>进程内的真实 Probe:用加入令牌注册出来的身份(节点自己生成的两把密钥)启动 <see cref="ProbeApp"/>。</summary>
public sealed class ProbeHarness : IAsyncDisposable public sealed class ProbeHarness : IAsyncDisposable
{ {
private readonly CancellationTokenSource _stop = new(); private readonly CancellationTokenSource _stop = new();
private readonly string _directory; private readonly string _directory;
private readonly Task _run; private readonly Task _run;
private readonly EnrolledProbe _enrolled;
private ProbeHarness(string directory, ProbeApp app) private ProbeHarness(string directory, ProbeApp app, EnrolledProbe enrolled)
{ {
_directory = directory; _directory = directory;
_enrolled = enrolled;
App = app; App = app;
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None); _run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
} }
@@ -25,21 +27,19 @@ public sealed class ProbeHarness : IAsyncDisposable
public string ProbeId { get; init; } = ""; public string ProbeId { get; init; } = "";
/// <summary>向后端申请身份(<c>POST /api/admin/probe/identity</c>),写成 <c>probe.json</c> 并启动。</summary> /// <summary>用加入令牌注册(节点自己生成密钥),把面板地址和身份目录写进 <c>probe.json</c>,然后启动。</summary>
public static async Task<ProbeHarness> StartAsync(BackendFixture backend, Action<ProbeFile>? configure = null) public static async Task<ProbeHarness> StartAsync(BackendFixture backend, Action<ProbeFile>? configure = null)
{ {
JsonNode created = await backend.CreateProbeAsync(); EnrolledProbe enrolled = await backend.CreateProbeAsync();
string json = created.ToJsonString(); string directory = enrolled.Directory;
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "probe.json"); 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); ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile);
configure?.Invoke(file); configure?.Invoke(file);
var app = new ProbeApp(file, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out)); var app = new ProbeApp(file, enrolled.Identity, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out));
return new ProbeHarness(directory, app) { ProbeId = created["probe_id"]!.GetValue<string>() }; return new ProbeHarness(directory, app, enrolled) { ProbeId = enrolled.ProbeId };
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -55,6 +55,7 @@ public sealed class ProbeHarness : IAsyncDisposable
} }
App.Dispose(); App.Dispose();
_enrolled.Dispose();
_stop.Dispose(); _stop.Dispose();
try try
{ {
@@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable
var tokens = new SqliteJoinTokenStore(_db); var tokens = new SqliteJoinTokenStore(_db);
var fleet = new SqliteAgentStore(_db); var fleet = new SqliteAgentStore(_db);
byte[] hash = TokenHash.Sha256("jt_race"); 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); 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)))); 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.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); 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.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
@@ -80,12 +80,14 @@ public sealed class AgentSimulator : IAsyncDisposable
string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)); byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken));
string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey)); string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey));
using NodeIdentity tls = NodeIdentity.Generate();
var body = new JsonObject var body = new JsonObject
{ {
["join_token"] = joinToken, ["join_token"] = joinToken,
["name"] = name, ["name"] = name,
["pubkey"] = pubkey, ["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, ["proof"] = proof,
["node_version"] = "0.0.1-sim", ["node_version"] = "0.0.1-sim",
["meta"] = new JsonObject ["meta"] = new JsonObject
+3 -5
View File
@@ -66,11 +66,9 @@ try {
Copy-Item -LiteralPath (Join-Path $root "backend\$role\README.md") -Destination $stage Copy-Item -LiteralPath (Join-Path $root "backend\$role\README.md") -Destination $stage
$executables = @("moshiro-$role$extension") $executables = @("moshiro-$role$extension")
if ($role -eq 'agent') { $enrollName = if ($target.Windows) { 'fleet-enroll.exe' } else { 'fleet-enroll' }
$enrollName = if ($target.Windows) { 'fleet-enroll.exe' } else { 'fleet-enroll' } Copy-Item -LiteralPath (Join-Path $enrollDirectory $enrollName) -Destination $stage
Copy-Item -LiteralPath (Join-Path $enrollDirectory $enrollName) -Destination $stage $executables += $enrollName
$executables += $enrollName
}
if ($target.Os -eq 'linux') { if ($target.Os -eq 'linux') {
Copy-Item -LiteralPath (Join-Path $root 'packaging/install.sh') -Destination $stage Copy-Item -LiteralPath (Join-Path $root 'packaging/install.sh') -Destination $stage
$executables += 'install.sh' $executables += 'install.sh'
+2 -6
View File
@@ -66,12 +66,8 @@ build_arch_image() {
curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \ curl --fail-with-body --silent --show-error --retry 3 --netrc-file "$netrc" \
"$gitea_base/api/packages/justme/generic/moshiro-node/$version/$filename" -o "$archive" "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$filename" -o "$archive"
mkdir -p "$context/app" "$context/config" mkdir -p "$context/app" "$context/config"
if [[ "$component" == agent ]]; then tar -xzf "$archive" -C "$work" "moshiro-$component" fleet-enroll
tar -xzf "$archive" -C "$work" moshiro-agent fleet-enroll install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll"
install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll"
else
tar -xzf "$archive" -C "$work" "moshiro-$component"
fi
install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component" install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component"
: > "$context/config/.keep" : > "$context/config/.keep"
+3 -4
View File
@@ -37,7 +37,7 @@
├─ auth/{login|logout|me} ├─ auth/{login|logout|me}
├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover} ├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover}
├─ join-tokens ├─ 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)连成线路(自动连接 + 手动选择) ├─ links links/{probe_id}/{agent_id} 线路开关:哪些(Probe,Agent)连成线路(自动连接 + 手动选择)
├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务 ├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务
├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel ├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel
@@ -126,11 +126,10 @@
| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` | | PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` |
| POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) | | POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) |
| DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 | | 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}` | 吊销 | | 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}` | 详情、改名、删除(历史数据按保留策略自然过期) | | 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/probes/{id}/revoke` | 吊销:公钥从所有 Agent 的名单里移除,Agent 立即断开它;记录和历史保留 |
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` | | 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 任务 | | GET/POST | `/api/admin/tasks` | 列表和创建:line 模板(不带 `agent_id`)、单个 Agent 的覆盖(带 `agent_id` + `base_task_id`)、target 任务 |
+6 -6
View File
@@ -102,8 +102,8 @@ Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库
# Agent(服务器):注册并启动 # Agent(服务器):注册并启动
./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1 ./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1
# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次) # Probe(家宽网络里的机器或 OpenWrt 路由器):用面板里 probe 角色的加入令牌注册,密钥由它自己生成
./install.sh probe --probe-json ./probe.json ./install.sh probe --server https://panel.example.com --join <加入令牌> --name home-1
./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册) ./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。 - **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 才连得上。 - **经隧道注册之后,检查 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/…`、停掉进程即可,没有留下任何东西。 - **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。
- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 - 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。
- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。 - 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。
@@ -212,7 +212,7 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2
```sh ```sh
# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) # 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起)
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <name> sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <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://<backend> --join <probe 令牌> --name <name>
# 之后更新节点:不带参数 # 之后更新节点:不带参数
sudo sh deploy-node.sh agent sudo sh deploy-node.sh agent
``` ```
@@ -230,7 +230,7 @@ JSON
podman compose up -d 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://<backend> --join <probe 令牌> --name <name> --identity-dir /etc/moshiro/probe-id`,再写 `probe.json`(`{"fleet":{"server":"https://<backend>","identity_dir":"/etc/moshiro/probe-id"}}`),然后 `podman compose up -d`。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册。要换密钥就在面板吊销再重新注册。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。
纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro-<role>-rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/<role>.image`;rootful Podman 使用 `moshiro-<role>-container.service`,Docker 使用 `moshiro-<role>-docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/<role>.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now <unit>` 或 `sudo systemctl enable --now <unit>`。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。 纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro-<role>-rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/<role>.image`;rootful Podman 使用 `moshiro-<role>-container.service`,Docker 使用 `moshiro-<role>-docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/<role>.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now <unit>` 或 `sudo systemctl enable --now <unit>`。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。
@@ -243,7 +243,7 @@ Backend 用根目录的 `compose.yaml`(见上文「Backend 容器」),数
Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。 Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。
- **在管理端控制**:系统 → 设置 →「定时重启」,开关、时刻和时区(默认**开**,北京时间 04:30)。保存后马上通知所有在线节点,新连上的节点会补发。 - **在管理端控制**:系统 → 设置 →「定时重启」,开关、时刻和时区(默认**开**,北京时间 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`。 - 启动后不满 10 分钟不会触发,所以重启之后不会立刻又重启一次。日志里有 `restart scheduled at=… source=panel|local` 和 `stopping result=scheduled_restart`。
- Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。 - Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。
+9 -7
View File
@@ -7,7 +7,7 @@
| 主体 | 凭据 | 生成方 | Backend 保存 | | 主体 | 凭据 | 生成方 | Backend 保存 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 | | 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) | 面板 | 密码哈希 | | 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 | | API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 |
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** | | 发布签名 | 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。 Probe 直接连面板(Fleet 长连接,身份是下面生成的密钥),Agent 列表由面板作为期望状态下发;测量数据仍然只走 Probe → Agent。
```text ```text
面板:POST /api/admin/probes {name} 面板:POST /api/admin/join-tokens {role: "probe"} → 一次性加入令牌 jt_…
└─ 生成 Fleet 节点身份,返回 probe.json(只能下载一次;轮换密钥 /rotate 会使旧密钥失效,吊销 /revoke 会让面板和所有 Agent 断开它) Probe 机器:fleet-enroll --server … --join jt_… --name home --identity-dir …
{ probe_id, private_key, server: "https://panel...", ├─ 本地生成两把密钥:node.key(连面板认证)和 tls.pfx 里的密钥(连 Agent 对端认证);面板从不接触私钥
limits: { max_test_mbps, max_total_pps } } ← 本地硬上限,面板无法修改;在路由器上建议调低 ├─ 注册请求带两把公钥(pubkey、tls_pubkey)和持有证明;面板据此写 probes 记录,ID 前缀 prb_
└─ 吊销 /revoke 会让面板和所有 Agent 断开它;换密钥 = 吊销后重新注册(没有 rotate)
probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← limits 是本地硬上限,面板无法修改;在路由器上建议调低
Probe 启动 Probe 启动
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息 ├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
@@ -87,8 +89,8 @@ Probe 启动
| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) | | GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) |
| GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 | | GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 |
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 | | POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 |
| GET/POST/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 身份的创建、列表、改名、删除 | | GET/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 |
| POST | `/api/admin/probes/{id}/rotate`、`/revoke` | 轮换密钥、吊销 | | POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) |
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe | | POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe |
| GET | `/install.sh`、`/install.ps1` | 安装脚本 | | GET | `/install.sh`、`/install.ps1` | 安装脚本 |
+1 -1
View File
@@ -263,7 +263,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec
```text ```text
零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏 零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏
一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 破坏性:旧 probe.json 失效 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效
二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 破坏性:旧 Probe 被拒绝 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 破坏性:旧 Probe 被拒绝
三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册 三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册
四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名 四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名
+1
View File
@@ -7,6 +7,7 @@ LABEL org.opencontainers.image.title="MoShiro Probe" \
org.opencontainers.image.version="$APP_VERSION" org.opencontainers.image.version="$APP_VERSION"
COPY --chown=1654:1654 app/moshiro-probe /usr/local/bin/moshiro-probe 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/ COPY --chown=1654:1654 config/ /etc/moshiro/
USER 1654:1654 USER 1654:1654
@@ -3,7 +3,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build
WORKDIR /src/backend WORKDIR /src/backend
COPY restore/backend/ ./ COPY restore/backend/ ./
# restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 # 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/ ./ COPY source/backend/ ./
# 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) # 构建信息: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 \ 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 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \
-p:UseAppHost=true -p:StripSymbols=true \ -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 的启动器 # 原生程序有几 MB;几十 KB 的是只会去找 .dll 的启动器
RUN test -x /out/probe/moshiro-probe \ 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/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 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" 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/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/ COPY --chown=1654:1654 config/ /etc/moshiro/
USER 1654:1654 USER 1654:1654
+15 -15
View File
@@ -3,7 +3,7 @@
# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。 # 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。
# #
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1 # 首次部署 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 (其余参数沿用上次的更新器配置) # 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
# #
# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。 # 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。
@@ -12,13 +12,13 @@ set -eu
usage() { usage() {
echo "usage: deploy-node.sh <agent|probe> [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2 echo "usage: deploy-node.sh <agent|probe> [--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 exit 2
} }
ROLE="${1:-}"; shift || true ROLE="${1:-}"; shift || true
case "$ROLE" in agent|probe) ;; *) usage ;; esac 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 while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--engine) ENGINE="$2"; shift 2 ;; --engine) ENGINE="$2"; shift 2 ;;
@@ -28,7 +28,6 @@ while [ $# -gt 0 ]; do
--server) SERVER="$2"; shift 2 ;; --server) SERVER="$2"; shift 2 ;;
--join) JOIN="$2"; shift 2 ;; --join) JOIN="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;;
--probe-json) PROBE_JSON="$2"; shift 2 ;;
--policy) POLICY="$2"; shift 2 ;; --policy) POLICY="$2"; shift 2 ;;
--authfile) AUTHFILE="$2"; shift 2 ;; --authfile) AUTHFILE="$2"; shift 2 ;;
*) usage ;; *) usage ;;
@@ -82,13 +81,14 @@ in_volume() {
} }
in_volume 'mkdir -p /etc/moshiro/update' </dev/null in_volume 'mkdir -p /etc/moshiro/update' </dev/null
# 3. 配置 # 3. 配置:身份由节点自己生成(Agent 和 Probe 一样),注册之后面板只认这把公钥
ID_DIR="/etc/moshiro/$ROLE-id"
if ! in_volume "test -f $ID_DIR/node.json" </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&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 [ "$ROLE" = agent ]; then
if ! in_volume 'test -f /etc/moshiro/agent-id/node.json' </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&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' </dev/null; then if ! in_volume 'test -f /etc/moshiro/agent.json' </dev/null; then
[ -n "$SERVER" ] || { echo "agent.json is missing; pass --server to create it" >&2; exit 1; } [ -n "$SERVER" ] || { echo "agent.json is missing; pass --server to create it" >&2; exit 1; }
printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}\n' "$SERVER" \ 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 -> ::" echo "config: listen.bind 0.0.0.0 -> ::"
fi fi
else else
if [ -n "$PROBE_JSON" ]; then if ! in_volume 'test -f /etc/moshiro/probe.json' </dev/null; then
in_volume 'umask 077; cat > /etc/moshiro/probe.json' < "$PROBE_JSON" [ -n "$SERVER" ] || { echo "probe.json is missing; pass --server to create it" >&2; exit 1; }
echo "config: imported probe.json" printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/probe-id"}}\n' "$SERVER" \
elif ! in_volume 'test -f /etc/moshiro/probe.json' </dev/null; then | in_volume 'umask 077; cat > /etc/moshiro/probe.json'
echo "first deployment needs --probe-json (download it from the panel)" >&2; exit 1 echo "config: created probe.json"
fi fi
fi fi
+29 -34
View File
@@ -3,7 +3,7 @@
# POSIX sh: runs on Debian/Ubuntu/Alpine/OpenWrt (busybox). # POSIX sh: runs on Debian/Ubuntu/Alpine/OpenWrt (busybox).
# #
# ./install.sh agent --server https://panel.example.com --join <join-token> --name hk-1 # ./install.sh agent --server https://panel.example.com --join <join-token> --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] # ./install.sh uninstall agent|probe [--purge]
# #
# Binaries go to /usr/lib/moshiro (root-owned: the collector cannot rewrite its own executable); # 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" put 0755 "$HERE/$src" "$PREFIX/$src"
} }
write_agent_config() { write_config() {
cat >"$CONFIG/agent.json" <<JSON if [ "$1" = agent ]; then
cat >"$CONFIG/agent.json" <<JSON
{ {
"fleet": { "server": "$1", "identity_dir": "$CONFIG" }, "fleet": { "server": "$2", "identity_dir": "$CONFIG" },
"listen": { "bind": "::", "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] }, "listen": { "bind": "::", "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
"limits": { "max_total_mbps": 200, "max_total_pps": 20000, "allowed_target_cidrs": [], "max_concurrent_tests": 1 } "limits": { "max_total_mbps": 200, "max_total_pps": 20000, "allowed_target_cidrs": [], "max_concurrent_tests": 1 }
} }
JSON JSON
chmod 0640 "$CONFIG/agent.json" else
if [ "$have_user" -eq 1 ]; then chown "root:$(id -gn "$USER_NAME")" "$CONFIG/agent.json"; fi cat >"$CONFIG/probe.json" <<JSON
{
"fleet": { "server": "$2", "identity_dir": "$CONFIG" }
}
JSON
fi
chmod 0640 "$CONFIG/$1.json"
if [ "$have_user" -eq 1 ]; then chown "root:$(id -gn "$USER_NAME")" "$CONFIG/$1.json"; fi
} }
install_role() { install_role() {
role="$1"; shift role="$1"; shift
server=""; join=""; name=""; probe_json=""; no_start=0 server=""; join=""; name=""; no_start=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--server) server="$2"; shift 2 ;; --server) server="$2"; shift 2 ;;
--join) join="$2"; shift 2 ;; --join) join="$2"; shift 2 ;;
--name) name="$2"; shift 2 ;; --name) name="$2"; shift 2 ;;
--probe-json) probe_json="$2"; shift 2 ;;
--no-start) no_start=1; shift ;; --no-start) no_start=1; shift ;;
*) die "unknown option: $1" ;; *) die "unknown option: $1" ;;
esac esac
@@ -96,7 +103,7 @@ install_role() {
chmod 0750 "$CONFIG" chmod 0750 "$CONFIG"
install_binary "moshiro-$role" install_binary "moshiro-$role"
if [ "$role" = agent ]; then install_binary fleet-enroll; fi install_binary fleet-enroll
say "installed binaries to $PREFIX" say "installed binaries to $PREFIX"
init="$(init_system)" init="$(init_system)"
@@ -114,25 +121,17 @@ install_role() {
esac esac
ready=0 ready=0
if [ "$role" = agent ]; then if [ -n "$server" ] && [ ! -f "$CONFIG/node.json" ]; then
if [ -n "$server" ] && [ ! -f "$CONFIG/node.json" ]; then [ -n "$join" ] && [ -n "$name" ] || die "--server needs --join and --name to enroll"
[ -n "$join" ] && [ -n "$name" ] || die "--server needs --join and --name to enroll" say "enrolling as $name"
say "enrolling as $name" if [ "$have_user" -eq 1 ]; then
if [ "$have_user" -eq 1 ]; then su -s /bin/sh "$USER_NAME" -c "'$PREFIX/fleet-enroll' --server '$server' --join '$join' --name '$name' --identity-dir '$CONFIG'"
su -s /bin/sh "$USER_NAME" -c "'$PREFIX/fleet-enroll' --server '$server' --join '$join' --name '$name' --identity-dir '$CONFIG'" else
else "$PREFIX/fleet-enroll" --server "$server" --join "$join" --name "$name" --identity-dir "$CONFIG"
"$PREFIX/fleet-enroll" --server "$server" --join "$join" --name "$name" --identity-dir "$CONFIG"
fi
fi fi
if [ ! -f "$CONFIG/agent.json" ] && [ -n "$server" ]; then write_agent_config "$server"; fi
if [ -f "$CONFIG/agent.json" ] && [ -f "$CONFIG/node.json" ]; then ready=1; fi
else
if [ -n "$probe_json" ]; then
put 0600 "$probe_json" "$CONFIG/probe.json"
if [ "$have_user" -eq 1 ]; then chown "$USER_NAME" "$CONFIG/probe.json"; fi
fi
if [ -f "$CONFIG/probe.json" ]; then ready=1; fi
fi fi
if [ ! -f "$CONFIG/$role.json" ] && [ -n "$server" ]; then write_config "$role" "$server"; fi
if [ -f "$CONFIG/$role.json" ] && [ -f "$CONFIG/node.json" ]; then ready=1; fi
if [ "$ready" -eq 1 ] && [ "$no_start" -eq 0 ]; then if [ "$ready" -eq 1 ] && [ "$no_start" -eq 0 ]; then
case "$init" in case "$init" in
@@ -142,13 +141,9 @@ install_role() {
say "moshiro-$role is installed" say "moshiro-$role is installed"
else else
say "installed, not started yet. Next:" say "installed, not started yet. Next:"
if [ "$role" = agent ]; then echo " $PREFIX/fleet-enroll --server <panel-url> --join <token> --name <name> --identity-dir $CONFIG (as the '$USER_NAME' user)"
echo " $PREFIX/fleet-enroll --server <panel-url> --join <token> --name <name> --identity-dir $CONFIG (as the '$USER_NAME' user)" echo " write $CONFIG/$role.json (see README.md), then: systemctl enable --now moshiro-$role"
echo " write $CONFIG/agent.json (see README.md), then: systemctl enable --now moshiro-agent" [ "$role" = agent ] || echo " (OpenWrt: /etc/init.d/moshiro-probe enable && /etc/init.d/moshiro-probe start)"
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
fi fi
} }
@@ -166,7 +161,7 @@ uninstall() {
rm -f /etc/init.d/moshiro-probe /lib/upgrade/keep.d/moshiro ;; rm -f /etc/init.d/moshiro-probe /lib/upgrade/keep.d/moshiro ;;
esac esac
rm -f "$PREFIX/moshiro-$role" 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 rmdir "$PREFIX" 2>/dev/null || true
if [ "$purge" -eq 1 ]; then if [ "$purge" -eq 1 ]; then
rm -rf "$CONFIG" rm -rf "$CONFIG"
+1 -1
View File
@@ -10,7 +10,7 @@ CONF=/etc/moshiro/probe.json
start_service() { start_service() {
[ -f "$CONF" ] || { [ -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 return 1
} }
procd_open_instance procd_open_instance
+5 -3
View File
@@ -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) 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" 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) 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 if [ "$mode" = install ]; then
# 安装到 /usr/lib/moshiro 和 /etc/moshiro,以 moshiro 用户运行(没有 systemd:自己启动) # 安装到 /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 & run_as /usr/lib/moshiro/moshiro-agent --config /etc/moshiro/agent.json >"$work/agent.log" 2>&1 &
agent_pid=$! agent_pid=$!
sleep 3 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 --server "$base" --join "$probe_join" --name smoke-probe --no-start)
(cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --probe-json "$work/probe/probe.json" --no-start)
run_as /usr/lib/moshiro/moshiro-probe --config /etc/moshiro/probe.json >"$work/probe.log" 2>&1 & run_as /usr/lib/moshiro/moshiro-probe --config /etc/moshiro/probe.json >"$work/probe.log" 2>&1 &
probe_pid=$! probe_pid=$!
else else
@@ -57,7 +57,9 @@ else
"$pub/agent/$rid/moshiro-agent" --config "$work/agent.json" >"$work/agent.log" 2>&1 & "$pub/agent/$rid/moshiro-agent" --config "$work/agent.json" >"$work/agent.log" 2>&1 &
agent_pid=$! agent_pid=$!
sleep 3 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 & "$pub/probe/$rid/moshiro-probe" --config "$work/probe/probe.json" >"$work/probe.log" 2>&1 &
probe_pid=$! probe_pid=$!
fi fi
+2 -2
View File
@@ -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 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) (cd /tmp/pkg/moshiro-agent-* && ./install.sh agent --server $base --join "$join" --name sysd-agent)
sleep 3 sleep 3
curl -fs -X POST $base/api/admin/probe/identity -H "$auth" -H 'Content-Type: application/json' -d '{}' >/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 --probe-json /tmp/probe.json) (cd /tmp/pkg/moshiro-probe-* && ./install.sh probe --server $base --join "$probe_join" --name sysd-probe)
sleep 25 sleep 25
systemctl is-active moshiro-agent moshiro-probe systemctl is-active moshiro-agent moshiro-probe
echo "agents: $(curl -fs $base/api/admin/agents -H "$auth" | jq -c '[.items[] | {name, status, probe_connected}]')" echo "agents: $(curl -fs $base/api/admin/agents -H "$auth" | jq -c '[.items[] | {name, status, probe_connected}]')"
+95 -70
View File
@@ -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 { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api } from '@shared/api/client' 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 { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks' import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes' import { NodeTag, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions' 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 两两连,关着时只连手动选中的; * 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
@@ -86,39 +86,96 @@ function LinkMatrix() {
) )
} }
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ /**
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) { * 创建 Probe 和创建 Agent 一样:生成一个一次性的加入令牌,在家宽机器上运行命令。
const json = JSON.stringify(identity, null, 2) * 密钥由 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<CreatedJoinTokenDto>()
if (created) {
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在家宽机器上运行" hint="把 <node-name> 换成这台 Probe 的名字,例如 home。Probe 会自己生成密钥并注册;Agent 列表和更新都由面板下发。">
<CommandTabs
storageKey="moshiro.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
]}
/>
</Field>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制令牌" />
</div>
</Field>
<p className="muted">一次性令牌,{fmtTime(created.token.expires_at)} 过期。</p>
</div>
</Dialog>
)
}
return ( return (
<Dialog <Dialog
title={title} title="添加 Probe"
onClose={onClose} onClose={onClose}
actions={ actions={
<button type="button" className="btn primary" onClick={onClose}> <>
我已保存 <button type="button" className="btn text" onClick={onClose}>
</button> 取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
role: 'probe',
note: note || null,
auto_approve: true,
max_uses: 1,
expires_in_hours: hours,
})
setCreated(token)
onCreated()
})
}}
>
创建加入令牌
</button>
</>
} }
> >
<div className="stack"> <p className="muted" style={{ marginBottom: 16 }}>
<div className="notice warn">私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。</div> 添加 Probe 就是创建一个一次性的加入令牌:在家宽机器上运行命令,Probe 会用它注册并出现在列表里。
<div className="row wrap"> </p>
<button type="button" className="btn primary" onClick={() => downloadText('probe.json', json)}> <div className="form-grid">
<Download size={16} /> <div className="wide">
下载 probe.json <Field label="备注" hint="例如这台机器的位置或运营商">
</button> <input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="上海电信家宽" />
<CopyButton text={json} label="复制内容" /> </Field>
</div> </div>
<Field label="在家宽机器上" hint="保存为 /etc/moshiro/probe.json,然后启动 Probe;它会连接 probe.json 里的面板地址,Agent 列表和更新都由面板下发。"> <Field label="有效期(小时)">
<pre className="code">{`moshiro-probe --config /etc/moshiro/probe.json`}</pre> <input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field> </Field>
<details>
<summary className="muted" style={{ cursor: 'pointer' }}>
查看内容
</summary>
<pre className="code" style={{ marginTop: 8 }}>
{json}
</pre>
</details>
</div> </div>
</Dialog> </Dialog>
) )
@@ -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() { export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000) const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
@@ -168,13 +225,11 @@ export function Probes() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>() const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>() const [pending, setPending] = useState<Pending>()
const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>()
const list = probes.data?.items ?? [] const list = probes.data?.items ?? []
const confirmText = { const confirmText = {
rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' }, revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复;要换密钥就吊销之后重新添加一个 Probe。' },
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' },
delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' }, delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' },
} }
@@ -186,7 +241,7 @@ export function Probes() {
{probes.loading && !probes.data ? <Spinner /> : null} {probes.loading && !probes.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setCreating(true)}> <button type="button" className="btn primary" onClick={() => setCreating(true)}>
<Plus size={18} /> <Plus size={18} />
创建 Probe 添加 Probe
</button> </button>
</span> </span>
</header> </header>
@@ -197,7 +252,7 @@ export function Probes() {
<LinkMatrix /> <LinkMatrix />
{probes.data && list.length === 0 ? ( {probes.data && list.length === 0 ? (
<div className="card"> <div className="card">
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty> <Empty>还没有 Probe。点击右上角「添加 Probe」,在家宽机器上运行命令即可注册。</Empty>
</div> </div>
) : null} ) : null}
{list.map((p) => ( {list.map((p) => (
@@ -221,16 +276,10 @@ export function Probes() {
改名 改名
</button> </button>
{!p.revoked ? ( {!p.revoked ? (
<> <button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
<button type="button" className="btn small" onClick={() => setPending({ kind: 'rotate', probe: p })}> <ShieldOff size={14} />
<KeyRound size={14} /> 吊销
轮换密钥 </button>
</button>
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
<ShieldOff size={14} />
吊销
</button>
</>
) : null} ) : null}
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}> <button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}>
<Trash2 size={14} /> <Trash2 size={14} />
@@ -275,24 +324,7 @@ export function Probes() {
))} ))}
</div> </div>
{creating ? ( {creating ? <AddProbeDialog onClose={() => setCreating(false)} onCreated={probes.reload} /> : null}
<NameDialog
title="创建 Probe"
initial=""
onClose={() => setCreating(false)}
onSubmit={async (name) => {
let created: ProbeIdentityDto | undefined
const ok = await run(async () => {
created = await api.post<ProbeIdentityDto>('/api/admin/probes', { name })
})
if (ok && created) {
setIdentity({ title: `Probe ${name} 已创建`, value: created })
probes.reload()
}
return ok
}}
/>
) : null}
{renaming ? ( {renaming ? (
<NameDialog <NameDialog
title="修改名称" title="修改名称"
@@ -313,13 +345,7 @@ export function Probes() {
onClose={() => setPending(undefined)} onClose={() => setPending(undefined)}
onConfirm={async () => { onConfirm={async () => {
const { kind, probe } = pending const { kind, probe } = pending
if (kind === 'rotate') { if (kind === 'revoke') {
let rotated: ProbeIdentityDto | undefined
const ok = await run(async () => {
rotated = await api.post<ProbeIdentityDto>(`/api/admin/probes/${probe.id}/rotate`)
})
if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated })
} else if (kind === 'revoke') {
await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销') await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销')
} else { } else {
await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除') await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除')
@@ -330,7 +356,6 @@ export function Probes() {
{confirmText[pending.kind].text} {confirmText[pending.kind].text}
</Confirm> </Confirm>
) : null} ) : null}
{identity ? <IdentityDialog title={identity.title} identity={identity.value} onClose={() => setIdentity(undefined)} /> : null}
</> </>
) )
} }
+5 -153
View File
@@ -493,7 +493,7 @@ export interface paths {
/** Join tokens */ /** Join tokens */
get: operations["join_tokens_list"]; get: operations["join_tokens_list"];
put?: never; put?: never;
/** Create a join token; the plaintext is returned only once */ /** Create a join token for an agent or a probe (role); the plaintext is returned only once */
post: operations["join_tokens_create"]; post: operations["join_tokens_create"];
delete?: never; delete?: never;
options?: never; options?: never;
@@ -528,8 +528,7 @@ export interface paths {
/** All probes, with the agents each one is connected to */ /** All probes, with the agents each one is connected to */
get: operations["probes_list"]; get: operations["probes_list"];
put?: never; put?: never;
/** Create a probe identity; the private key is returned only once */ post?: never;
post: operations["probes_create"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -555,23 +554,6 @@ export interface paths {
patch: operations["probes_update"]; patch: operations["probes_update"];
trace?: never; trace?: never;
}; };
"/api/admin/probes/{id}/rotate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 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 */
post: operations["probes_rotate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/probes/{id}/revoke": { "/api/admin/probes/{id}/revoke": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1168,9 +1150,7 @@ export interface components {
max_uses: null | number | string; max_uses: null | number | string;
/** Format: int32 */ /** Format: int32 */
expires_in_hours: null | number | string; expires_in_hours: null | number | string;
}; role?: null | string;
CreateProbeRequest: {
name: null | string;
}; };
CreateTaskRequest: { CreateTaskRequest: {
scope: string; scope: string;
@@ -1353,6 +1333,8 @@ export interface components {
revoked_at: unknown; revoked_at: unknown;
/** Format: date-time */ /** Format: date-time */
created_at: unknown; created_at: unknown;
/** @default agent */
role: string;
}; };
JsonElement: unknown; JsonElement: unknown;
KindDto: { KindDto: {
@@ -1644,18 +1626,6 @@ export interface components {
connections: components["schemas"]["ProbeConnectionDto"][]; connections: components["schemas"]["ProbeConnectionDto"][];
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
}; };
ProbeIdentityDto: {
probe_id: string;
private_key: string;
server: string;
limits: components["schemas"]["ProbeLimitsDto"];
};
ProbeLimitsDto: {
/** Format: double */
max_test_mbps: number | string;
/** Format: int64 */
max_total_pps: number | string;
};
ProblemDto: { ProblemDto: {
type: string; type: string;
title: string; title: string;
@@ -3485,66 +3455,6 @@ export interface operations {
}; };
}; };
}; };
probes_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": null | components["schemas"]["CreateProbeRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProbeIdentityDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_get: { probes_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3705,64 +3615,6 @@ export interface operations {
}; };
}; };
}; };
probes_rotate: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProbeIdentityDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_revoke: { probes_revoke: {
parameters: { parameters: {
query?: never; query?: never;
-1
View File
@@ -11,7 +11,6 @@ export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']> export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
export type ProbeDto = Narrow<S['ProbeDto']> export type ProbeDto = Narrow<S['ProbeDto']>
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']> export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
export type LineDto = Narrow<S['LineDto']> export type LineDto = Narrow<S['LineDto']>
export type NodeInfoDto = Narrow<S['NodeInfoDto']> export type NodeInfoDto = Narrow<S['NodeInfoDto']>
export type AboutDto = Narrow<S['AboutDto']> export type AboutDto = Narrow<S['AboutDto']>