feat(backend): report node platform, geo and build info
Agents and probes report OS, arch and build (version, tag, commit, time). The backend looks up country and ISP of public addresses only, stores them in node_meta (schema v5) and exposes display info via /api/public/nodes, /api/public/about and /api/admin/build-info behind site.show_build.
This commit is contained in:
1 parent
2e470607fa
commit
1e5c660d05
38 files changed
+1532
-15
No files matched your search
@@ -12,7 +12,7 @@ namespace Monitor.Agent;
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
@@ -244,6 +244,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Platform = Monitor.Measurement.PlatformDetector.Detect(),
|
||||
TlsSpki = _identity.File.TlsSpki,
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
|
||||
@@ -64,6 +64,9 @@ public sealed class ProbeSession : DataSession
|
||||
|
||||
public long ConnectedAtUtcMs { get; init; }
|
||||
|
||||
/// <summary>Probe 自报的系统和架构(随列表同步一起来,只用于展示);还没报过就是 null。</summary>
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
|
||||
@@ -220,7 +220,7 @@ public sealed class PeerHub
|
||||
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, Platform = p.Platform };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
@@ -306,7 +306,15 @@ public sealed class PeerHub
|
||||
MonitorMethods.ProbeAgents,
|
||||
MonitorJsonContext.Default.ProbeAgentsParams,
|
||||
MonitorJsonContext.Default.ProbeAgentsResult,
|
||||
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
|
||||
(context, request, ct) =>
|
||||
{
|
||||
if (request.Platform is { } platform)
|
||||
{
|
||||
context.Session.Platform = platform;
|
||||
}
|
||||
|
||||
return uplink.RequestProbeAgentsAsync(request, ct);
|
||||
})
|
||||
.On(
|
||||
MonitorMethods.Clock,
|
||||
MonitorJsonContext.Default.ClockParams,
|
||||
|
||||
@@ -862,6 +862,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/nodes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)",
|
||||
"operationId": "nodes_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfNodeInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/about": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public-nodes"
|
||||
],
|
||||
"summary": "The backend's version, tag and build time, if the site shows build info",
|
||||
"operationId": "about_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AboutDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/catalog/kinds": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3629,6 +3691,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/build-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Version, tag, commit and build time of the backend and every agent and probe",
|
||||
"operationId": "build_info_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BuildInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/healthz": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3753,6 +3856,28 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AboutDto": {
|
||||
"required": [
|
||||
"show_build",
|
||||
"backend"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_build": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"backend": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -4039,6 +4164,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildDto": {
|
||||
"required": [
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BuildInfoDto": {
|
||||
"required": [
|
||||
"backend",
|
||||
"nodes"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backend": {
|
||||
"$ref": "#/components/schemas/BuildDto"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeBuildDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChangePasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
@@ -5332,6 +5507,139 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBuildDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"version",
|
||||
"tag",
|
||||
"commit",
|
||||
"built_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"built_at": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeInfoDto": {
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"os",
|
||||
"os_name",
|
||||
"arch",
|
||||
"country",
|
||||
"isp_key",
|
||||
"isp_name"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"os_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"country": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_key": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isp_name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"OverviewDto": {
|
||||
"required": [
|
||||
"range",
|
||||
@@ -5604,6 +5912,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfNodeInfoDto": {
|
||||
"required": [
|
||||
"items",
|
||||
"next_cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeInfoDto"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfPlanDto": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -6814,6 +7143,9 @@
|
||||
{
|
||||
"name": "public"
|
||||
},
|
||||
{
|
||||
"name": "public-nodes"
|
||||
},
|
||||
{
|
||||
"name": "catalog"
|
||||
},
|
||||
@@ -6847,6 +7179,9 @@
|
||||
{
|
||||
"name": "admin-users"
|
||||
},
|
||||
{
|
||||
"name": "admin-build"
|
||||
},
|
||||
{
|
||||
"name": "ops"
|
||||
},
|
||||
|
||||
@@ -163,10 +163,13 @@ public sealed class AgentConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>本机的系统和架构:随列表同步一起告诉 Agent,只用于在面板上展示。</summary>
|
||||
private static readonly PlatformInfo s_platform = PlatformDetector.Detect();
|
||||
|
||||
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version, Platform = s_platform }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
|
||||
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
|
||||
{
|
||||
_env.Directory.Apply(agents);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Monitor.Probe;
|
||||
/// </summary>
|
||||
public sealed class ProbeApp : IDisposable
|
||||
{
|
||||
public const string Version = "0.1.1";
|
||||
public const string Version = Monitor.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 读出本机的系统名和架构,只用于在面板上展示。故意只取粗粒度的信息:
|
||||
/// 「Debian 12」「Windows 11」「x64」,不读主机名、内核版本、CPU 型号。
|
||||
/// </summary>
|
||||
public static class PlatformDetector
|
||||
{
|
||||
public static PlatformInfo Detect()
|
||||
{
|
||||
PlatformInfo info = Detect(File.Exists("/etc/os-release") ? File.ReadAllLines("/etc/os-release") : []);
|
||||
info.Version = Empty(BuildInfo.Version);
|
||||
info.Tag = Empty(BuildInfo.Tag);
|
||||
info.Commit = Empty(BuildInfo.Commit);
|
||||
info.BuiltAt = Empty(BuildInfo.BuiltAt);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static string? Empty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
/// <summary>`osRelease` 是 /etc/os-release 的各行(Linux 之外不用)。拆出来是为了好测。</summary>
|
||||
internal static PlatformInfo Detect(IEnumerable<string> osRelease)
|
||||
{
|
||||
string arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.Arm => "arm",
|
||||
Architecture.X86 => "x86",
|
||||
var other => other.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows 11 的版本号还是 10.0,靠内部版本号(22000 起)区分
|
||||
string name = Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10";
|
||||
return new PlatformInfo { Os = "windows", Name = name, Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new PlatformInfo { Os = "macos", Name = "macOS", Arch = arch };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new PlatformInfo { Os = "linux", Name = LinuxName(osRelease), Arch = arch };
|
||||
}
|
||||
|
||||
return new PlatformInfo { Os = "other", Name = null, Arch = arch };
|
||||
}
|
||||
|
||||
/// <summary>从 os-release 里取发行版名字和主版本:「Debian GNU/Linux 12 (bookworm)」→「Debian 12」,「OpenWrt 24.10.0」→「OpenWrt 24.10」。</summary>
|
||||
internal static string LinuxName(IEnumerable<string> osRelease)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string line in osRelease)
|
||||
{
|
||||
int eq = line.IndexOf('=', StringComparison.Ordinal);
|
||||
if (eq > 0)
|
||||
{
|
||||
values[line[..eq].Trim()] = line[(eq + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
|
||||
string name = values.GetValueOrDefault("NAME") ?? values.GetValueOrDefault("ID") ?? "Linux";
|
||||
name = name.Replace(" GNU/Linux", string.Empty, StringComparison.Ordinal).Trim();
|
||||
string? version = values.GetValueOrDefault("VERSION_ID");
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
// 只留 主.次:Alpine 的 3.22.1、OpenWrt 的 24.10.0 都截成两段;Debian 的 12 本来就一段
|
||||
string[] parts = version.Split('.');
|
||||
return $"{name} {string.Join('.', parts.Take(2))}";
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,30 @@
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
</ItemGroup>
|
||||
<!--
|
||||
构建信息:版本来自 $(Version),tag、提交、构建时间来自环境变量 MONITOR_TAG / MONITOR_COMMIT / MONITOR_BUILT_AT
|
||||
(scripts/publish.sh 和 CI 会设置;本地开发不设置就是空,这样本地构建是可重复的、不会每次都重新编译)。
|
||||
生成一个带常量的源文件,NativeAOT 下不用反射。
|
||||
-->
|
||||
<Target Name="WriteBuildInfo" BeforeTargets="CoreCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace Monitor.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MONITOR_TAG)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Commit = "$(MONITOR_COMMIT)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuiltAt = "$(MONITOR_BUILT_AT)"%3B" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoFile)" />
|
||||
<FileWrites Include="$(BuildInfoFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -152,6 +152,10 @@ public sealed class ProbeAgentsParams
|
||||
/// <summary>Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。</summary>
|
||||
[JsonPropertyName("have_version")]
|
||||
public long HaveVersion { get; set; }
|
||||
|
||||
/// <summary>Probe 的系统和架构(只用于展示;Agent 记在会话上,随连接状态报给 Backend)。</summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProbeAgentsResult
|
||||
@@ -194,9 +198,41 @@ public sealed class LocalLimits
|
||||
public int MaxConcurrentTests { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点的系统和架构(粗粒度,只用于展示):<c>os</c> 是 linux / windows / macos / other,<c>name</c> 是「Debian 12」「Windows 11」这样的名字,
|
||||
/// <c>arch</c> 是 x64 / arm64 / arm / x86。<b>没有主机名、内核版本、具体型号</b>。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
|
||||
/// </summary>
|
||||
public sealed class PlatformInfo
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string Os { get; set; } = "other";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("arch")]
|
||||
public string? Arch { get; set; }
|
||||
|
||||
/// <summary>程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601):<see cref="BuildInfo"/> 构建时写进去的,没设置就是空。</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[JsonPropertyName("commit")]
|
||||
public string? Commit { get; set; }
|
||||
|
||||
[JsonPropertyName("built_at")]
|
||||
public string? BuiltAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string? TlsSpki { get; set; }
|
||||
|
||||
@@ -266,6 +302,9 @@ public sealed class ConnectedProbe
|
||||
|
||||
[JsonPropertyName("since_utc_ms")]
|
||||
public long SinceUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
@@ -434,6 +473,7 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(ProbeAgentsParams))]
|
||||
[JsonSerializable(typeof(ProbeAgentsResult))]
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Monitor.Measurement.Tests;
|
||||
|
||||
public sealed class PlatformDetectorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"|NAME=\"Debian GNU/Linux\"|VERSION_ID=\"12\"|ID=debian", "Debian 12")]
|
||||
[InlineData("NAME=\"OpenWrt\"|VERSION_ID=\"24.10.0\"|ID=openwrt", "OpenWrt 24.10")]
|
||||
[InlineData("NAME=\"Alpine Linux\"|ID=alpine|VERSION_ID=3.22.1", "Alpine Linux 3.22")]
|
||||
[InlineData("NAME=\"Ubuntu\"|VERSION_ID=\"24.04\"", "Ubuntu 24.04")]
|
||||
[InlineData("ID=arch", "arch")]
|
||||
[InlineData("", "Linux")]
|
||||
public void LinuxName_IsTheDistroAndItsMajorMinorVersion(string osRelease, string expected)
|
||||
{
|
||||
Assert.Equal(expected, PlatformDetector.LinuxName(osRelease.Split('|', StringSplitOptions.RemoveEmptyEntries)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ReportsOnlyCoarseInformation()
|
||||
{
|
||||
var platform = PlatformDetector.Detect();
|
||||
Assert.Contains(platform.Os, new[] { "linux", "windows", "macos", "other" });
|
||||
Assert.Contains(platform.Arch, new[] { "x64", "arm64", "arm", "x86" });
|
||||
// 没有主机名、内核版本:名字很短,也不含小数点以外的版本细节
|
||||
Assert.True((platform.Name ?? string.Empty).Length < 40);
|
||||
Assert.DoesNotContain(Environment.MachineName, platform.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -124,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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -61,6 +61,12 @@ public sealed class BackendOptions
|
||||
/// </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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -192,6 +192,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.InitialAdminUser = AdminUser;
|
||||
options.InitialAdminPassword = AdminPassword;
|
||||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
});
|
||||
await App.StartAsync();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Tests;
|
||||
|
||||
public sealed partial class NodeInfoTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
|
||||
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
|
||||
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
|
||||
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
|
||||
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
|
||||
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
|
||||
{
|
||||
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
|
||||
{
|
||||
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
|
||||
Assert.Equal("other", key);
|
||||
Assert.Equal("Hangzhou Alibaba Advertising", name);
|
||||
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
|
||||
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("223.5.5.5", true)]
|
||||
[InlineData("2001:4860:4860::8888", true)]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("127.0.0.1", false)]
|
||||
[InlineData("10.1.2.3", false)]
|
||||
[InlineData("172.16.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("192.168.1.10", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("100.64.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("fe80::1", false)]
|
||||
[InlineData("fd00::1", false)]
|
||||
[InlineData("::ffff:192.168.1.1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("printer.local", false)]
|
||||
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, NodeInfoService.IsPublic(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
|
||||
{
|
||||
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
|
||||
var store = new MemoryStore();
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(store, geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
||||
Assert.Empty(geo.Calls);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
|
||||
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
|
||||
|
||||
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
|
||||
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
||||
|
||||
// 地址变了才重新查;过了一周也重新查
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
time.Advance(TimeSpan.FromDays(8));
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(3, geo.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
|
||||
{
|
||||
var geo = new FakeGeo(null);
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_PlatformIsSavedAndSurvivesARestart()
|
||||
{
|
||||
var store = new MemoryStore();
|
||||
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
|
||||
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{"ip":"223.5.5.5","found":true,"is_private":false,
|
||||
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
|
||||
"city":{"names":{"zh-CN":"杭州市"}},
|
||||
"location":{"latitude":30.25,"longitude":120.21},
|
||||
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
|
||||
""");
|
||||
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
|
||||
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
|
||||
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
|
||||
private static partial Regex Ipv4();
|
||||
|
||||
private sealed class FakeGeo : IGeoLookup
|
||||
{
|
||||
private readonly GeoInfo? _result;
|
||||
|
||||
public FakeGeo(GeoInfo? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeMetaStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeMeta> _items = [];
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_items[meta.Info.NodeId] = meta;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_items.Remove(nodeId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
||||
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed partial class NodePrivacyTests
|
||||
{
|
||||
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public NodePrivacyTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
||||
JsonNode? agentNode = null;
|
||||
JsonNode? probeNode = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
||||
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(40),
|
||||
"the agent (hello) and the probe (agent list sync) both report their platform");
|
||||
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
||||
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
||||
|
||||
string[] paths =
|
||||
[
|
||||
"/api/public/nodes",
|
||||
"/api/public/hosts",
|
||||
"/api/public/overview?range=1h&metrics=rtt.p50",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
||||
"/api/public/catalog/plans",
|
||||
];
|
||||
foreach (string path in paths)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotMatch(Ipv4(), body);
|
||||
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
|
||||
foreach (string key in KeysOf(JsonNode.Parse(body)))
|
||||
{
|
||||
Assert.DoesNotContain(key, s_forbiddenKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
|
||||
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
|
||||
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.False(about["show_build"]!.GetValue<bool>());
|
||||
Assert.Null(about["backend"]);
|
||||
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
Assert.All(hidden["items"]!.AsArray(), n =>
|
||||
{
|
||||
Assert.Null(n!["version"]);
|
||||
Assert.Null(n["tag"]);
|
||||
});
|
||||
|
||||
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
|
||||
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent reports its version in hello");
|
||||
|
||||
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
|
||||
on.EnsureSuccessStatusCode();
|
||||
try
|
||||
{
|
||||
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.True(shown["show_build"]!.GetValue<bool>());
|
||||
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
|
||||
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
|
||||
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
|
||||
off.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> KeysOf(JsonNode? node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach ((string key, JsonNode? value) in obj)
|
||||
{
|
||||
yield return key;
|
||||
foreach (string inner in KeysOf(value))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case JsonArray array:
|
||||
foreach (JsonNode? item in array)
|
||||
{
|
||||
foreach (string inner in KeysOf(item))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
||||
private static partial Regex Ipv4();
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
|
||||
|
||||
WORKDIR /src/backend
|
||||
# 构建信息:CI 通过 --build-arg 传入;不传就是空
|
||||
ARG MONITOR_TAG=
|
||||
ARG MONITOR_COMMIT=
|
||||
ARG MONITOR_BUILT_AT=
|
||||
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
|
||||
COPY restore/backend/ ./
|
||||
RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 --artifacts-path /artifacts \
|
||||
&& dotnet restore fleet/tools/fleet-enroll -r linux-x64 --artifacts-path /artifacts
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
|
||||
|
||||
WORKDIR /src/backend
|
||||
# 构建信息:CI 通过 --build-arg 传入;不传就是空
|
||||
ARG MONITOR_TAG=
|
||||
ARG MONITOR_COMMIT=
|
||||
ARG MONITOR_BUILT_AT=
|
||||
ENV MONITOR_TAG=$MONITOR_TAG MONITOR_COMMIT=$MONITOR_COMMIT MONITOR_BUILT_AT=$MONITOR_BUILT_AT
|
||||
COPY restore/backend/ ./
|
||||
RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 --artifacts-path /artifacts
|
||||
|
||||
|
||||
+8
-2
@@ -16,17 +16,23 @@ case "$project" in
|
||||
*) echo "unknown project: $project" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# 构建信息(程序里显示的 tag、提交、构建时间):没在 git 仓库里就留空
|
||||
export MONITOR_TAG="${MONITOR_TAG:-$(git -C "$root" describe --tags --always --dirty 2>/dev/null || true)}"
|
||||
export MONITOR_COMMIT="${MONITOR_COMMIT:-$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)}"
|
||||
export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
build_env=(-e MONITOR_TAG -e MONITOR_COMMIT -e MONITOR_BUILT_AT)
|
||||
|
||||
out="backend/artifacts/publish/$project/$rid"
|
||||
args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "/src/$out" --artifacts-path /artifacts -nologo -v q)
|
||||
|
||||
case "$rid" in
|
||||
linux-musl-x64)
|
||||
# SDK 镜像的版本可以比 global.json 新(rollForward = latestFeature)
|
||||
docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
|
||||
docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot dotnet "${args[@]}"
|
||||
;;
|
||||
linux-x64)
|
||||
docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
|
||||
docker run --rm "${build_env[@]}" -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}"
|
||||
;;
|
||||
linux-musl-arm64|linux-arm64)
|
||||
echo "$rid needs an arm64 Alpine builder (native or qemu): run this script on an arm64 host or in CI" >&2
|
||||
|
||||
Reference in new issue
Block a user