feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
This commit is contained in:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
@@ -12,7 +12,7 @@ namespace Monitor.Agent;
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
@@ -244,6 +244,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Platform = Monitor.Measurement.PlatformDetector.Detect(),
|
||||
TlsSpki = _identity.File.TlsSpki,
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
|
||||
@@ -64,6 +64,9 @@ public sealed class ProbeSession : DataSession
|
||||
|
||||
public long ConnectedAtUtcMs { get; init; }
|
||||
|
||||
/// <summary>Probe 自报的系统和架构(随列表同步一起来,只用于展示);还没报过就是 null。</summary>
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
|
||||
@@ -220,7 +220,7 @@ public sealed class PeerHub
|
||||
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, Platform = p.Platform };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
@@ -306,7 +306,15 @@ public sealed class PeerHub
|
||||
MonitorMethods.ProbeAgents,
|
||||
MonitorJsonContext.Default.ProbeAgentsParams,
|
||||
MonitorJsonContext.Default.ProbeAgentsResult,
|
||||
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
|
||||
(context, request, ct) =>
|
||||
{
|
||||
if (request.Platform is { } platform)
|
||||
{
|
||||
context.Session.Platform = platform;
|
||||
}
|
||||
|
||||
return uplink.RequestProbeAgentsAsync(request, ct);
|
||||
})
|
||||
.On(
|
||||
MonitorMethods.Clock,
|
||||
MonitorJsonContext.Default.ClockParams,
|
||||
|
||||
+381
-6
@@ -862,6 +862,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/nodes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)",
|
||||
"operationId": "nodes_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfNodeInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/about": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "The backend's version, tag and build time, if the site shows build info",
|
||||
"operationId": "about_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AboutDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/catalog/kinds": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1770,7 +1832,7 @@
|
||||
"tags": [
|
||||
"admin-join-tokens"
|
||||
],
|
||||
"summary": "Revoke a join token",
|
||||
"summary": "Revoke a join token; with purge=true delete the record for good",
|
||||
"operationId": "join_tokens_revoke",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1780,6 +1842,13 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "purge",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3622,6 +3691,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/build-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Version, tag, commit and build time of the backend and every agent and probe",
|
||||
"operationId": "build_info_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BuildInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3746,6 +3856,28 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AboutDto": {
|
||||
"required": [
|
||||
"show_build",
|
||||
"backend"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_build": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"backend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -4032,6 +4164,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildDto": {
|
||||
"required": [
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildInfoDto": {
|
||||
"required": [
|
||||
"backend",
|
||||
"nodes"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backend": {
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeBuildDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChangePasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
@@ -4345,7 +4527,7 @@
|
||||
"required": [
|
||||
"token",
|
||||
"plaintext",
|
||||
"install_command"
|
||||
"install_commands"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4355,8 +4537,8 @@
|
||||
"plaintext": {
|
||||
"type": "string"
|
||||
},
|
||||
"install_command": {
|
||||
"type": "string"
|
||||
"install_commands": {
|
||||
"$ref": "#/components/schemas/InstallCommandsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4908,6 +5090,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"InstallCommandsDto": {
|
||||
"required": [
|
||||
"linux",
|
||||
"macos",
|
||||
"windows"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linux": {
|
||||
"type": "string"
|
||||
},
|
||||
"macos": {
|
||||
"type": "string"
|
||||
},
|
||||
"windows": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"JoinTokenDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -5120,7 +5321,8 @@
|
||||
"LoginRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
"password",
|
||||
"return_session"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5129,6 +5331,12 @@
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"return_session": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5137,7 +5345,8 @@
|
||||
"user_id",
|
||||
"username",
|
||||
"csrf_token",
|
||||
"expires_at"
|
||||
"expires_at",
|
||||
"session_token"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5152,6 +5361,12 @@
|
||||
},
|
||||
"expires_at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"session_token": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5292,6 +5507,139 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBuildDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeInfoDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"country",
|
||||
"isp_key",
|
||||
"isp_name"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"country": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_key": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"OverviewDto": {
|
||||
"required": [
|
||||
"range",
|
||||
@@ -5564,6 +5912,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfNodeInfoDto": {
|
||||
"required": [
|
||||
"items",
|
||||
"next_cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeInfoDto"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfPlanDto": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -6774,6 +7143,9 @@
|
||||
{
|
||||
"name": "public"
|
||||
},
|
||||
{
|
||||
"name": "public-nodes"
|
||||
},
|
||||
{
|
||||
"name": "catalog"
|
||||
},
|
||||
@@ -6807,6 +7179,9 @@
|
||||
{
|
||||
"name": "admin-users"
|
||||
},
|
||||
{
|
||||
"name": "admin-build"
|
||||
},
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
|
||||
@@ -163,10 +163,13 @@ public sealed class AgentConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>本机的系统和架构:随列表同步一起告诉 Agent,只用于在面板上展示。</summary>
|
||||
private static readonly PlatformInfo s_platform = PlatformDetector.Detect();
|
||||
|
||||
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version, Platform = s_platform }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
|
||||
{
|
||||
_env.Directory.Apply(agents);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Monitor.Probe;
|
||||
/// </summary>
|
||||
public sealed class ProbeApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 读出本机的系统名和架构,只用于在面板上展示。故意只取粗粒度的信息:
|
||||
/// 「Debian 12」「Windows 11」「x64」,不读主机名、内核版本、CPU 型号。
|
||||
/// </summary>
|
||||
public static class PlatformDetector
|
||||
{
|
||||
public static PlatformInfo Detect()
|
||||
{
|
||||
PlatformInfo info = Detect(File.Exists("/etc/os-release") ? File.ReadAllLines("/etc/os-release") : []);
|
||||
info.Version = Empty(BuildInfo.Version);
|
||||
info.Tag = Empty(BuildInfo.Tag);
|
||||
info.Commit = Empty(BuildInfo.Commit);
|
||||
info.BuiltAt = Empty(BuildInfo.BuiltAt);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static string? Empty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
/// <summary>`osRelease` 是 /etc/os-release 的各行(Linux 之外不用)。拆出来是为了好测。</summary>
|
||||
internal static PlatformInfo Detect(IEnumerable<string> osRelease)
|
||||
{
|
||||
string arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.X86 => "x86",
|
||||
var other => other.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows 11 的版本号还是 10.0,靠内部版本号(22000 起)区分
|
||||
string name = Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10";
|
||||
return new PlatformInfo { Os = "windows", Name = name, Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new PlatformInfo { Os = "macos", Name = "macOS", Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new PlatformInfo { Os = "linux", Name = LinuxName(osRelease), Arch = arch };
|
||||
}
|
||||
|
||||
return new PlatformInfo { Os = "other", Name = null, Arch = arch };
|
||||
}
|
||||
|
||||
/// <summary>从 os-release 里取发行版名字和主版本:「Debian GNU/Linux 12 (bookworm)」→「Debian 12」,「OpenWrt 24.10.0」→「OpenWrt 24.10」。</summary>
|
||||
internal static string LinuxName(IEnumerable<string> osRelease)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string line in osRelease)
|
||||
{
|
||||
int eq = line.IndexOf('=', StringComparison.Ordinal);
|
||||
if (eq > 0)
|
||||
{
|
||||
values[line[..eq].Trim()] = line[(eq + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
|
||||
string name = values.GetValueOrDefault("NAME") ?? values.GetValueOrDefault("ID") ?? "Linux";
|
||||
name = name.Replace(" GNU/Linux", string.Empty, StringComparison.Ordinal).Trim();
|
||||
string? version = values.GetValueOrDefault("VERSION_ID");
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
// 只留 主.次:Alpine 的 3.22.1、OpenWrt 的 24.10.0 都截成两段;Debian 的 12 本来就一段
|
||||
string[] parts = version.Split('.');
|
||||
return $"{name} {string.Join('.', parts.Take(2))}";
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,30 @@
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
<!--
|
||||
构建信息:版本来自 $(Version),tag、提交、构建时间来自环境变量 MONITOR_TAG / MONITOR_COMMIT / MONITOR_BUILT_AT
|
||||
(scripts/publish.sh 和 CI 会设置;本地开发不设置就是空,这样本地构建是可重复的、不会每次都重新编译)。
|
||||
生成一个带常量的源文件,NativeAOT 下不用反射。
|
||||
-->
|
||||
<Target Name="WriteBuildInfo" BeforeTargets="CoreCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace Monitor.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MONITOR_TAG)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Commit = "$(MONITOR_COMMIT)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuiltAt = "$(MONITOR_BUILT_AT)"%3B" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoFile)" />
|
||||
<FileWrites Include="$(BuildInfoFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -152,6 +152,10 @@ public sealed class ProbeAgentsParams
|
||||
/// <summary>Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。</summary>
|
||||
[JsonPropertyName("have_version")]
|
||||
public long HaveVersion { get; set; }
|
||||
|
||||
/// <summary>Probe 的系统和架构(只用于展示;Agent 记在会话上,随连接状态报给 Backend)。</summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProbeAgentsResult
|
||||
@@ -194,9 +198,41 @@ public sealed class LocalLimits
|
||||
public int MaxConcurrentTests { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点的系统和架构(粗粒度,只用于展示):<c>os</c> 是 linux / windows / macos / other,<c>name</c> 是「Debian 12」「Windows 11」这样的名字,
|
||||
/// <c>arch</c> 是 x64 / arm64 / arm / x86。<b>没有主机名、内核版本、具体型号</b>。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
|
||||
/// </summary>
|
||||
public sealed class PlatformInfo
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string Os { get; set; } = "other";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("arch")]
|
||||
public string? Arch { get; set; }
|
||||
|
||||
/// <summary>程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601):<see cref="BuildInfo"/> 构建时写进去的,没设置就是空。</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[JsonPropertyName("commit")]
|
||||
public string? Commit { get; set; }
|
||||
|
||||
[JsonPropertyName("built_at")]
|
||||
public string? BuiltAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string? TlsSpki { get; set; }
|
||||
|
||||
@@ -266,6 +302,9 @@ public sealed class ConnectedProbe
|
||||
|
||||
[JsonPropertyName("since_utc_ms")]
|
||||
public long SinceUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
@@ -434,6 +473,7 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(ProbeAgentsParams))]
|
||||
[JsonSerializable(typeof(ProbeAgentsResult))]
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Monitor.Measurement.Tests;
|
||||
|
||||
public sealed class PlatformDetectorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"|NAME=\"Debian GNU/Linux\"|VERSION_ID=\"12\"|ID=debian", "Debian 12")]
|
||||
[InlineData("NAME=\"OpenWrt\"|VERSION_ID=\"24.10.0\"|ID=openwrt", "OpenWrt 24.10")]
|
||||
[InlineData("NAME=\"Alpine Linux\"|ID=alpine|VERSION_ID=3.22.1", "Alpine Linux 3.22")]
|
||||
[InlineData("NAME=\"Ubuntu\"|VERSION_ID=\"24.04\"", "Ubuntu 24.04")]
|
||||
[InlineData("ID=arch", "arch")]
|
||||
[InlineData("", "Linux")]
|
||||
public void LinuxName_IsTheDistroAndItsMajorMinorVersion(string osRelease, string expected)
|
||||
{
|
||||
Assert.Equal(expected, PlatformDetector.LinuxName(osRelease.Split('|', StringSplitOptions.RemoveEmptyEntries)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ReportsOnlyCoarseInformation()
|
||||
{
|
||||
var platform = PlatformDetector.Detect();
|
||||
Assert.Contains(platform.Os, new[] { "linux", "windows", "macos", "other" });
|
||||
Assert.Contains(platform.Arch, new[] { "x64", "arm64", "arm", "x86" });
|
||||
// 没有主机名、内核版本:名字很短,也不含小数点以外的版本细节
|
||||
Assert.True((platform.Name ?? string.Empty).Length < 40);
|
||||
Assert.DoesNotContain(Environment.MachineName, platform.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public static class AgentEndpoints
|
||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token", TokensTag)
|
||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
@@ -130,12 +130,20 @@ public static class AgentEndpoints
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct)
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
|
||||
{
|
||||
await tokens.RevokeAsync(id, ct);
|
||||
if (purge == true)
|
||||
{
|
||||
await tokens.DeleteAsync(id, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await tokens.RevokeAsync(id, ct);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,24 +44,35 @@ public static class AuthEndpoints
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Cookies.Append(
|
||||
PrincipalResolver.SessionCookie,
|
||||
login.SessionToken,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = options.SecureCookies,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||
});
|
||||
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
||||
bool returnSession = request.ReturnSession == true;
|
||||
if (!returnSession)
|
||||
{
|
||||
http.Response.Cookies.Append(
|
||||
PrincipalResolver.SessionCookie,
|
||||
login.SessionToken,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = options.SecureCookies,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||
});
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse);
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
string? bearer = PrincipalResolver.BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
await auth.LogoutAsync(bearer, http.RequestAborted); // 登录时 return_session 拿到的会话令牌;API 令牌不是会话,这里什么也不会发生
|
||||
}
|
||||
else if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
{
|
||||
await auth.LogoutAsync(session, http.RequestAborted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>管理端的构建信息:后端自己和每个 Agent、Probe 的版本、tag、提交、构建时间(节点上报的,不含任何地址)。</summary>
|
||||
public static class BuildEndpoints
|
||||
{
|
||||
private const string Tag = "admin-build";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/build-info", GetAsync)
|
||||
.Api("build_info_get", "Version, tag, commit and build time of the backend and every agent and probe", Tag)
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, CancellationToken ct)
|
||||
{
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items), ApiJsonContext.Default.BuildInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public static class ApiModule
|
||||
QueryEndpoints.Map(publicGroup);
|
||||
LiveEndpoints.Map(publicGroup);
|
||||
HostEndpoints.Map(publicGroup);
|
||||
NodeEndpoints.Map(publicGroup);
|
||||
CatalogEndpoints.Map(publicGroup);
|
||||
|
||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||
@@ -108,6 +109,7 @@ public static class ApiModule
|
||||
TaskEndpoints.Map(guarded);
|
||||
DataEndpoints.Map(guarded);
|
||||
SystemEndpoints.Map(guarded);
|
||||
BuildEndpoints.Map(guarded);
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
|
||||
@@ -23,14 +23,25 @@ public static class PrincipalResolver
|
||||
public const string SessionCookie = "monitor_session";
|
||||
public const string CsrfHeader = "X-CSRF-Token";
|
||||
|
||||
public static string? BearerOf(HttpContext http)
|
||||
{
|
||||
string? header = http.Request.Headers.Authorization;
|
||||
return header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null;
|
||||
}
|
||||
|
||||
public static async Task ResolveAsync(HttpContext http, Func<Task> next)
|
||||
{
|
||||
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
|
||||
Principal? principal = null;
|
||||
string? header = http.Request.Headers.Authorization;
|
||||
if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
string? bearer = BearerOf(http);
|
||||
if (bearer is not null)
|
||||
{
|
||||
principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted);
|
||||
principal = await auth.AuthenticateTokenAsync(bearer, http.RequestAborted);
|
||||
if (principal is null && await auth.AuthenticateSessionAsync(bearer, http.RequestAborted) is { } viaSession)
|
||||
{
|
||||
// 登录时用 return_session 拿到的会话令牌:和 API 令牌一样走 Bearer,不是 Cookie,所以不需要 CSRF
|
||||
principal = viaSession with { Kind = PrincipalKind.Token, CsrfToken = null };
|
||||
}
|
||||
}
|
||||
else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session))
|
||||
{
|
||||
|
||||
@@ -2,9 +2,10 @@ using System.Text.Json;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
public sealed record LoginRequest(string Username, string Password);
|
||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt);
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
|
||||
|
||||
@@ -46,7 +47,9 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand);
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
@@ -121,3 +124,8 @@ public sealed record CreateUserRequest(string Username, string Password);
|
||||
public sealed record ChangePasswordRequest(string Password);
|
||||
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
|
||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
|
||||
public sealed record BuildInfoDto(BuildDto Backend, IReadOnlyList<NodeBuildDto> Nodes);
|
||||
@@ -37,6 +37,12 @@ namespace Monitor.Backend.Api;
|
||||
[JsonSerializable(typeof(AgentDto))]
|
||||
[JsonSerializable(typeof(PageDto<AgentDto>))]
|
||||
[JsonSerializable(typeof(AgentPatchRequest))]
|
||||
[JsonSerializable(typeof(BuildDto))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(NodeInfoDto))]
|
||||
[JsonSerializable(typeof(PageDto<NodeInfoDto>))]
|
||||
[JsonSerializable(typeof(JoinTokenDto))]
|
||||
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
||||
|
||||
@@ -167,6 +167,15 @@ public sealed record HostDto(
|
||||
DateTimeOffset At);
|
||||
|
||||
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
|
||||
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
|
||||
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
|
||||
|
||||
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
|
||||
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
|
||||
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
|
||||
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
|
||||
|
||||
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
|
||||
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的展示信息:系统、架构、国家、运营商。<b>只有这几项,没有任何 IP 地址、城市、ASN、经纬度</b>——
|
||||
/// 这些在 Backend 里只在查询的那一刻用到,存起来的记录里也没有,所以访客(和任何别的接口)都拿不到。
|
||||
/// </summary>
|
||||
public static class NodeEndpoints
|
||||
{
|
||||
private const string Tag = "public-nodes";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/nodes", ListAsync)
|
||||
.Api("nodes_list", "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", Tag)
|
||||
.Returns<PageDto<NodeInfoDto>>().ProducesProblems(403);
|
||||
group.MapGet("/about", AboutAsync)
|
||||
.Api("about_get", "The backend's version, tag and build time, if the site shows build info", Tag)
|
||||
.Returns<AboutDto>().ProducesProblems(403);
|
||||
}
|
||||
|
||||
/// <summary>这个后端自己的构建信息;没有(本地构建)的字段是 <c>null</c>。</summary>
|
||||
internal static BuildDto Backend(bool withCommit) =>
|
||||
new(Monitor.Protocol.BuildInfo.Version, Empty(Monitor.Protocol.BuildInfo.Tag), withCommit ? Empty(Monitor.Protocol.BuildInfo.Commit) : null, Empty(Monitor.Protocol.BuildInfo.BuiltAt));
|
||||
|
||||
private static string? Empty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
private static async Task<IResult> AboutAsync(HttpContext http, SettingsService settings)
|
||||
{
|
||||
bool show = await settings.IsShowBuildAsync(http.RequestAborted);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new AboutDto(show, show ? Backend(withCommit: false) : null), ApiJsonContext.Default.AboutDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
|
||||
{
|
||||
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
|
||||
var visible = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (AgentView view in await agents.ListAsync(ct))
|
||||
{
|
||||
if (view.Agent.Status == Fleet.Server.NodeStatus.Active && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
{
|
||||
visible.Add(view.Agent.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ProbeView probe in await probes.ListAsync(ct))
|
||||
{
|
||||
if (!probe.Revoked)
|
||||
{
|
||||
visible.Add(probe.Probe.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// 版本和 tag 只在站点打开「显示构建信息」时才给访客;关着就是 null
|
||||
bool showBuild = await settings.IsShowBuildAsync(ct);
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.Where(n => visible.Contains(n.NodeId))
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ namespace Monitor.Backend.Application;
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand);
|
||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
@@ -55,11 +58,16 @@ public sealed class JoinTokenService
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
||||
|
||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command));
|
||||
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
||||
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
|
||||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public sealed record SettingView(string Key, string Type, string Value, string D
|
||||
public sealed class SettingsService
|
||||
{
|
||||
public const string SitePublic = "site.public";
|
||||
public const string SiteShowBuild = "site.show_build";
|
||||
public const string RawDays = "retention.raw_days";
|
||||
public const string TestRawDays = "retention.test_raw_days";
|
||||
public const string Metric10sHours = "retention.metric_10s_hours";
|
||||
@@ -40,6 +41,7 @@ public sealed class SettingsService
|
||||
private static IReadOnlyList<SettingSpec> Known(BackendOptions o) =>
|
||||
[
|
||||
new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."),
|
||||
new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend, agents and probes."),
|
||||
new(RawDays, "int", o.RawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of continuous tasks."),
|
||||
new(TestRawDays, "int", o.TestRawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of on-demand tests."),
|
||||
new(Metric10sHours, "int", o.Metric10sRetentionHours.ToString(CultureInfo.InvariantCulture), 1, 720, "Hours to keep the 10 s metric layer."),
|
||||
@@ -114,6 +116,9 @@ public sealed class SettingsService
|
||||
return _sitePublic;
|
||||
}
|
||||
|
||||
/// <summary>访客能不能看到版本、tag 和构建时间(站点公开之后的另一道开关,默认关)。</summary>
|
||||
public async ValueTask<bool> IsShowBuildAsync(CancellationToken ct) => await _store.GetAsync(SiteShowBuild, ct) == "true";
|
||||
|
||||
public async ValueTask<int> GetIntAsync(string key, CancellationToken ct)
|
||||
{
|
||||
SettingSpec spec = Specs.First(s => s.Key == key);
|
||||
|
||||
@@ -9,12 +9,14 @@ public sealed class AgentEventService
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IEventStore _events;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time)
|
||||
public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
_time = time;
|
||||
_nodes = nodes;
|
||||
}
|
||||
|
||||
public async ValueTask OnEventAsync(NodeId agent, AgentEventParams e, CancellationToken ct)
|
||||
@@ -32,7 +34,8 @@ public sealed class AgentEventService
|
||||
switch (e.Kind)
|
||||
{
|
||||
case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.Platform) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote, e.Probe?.Platform);
|
||||
break;
|
||||
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.RemoveProbe(e.Detail);
|
||||
|
||||
@@ -5,7 +5,7 @@ using Monitor.Protocol;
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, PlatformInfo? Platform = null);
|
||||
|
||||
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
|
||||
public sealed class AgentRuntimeState
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed partial class AgentService
|
||||
private readonly TaskPlanner _planner;
|
||||
private readonly ISettingsStore _settings;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings)
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
@@ -114,6 +117,7 @@ public sealed partial class AgentService
|
||||
{
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,11 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
private readonly IEventStore _events;
|
||||
private readonly LiveHub _live;
|
||||
private readonly AgentService _service;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service)
|
||||
public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_events = events;
|
||||
@@ -87,7 +89,11 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
state.LastHeartbeatAtMs = nodeEvent.AtMs;
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.Platform)));
|
||||
foreach (ConnectedProbe p in beat.Probes)
|
||||
{
|
||||
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote, p.Platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -142,6 +148,7 @@ public sealed class MonitorNodeEventSink : INodeEventSink
|
||||
|
||||
// 端口或证书指纹变了,Probe 需要新的列表
|
||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||
if (before is not null && after is not null
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts))
|
||||
&& after.Status == NodeStatus.Active)
|
||||
|
||||
@@ -35,9 +35,11 @@ public sealed partial class ProbeService
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly NodeInfoService _nodes;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time)
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
_agents = agents;
|
||||
_agentService = agentService;
|
||||
@@ -138,6 +140,7 @@ public sealed partial class ProbeService
|
||||
bool deleted = await _probes.DeleteAsync(id, ct);
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<AgentService>();
|
||||
services.AddSingleton<AgentEventService>();
|
||||
services.AddSingleton<HostStatusService>();
|
||||
services.AddSingleton<NodeInfoService>();
|
||||
services.AddSingleton<ProbeService>();
|
||||
services.AddSingleton<LineService>();
|
||||
services.AddSingleton<TestService>();
|
||||
|
||||
@@ -55,6 +55,18 @@ public sealed class BackendOptions
|
||||
|
||||
public int MaxCompareAgents { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
|
||||
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
|
||||
/// </summary>
|
||||
public double PhaseDurationScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
|
||||
/// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 把「ASN 和组织名」归成几个常见的国内运营商:mobile / telecom / unicom / broadnet;别的是 other(显示组织名)。
|
||||
/// 只用来挑展示用的图标和中文名。
|
||||
/// </summary>
|
||||
public static class IspClassifier
|
||||
{
|
||||
public const string Mobile = "mobile";
|
||||
public const string Telecom = "telecom";
|
||||
public const string Unicom = "unicom";
|
||||
public const string Broadnet = "broadnet";
|
||||
public const string Other = "other";
|
||||
|
||||
private static readonly HashSet<int> s_mobile = [9808, 24400, 24445, 56040, 56041, 56042, 56044, 56046, 56047, 56048, 132525, 138407];
|
||||
private static readonly HashSet<int> s_telecom = [4134, 4809, 4811, 4812, 4813, 9394, 17638, 23724, 38283, 58466, 58519, 136188, 137693];
|
||||
private static readonly HashSet<int> s_unicom = [4808, 4837, 9929, 10099, 17621, 17622, 17623, 17816, 136958, 138421];
|
||||
private static readonly HashSet<int> s_broadnet = [63838, 140061];
|
||||
|
||||
public static (string Key, string Name) Classify(int? asn, string? organization)
|
||||
{
|
||||
if (asn is { } a)
|
||||
{
|
||||
if (s_mobile.Contains(a))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (s_telecom.Contains(a))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (s_unicom.Contains(a))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (s_broadnet.Contains(a))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
}
|
||||
|
||||
string org = organization ?? string.Empty;
|
||||
if (Has(org, "china mobile") || Has(org, "cmnet") || Has(org, "中国移动"))
|
||||
{
|
||||
return (Mobile, "中国移动");
|
||||
}
|
||||
|
||||
if (Has(org, "china telecom") || Has(org, "chinanet") || Has(org, "中国电信"))
|
||||
{
|
||||
return (Telecom, "中国电信");
|
||||
}
|
||||
|
||||
if (Has(org, "china unicom") || Has(org, "cncgroup") || Has(org, "中国联通"))
|
||||
{
|
||||
return (Unicom, "中国联通");
|
||||
}
|
||||
|
||||
if (Has(org, "china broadnet") || Has(org, "中国广电"))
|
||||
{
|
||||
return (Broadnet, "中国广电");
|
||||
}
|
||||
|
||||
return (Other, Shorten(org));
|
||||
}
|
||||
|
||||
private static bool Has(string text, string needle) => text.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
|
||||
internal static string Shorten(string organization)
|
||||
{
|
||||
string name = organization.Trim();
|
||||
foreach (string suffix in new[] { ", Inc.", " Inc.", ", LLC", " LLC", " Co.,Ltd.", " Co., Ltd.", " Co.,Ltd", " Co., Ltd", " Ltd.", " Ltd", " Corporation", " Corp.", " Limited", " GmbH", " B.V.", " S.A." })
|
||||
{
|
||||
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name[..^suffix.Length].TrimEnd(',', ' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return name.Length > 28 ? name[..28].TrimEnd() : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
|
||||
/// <b>不含任何 IP 地址、城市、ASN 或经纬度</b>——这些只在查询的那一刻用到,不留下,也不对外。
|
||||
/// </summary>
|
||||
public sealed record NodeInfo(
|
||||
string NodeId,
|
||||
string Kind,
|
||||
string? Os,
|
||||
string? OsName,
|
||||
string? Arch,
|
||||
string? Country,
|
||||
string? IspKey,
|
||||
string? IspName,
|
||||
string? Version = null,
|
||||
string? Tag = null,
|
||||
string? Commit = null,
|
||||
string? BuiltAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
|
||||
/// 这个字段不会出现在任何对外的接口里。
|
||||
/// </summary>
|
||||
public sealed record NodeMeta(NodeInfo Info, string? GeoHost, long GeoAtMs, long UpdatedAtMs);
|
||||
|
||||
public static class NodeKinds
|
||||
{
|
||||
public const string Agent = "agent";
|
||||
public const string Probe = "probe";
|
||||
}
|
||||
|
||||
/// <summary>IP 归属查询的结果(只取需要的几项)。</summary>
|
||||
public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
|
||||
/// <list type="bullet">
|
||||
/// <item>Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
|
||||
/// <item>只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。</item>
|
||||
/// <item>查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class NodeInfoService
|
||||
{
|
||||
/// <summary>同一个地址已经查过的结果保留多久,过期后在节点下次出现时重新查(运营商偶尔会变)。</summary>
|
||||
private static readonly TimeSpan s_geoTtl = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>查询失败之后多久再试。</summary>
|
||||
private static readonly TimeSpan s_retryAfter = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly INodeMetaStore _store;
|
||||
private readonly IGeoLookup _geo;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ConcurrentDictionary<string, NodeMeta> _cache = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _inflight = new();
|
||||
private Task? _load;
|
||||
|
||||
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_geo = geo;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>所有节点的展示信息(Agent 和 Probe)。</summary>
|
||||
public async ValueTask<IReadOnlyList<NodeInfo>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
return [.. _cache.Values.Select(m => m.Info)];
|
||||
}
|
||||
|
||||
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
|
||||
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
|
||||
{
|
||||
if (platform is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
string? os = Clean(platform.Os, 16);
|
||||
string? name = Clean(platform.Name, 40);
|
||||
string? arch = Clean(platform.Arch, 16);
|
||||
string? version = Clean(platform.Version, 32);
|
||||
string? tag = Clean(platform.Tag, 64);
|
||||
string? commit = Clean(platform.Commit, 40);
|
||||
string? builtAt = Clean(platform.BuiltAt, 32);
|
||||
NodeInfo i = current.Info;
|
||||
if (i.Os == os && i.OsName == name && i.Arch == arch && i.Version == version && i.Tag == tag && i.Commit == commit && i.BuiltAt == builtAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveAsync(current with { Info = i with { Os = os, OsName = name, Arch = arch, Version = version, Tag = tag, Commit = commit, BuiltAt = builtAt }, UpdatedAtMs = Now() }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
|
||||
/// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
|
||||
/// </summary>
|
||||
public async ValueTask ObserveAddressAsync(string nodeId, string kind, string? host, CancellationToken ct)
|
||||
{
|
||||
host = host?.Trim();
|
||||
if (string.IsNullOrEmpty(host) || !IsPublic(host) || !_inflight.TryAdd(nodeId, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureLoadedAsync(ct);
|
||||
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
|
||||
long now = Now();
|
||||
bool same = string.Equals(current.GeoHost, host, StringComparison.OrdinalIgnoreCase);
|
||||
bool fresh = current.Info.Country is not null && now - current.GeoAtMs < s_geoTtl.TotalMilliseconds;
|
||||
bool recentlyFailed = current.Info.Country is null && now - current.GeoAtMs < s_retryAfter.TotalMilliseconds;
|
||||
if (same && (fresh || recentlyFailed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GeoInfo? geo = await _geo.LookupAsync(host, ct);
|
||||
if (geo is null || geo.IsPrivate || string.IsNullOrEmpty(geo.Country))
|
||||
{
|
||||
// 记下这次没查到(地址和时间),免得每次心跳都去查
|
||||
await SaveAsync(current with { GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
(string key, string name) = IspClassifier.Classify(geo.Asn, geo.Organization);
|
||||
string country = geo.Country.ToUpperInvariant();
|
||||
await SaveAsync(current with { Info = current.Info with { Country = country, IspKey = key, IspName = Clean(name, 40) }, GeoHost = host, GeoAtMs = now, UpdatedAtMs = now }, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflight.TryRemove(nodeId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probe 连上来或心跳报告它还连着:记下它的系统信息,并在后台查它的国家和运营商。出错只吞掉,不影响调用方。</summary>
|
||||
public void ObserveProbeInBackground(string probeId, string? remote, PlatformInfo? platform) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await SetPlatformAsync(probeId, NodeKinds.Probe, platform, cts.Token);
|
||||
await ObserveAddressAsync(probeId, NodeKinds.Probe, remote, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 展示信息拿不到也没关系,下次再试
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Agent 打招呼(hello):记下它的系统信息,并用它的公开地址在后台查国家和运营商。</summary>
|
||||
public void ObserveAgentInBackground(string agentId, string? publicHost, PlatformInfo? platform) =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await SetPlatformAsync(agentId, NodeKinds.Agent, platform, cts.Token);
|
||||
await ObserveAddressAsync(agentId, NodeKinds.Agent, publicHost, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
|
||||
{
|
||||
// 同上
|
||||
}
|
||||
});
|
||||
|
||||
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_cache.TryRemove(nodeId, out _);
|
||||
await _store.DeleteAsync(nodeId, ct);
|
||||
}
|
||||
|
||||
/// <summary>公网地址才值得查:回环、内网、CGNAT、链路本地、未指定的地址一律不发出去。域名放行(由查询服务自己解析)。</summary>
|
||||
public static bool IsPublic(string host)
|
||||
{
|
||||
if (!IPAddress.TryParse(host, out IPAddress? ip))
|
||||
{
|
||||
return host.Contains('.', StringComparison.Ordinal) && !host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) && !host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(ip) || ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any) || ip.Equals(IPAddress.None))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
{
|
||||
ip = ip.MapToIPv4();
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] b = ip.GetAddressBytes();
|
||||
return !(b[0] == 10
|
||||
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| (b[0] == 169 && b[1] == 254)
|
||||
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|
||||
|| b[0] == 0
|
||||
|| b[0] >= 224);
|
||||
}
|
||||
|
||||
byte[] v6 = ip.GetAddressBytes();
|
||||
bool linkLocal = v6[0] == 0xfe && (v6[1] & 0xc0) == 0x80;
|
||||
bool uniqueLocal = (v6[0] & 0xfe) == 0xfc;
|
||||
bool multicast = v6[0] == 0xff;
|
||||
return !(linkLocal || uniqueLocal || multicast);
|
||||
}
|
||||
|
||||
/// <summary>第一次用到时从库里读一次(只读一次,之后都在内存里)。</summary>
|
||||
private async ValueTask EnsureLoadedAsync(CancellationToken ct)
|
||||
{
|
||||
Task load = Volatile.Read(ref _load) ?? Interlocked.CompareExchange(ref _load, LoadAsync(), null) ?? _load!;
|
||||
await load.WaitAsync(ct);
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
foreach (NodeMeta meta in await _store.ListAsync(CancellationToken.None))
|
||||
{
|
||||
_cache.TryAdd(meta.Info.NodeId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_cache[meta.Info.NodeId] = meta;
|
||||
await _store.SaveAsync(meta, ct);
|
||||
}
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
|
||||
private static NodeMeta Empty(string nodeId, string kind) => new(new NodeInfo(nodeId, kind, null, null, null, null, null, null), null, 0, 0);
|
||||
|
||||
/// <summary>节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。</summary>
|
||||
private static string? Clean(string? text, int max)
|
||||
{
|
||||
string trimmed = text?.Trim() ?? string.Empty;
|
||||
return trimmed.Length == 0 ? null : trimmed.Length > max ? trimmed[..max] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,22 @@ public interface IAgentStore
|
||||
ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>节点的展示信息(系统、架构、国家、运营商)。</summary>
|
||||
public interface INodeMetaStore
|
||||
{
|
||||
ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask SaveAsync(NodeMeta meta, CancellationToken ct);
|
||||
|
||||
ValueTask DeleteAsync(string nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>查一个 IP(或域名)的归属:国家、ASN、组织。实现负责超时和失败(失败返回 null,不抛)。</summary>
|
||||
public interface IGeoLookup
|
||||
{
|
||||
ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -40,6 +56,8 @@ public interface IJoinTokenStore
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IProbeStore
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Monitor.Backend.Application;
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
public sealed class TestService
|
||||
{
|
||||
private readonly double _phaseScale;
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
@@ -16,8 +17,9 @@ public sealed class TestService
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time)
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time, BackendOptions options)
|
||||
{
|
||||
_phaseScale = options.PhaseDurationScale;
|
||||
_tests = tests;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
@@ -52,7 +54,7 @@ public sealed class TestService
|
||||
return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
|
||||
}
|
||||
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent);
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent, _phaseScale);
|
||||
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
|
||||
{
|
||||
return Result<TestRecord>.Fail(error);
|
||||
@@ -173,7 +175,7 @@ public sealed class TestService
|
||||
|
||||
public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted;
|
||||
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent)
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent, double phaseScale)
|
||||
{
|
||||
if (overrides is not null)
|
||||
{
|
||||
@@ -245,7 +247,7 @@ public sealed class TestService
|
||||
return Result<List<TestPhase>>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s");
|
||||
}
|
||||
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds;
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale));
|
||||
phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Monitor.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 3;
|
||||
public const int CurrentVersion = 5;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -55,6 +55,41 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 3");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 4)
|
||||
{
|
||||
// 节点的展示信息:系统、架构、国家、运营商;geo_host 是查询用的地址,只在 Backend 内部用
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("""
|
||||
CREATE TABLE node_meta (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
os TEXT,
|
||||
os_name TEXT,
|
||||
arch TEXT,
|
||||
country TEXT,
|
||||
isp_key TEXT,
|
||||
isp_name TEXT,
|
||||
geo_host TEXT,
|
||||
geo_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
) WITHOUT ROWID
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 4");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 5)
|
||||
{
|
||||
// 节点自报的构建信息:版本、tag、提交、构建时间
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN version TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN tag TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN commit_id TEXT");
|
||||
connection.Exec("ALTER TABLE node_meta ADD COLUMN built_at TEXT");
|
||||
connection.Exec("PRAGMA user_version = 5");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct);
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct);
|
||||
}
|
||||
|
||||
public sealed class SqliteProbeStore : IProbeStore
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend.Storage.Sqlite;
|
||||
|
||||
public sealed class SqliteNodeMetaStore : INodeMetaStore
|
||||
{
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
public SqliteNodeMetaStore(SqliteDatabase db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
|
||||
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at FROM node_meta", Read),
|
||||
ct);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(
|
||||
c => c.Exec(
|
||||
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at) " +
|
||||
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt) ON CONFLICT(node_id) DO UPDATE SET " +
|
||||
"kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " +
|
||||
"isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " +
|
||||
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at",
|
||||
("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country),
|
||||
("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs),
|
||||
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt)),
|
||||
ct);
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(c => c.Exec("DELETE FROM node_meta WHERE node_id = @id", ("@id", nodeId)), ct);
|
||||
|
||||
private static NodeMeta Read(SqliteDataReader r)
|
||||
{
|
||||
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i);
|
||||
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14)), S(r, 8), r.GetInt64(9), r.GetInt64(10));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<ITestStore, SqliteTestStore>();
|
||||
services.AddSingleton<IEventStore, SqliteEventStore>();
|
||||
services.AddSingleton<IHostStore, SqliteHostStore>();
|
||||
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
|
||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
|
||||
@@ -38,6 +38,16 @@ public static class BackendHost
|
||||
IServiceCollection services = builder.Services;
|
||||
services.AddSingleton(time ?? TimeProvider.System);
|
||||
services.AddMonitorSqlite(options.DataDirectory);
|
||||
// 国家和运营商:Backend 用节点的来源地址查一次(Probe、Agent 不访问任何外部服务);地址留空就不查
|
||||
if (string.IsNullOrWhiteSpace(options.GeoLookupUrl))
|
||||
{
|
||||
services.AddSingleton<IGeoLookup, NoGeoLookup>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IGeoLookup>(new HttpGeoLookup(options.GeoLookupUrl));
|
||||
}
|
||||
|
||||
services.AddMonitorApplication(options);
|
||||
services.AddMonitorGateway();
|
||||
services.AddMonitorApi();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using Monitor.Backend.Application;
|
||||
|
||||
namespace Monitor.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// 用 IP 归属查询服务(<c>GET {地址}/lookup?ip=…</c>)查国家和运营商。
|
||||
/// 失败(超时、网络不通、服务返回错误)一律返回 null,不往外抛:展示信息没有也不影响别的功能。
|
||||
/// </summary>
|
||||
public sealed class HttpGeoLookup : IGeoLookup, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public HttpGeoLookup(string baseUrl)
|
||||
{
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(6) };
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyMonitor");
|
||||
}
|
||||
|
||||
public async ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _http.GetAsync("lookup?ip=" + Uri.EscapeDataString(host), ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using JsonDocument doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
|
||||
return Parse(doc.RootElement);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>只取国家代码、ASN、组织名;其余字段(城市、经纬度、网段……)一概不读。</summary>
|
||||
public static GeoInfo? Parse(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("found", out JsonElement found) && found.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isPrivate = root.TryGetProperty("is_private", out JsonElement priv) && priv.ValueKind == JsonValueKind.True;
|
||||
string? country = root.TryGetProperty("country_region", out JsonElement region) && region.TryGetProperty("iso_code", out JsonElement code) && code.ValueKind == JsonValueKind.String ? code.GetString() : null;
|
||||
int? asn = null;
|
||||
string? organization = null;
|
||||
if (root.TryGetProperty("network", out JsonElement network))
|
||||
{
|
||||
if (network.TryGetProperty("asn", out JsonElement asnValue) && asnValue.TryGetInt32(out int number))
|
||||
{
|
||||
asn = number;
|
||||
}
|
||||
|
||||
string? isp = network.TryGetProperty("isp", out JsonElement ispValue) && ispValue.ValueKind == JsonValueKind.String ? ispValue.GetString() : null;
|
||||
string? org = network.TryGetProperty("organization", out JsonElement orgValue) && orgValue.ValueKind == JsonValueKind.String ? orgValue.GetString() : null;
|
||||
organization = string.IsNullOrWhiteSpace(isp) ? org : isp;
|
||||
}
|
||||
|
||||
return new GeoInfo(country, asn, organization, isPrivate);
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>不查(<c>Monitor__GeoLookupUrl</c> 留空)。</summary>
|
||||
public sealed class NoGeoLookup : IGeoLookup
|
||||
{
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct) => ValueTask.FromResult<GeoInfo?>(null);
|
||||
}
|
||||
@@ -256,7 +256,7 @@ public sealed class AgentEndToEndTests
|
||||
string? startFailure = null;
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
|
||||
accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
break;
|
||||
@@ -291,7 +291,7 @@ public sealed class AgentEndToEndTests
|
||||
Assert.Equal(4, probe.Phases.Count);
|
||||
Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind));
|
||||
|
||||
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块
|
||||
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 Probe 切成两个列块
|
||||
Assert.True(agent.App.Counters.Fragments > 4);
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments,
|
||||
|
||||
@@ -58,6 +58,39 @@ public sealed class ApiBasicsTests
|
||||
Assert.Equal("no-store", ok.Headers.CacheControl!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
Assert.False(login.Headers.Contains("Set-Cookie"));
|
||||
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
||||
Assert.False(string.IsNullOrEmpty(session));
|
||||
|
||||
// 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作
|
||||
using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||
|
||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
|
||||
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage created = await client.SendAsync(create);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
// 退出之后这个会话令牌就失效
|
||||
using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout");
|
||||
logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage loggedOut = await client.SendAsync(logout);
|
||||
Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode);
|
||||
using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage afterResponse = await client.SendAsync(after);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
||||
{
|
||||
|
||||
@@ -192,6 +192,8 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.InitialAdminUser = AdminUser;
|
||||
options.InitialAdminPassword = AdminPassword;
|
||||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
});
|
||||
await App.StartAsync();
|
||||
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
|
||||
@@ -83,6 +83,14 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id);
|
||||
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked")));
|
||||
|
||||
// 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true");
|
||||
Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode);
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Tests;
|
||||
|
||||
public sealed partial class NodeInfoTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
|
||||
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
|
||||
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
|
||||
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
|
||||
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
|
||||
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
|
||||
{
|
||||
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
|
||||
{
|
||||
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
|
||||
Assert.Equal("other", key);
|
||||
Assert.Equal("Hangzhou Alibaba Advertising", name);
|
||||
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
|
||||
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("223.5.5.5", true)]
|
||||
[InlineData("2001:4860:4860::8888", true)]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("127.0.0.1", false)]
|
||||
[InlineData("10.1.2.3", false)]
|
||||
[InlineData("172.16.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("192.168.1.10", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("100.64.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("fe80::1", false)]
|
||||
[InlineData("fd00::1", false)]
|
||||
[InlineData("::ffff:192.168.1.1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("printer.local", false)]
|
||||
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, NodeInfoService.IsPublic(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
|
||||
{
|
||||
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
|
||||
var store = new MemoryStore();
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(store, geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
||||
Assert.Empty(geo.Calls);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
|
||||
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
|
||||
|
||||
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
|
||||
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
||||
|
||||
// 地址变了才重新查;过了一周也重新查
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
time.Advance(TimeSpan.FromDays(8));
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(3, geo.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
|
||||
{
|
||||
var geo = new FakeGeo(null);
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_PlatformIsSavedAndSurvivesARestart()
|
||||
{
|
||||
var store = new MemoryStore();
|
||||
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
|
||||
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{"ip":"223.5.5.5","found":true,"is_private":false,
|
||||
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
|
||||
"city":{"names":{"zh-CN":"杭州市"}},
|
||||
"location":{"latitude":30.25,"longitude":120.21},
|
||||
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
|
||||
""");
|
||||
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
|
||||
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
|
||||
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
|
||||
private static partial Regex Ipv4();
|
||||
|
||||
private sealed class FakeGeo : IGeoLookup
|
||||
{
|
||||
private readonly GeoInfo? _result;
|
||||
|
||||
public FakeGeo(GeoInfo? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeMetaStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeMeta> _items = [];
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_items[meta.Info.NodeId] = meta;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_items.Remove(nodeId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
||||
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed partial class NodePrivacyTests
|
||||
{
|
||||
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public NodePrivacyTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
||||
JsonNode? agentNode = null;
|
||||
JsonNode? probeNode = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
||||
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(40),
|
||||
"the agent (hello) and the probe (agent list sync) both report their platform");
|
||||
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
||||
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
||||
|
||||
string[] paths =
|
||||
[
|
||||
"/api/public/nodes",
|
||||
"/api/public/hosts",
|
||||
"/api/public/overview?range=1h&metrics=rtt.p50",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
||||
"/api/public/catalog/plans",
|
||||
];
|
||||
foreach (string path in paths)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotMatch(Ipv4(), body);
|
||||
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
|
||||
foreach (string key in KeysOf(JsonNode.Parse(body)))
|
||||
{
|
||||
Assert.DoesNotContain(key, s_forbiddenKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
|
||||
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
|
||||
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.False(about["show_build"]!.GetValue<bool>());
|
||||
Assert.Null(about["backend"]);
|
||||
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
Assert.All(hidden["items"]!.AsArray(), n =>
|
||||
{
|
||||
Assert.Null(n!["version"]);
|
||||
Assert.Null(n["tag"]);
|
||||
});
|
||||
|
||||
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
|
||||
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent reports its version in hello");
|
||||
|
||||
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
|
||||
on.EnsureSuccessStatusCode();
|
||||
try
|
||||
{
|
||||
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.True(shown["show_build"]!.GetValue<bool>());
|
||||
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
|
||||
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
|
||||
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
|
||||
off.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> KeysOf(JsonNode? node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach ((string key, JsonNode? value) in obj)
|
||||
{
|
||||
yield return key;
|
||||
foreach (string inner in KeysOf(value))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case JsonArray array:
|
||||
foreach (JsonNode? item in array)
|
||||
{
|
||||
foreach (string inner in KeysOf(item))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
||||
private static partial Regex Ipv4();
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public sealed class ProbeEndToEndTests
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests
|
||||
TimeSpan.FromSeconds(90));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments");
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user