diff --git a/backend/agent/src/Monitor.Agent/AgentApp.cs b/backend/agent/src/Monitor.Agent/AgentApp.cs
index c79fb0a..f2bda34 100644
--- a/backend/agent/src/Monitor.Agent/AgentApp.cs
+++ b/backend/agent/src/Monitor.Agent/AgentApp.cs
@@ -12,7 +12,7 @@ namespace Monitor.Agent;
/// Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。
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],
diff --git a/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs b/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs
index fff6753..4f97f85 100644
--- a/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs
+++ b/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs
@@ -64,6 +64,9 @@ public sealed class ProbeSession : DataSession
public long ConnectedAtUtcMs { get; init; }
+ /// Probe 自报的系统和架构(随列表同步一起来,只用于展示);还没报过就是 null。
+ public PlatformInfo? Platform { get; set; }
+
public RpcSession? Rpc { get; set; }
/// 最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。
diff --git a/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs b/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs
index a782712..1d8bf66 100644
--- a/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs
+++ b/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs
@@ -220,7 +220,7 @@ public sealed class PeerHub
/// 当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。
public IReadOnlyList 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 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,
diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json
index 510a990..eb08ea3 100644
--- a/backend/openapi/v1.json
+++ b/backend/openapi/v1.json
@@ -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": [
@@ -3629,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": [
@@ -3753,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",
@@ -4039,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"
@@ -5332,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",
@@ -5604,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",
@@ -6814,6 +7143,9 @@
{
"name": "public"
},
+ {
+ "name": "public-nodes"
+ },
{
"name": "catalog"
},
@@ -6847,6 +7179,9 @@
{
"name": "admin-users"
},
+ {
+ "name": "admin-build"
+ },
{
"name": "ops"
},
diff --git a/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs b/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs
index 6141819..2a7661c 100644
--- a/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs
+++ b/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs
@@ -163,10 +163,13 @@ public sealed class AgentConnection
}
}
+ /// 本机的系统和架构:随列表同步一起告诉 Agent,只用于在面板上展示。
+ private static readonly PlatformInfo s_platform = PlatformDetector.Detect();
+
private async Task RefreshDirectoryAsync(RpcSession rpc, CancellationToken ct)
{
Result 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);
diff --git a/backend/probe/src/Monitor.Probe/ProbeApp.cs b/backend/probe/src/Monitor.Probe/ProbeApp.cs
index aeb880c..24ece9a 100644
--- a/backend/probe/src/Monitor.Probe/ProbeApp.cs
+++ b/backend/probe/src/Monitor.Probe/ProbeApp.cs
@@ -11,7 +11,7 @@ namespace Monitor.Probe;
///
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 _connections = [];
diff --git a/backend/shared/Monitor.Measurement/Platform/PlatformDetector.cs b/backend/shared/Monitor.Measurement/Platform/PlatformDetector.cs
new file mode 100644
index 0000000..b3e5dd4
--- /dev/null
+++ b/backend/shared/Monitor.Measurement/Platform/PlatformDetector.cs
@@ -0,0 +1,81 @@
+using System.Runtime.InteropServices;
+using Monitor.Protocol;
+
+namespace Monitor.Measurement;
+
+///
+/// 读出本机的系统名和架构,只用于在面板上展示。故意只取粗粒度的信息:
+/// 「Debian 12」「Windows 11」「x64」,不读主机名、内核版本、CPU 型号。
+///
+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;
+
+ /// `osRelease` 是 /etc/os-release 的各行(Linux 之外不用)。拆出来是为了好测。
+ internal static PlatformInfo Detect(IEnumerable 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 };
+ }
+
+ /// 从 os-release 里取发行版名字和主版本:「Debian GNU/Linux 12 (bookworm)」→「Debian 12」,「OpenWrt 24.10.0」→「OpenWrt 24.10」。
+ internal static string LinuxName(IEnumerable osRelease)
+ {
+ var values = new Dictionary(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))}";
+ }
+}
diff --git a/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj b/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj
index 12d5252..1d2bc78 100644
--- a/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj
+++ b/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj
@@ -6,4 +6,30 @@
+
+
+
+ $(IntermediateOutputPath)BuildInfo.g.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
index c7466da..356b373 100644
--- a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
+++ b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs
@@ -152,6 +152,10 @@ public sealed class ProbeAgentsParams
/// Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。
[JsonPropertyName("have_version")]
public long HaveVersion { get; set; }
+
+ /// Probe 的系统和架构(只用于展示;Agent 记在会话上,随连接状态报给 Backend)。
+ [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;
}
+///
+/// 节点的系统和架构(粗粒度,只用于展示):os 是 linux / windows / macos / other,name 是「Debian 12」「Windows 11」这样的名字,
+/// arch 是 x64 / arm64 / arm / x86。没有主机名、内核版本、具体型号。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
+///
+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; }
+
+ /// 程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601): 构建时写进去的,没设置就是空。
+ [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; }
+}
+
/// fleet.hello 的附加字段。
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; }
}
/// fleet.heartbeat 的附加字段:只有进程内的计数,没有主机信息。
@@ -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))]
diff --git a/backend/shared/tests/Monitor.Measurement.Tests/PlatformDetectorTests.cs b/backend/shared/tests/Monitor.Measurement.Tests/PlatformDetectorTests.cs
new file mode 100644
index 0000000..365c7df
--- /dev/null
+++ b/backend/shared/tests/Monitor.Measurement.Tests/PlatformDetectorTests.cs
@@ -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);
+ }
+}
diff --git a/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs
new file mode 100644
index 0000000..09c546f
--- /dev/null
+++ b/backend/src/Monitor.Backend.Api/Admin/BuildEndpoints.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Monitor.Backend.Application;
+
+namespace Monitor.Backend.Api;
+
+/// 管理端的构建信息:后端自己和每个 Agent、Probe 的版本、tag、提交、构建时间(节点上报的,不含任何地址)。
+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().ProducesProblems(401, 403);
+ }
+
+ private static async Task 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);
+ }
+}
diff --git a/backend/src/Monitor.Backend.Api/ApiModule.cs b/backend/src/Monitor.Backend.Api/ApiModule.cs
index 227d89e..cf57733 100644
--- a/backend/src/Monitor.Backend.Api/ApiModule.cs
+++ b/backend/src/Monitor.Backend.Api/ApiModule.cs
@@ -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");
diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs
index 1f578fd..83ce68b 100644
--- a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs
+++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs
@@ -124,3 +124,8 @@ public sealed record CreateUserRequest(string Username, string Password);
public sealed record ChangePasswordRequest(string Password);
public sealed record RecomputeDto(string State, IReadOnlyList Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
+
+/// 管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。
+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 Nodes);
diff --git a/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs
index 1f35807..35b3cc1 100644
--- a/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs
+++ b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs
@@ -37,6 +37,12 @@ namespace Monitor.Backend.Api;
[JsonSerializable(typeof(AgentDto))]
[JsonSerializable(typeof(PageDto))]
[JsonSerializable(typeof(AgentPatchRequest))]
+[JsonSerializable(typeof(BuildDto))]
+[JsonSerializable(typeof(AboutDto))]
+[JsonSerializable(typeof(NodeBuildDto))]
+[JsonSerializable(typeof(BuildInfoDto))]
+[JsonSerializable(typeof(NodeInfoDto))]
+[JsonSerializable(typeof(PageDto))]
[JsonSerializable(typeof(JoinTokenDto))]
[JsonSerializable(typeof(PageDto))]
[JsonSerializable(typeof(CreateJoinTokenRequest))]
diff --git a/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs
index 552006b..64322f7 100644
--- a/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs
+++ b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs
@@ -167,6 +167,15 @@ public sealed record HostDto(
DateTimeOffset At);
/// 一个 Agent 的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。
+/// 节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。
+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);
+
+/// 一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 null。
+public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
+
+/// 公开的「关于」:站点没有打开「显示构建信息」时只有 show_build: false。
+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);
diff --git a/backend/src/Monitor.Backend.Api/Public/NodeEndpoints.cs b/backend/src/Monitor.Backend.Api/Public/NodeEndpoints.cs
new file mode 100644
index 0000000..35da907
--- /dev/null
+++ b/backend/src/Monitor.Backend.Api/Public/NodeEndpoints.cs
@@ -0,0 +1,69 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Monitor.Backend.Application;
+
+namespace Monitor.Backend.Api;
+
+///
+/// 节点的展示信息:系统、架构、国家、运营商。只有这几项,没有任何 IP 地址、城市、ASN、经纬度——
+/// 这些在 Backend 里只在查询的那一刻用到,存起来的记录里也没有,所以访客(和任何别的接口)都拿不到。
+///
+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>().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().ProducesProblems(403);
+ }
+
+ /// 这个后端自己的构建信息;没有(本地构建)的字段是 null。
+ 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 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 ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
+ {
+ // 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
+ var visible = new HashSet(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(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
+ }
+}
diff --git a/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs
index 115c76b..566f91b 100644
--- a/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs
+++ b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs
@@ -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 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;
}
+ /// 访客能不能看到版本、tag 和构建时间(站点公开之后的另一道开关,默认关)。
+ public async ValueTask IsShowBuildAsync(CancellationToken ct) => await _store.GetAsync(SiteShowBuild, ct) == "true";
+
public async ValueTask GetIntAsync(string key, CancellationToken ct)
{
SettingSpec spec = Specs.First(s => s.Key == key);
diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs b/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs
index ccff24a..1afbb61 100644
--- a/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs
+++ b/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs
@@ -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);
diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs b/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs
index 98e7738..fd1d2ba 100644
--- a/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs
+++ b/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs
@@ -5,7 +5,7 @@ using Monitor.Protocol;
namespace Monitor.Backend.Application;
/// Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。
-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);
/// Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。
public sealed class AgentRuntimeState
diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentService.cs b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs
index ffc8ee9..c3418b7 100644
--- a/backend/src/Monitor.Backend.Application/Agents/AgentService.cs
+++ b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs
@@ -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);
}
diff --git a/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs b/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs
index 418c27f..d8f6b27 100644
--- a/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs
+++ b/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs
@@ -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)
diff --git a/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs
index 48a0005..263db9f 100644
--- a/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs
+++ b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs
@@ -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();
}
diff --git a/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs
index e5800b6..e0ca650 100644
--- a/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs
@@ -27,6 +27,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/backend/src/Monitor.Backend.Application/BackendOptions.cs b/backend/src/Monitor.Backend.Application/BackendOptions.cs
index 55e908f..f38727d 100644
--- a/backend/src/Monitor.Backend.Application/BackendOptions.cs
+++ b/backend/src/Monitor.Backend.Application/BackendOptions.cs
@@ -61,6 +61,12 @@ public sealed class BackendOptions
///
public double PhaseDurationScale { get; set; } = 1.0;
+ ///
+ /// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
+ /// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
+ ///
+ public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
+
/// 初始管理员(只在没有任何用户时创建)。
public string? InitialAdminUser { get; set; }
diff --git a/backend/src/Monitor.Backend.Application/Nodes/IspClassifier.cs b/backend/src/Monitor.Backend.Application/Nodes/IspClassifier.cs
new file mode 100644
index 0000000..46205d9
--- /dev/null
+++ b/backend/src/Monitor.Backend.Application/Nodes/IspClassifier.cs
@@ -0,0 +1,86 @@
+namespace Monitor.Backend.Application;
+
+///
+/// 把「ASN 和组织名」归成几个常见的国内运营商:mobile / telecom / unicom / broadnet;别的是 other(显示组织名)。
+/// 只用来挑展示用的图标和中文名。
+///
+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 s_mobile = [9808, 24400, 24445, 56040, 56041, 56042, 56044, 56046, 56047, 56048, 132525, 138407];
+ private static readonly HashSet s_telecom = [4134, 4809, 4811, 4812, 4813, 9394, 17638, 23724, 38283, 58466, 58519, 136188, 137693];
+ private static readonly HashSet s_unicom = [4808, 4837, 9929, 10099, 17621, 17622, 17623, 17816, 136958, 138421];
+ private static readonly HashSet 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);
+
+ /// 组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。
+ 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;
+ }
+}
diff --git a/backend/src/Monitor.Backend.Application/Nodes/NodeInfo.cs b/backend/src/Monitor.Backend.Application/Nodes/NodeInfo.cs
new file mode 100644
index 0000000..b5212d1
--- /dev/null
+++ b/backend/src/Monitor.Backend.Application/Nodes/NodeInfo.cs
@@ -0,0 +1,34 @@
+namespace Monitor.Backend.Application;
+
+///
+/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
+/// 不含任何 IP 地址、城市、ASN 或经纬度——这些只在查询的那一刻用到,不留下,也不对外。
+///
+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);
+
+///
+/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
+/// 这个字段不会出现在任何对外的接口里。
+///
+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";
+}
+
+/// IP 归属查询的结果(只取需要的几项)。
+public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
diff --git a/backend/src/Monitor.Backend.Application/Nodes/NodeInfoService.cs b/backend/src/Monitor.Backend.Application/Nodes/NodeInfoService.cs
new file mode 100644
index 0000000..f3c3e23
--- /dev/null
+++ b/backend/src/Monitor.Backend.Application/Nodes/NodeInfoService.cs
@@ -0,0 +1,220 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using Monitor.Protocol;
+
+namespace Monitor.Backend.Application;
+
+///
+/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
+///
+/// - Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。
+/// - 只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。
+/// - 查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。
+///
+///
+public sealed class NodeInfoService
+{
+ /// 同一个地址已经查过的结果保留多久,过期后在节点下次出现时重新查(运营商偶尔会变)。
+ private static readonly TimeSpan s_geoTtl = TimeSpan.FromDays(7);
+
+ /// 查询失败之后多久再试。
+ private static readonly TimeSpan s_retryAfter = TimeSpan.FromMinutes(10);
+
+ private readonly INodeMetaStore _store;
+ private readonly IGeoLookup _geo;
+ private readonly TimeProvider _time;
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly ConcurrentDictionary _inflight = new();
+ private Task? _load;
+
+ public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
+ {
+ _store = store;
+ _geo = geo;
+ _time = time;
+ }
+
+ /// 所有节点的展示信息(Agent 和 Probe)。
+ public async ValueTask> ListAsync(CancellationToken ct)
+ {
+ await EnsureLoadedAsync(ct);
+ return [.. _cache.Values.Select(m => m.Info)];
+ }
+
+ /// 节点自报的系统、架构和构建信息;没有变化就不写库。
+ 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);
+ }
+
+ ///
+ /// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
+ /// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
+ ///
+ 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 _);
+ }
+ }
+
+ /// Probe 连上来或心跳报告它还连着:记下它的系统信息,并在后台查它的国家和运营商。出错只吞掉,不影响调用方。
+ 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)
+ {
+ // 展示信息拿不到也没关系,下次再试
+ }
+ });
+
+ /// Agent 打招呼(hello):记下它的系统信息,并用它的公开地址在后台查国家和运营商。
+ 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);
+ }
+
+ /// 公网地址才值得查:回环、内网、CGNAT、链路本地、未指定的地址一律不发出去。域名放行(由查询服务自己解析)。
+ 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);
+ }
+
+ /// 第一次用到时从库里读一次(只读一次,之后都在内存里)。
+ 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);
+
+ /// 节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。
+ 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;
+ }
+}
diff --git a/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs
index dc460e8..b600021 100644
--- a/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs
+++ b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs
@@ -33,6 +33,22 @@ public interface IAgentStore
ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct);
}
+/// 节点的展示信息(系统、架构、国家、运营商)。
+public interface INodeMetaStore
+{
+ ValueTask> ListAsync(CancellationToken ct);
+
+ ValueTask SaveAsync(NodeMeta meta, CancellationToken ct);
+
+ ValueTask DeleteAsync(string nodeId, CancellationToken ct);
+}
+
+/// 查一个 IP(或域名)的归属:国家、ASN、组织。实现负责超时和失败(失败返回 null,不抛)。
+public interface IGeoLookup
+{
+ ValueTask LookupAsync(string host, CancellationToken ct);
+}
+
public interface IJoinTokenStore
{
ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs b/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs
index 1c567ba..a1276dd 100644
--- a/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs
+++ b/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs
@@ -5,7 +5,7 @@ namespace Monitor.Backend.Storage.Sqlite;
/// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。
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();
+ }
}
///
diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs
new file mode 100644
index 0000000..4dc92dc
--- /dev/null
+++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs
@@ -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> ListAsync(CancellationToken ct) =>
+ _db.ReadAsync>(
+ 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));
+ }
+}
diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs
index 889ae14..ee7402a 100644
--- a/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs
+++ b/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs
@@ -20,6 +20,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/backend/src/Monitor.Backend/BackendHost.cs b/backend/src/Monitor.Backend/BackendHost.cs
index e7aff5d..dd14010 100644
--- a/backend/src/Monitor.Backend/BackendHost.cs
+++ b/backend/src/Monitor.Backend/BackendHost.cs
@@ -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();
+ }
+ else
+ {
+ services.AddSingleton(new HttpGeoLookup(options.GeoLookupUrl));
+ }
+
services.AddMonitorApplication(options);
services.AddMonitorGateway();
services.AddMonitorApi();
diff --git a/backend/src/Monitor.Backend/HttpGeoLookup.cs b/backend/src/Monitor.Backend/HttpGeoLookup.cs
new file mode 100644
index 0000000..d8b50e2
--- /dev/null
+++ b/backend/src/Monitor.Backend/HttpGeoLookup.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+using Monitor.Backend.Application;
+
+namespace Monitor.Backend;
+
+///
+/// 用 IP 归属查询服务(GET {地址}/lookup?ip=…)查国家和运营商。
+/// 失败(超时、网络不通、服务返回错误)一律返回 null,不往外抛:展示信息没有也不影响别的功能。
+///
+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 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;
+ }
+ }
+
+ /// 只取国家代码、ASN、组织名;其余字段(城市、经纬度、网段……)一概不读。
+ 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();
+}
+
+/// 不查(Monitor__GeoLookupUrl 留空)。
+public sealed class NoGeoLookup : IGeoLookup
+{
+ public ValueTask LookupAsync(string host, CancellationToken ct) => ValueTask.FromResult(null);
+}
diff --git a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs
index 590ea4b..86c2970 100644
--- a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs
+++ b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs
@@ -192,6 +192,7 @@ 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();
diff --git a/backend/tests/Monitor.Backend.Tests/NodeInfoTests.cs b/backend/tests/Monitor.Backend.Tests/NodeInfoTests.cs
new file mode 100644
index 0000000..b9ecaf2
--- /dev/null
+++ b/backend/tests/Monitor.Backend.Tests/NodeInfoTests.cs
@@ -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 Calls { get; } = [];
+
+ public ValueTask LookupAsync(string host, CancellationToken ct)
+ {
+ Calls.Add(host);
+ return ValueTask.FromResult(_result);
+ }
+ }
+
+ private sealed class MemoryStore : INodeMetaStore
+ {
+ private readonly Dictionary _items = [];
+
+ public ValueTask> ListAsync(CancellationToken ct) => ValueTask.FromResult>([.. _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;
+ }
+ }
+}
+
+///
+/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
+/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
+///
+[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());
+
+ // 两端都自报了系统和架构(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() == agent.Id);
+ probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == 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());
+ Assert.Equal("probe", probeNode!["kind"]!.GetValue());
+
+ 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());
+ 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()));
+ await BackendFixture.EventuallyAsync(
+ async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue() == 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());
+ Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue()));
+ 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() == agent.Id)!;
+ Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue()));
+ }
+ 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 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();
+}
diff --git a/packaging/container/Agent.Fastlane.Containerfile b/packaging/container/Agent.Fastlane.Containerfile
index dd628d1..2bcbc6d 100644
--- a/packaging/container/Agent.Fastlane.Containerfile
+++ b/packaging/container/Agent.Fastlane.Containerfile
@@ -1,6 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src/backend
+# 构建信息:CI 通过 --build-arg 传入;不传就是空
+ARG MONITOR_TAG=
+ARG MONITOR_COMMIT=
+ARG MONITOR_BUILT_AT=
+ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
COPY restore/backend/ ./
RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 --artifacts-path /artifacts \
&& dotnet restore fleet/tools/fleet-enroll -r linux-x64 --artifacts-path /artifacts
diff --git a/packaging/container/Probe.Fastlane.Containerfile b/packaging/container/Probe.Fastlane.Containerfile
index fccb043..a87fbcf 100644
--- a/packaging/container/Probe.Fastlane.Containerfile
+++ b/packaging/container/Probe.Fastlane.Containerfile
@@ -1,6 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src/backend
+# 构建信息:CI 通过 --build-arg 传入;不传就是空
+ARG MONITOR_TAG=
+ARG MONITOR_COMMIT=
+ARG MONITOR_BUILT_AT=
+ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
COPY restore/backend/ ./
RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 --artifacts-path /artifacts
diff --git a/scripts/publish.sh b/scripts/publish.sh
index fe1fc70..f5f18f9 100644
--- a/scripts/publish.sh
+++ b/scripts/publish.sh
@@ -16,17 +16,23 @@ case "$project" in
*) echo "unknown project: $project" >&2; exit 2 ;;
esac
+# 构建信息(程序里显示的 tag、提交、构建时间):没在 git 仓库里就留空
+export MONITOR_TAG="${MONITOR_TAG:-$(git -C "$root" describe --tags --always --dirty 2>/dev/null || true)}"
+export MONITOR_COMMIT="${MONITOR_COMMIT:-$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)}"
+export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
+build_env=(-e MONITOR_TAG -e MONITOR_COMMIT -e MONITOR_BUILT_AT)
+
out="backend/artifacts/publish/$project/$rid"
args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "/src/$out" --artifacts-path /artifacts -nologo -v q)
case "$rid" in
linux-musl-x64)
# SDK 镜像的版本可以比 global.json 新(rollForward = latestFeature)
- docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
+ docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot dotnet "${args[@]}"
;;
linux-x64)
- docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
+ docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
;;
linux-musl-arm64|linux-arm64)
echo "$rid needs an arm64 Alpine builder (native or qemu): run this script on an arm64 host or in CI" >&2