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