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:
justme committed 2026-10-06 21:22:04 +08:00
1 parent 2e470607fa
commit 1e5c660d05
38 files changed
+1532 -15

No files matched your search

@@ -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="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" />
<BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" />
<BuildInfoLine Include=" public const string Tag = &quot;$(MONITOR_TAG)&quot;%3B" />
<BuildInfoLine Include=" public const string Commit = &quot;$(MONITOR_COMMIT)&quot;%3B" />
<BuildInfoLine Include=" public const string BuiltAt = &quot;$(MONITOR_BUILT_AT)&quot;%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);
}
}