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

@@ -43,7 +43,7 @@ public static class AgentEndpoints
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(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<object>(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<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct)
private static async Task<IResult> 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();
}
@@ -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<IResult> 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);
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Monitor.Backend.Application;
namespace Monitor.Backend.Api;
/// <summary>管理端的构建信息:后端自己和每个 Agent、Probe 的版本、tag、提交、构建时间(节点上报的,不含任何地址)。</summary>
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<BuildInfoDto>().ProducesProblems(401, 403);
}
private static async Task<IResult> 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);
}
}
@@ -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");
@@ -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<Task> next)
{
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
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))
{
@@ -2,9 +2,10 @@ using System.Text.Json;
namespace Monitor.Backend.Api;
public sealed record LoginRequest(string Username, string Password);
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
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<string>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
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);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
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<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
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<NodeBuildDto> Nodes);
@@ -37,6 +37,12 @@ namespace Monitor.Backend.Api;
[JsonSerializable(typeof(AgentDto))]
[JsonSerializable(typeof(PageDto<AgentDto>))]
[JsonSerializable(typeof(AgentPatchRequest))]
[JsonSerializable(typeof(BuildDto))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(NodeInfoDto))]
[JsonSerializable(typeof(PageDto<NodeInfoDto>))]
[JsonSerializable(typeof(JoinTokenDto))]
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
[JsonSerializable(typeof(CreateJoinTokenRequest))]
@@ -167,6 +167,15 @@ public sealed record HostDto(
DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
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);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
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);
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Monitor.Backend.Application;
namespace Monitor.Backend.Api;
/// <summary>
/// 节点的展示信息:系统、架构、国家、运营商。<b>只有这几项,没有任何 IP 地址、城市、ASN、经纬度</b>——
/// 这些在 Backend 里只在查询的那一刻用到,存起来的记录里也没有,所以访客(和任何别的接口)都拿不到。
/// </summary>
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<PageDto<NodeInfoDto>>().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<AboutDto>().ProducesProblems(403);
}
/// <summary>这个后端自己的构建信息;没有(本地构建)的字段是 <c>null</c>。</summary>
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<IResult> 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<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
{
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
var visible = new HashSet<string>(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<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
}
}
@@ -4,7 +4,10 @@ namespace Monitor.Backend.Application;
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand);
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
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 <node-name>";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, command));
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
}
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
/// <summary>彻底删除这条令牌记录(任何状态都可以);还没用完的令牌删除后立即不能再用。</summary>
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) => _store.DeleteAsync(id, ct);
}
@@ -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<SettingSpec> 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;
}
/// <summary>访客能不能看到版本、tag 和构建时间(站点公开之后的另一道开关,默认关)。</summary>
public async ValueTask<bool> IsShowBuildAsync(CancellationToken ct) => await _store.GetAsync(SiteShowBuild, ct) == "true";
public async ValueTask<int> GetIntAsync(string key, CancellationToken ct)
{
SettingSpec spec = Specs.First(s => s.Key == key);
@@ -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);
@@ -5,7 +5,7 @@ using Monitor.Protocol;
namespace Monitor.Backend.Application;
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
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);
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
public sealed class AgentRuntimeState
@@ -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);
}
@@ -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)
@@ -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();
}
@@ -27,6 +27,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AgentService>();
services.AddSingleton<AgentEventService>();
services.AddSingleton<HostStatusService>();
services.AddSingleton<NodeInfoService>();
services.AddSingleton<ProbeService>();
services.AddSingleton<LineService>();
services.AddSingleton<TestService>();
@@ -55,6 +55,18 @@ public sealed class BackendOptions
public int MaxCompareAgents { get; set; } = 20;
/// <summary>
/// 测试方案里「固定时长」阶段(没有 <c>duration_s</c> 参数的,例如 connect、hold)的缩放比例,缺省 1。
/// 只给自动化测试用:端到端测试里把 30 秒的阶段缩短,不用真等。
/// </summary>
public double PhaseDurationScale { get; set; } = 1.0;
/// <summary>
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
/// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
/// </summary>
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
public string? InitialAdminUser { get; set; }
@@ -0,0 +1,86 @@
namespace Monitor.Backend.Application;
/// <summary>
/// 把「ASN 和组织名」归成几个常见的国内运营商:mobile / telecom / unicom / broadnet;别的是 other(显示组织名)。
/// 只用来挑展示用的图标和中文名。
/// </summary>
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<int> s_mobile = [9808, 24400, 24445, 56040, 56041, 56042, 56044, 56046, 56047, 56048, 132525, 138407];
private static readonly HashSet<int> s_telecom = [4134, 4809, 4811, 4812, 4813, 9394, 17638, 23724, 38283, 58466, 58519, 136188, 137693];
private static readonly HashSet<int> s_unicom = [4808, 4837, 9929, 10099, 17621, 17622, 17623, 17816, 136958, 138421];
private static readonly HashSet<int> 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);
/// <summary>组织名常常很长(「Hangzhou Alibaba Advertising Co.,Ltd.」):去掉公司后缀,最多留 28 个字符。</summary>
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;
}
}
@@ -0,0 +1,34 @@
namespace Monitor.Backend.Application;
/// <summary>
/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
/// <b>不含任何 IP 地址、城市、ASN 或经纬度</b>——这些只在查询的那一刻用到,不留下,也不对外。
/// </summary>
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);
/// <summary>
/// 存起来的完整记录(只给 Backend 内部用):多了一个「查询用的主机」,用来判断地址变了没有、要不要重新查。
/// 这个字段不会出现在任何对外的接口里。
/// </summary>
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";
}
/// <summary>IP 归属查询的结果(只取需要的几项)。</summary>
public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
@@ -0,0 +1,220 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Monitor.Protocol;
namespace Monitor.Backend.Application;
/// <summary>
/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
/// <list type="bullet">
/// <item>Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
/// <item>只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。</item>
/// <item>查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。</item>
/// </list>
/// </summary>
public sealed class NodeInfoService
{
/// <summary>同一个地址已经查过的结果保留多久,过期后在节点下次出现时重新查(运营商偶尔会变)。</summary>
private static readonly TimeSpan s_geoTtl = TimeSpan.FromDays(7);
/// <summary>查询失败之后多久再试。</summary>
private static readonly TimeSpan s_retryAfter = TimeSpan.FromMinutes(10);
private readonly INodeMetaStore _store;
private readonly IGeoLookup _geo;
private readonly TimeProvider _time;
private readonly ConcurrentDictionary<string, NodeMeta> _cache = new();
private readonly ConcurrentDictionary<string, byte> _inflight = new();
private Task? _load;
public NodeInfoService(INodeMetaStore store, IGeoLookup geo, TimeProvider time)
{
_store = store;
_geo = geo;
_time = time;
}
/// <summary>所有节点的展示信息(Agent 和 Probe)。</summary>
public async ValueTask<IReadOnlyList<NodeInfo>> ListAsync(CancellationToken ct)
{
await EnsureLoadedAsync(ct);
return [.. _cache.Values.Select(m => m.Info)];
}
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
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);
}
/// <summary>
/// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
/// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
/// </summary>
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 _);
}
}
/// <summary>Probe 连上来或心跳报告它还连着:记下它的系统信息,并在后台查它的国家和运营商。出错只吞掉,不影响调用方。</summary>
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)
{
// 展示信息拿不到也没关系,下次再试
}
});
/// <summary>Agent 打招呼(hello):记下它的系统信息,并用它的公开地址在后台查国家和运营商。</summary>
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);
}
/// <summary>公网地址才值得查:回环、内网、CGNAT、链路本地、未指定的地址一律不发出去。域名放行(由查询服务自己解析)。</summary>
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);
}
/// <summary>第一次用到时从库里读一次(只读一次,之后都在内存里)。</summary>
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);
/// <summary>节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。</summary>
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;
}
}
@@ -33,6 +33,22 @@ public interface IAgentStore
ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct);
}
/// <summary>节点的展示信息(系统、架构、国家、运营商)。</summary>
public interface INodeMetaStore
{
ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct);
ValueTask SaveAsync(NodeMeta meta, CancellationToken ct);
ValueTask DeleteAsync(string nodeId, CancellationToken ct);
}
/// <summary>查一个 IP(或域名)的归属:国家、ASN、组织。实现负责超时和失败(失败返回 null,不抛)。</summary>
public interface IGeoLookup
{
ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct);
}
public interface IJoinTokenStore
{
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
@@ -40,6 +56,8 @@ public interface IJoinTokenStore
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
}
public interface IProbeStore
@@ -9,6 +9,7 @@ namespace Monitor.Backend.Application;
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
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<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
}
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent);
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent, _phaseScale);
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
{
return Result<TestRecord>.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<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent)
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent, double phaseScale)
{
if (overrides is not null)
{
@@ -245,7 +247,7 @@ public sealed class TestService
return Result<List<TestPhase>>.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<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
}
@@ -5,7 +5,7 @@ namespace Monitor.Backend.Storage.Sqlite;
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
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();
}
}
/// <summary>
@@ -34,6 +34,9 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
public ValueTask<bool> 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<bool> 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
@@ -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<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
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));
}
}
@@ -20,6 +20,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<ITestStore, SqliteTestStore>();
services.AddSingleton<IEventStore, SqliteEventStore>();
services.AddSingleton<IHostStore, SqliteHostStore>();
services.AddSingleton<INodeMetaStore, SqliteNodeMetaStore>();
services.AddSingleton<IAuthStore, SqliteAuthStore>();
services.AddSingleton<IRawStore, SqliteRawStore>();
services.AddSingleton<IMetricStore, SqliteMetricStore>();
@@ -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<IGeoLookup, NoGeoLookup>();
}
else
{
services.AddSingleton<IGeoLookup>(new HttpGeoLookup(options.GeoLookupUrl));
}
services.AddMonitorApplication(options);
services.AddMonitorGateway();
services.AddMonitorApi();
@@ -0,0 +1,73 @@
using System.Text.Json;
using Monitor.Backend.Application;
namespace Monitor.Backend;
/// <summary>
/// 用 IP 归属查询服务(<c>GET {地址}/lookup?ip=…</c>)查国家和运营商。
/// 失败(超时、网络不通、服务返回错误)一律返回 null,不往外抛:展示信息没有也不影响别的功能。
/// </summary>
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<GeoInfo?> 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;
}
}
/// <summary>只取国家代码、ASN、组织名;其余字段(城市、经纬度、网段……)一概不读。</summary>
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();
}
/// <summary>不查(<c>Monitor__GeoLookupUrl</c> 留空)。</summary>
public sealed class NoGeoLookup : IGeoLookup
{
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct) => ValueTask.FromResult<GeoInfo?>(null);
}