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(); }