feat: node info, build info and acrylic web theme

Squash of recent dev work: return_session login, backend switcher, line
detail timeline, overview strips, node platform/country/ISP info with flags
and icons, build info (version, tag, time) behind site.show_build, and the
acrylic display theme.
This commit is contained in:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

@@ -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<string>() == "udp_train") == agent.App.Counters.Fragments,
@@ -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<string>();
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<string>());
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()
{
@@ -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<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
@@ -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<InvalidOperationException>(() => 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<string>() == 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<string>() == id);
}
[Fact]
@@ -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<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>
[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<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();
}
@@ -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<bool>());
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<string>();
@@ -78,7 +78,7 @@ public sealed class ProbeEndToEndTests
TimeSpan.FromSeconds(90));
Assert.True(test["status"]!.GetValue<string>() == "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);
}