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 bcc200e..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": [ @@ -1770,7 +1832,7 @@ "tags": [ "admin-join-tokens" ], - "summary": "Revoke a join token", + "summary": "Revoke a join token; with purge=true delete the record for good", "operationId": "join_tokens_revoke", "parameters": [ { @@ -1780,6 +1842,13 @@ "schema": { "type": "string" } + }, + { + "name": "purge", + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -3622,6 +3691,47 @@ } } }, + "/api/admin/build-info": { + "get": { + "tags": [ + "admin-build" + ], + "summary": "Version, tag, commit and build time of the backend and every agent and probe", + "operationId": "build_info_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildInfoDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/healthz": { "get": { "tags": [ @@ -3746,6 +3856,28 @@ }, "components": { "schemas": { + "AboutDto": { + "required": [ + "show_build", + "backend" + ], + "type": "object", + "properties": { + "show_build": { + "type": "boolean" + }, + "backend": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BuildDto" + } + ] + } + } + }, "AgentDto": { "required": [ "id", @@ -4032,6 +4164,56 @@ } } }, + "BuildDto": { + "required": [ + "version", + "tag", + "commit", + "built_at" + ], + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "tag": { + "type": [ + "null", + "string" + ] + }, + "commit": { + "type": [ + "null", + "string" + ] + }, + "built_at": { + "type": [ + "null", + "string" + ] + } + } + }, + "BuildInfoDto": { + "required": [ + "backend", + "nodes" + ], + "type": "object", + "properties": { + "backend": { + "$ref": "#/components/schemas/BuildDto" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeBuildDto" + } + } + } + }, "ChangePasswordRequest": { "required": [ "password" @@ -4345,7 +4527,7 @@ "required": [ "token", "plaintext", - "install_command" + "install_commands" ], "type": "object", "properties": { @@ -4355,8 +4537,8 @@ "plaintext": { "type": "string" }, - "install_command": { - "type": "string" + "install_commands": { + "$ref": "#/components/schemas/InstallCommandsDto" } } }, @@ -4908,6 +5090,25 @@ } } }, + "InstallCommandsDto": { + "required": [ + "linux", + "macos", + "windows" + ], + "type": "object", + "properties": { + "linux": { + "type": "string" + }, + "macos": { + "type": "string" + }, + "windows": { + "type": "string" + } + } + }, "JoinTokenDto": { "required": [ "id", @@ -5120,7 +5321,8 @@ "LoginRequest": { "required": [ "username", - "password" + "password", + "return_session" ], "type": "object", "properties": { @@ -5129,6 +5331,12 @@ }, "password": { "type": "string" + }, + "return_session": { + "type": [ + "null", + "boolean" + ] } } }, @@ -5137,7 +5345,8 @@ "user_id", "username", "csrf_token", - "expires_at" + "expires_at", + "session_token" ], "type": "object", "properties": { @@ -5152,6 +5361,12 @@ }, "expires_at": { "format": "date-time" + }, + "session_token": { + "type": [ + "null", + "string" + ] } } }, @@ -5292,6 +5507,139 @@ } } }, + "NodeBuildDto": { + "required": [ + "id", + "kind", + "os", + "os_name", + "arch", + "version", + "tag", + "commit", + "built_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "os": { + "type": [ + "null", + "string" + ] + }, + "os_name": { + "type": [ + "null", + "string" + ] + }, + "arch": { + "type": [ + "null", + "string" + ] + }, + "version": { + "type": [ + "null", + "string" + ] + }, + "tag": { + "type": [ + "null", + "string" + ] + }, + "commit": { + "type": [ + "null", + "string" + ] + }, + "built_at": { + "type": [ + "null", + "string" + ] + } + } + }, + "NodeInfoDto": { + "required": [ + "id", + "kind", + "os", + "os_name", + "arch", + "country", + "isp_key", + "isp_name" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "os": { + "type": [ + "null", + "string" + ] + }, + "os_name": { + "type": [ + "null", + "string" + ] + }, + "arch": { + "type": [ + "null", + "string" + ] + }, + "country": { + "type": [ + "null", + "string" + ] + }, + "isp_key": { + "type": [ + "null", + "string" + ] + }, + "isp_name": { + "type": [ + "null", + "string" + ] + }, + "version": { + "type": [ + "null", + "string" + ] + }, + "tag": { + "type": [ + "null", + "string" + ] + } + } + }, "OverviewDto": { "required": [ "range", @@ -5564,6 +5912,27 @@ } } }, + "PageDtoOfNodeInfoDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeInfoDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfPlanDto": { "required": [ "items", @@ -6774,6 +7143,9 @@ { "name": "public" }, + { + "name": "public-nodes" + }, { "name": "catalog" }, @@ -6807,6 +7179,9 @@ { "name": "admin-users" }, + { + "name": "admin-build" + }, { "name": "ops" }, 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/AgentEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs index e0f20c2..a4f0d18 100644 --- a/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs @@ -43,7 +43,7 @@ public static class AgentEndpoints .Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) - .Api("join_tokens_revoke", "Revoke a join token", TokensTag) + .Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); group.MapGet("/probes", ListProbesAsync) @@ -130,12 +130,20 @@ public static class AgentEndpoints http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.Location = "/api/admin/join-tokens"; - return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); + return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); } - private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct) + private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) { - await tokens.RevokeAsync(id, ct); + if (purge == true) + { + await tokens.DeleteAsync(id, ct); + } + else + { + await tokens.RevokeAsync(id, ct); + } + return TypedResults.NoContent(); } diff --git a/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs index 5c8bbcf..6294413 100644 --- a/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs @@ -44,24 +44,35 @@ public static class AuthEndpoints return Problems.From(http, error); } - http.Response.Cookies.Append( - PrincipalResolver.SessionCookie, - login.SessionToken, - new CookieOptions - { - HttpOnly = true, - Secure = options.SecureCookies, - SameSite = SameSiteMode.Strict, - Path = "/", - Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs), - }); + // 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上 + bool returnSession = request.ReturnSession == true; + if (!returnSession) + { + http.Response.Cookies.Append( + PrincipalResolver.SessionCookie, + login.SessionToken, + new CookieOptions + { + HttpOnly = true, + Secure = options.SecureCookies, + SameSite = SameSiteMode.Strict, + Path = "/", + Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs), + }); + } + http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse); + return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse); } private static async Task LogoutAsync(HttpContext http, AuthService auth) { - if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session)) + string? bearer = PrincipalResolver.BearerOf(http); + if (bearer is not null) + { + await auth.LogoutAsync(bearer, http.RequestAborted); // 登录时 return_session 拿到的会话令牌;API 令牌不是会话,这里什么也不会发生 + } + else if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session)) { await auth.LogoutAsync(session, http.RequestAborted); } 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/Common/Guards.cs b/backend/src/Monitor.Backend.Api/Common/Guards.cs index 867963b..4167689 100644 --- a/backend/src/Monitor.Backend.Api/Common/Guards.cs +++ b/backend/src/Monitor.Backend.Api/Common/Guards.cs @@ -23,14 +23,25 @@ public static class PrincipalResolver public const string SessionCookie = "monitor_session"; public const string CsrfHeader = "X-CSRF-Token"; + public static string? BearerOf(HttpContext http) + { + string? header = http.Request.Headers.Authorization; + return header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null; + } + public static async Task ResolveAsync(HttpContext http, Func next) { AuthService auth = http.RequestServices.GetRequiredService(); Principal? principal = null; - string? header = http.Request.Headers.Authorization; - if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + string? bearer = BearerOf(http); + if (bearer is not null) { - principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted); + principal = await auth.AuthenticateTokenAsync(bearer, http.RequestAborted); + if (principal is null && await auth.AuthenticateSessionAsync(bearer, http.RequestAborted) is { } viaSession) + { + // 登录时用 return_session 拿到的会话令牌:和 API 令牌一样走 Bearer,不是 Cookie,所以不需要 CSRF + principal = viaSession with { Kind = PrincipalKind.Token, CsrfToken = null }; + } } else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session)) { diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs index 96fffa0..83ce68b 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs @@ -2,9 +2,10 @@ using System.Text.Json; namespace Monitor.Backend.Api; -public sealed record LoginRequest(string Username, string Password); +/// return_session:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 Authorization: Bearer 带上。 +public sealed record LoginRequest(string Username, string Password, bool? ReturnSession); -public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt); +public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken); @@ -46,7 +47,9 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); /// 令牌明文只在创建的响应里出现一次。 -public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand); +public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); + +public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); /// Agent 眼中连着的一个 Probe(Agent 上报)。 public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince); @@ -121,3 +124,8 @@ public sealed record CreateUserRequest(string Username, string Password); public sealed record ChangePasswordRequest(string Password); public sealed record RecomputeDto(string State, IReadOnlyList 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/JoinTokenService.cs b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs index 658e13d..813bca0 100644 --- a/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs @@ -4,7 +4,10 @@ namespace Monitor.Backend.Application; public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); -public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand); +/// 各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。 +public sealed record InstallCommands(string Linux, string Macos, string Windows); + +public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands); /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService @@ -55,11 +58,16 @@ public sealed class JoinTokenService request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); - string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; - return Result.Ok(new CreatedJoinToken(info, plaintext, command)); + string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; + // PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴 + string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name \""; + return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell))); } public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + + /// 彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。 + public ValueTask DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct); } 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 ded13ac..f38727d 100644 --- a/backend/src/Monitor.Backend.Application/BackendOptions.cs +++ b/backend/src/Monitor.Backend.Application/BackendOptions.cs @@ -55,6 +55,18 @@ public sealed class BackendOptions public int MaxCompareAgents { get; set; } = 20; + /// + /// 测试方案里「固定时长」阶段(没有 duration_s 参数的,例如 connect、hold)的缩放比例,缺省 1。 + /// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。 + /// + 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 2b81e70..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); @@ -40,6 +56,8 @@ public interface IJoinTokenStore ValueTask> ListAsync(CancellationToken ct); ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); + + ValueTask DeleteAsync(string id, CancellationToken ct); } public interface IProbeStore diff --git a/backend/src/Monitor.Backend.Application/Tests/TestService.cs b/backend/src/Monitor.Backend.Application/Tests/TestService.cs index e489309..d0dfaad 100644 --- a/backend/src/Monitor.Backend.Application/Tests/TestService.cs +++ b/backend/src/Monitor.Backend.Application/Tests/TestService.cs @@ -9,6 +9,7 @@ namespace Monitor.Backend.Application; /// 按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。 public sealed class TestService { + private readonly double _phaseScale; private readonly ITestStore _tests; private readonly IAgentStore _agents; private readonly AgentRuntime _runtime; @@ -16,8 +17,9 @@ public sealed class TestService private readonly LiveHub _live; private readonly TimeProvider _time; - public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time) + public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time, BackendOptions options) { + _phaseScale = options.PhaseDurationScale; _tests = tests; _agents = agents; _runtime = runtime; @@ -52,7 +54,7 @@ public sealed class TestService return Result.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs"); } - Result> phases = BuildPhases(plan, request.Params, agent); + Result> phases = BuildPhases(plan, request.Params, agent, _phaseScale); if (!phases.TryGet(out List? phaseList, out FleetError? error)) { return Result.Fail(error); @@ -173,7 +175,7 @@ public sealed class TestService public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted; - private static Result> BuildPhases(PlanSpec plan, IReadOnlyDictionary? overrides, AgentRecord agent) + private static Result> BuildPhases(PlanSpec plan, IReadOnlyDictionary? overrides, AgentRecord agent, double phaseScale) { if (overrides is not null) { @@ -245,7 +247,7 @@ public sealed class TestService return Result>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s"); } - int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds; + int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale)); phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary(resolved.Values.Values), DurationSeconds = duration }); } 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/SqliteConfigStores.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs index 83656f0..96e0f55 100644 --- a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => _db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); + + public ValueTask DeleteAsync(string id, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct); } public sealed class SqliteProbeStore : IProbeStore 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/AgentEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs index cc4ae75..423762d 100644 --- a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs @@ -256,7 +256,7 @@ public sealed class AgentEndToEndTests string? startFailure = null; for (int attempt = 0; attempt < 5; attempt++) { - accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted) { break; @@ -291,7 +291,7 @@ public sealed class AgentEndToEndTests Assert.Equal(4, probe.Phases.Count); Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind)); - // 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块 + // 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 Probe 切成两个列块 Assert.True(agent.App.Counters.Fragments > 4); await BackendFixture.EventuallyAsync( async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == "udp_train") == agent.App.Counters.Fragments, diff --git a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs index bf95a14..06dbe59 100644 --- a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs @@ -58,6 +58,39 @@ public sealed class ApiBasicsTests Assert.Equal("no-store", ok.Headers.CacheControl!.ToString()); } + [Fact] + public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded() + { + using HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true })); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + Assert.False(login.Headers.Contains("Set-Cookie")); + string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue(); + Assert.False(string.IsNullOrEmpty(session)); + + // 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作 + using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me"); + me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage meResponse = await client.SendAsync(me); + Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); + Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue()); + + using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage created = await client.SendAsync(create); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + // 退出之后这个会话令牌就失效 + using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout"); + logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage loggedOut = await client.SendAsync(logout); + Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode); + using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me"); + after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); + using HttpResponseMessage afterResponse = await client.SendAsync(after); + Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode); + } + [Fact] public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods() { diff --git a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs index ea0d71e..86c2970 100644 --- a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs +++ b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs @@ -192,6 +192,8 @@ public class BackendFixture : IAsyncLifetime options.InitialAdminUser = AdminUser; options.InitialAdminPassword = AdminPassword; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; + options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务 + options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }); await App.StartAsync(); string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); diff --git a/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs index 446346d..e383a97 100644 --- a/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs +++ b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs @@ -83,6 +83,14 @@ public sealed class EnrollmentAndTaskSetTests using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id); Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked"))); + + // 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除 + tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); + Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); + using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true"); + Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode); + tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); + Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); } [Fact] 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/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs index d5f1b85..2bc74b1 100644 --- a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs +++ b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs @@ -64,7 +64,7 @@ public sealed class ProbeEndToEndTests await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); @@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests TimeSpan.FromSeconds(90)); Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); - await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments"); + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments"); Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); } diff --git a/docs/api.md b/docs/api.md index 9215bb4..1e847ce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,15 @@ `host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。 +### 2.3.2 节点的展示信息 + +`GET /api/public/nodes`:看得见的 Agent 和没被吊销的 Probe 的展示信息,`{id, kind, os, os_name, arch, country, isp_key, isp_name}`——系统(`linux` / `windows` / `macos`)、「Debian 12」这样的系统名、架构(`x64` / `arm64`…)、国家代码(`CN`)、运营商类别(`mobile` / `telecom` / `unicom` / `broadnet` / `other`)和名字。**没有 IP 地址、城市、ASN、经纬度**,有测试保证:公开接口的整个响应里不出现 IP 形状的字符串,也没有 `ip`、`city`、`asn` 这些字段。管理员在设置里打开 `site.show_build` 之后,每项还有 `version` 和 `tag`(节点自己上报的程序版本和 tag);关着时这两个字段是 `null`。 + +`GET /api/public/about`:`{show_build, backend}`。`show_build` 关着时 `backend` 是 `null`;打开后 `backend` 是 `{version, tag, built_at}`(后端自己的版本、tag、构建时间,不含提交号)。 + +- 系统和架构是节点自报的:Agent 在 `fleet.hello` 里,Probe 随列表同步(`monitor.probe.agents` 的 `platform`)告诉 Agent,Agent 再放进心跳的 `probes[]`。只有粗粒度的名字,没有主机名、内核版本、CPU 型号。 +- 国家和运营商由 **Backend** 用节点的来源地址向 IP 归属服务查一次(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`,留空就不查):Probe 用 Agent 看到的来源 IP,Agent 用它的公开地址。**Probe 和 Agent 不访问任何外部服务。** 只查公网地址(回环、内网、CGNAT、链路本地一律不发出去);同一个地址一周内只查一次,失败 10 分钟后再试;查到的只留国家代码和运营商,地址只存在 Backend 内部的 `node_meta.geo_host`,不对外。 + ### 2.4 测试结果与目录 - `GET /api/public/tests/{test_id}`:包括计划、参数、状态、阶段,`metrics` 与 `scores`,`series`(例如码率–丢包曲线),以及置信说明 `caveats`。 @@ -125,7 +134,8 @@ | GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) | | POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 | | POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` | -| GET/PATCH | `/api/admin/settings` | 站点公开或私有、保留期等 | +| GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期等 | +| GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统(管理端「系统 → 版本」) | | GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | 任务的请求体示例: diff --git a/docs/deployment.md b/docs/deployment.md index 3c4c09e..0a621fd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,6 +9,8 @@ scripts/publish.sh → backend/art scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) ``` +构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MONITOR_TAG`、`MONITOR_COMMIT`、`MONITOR_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器的 Fastlane 构建用同名的 `--build-arg`);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。 + | rid | 目标 | 构建方式 | | --- | --- | --- | | `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 | @@ -174,6 +176,8 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 - VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。 - 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。 +**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。 + **从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。 实测(Probe 在广东移动家宽,Agent 在日本 IIJ): diff --git a/packaging/container/Agent.NativeBuild.Containerfile b/packaging/container/Agent.NativeBuild.Containerfile index dd628d1..2bcbc6d 100644 --- a/packaging/container/Agent.NativeBuild.Containerfile +++ b/packaging/container/Agent.NativeBuild.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.NativeBuild.Containerfile b/packaging/container/Probe.NativeBuild.Containerfile index fccb043..a87fbcf 100644 --- a/packaging/container/Probe.NativeBuild.Containerfile +++ b/packaging/container/Probe.NativeBuild.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 diff --git a/web/README.md b/web/README.md index 20b9fe0..298fcfa 100644 --- a/web/README.md +++ b/web/README.md @@ -10,18 +10,21 @@ ```sh npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces) -npm run dev # 两个一起启动 +npm run dev # 两个一起启动:展示端 http://localhost:5173/ ,管理端 http://localhost:5173/admin/(也可以直接开 5175) BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/ -BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/ +BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5175/admin/ # 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display npm run build # 各自输出到 display/dist 和 admin/dist npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌 ``` -连别的后端(本机跑前端、连线上的):点页面右上角(管理端登录页右下角)的插头图标,填后端地址和 API 令牌(展示端:站点私有时需要只读令牌;管理端:admin 范围的令牌),保存后刷新。设置存在浏览器里;跨域时不带 Cookie,所以后端要设 `Monitor__CorsOrigins=<这个前端的来源>`(如 `http://localhost:5173`)。也可以用构建时的 `VITE_API_BASE` 给默认地址。 +连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。 约定: - 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 -- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 +- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 +- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。 +- 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。 +- 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 - 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。 diff --git a/web/admin/index.html b/web/admin/index.html index bad5a5c..20cdccb 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -3,7 +3,7 @@ - 线路质量 · 管理 + MyMonitor · 管理
diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index e241a18..c930c93 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -1,7 +1,7 @@ -import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react' +import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' import { useState, type FormEvent } from 'react' import { NavLink, Navigate, Route, Routes } from 'react-router-dom' -import { isRemote } from '@shared/api/source' +import { activateSource, getBase, listSources } from '@shared/api/source' import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' import { useAuth } from './auth' import { AgentDetail } from './pages/AgentDetail' @@ -11,22 +11,34 @@ import { System } from './pages/System' import { Tasks } from './pages/Tasks' import { Tests } from './pages/Tests' +/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */ function Login() { - const { login, loginWithToken } = useAuth() - const remote = isRemote() - const [token, setTokenText] = useState('') + const { login } = useAuth() + const active = getBase() + const saved = listSources() + const [adding, setAdding] = useState(false) + const [url, setUrl] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [busy, setBusy] = useState(false) + const choose = (base: string) => { + setAdding(false) + setError('') + setPassword('') + if (base !== active) { + activateSource(base) + location.reload() + } + } + const submit = async (e: FormEvent) => { e.preventDefault() setBusy(true) setError('') try { - if (remote) await loginWithToken(token) - else await login(username, password) + await login(username, password, adding ? url : undefined) } catch (err) { setError(describeError(err)) } finally { @@ -34,35 +46,67 @@ function Login() { } } + const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))] + const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin + return (
-
-
-

线路质量

-

管理端

-
- {remote ? ( - - setTokenText(e.target.value)} autoComplete="off" autoFocus required /> + + +
+

{title}

+ {adding ? ( + + setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> + + ) : null} + + setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required /> - ) : ( - <> - - setUsername(e.target.value)} autoComplete="username" autoFocus required /> - - - setPassword(e.target.value)} autoComplete="current-password" required /> - - - )} - {error ?
{error}
: null} - + + setPassword(e.target.value)} autoComplete="current-password" required /> + + {error ?
{error}
: null} + +
-
- -
) } @@ -88,7 +132,7 @@ export function App() {