311 lines
14 KiB
C#
311 lines
14 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using MoShiro.Backend.Application;
|
|
using MoShiro.Protocol;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
public sealed partial class NodeInfoTests
|
|
{
|
|
[Theory]
|
|
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
|
|
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
|
|
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
|
|
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
|
|
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
|
|
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
|
|
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
|
|
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
|
|
{
|
|
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
|
|
}
|
|
|
|
[Fact]
|
|
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
|
|
{
|
|
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
|
|
Assert.Equal("other", key);
|
|
Assert.Equal("Hangzhou Alibaba Advertising", name);
|
|
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
|
|
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("8.8.8.8", true)]
|
|
[InlineData("223.5.5.5", true)]
|
|
[InlineData("2001:4860:4860::8888", true)]
|
|
[InlineData("example.com", true)]
|
|
[InlineData("127.0.0.1", false)]
|
|
[InlineData("10.1.2.3", false)]
|
|
[InlineData("172.16.0.1", false)]
|
|
[InlineData("172.32.0.1", true)]
|
|
[InlineData("192.168.1.10", false)]
|
|
[InlineData("169.254.1.1", false)]
|
|
[InlineData("100.64.0.1", false)]
|
|
[InlineData("0.0.0.0", false)]
|
|
[InlineData("::1", false)]
|
|
[InlineData("fe80::1", false)]
|
|
[InlineData("fd00::1", false)]
|
|
[InlineData("::ffff:192.168.1.1", false)]
|
|
[InlineData("localhost", false)]
|
|
[InlineData("printer.local", false)]
|
|
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
|
|
{
|
|
Assert.Equal(expected, NodeInfoService.IsPublic(host));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
|
|
{
|
|
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
|
|
var store = new MemoryStore();
|
|
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
|
var service = new NodeInfoService(store, geo, time);
|
|
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
|
Assert.Empty(geo.Calls);
|
|
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
|
Assert.Single(geo.Calls);
|
|
|
|
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
|
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
|
|
|
|
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
|
|
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
|
|
|
// 地址变了才重新查;过了一周也重新查
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
|
Assert.Equal(2, geo.Calls.Count);
|
|
time.Advance(TimeSpan.FromDays(8));
|
|
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
|
Assert.Equal(3, geo.Calls.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
|
|
{
|
|
var geo = new FakeGeo(null);
|
|
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
|
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
|
|
|
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
|
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
|
Assert.Single(geo.Calls);
|
|
time.Advance(TimeSpan.FromMinutes(11));
|
|
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
|
Assert.Equal(2, geo.Calls.Count);
|
|
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Service_PlatformIsSavedAndSurvivesARestart()
|
|
{
|
|
var store = new MemoryStore();
|
|
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
|
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
|
|
|
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
|
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
|
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
|
|
}
|
|
|
|
[Fact]
|
|
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
|
|
{
|
|
using JsonDocument doc = JsonDocument.Parse("""
|
|
{"ip":"223.5.5.5","found":true,"is_private":false,
|
|
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
|
|
"city":{"names":{"zh-CN":"杭州市"}},
|
|
"location":{"latitude":30.25,"longitude":120.21},
|
|
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
|
|
""");
|
|
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
|
|
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
|
|
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
|
|
}
|
|
|
|
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
|
|
private static partial Regex Ipv4();
|
|
|
|
private sealed class FakeGeo : IGeoLookup
|
|
{
|
|
private readonly GeoInfo? _result;
|
|
|
|
public FakeGeo(GeoInfo? result)
|
|
{
|
|
_result = result;
|
|
}
|
|
|
|
public List<string> Calls { get; } = [];
|
|
|
|
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
|
{
|
|
Calls.Add(host);
|
|
return ValueTask.FromResult(_result);
|
|
}
|
|
}
|
|
|
|
private sealed class MemoryStore : INodeMetaStore
|
|
{
|
|
private readonly Dictionary<string, NodeMeta> _items = [];
|
|
|
|
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
|
|
|
|
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
|
{
|
|
_items[meta.Info.NodeId] = meta;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
|
|
{
|
|
_items.Remove(nodeId);
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
|
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
|
/// </summary>
|
|
[Trait("Category", "Slow")]
|
|
public sealed partial class NodePrivacyTests : IClassFixture<BackendFixture>
|
|
{
|
|
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
|
|
|
|
private readonly BackendFixture _backend;
|
|
|
|
public NodePrivacyTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
|
await agent.OverrideLineTasksAsync();
|
|
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
|
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
|
|
|
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
|
JsonNode? agentNode = null;
|
|
JsonNode? probeNode = null;
|
|
await BackendFixture.EventuallyAsync(
|
|
async () =>
|
|
{
|
|
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
|
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
|
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
|
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
|
},
|
|
TimeSpan.FromSeconds(40),
|
|
"the agent (hello) and the probe (agent list sync) both report their platform");
|
|
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
|
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
|
|
|
string[] paths =
|
|
[
|
|
"/api/public/nodes",
|
|
"/api/public/hosts",
|
|
"/api/public/overview?range=1h&metrics=rtt.p50",
|
|
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
|
"/api/public/catalog/plans",
|
|
];
|
|
foreach (string path in paths)
|
|
{
|
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
|
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
|
|
string body = await response.Content.ReadAsStringAsync();
|
|
Assert.DoesNotMatch(Ipv4(), body);
|
|
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
|
|
foreach (string key in KeysOf(JsonNode.Parse(body)))
|
|
{
|
|
Assert.DoesNotContain(key, s_forbiddenKeys);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
|
|
|
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
|
|
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
|
Assert.False(about["show_build"]!.GetValue<bool>());
|
|
Assert.Null(about["backend"]);
|
|
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
|
Assert.All(hidden["items"]!.AsArray(), n =>
|
|
{
|
|
Assert.Null(n!["version"]);
|
|
Assert.Null(n["tag"]);
|
|
});
|
|
|
|
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
|
|
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
|
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
|
|
await BackendFixture.EventuallyAsync(
|
|
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
|
|
TimeSpan.FromSeconds(30),
|
|
"the agent reports its version in hello");
|
|
|
|
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
|
|
on.EnsureSuccessStatusCode();
|
|
try
|
|
{
|
|
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
|
Assert.True(shown["show_build"]!.GetValue<bool>());
|
|
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
|
|
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
|
|
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
|
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
|
|
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
|
|
}
|
|
finally
|
|
{
|
|
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
|
|
off.EnsureSuccessStatusCode();
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> KeysOf(JsonNode? node)
|
|
{
|
|
switch (node)
|
|
{
|
|
case JsonObject obj:
|
|
foreach ((string key, JsonNode? value) in obj)
|
|
{
|
|
yield return key;
|
|
foreach (string inner in KeysOf(value))
|
|
{
|
|
yield return inner;
|
|
}
|
|
}
|
|
|
|
break;
|
|
case JsonArray array:
|
|
foreach (JsonNode? item in array)
|
|
{
|
|
foreach (string inner in KeysOf(item))
|
|
{
|
|
yield return inner;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
|
private static partial Regex Ipv4();
|
|
}
|