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