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