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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user