diff --git a/AGENTS.md b/AGENTS.md index 56d3aee..5925e03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,9 @@ Monitor is a self-hosted network path quality monitor. Backend coordinates Agent ## Local development and builds -Requirements: .NET SDK 10 and Node.js/npm. The SDK selection is defined by `backend/global.json`. +Requirements: .NET SDK 10 and Node.js/npm. The SDK selection is defined by the root `global.json`; NuGet package versions are centralized in the root `Directory.Packages.props`. +- All .NET projects: `dotnet build Monitor.slnx -c Release` from the repository root. - Backend build: `cd backend && dotnet build Monitor.slnx -c Release` - Backend tests: `cd backend && dotnet test --solution Monitor.slnx -c Release` - Frontend dependencies/build: `npm ci --prefix web && npm run build --prefix web` diff --git a/backend/Directory.Packages.props b/Directory.Packages.props similarity index 60% rename from backend/Directory.Packages.props rename to Directory.Packages.props index 95a8593..55fb462 100644 --- a/backend/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,15 @@ true + + 3530400 + 2026 + 1e71ddf93849c6a6ecf58b827c0692073d2dd7ee40196158068f7b29f422e87d + diff --git a/Monitor.slnx b/Monitor.slnx new file mode 100644 index 0000000..8a5c58c --- /dev/null +++ b/Monitor.slnx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 0bc275b..307da72 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,19 @@ Probe <──测量会话──> Agent ──长连接与数据上报──> Bac ## 快速开始 ### 环境要求 -- .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。 +- .NET SDK 10;项目使用 `global.json` 指定 SDK 版本和滚动策略。 - Node.js 与 npm,用于构建 Web 前端。 ### 构建 -从仓库根目录构建后端: +从仓库根目录构建全部 .NET 项目(Backend、Agent、Probe、Fleet、测试和 CI 工具): ```sh -cd backend dotnet build Monitor.slnx -c Release ``` +NuGet 包版本统一在根目录 `Directory.Packages.props` 管理,项目中的 `PackageReference` 不写版本号。AotAnywhere 的 MSBuild SDK 版本在根目录 `global.json` 的 `msbuild-sdks` 中管理。`backend/Monitor.slnx` 保留用于只构建后端目录内的项目。 + 安装前端依赖并构建展示端和管理端(在仓库根目录运行): ```sh @@ -92,7 +93,6 @@ npm run dev --prefix web ## 测试 ```sh -cd backend dotnet test --solution Monitor.slnx ``` diff --git a/backend/agent/Directory.Build.props b/backend/agent/Directory.Build.props index 22f99e2..17e23af 100644 --- a/backend/agent/Directory.Build.props +++ b/backend/agent/Directory.Build.props @@ -8,6 +8,10 @@ false false false + false + false + true + false false false diff --git a/backend/agent/src/Monitor.Agent/Host/HostSampler.cs b/backend/agent/src/Monitor.Agent/Host/HostSampler.cs index 23e5736..b24d775 100644 --- a/backend/agent/src/Monitor.Agent/Host/HostSampler.cs +++ b/backend/agent/src/Monitor.Agent/Host/HostSampler.cs @@ -6,23 +6,30 @@ namespace Monitor.Agent; /// /// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 只有汇总的数字:不采集 CPU 型号、主机名、网卡名和任何 IP 地址,所以面板和展示页想泄漏也泄漏不出去。 -/// Linux 读 /proc;读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 +/// Linux 读 /proc 和 /sys;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 +/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 /// public sealed class HostSampler { - private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"]; + private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "podman", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"]; private readonly TimeProvider _time; private readonly Func _read; private readonly Func<(long Total, long Used)?> _disk; + private readonly string _procRoot; + private readonly string _sysRoot; private (long Total, long Idle)? _previousCpu; private (long At, long Rx, long Tx)? _previousNet; - public HostSampler(TimeProvider time, Func? read = null, Func<(long Total, long Used)?>? disk = null) + public HostSampler(TimeProvider time, Func? read = null, Func<(long Total, long Used)?>? disk = null, + string? procRoot = null, string? sysRoot = null, string? diskRoot = null) { _time = time; _read = read ?? ReadProc; - _disk = disk ?? ReadRootDisk; + _procRoot = Root(procRoot, "HOST_PROC", "/proc"); + _sysRoot = Root(sysRoot, "HOST_SYS", "/sys"); + string root = Root(diskRoot, "HOST_ROOT", OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/"); + _disk = disk ?? (() => ReadRootDisk(root)); } public HostSnapshot Sample() @@ -30,7 +37,7 @@ public sealed class HostSampler long now = _time.GetTimestamp(); var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; - if (_read("/proc/stat") is { } stat && ParseCpu(stat) is { } cpu) + if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu) { if (_previousCpu is { } before && cpu.Total > before.Total) { @@ -41,7 +48,7 @@ public sealed class HostSampler _previousCpu = cpu; } - if (_read("/proc/meminfo") is { } meminfo && ParseMemory(meminfo) is { } memory) + if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory) { snapshot.MemTotal = memory.Total; snapshot.MemUsed = memory.Used; @@ -53,10 +60,11 @@ public sealed class HostSampler snapshot.DiskUsed = disk.Used; } - if (_read("/proc/net/dev") is { } dev && ParseNetwork(dev) is { } net) + if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net) { snapshot.NetRxTotal = net.Rx; snapshot.NetTxTotal = net.Tx; + snapshot.NetLinkMbps = ReadLinkSpeed(dev); if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) { double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); @@ -67,7 +75,11 @@ public sealed class HostSampler _previousNet = (now, net.Rx, net.Tx); } - snapshot.UptimeSeconds = _read("/proc/uptime") is { } uptime && ParseUptime(uptime) is { } up ? up : Environment.TickCount64 / 1000; + snapshot.UptimeSeconds = _read(Path.Combine(_procRoot, "uptime")) is { } uptime && ParseUptime(uptime) is { } up ? up : null; + if (!OperatingSystem.IsLinux() && snapshot.UptimeSeconds is null) + { + snapshot.UptimeSeconds = Environment.TickCount64 / 1000; + } return snapshot; } @@ -165,11 +177,54 @@ public sealed class HostSampler } } - private static (long Total, long Used)? ReadRootDisk() + private static string Root(string? value, string variable, string fallback) + { + value ??= Environment.GetEnvironmentVariable(variable); + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + + private long? ReadLinkSpeed(string dev) + { + long total = 0; + bool any = false; + foreach (string line in dev.Split('\n')) + { + int colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon < 0) + { + continue; + } + + string name = line[..colon].Trim(); + if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal))) + { + continue; + } + + string? speed = _read(Path.Combine(_sysRoot, "class/net", name, "speed")); + if (long.TryParse(speed?.Trim(), CultureInfo.InvariantCulture, out long mbps) && mbps > 0) + { + total += mbps; + any = true; + } + else + { + return null; + } + } + + return any ? total : null; + } + + private static (long Total, long Used)? ReadRootDisk(string root) { try { - string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/"; + if (!Directory.Exists(root)) + { + return null; + } + var drive = new DriveInfo(root); return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null; } diff --git a/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj index 081f0c7..f25af4d 100644 --- a/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj +++ b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj @@ -7,6 +7,12 @@ monitor-agent Monitor.Agent true + true + true + full + true + true + false diff --git a/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj index e654abe..6c646cc 100644 --- a/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj +++ b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj @@ -7,6 +7,25 @@ fleet-enroll FleetEnroll true + true + true + full + true + true + false + Size + true + false + false + false + false + false + false + true + false + true + false + false diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index c647fec..bc9085e 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5172,6 +5172,7 @@ "net_tx_total", "net_rx_bps", "net_tx_bps", + "net_link_mbps", "uptime_seconds", "at" ], @@ -5258,6 +5259,15 @@ ], "format": "int64" }, + "net_link_mbps": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, "uptime_seconds": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ diff --git a/backend/probe/Directory.Build.props b/backend/probe/Directory.Build.props index 22f99e2..17e23af 100644 --- a/backend/probe/Directory.Build.props +++ b/backend/probe/Directory.Build.props @@ -8,6 +8,10 @@ false false false + false + false + true + false false false diff --git a/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj index 122cc53..6a2dca7 100644 --- a/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj +++ b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj @@ -7,6 +7,12 @@ monitor-probe Monitor.Probe true + true + true + full + true + true + false diff --git a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs index 35ba9f2..d5228e7 100644 --- a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs +++ b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs @@ -339,6 +339,10 @@ public sealed class HostSnapshot [JsonPropertyName("net_tx_bps")] public long? NetTxBps { get; set; } + /// 已统计接口的内核报告链路速率之和(Mbit/s);驱动不支持或无法读取时为空,不代表互联网带宽。 + [JsonPropertyName("net_link_mbps")] + public long? NetLinkMbps { get; set; } + [JsonPropertyName("uptime_s")] public long? UptimeSeconds { get; set; } diff --git a/backend/src/Monitor.Backend.Api/Common/Mappers.cs b/backend/src/Monitor.Backend.Api/Common/Mappers.cs index 198d99a..98ea0d1 100644 --- a/backend/src/Monitor.Backend.Api/Common/Mappers.cs +++ b/backend/src/Monitor.Backend.Api/Common/Mappers.cs @@ -34,7 +34,7 @@ public static class Mappers line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest)); public static HostDto ToDto(HostSnapshot h) => - new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.UptimeSeconds, Time(h.AtUtcMs)); + new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs)); public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs)); diff --git a/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs index 64322f7..c52543e 100644 --- a/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs @@ -163,6 +163,7 @@ public sealed record HostDto( long? NetTxTotal, long? NetRxBps, long? NetTxBps, + long? NetLinkMbps, long? UptimeSeconds, DateTimeOffset At); diff --git a/backend/src/Monitor.Backend/Monitor.Backend.csproj b/backend/src/Monitor.Backend/Monitor.Backend.csproj index 4680b73..93afdac 100644 --- a/backend/src/Monitor.Backend/Monitor.Backend.csproj +++ b/backend/src/Monitor.Backend/Monitor.Backend.csproj @@ -1,9 +1,23 @@ - + + + Exe monitor-backend Monitor.Backend true + true + Size + false + false + false + false + false + true + true + + false + true true true true @@ -12,8 +26,13 @@ + + + + + diff --git a/backend/src/Monitor.Backend/StaticSqlite.targets b/backend/src/Monitor.Backend/StaticSqlite.targets new file mode 100644 index 0000000..e1c17d8 --- /dev/null +++ b/backend/src/Monitor.Backend/StaticSqlite.targets @@ -0,0 +1,71 @@ + + + <_SqliteToolchainTargets>SetupOSSpecificProps + <_SqliteToolchainTargets Condition="'$(UseAotAnywhere)' == 'true'">SetupOSSpecificProps;OverwriteTargetTriple;SetPathToZig + + + + + + + + + <_SqliteCache>$([System.IO.Path]::GetFullPath('$(BaseIntermediateOutputPath)sqlite/$(MonitorSqliteSourceVersion)/')) + <_SqliteArchive>$(_SqliteCache)sqlite-amalgamation-$(MonitorSqliteSourceVersion).zip + <_SqliteSource>$(_SqliteCache)sqlite-amalgamation-$(MonitorSqliteSourceVersion)/sqlite3.c + <_SqliteObjectDirectory>$(_SqliteCache)$(RuntimeIdentifier)/$(UseAotAnywhere)/ + <_SqliteObject>$(_SqliteObjectDirectory)e_sqlite3.o + <_SqliteObject Condition="$(RuntimeIdentifier.StartsWith('win'))">$(_SqliteObjectDirectory)e_sqlite3.obj + + + + + + + + + + + + + <_SqliteUseMsvc>false + <_SqliteUseMsvc Condition="$(RuntimeIdentifier.StartsWith('win')) and '$(_AotAnywhereWindowsCross)' != 'true'">true + <_SqliteCompiler>"$(CppCompilerAndLinker)" + <_SqliteCompiler Condition="'$(CppCompilerAndLinker)' == ''">cc + <_SqliteCompiler Condition="'$(TargetTriple)' != ''">$(_SqliteCompiler) --target=$(TargetTriple) + <_SqliteCompiler Condition="'$(SysRoot)' != ''">$(_SqliteCompiler) --sysroot="$(SysRoot)" + <_SqliteCompiler Condition="'$(UseAotAnywhere)' == 'true'">"$(_AotAnywhereZigExe)" cc -target $(TargetTriple) + <_SqliteCompiler Condition="'$(_SqliteUseMsvc)' == 'true'">cl + + + + + + + + + + + + + + + + + + + + diff --git a/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs b/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs index 108acd1..5c434d4 100644 --- a/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs @@ -35,11 +35,20 @@ public sealed class AotSmokeTests string token = JsonNode.Parse(await tokenResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); + settingsResponse.EnsureSuccessStatusCode(); + using HttpResponseMessage probeResponse = await http.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "aot-probe-" + Guid.NewGuid().ToString("N")[..6] })); + probeResponse.EnsureSuccessStatusCode(); + string probeId = JsonNode.Parse(await probeResponse.Content.ReadAsStringAsync())!["probe_id"]!.GetValue(); + // Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态 using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 })); + joinResponse.EnsureSuccessStatusCode(); string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); using var anonymous = new HttpClient { BaseAddress = baseAddress }; await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]); + agent.ProbeForChunks = new ProbeId(probeId); + string lineId = $"{agent.NodeId.Value}~{probeId}"; await agent.ConnectAsync(baseAddress); TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2); Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo"); @@ -54,7 +63,7 @@ public sealed class AotSmokeTests // 查询:热层的精确汇总,随后是持久层的分钟窗口 string range = $"from={Iso(baseMs)}&to={Iso(baseMs + minute)}&metrics=loss.total,rtt.p95"; - JsonNode hot = await GetAsync(http, $"/api/public/lines/{agent.NodeId.Value}/series?{range}&step=1s"); + JsonNode hot = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1s"); Assert.Equal("hot", hot["source"]!.GetValue()); Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue()); Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); @@ -62,7 +71,7 @@ public sealed class AotSmokeTests JsonNode persisted; do { - persisted = await GetAsync(http, $"/api/public/lines/{agent.NodeId.Value}/series?{range}&step=1m"); + persisted = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1m"); if (persisted["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600) { break; @@ -80,16 +89,16 @@ public sealed class AotSmokeTests Assert.True(RawChunkCodec.Decode(bytes).IsSuccess); // SSE:快照 + 增量 - await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{agent.NodeId.Value}:1s:udp"); + await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{lineId}:1s:udp"); await sse.WaitForAsync(e => e.Name == "series"); // 按需测试:Probe 事件 → 下发 → 状态 - await agent.SendEventAsync(AgentEventKinds.ProbeConnected); + await agent.SendEventAsync(AgentEventKinds.ProbeConnected, probeId); JsonNode started = null!; long begun = TimeProvider.System.GetTimestamp(); while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10)) { - using HttpResponseMessage response = await http.PostAsync($"/api/admin/lines/{agent.NodeId.Value}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + using HttpResponseMessage response = await http.PostAsync($"/api/admin/lines/{lineId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); if (response.StatusCode == System.Net.HttpStatusCode.Accepted) { started = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; diff --git a/backend/tests/Monitor.Backend.Tests/HostTests.cs b/backend/tests/Monitor.Backend.Tests/HostTests.cs index 6babd41..d6ba7ce 100644 --- a/backend/tests/Monitor.Backend.Tests/HostTests.cs +++ b/backend/tests/Monitor.Backend.Tests/HostTests.cs @@ -21,6 +21,7 @@ public sealed class HostSamplerTests Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1)); Assert.Equal(3600L, HostSampler.ParseUptime("3600.55 7000.1\n")); Assert.Null(HostSampler.ParseNetwork("nothing useful")); + Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n")); } [Fact] @@ -29,7 +30,7 @@ public sealed class HostSamplerTests var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider(); string stat = Stat1; string dev = Dev1; - var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250)); + var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys"); HostSnapshot first = sampler.Sample(); Assert.Null(first.CpuPercent); // 第一次没有基线 Assert.Null(first.NetRxBps); @@ -45,6 +46,54 @@ public sealed class HostSamplerTests Assert.Equal(2000, second.NetTxBps); Assert.Equal(3000, second.NetRxTotal); } + + [Fact] + public void Sample_UsesMountedHostProcAndSys_InsteadOfContainerPaths() + { + var reads = new List(); + var sampler = new HostSampler(TimeProvider.System, path => + { + reads.Add(path); + return path switch + { + "/host/proc/stat" => Stat1, + "/host/proc/meminfo" => "MemTotal: 8192 kB\nMemAvailable: 6144 kB\n", + "/host/proc/net/dev" => Dev1, + "/host/proc/uptime" => "3600.0 7000.0", + "/host/sys/class/net/eth0/speed" => "10000\n", + _ => throw new InvalidOperationException("unexpected container path: " + path), + }; + }, () => (1000, 250), procRoot: "/host/proc", sysRoot: "/host/sys"); + + HostSnapshot snapshot = sampler.Sample(); + Assert.Equal(8L * 1024 * 1024, snapshot.MemTotal); + Assert.Equal(1000, snapshot.NetRxTotal); + Assert.Equal(10000, snapshot.NetLinkMbps); + Assert.Equal(3600, snapshot.UptimeSeconds); + Assert.DoesNotContain(reads, path => path.StartsWith("/proc/", StringComparison.Ordinal)); + Assert.DoesNotContain(reads, path => path.Contains("docker0", StringComparison.Ordinal) || path.Contains("/lo/", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(null)] + [InlineData("-1")] + [InlineData("0")] + [InlineData("not supported")] + public void Sample_DoesNotInventLinkSpeedOrContainerDisk_WhenHostDataIsUnavailable(string? speed) + { + var sampler = new HostSampler(TimeProvider.System, + path => path.EndsWith("net/dev", StringComparison.Ordinal) ? Dev1 : path.EndsWith("/speed", StringComparison.Ordinal) ? speed : null, + procRoot: "/host/proc", sysRoot: "/host/sys", diskRoot: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + + HostSnapshot snapshot = sampler.Sample(); + Assert.Null(snapshot.NetLinkMbps); + Assert.Null(snapshot.DiskTotal); + Assert.Null(snapshot.DiskUsed); + if (OperatingSystem.IsLinux()) + { + Assert.Null(snapshot.UptimeSeconds); + } + } } [Collection("backend")] @@ -61,6 +110,7 @@ public sealed class HostStatusTests { CpuPercent = cpu, MemUsed = 2L << 30, MemTotal = 8L << 30, DiskUsed = 10L << 30, DiskTotal = 40L << 30, NetRxTotal = 600L << 30, NetTxTotal = 180L << 30, NetRxBps = 2500, NetTxBps = 4800, UptimeSeconds = 3_000_000, + NetLinkMbps = 1000, }; [Fact] @@ -81,6 +131,7 @@ public sealed class HostStatusTests JsonNode item = list["items"]!.AsArray().Single(i => i!["agent_id"]!.GetValue() == id)!; Assert.False(item["stale"]!.GetValue()); Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue()); + Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue()); // 展示页能拿到的只有汇总数字:整份响应里没有型号、主机名、地址之类的字段 string text = list.ToJsonString(); diff --git a/ci/release-linux.sh b/ci/release-linux.sh index 1d56bff..f7e34f8 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build a framework-dependent Backend OCI image for the requested arch; publishing is optional. +# Build a musl NativeAOT Backend OCI image with statically linked SQLite; publishing is optional. set -euo pipefail arch="${1:?usage: release-linux.sh }" @@ -28,7 +28,8 @@ if [[ "$image_publish" == true ]]; then } fi case "$arch" in - amd64|arm64) ;; + amd64) rid=linux-musl-x64 ;; + arm64) rid=linux-musl-arm64 ;; *) echo "unsupported architecture: $arch" >&2; exit 2 ;; esac @@ -51,12 +52,13 @@ export MONITOR_COMMIT="${MONITOR_COMMIT:-${CI_COMMIT_SHA:0:10}}" export MONITOR_BUILT_AT="${MONITOR_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" (cd "$root/backend" && dotnet publish src/Monitor.Backend \ - -m:3 -c Release --no-self-contained \ + -m:3 -c Release -r "$rid" --self-contained true \ -p:DebugType=none -p:DebugSymbols=false \ - -p:UseAppHost=false -p:OpenApiGenerateDocuments=false \ + -p:PublishAot=true -p:UseAotAnywhere=true -p:SqliteStaticLink=true -p:OpenApiGenerateDocuments=false \ --artifacts-path "$build_root" -o "$output") -find "$output" -type f -name '*.pdb' -delete -cp -a "$output/." "$container_context/app/" +[[ -x "$output/monitor-backend" ]] || { echo "NativeAOT Backend executable is missing" >&2; exit 1; } +[[ "$(wc -c < "$output/monitor-backend")" -gt 2000000 ]] || { echo "Backend output is an apphost, not a NativeAOT executable" >&2; exit 1; } +install -m 0755 "$output/monitor-backend" "$container_context/app/monitor-backend" : > "$container_context/data/.keep" # Woodpecker's local backend isolates HOME per workflow. Keep Buildah's image diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index 8adb51f..95eaa7e 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Publish glibc OCI images for the Linux Agent and Probe on amd64 and arm64. +# Publish musl OCI images for the Linux Agent and Probe on amd64 and arm64. set -euo pipefail tag="${CI_COMMIT_TAG:-}" @@ -113,7 +113,7 @@ publish_index() { } for component in agent probe; do - build_arch_image "$component" amd64 amd64 - build_arch_image "$component" arm64 arm64 + build_arch_image "$component" amd64 musl-amd64 + build_arch_image "$component" arm64 musl-arm64 publish_index "$component" done diff --git a/ci/stage-node-image.py b/ci/stage-node-image.py index 801b628..e51f744 100644 --- a/ci/stage-node-image.py +++ b/ci/stage-node-image.py @@ -18,6 +18,9 @@ GLOBAL_BACKEND_FILES = { "NuGet.Config", "NuGet.config", } +# The container stages backend as a standalone tree. Copy the repository-wide +# SDK and package versions into it so restore/publish use the same configuration. +ROOT_BUILD_FILES = {"Directory.Packages.props", "global.json"} CONTAINERFILES = { "agent": ROOT / "packaging/container/Agent.NativeBuild.Containerfile", "probe": ROOT / "packaging/container/Probe.NativeBuild.Containerfile", @@ -49,6 +52,10 @@ def stage(component: str, destination: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) + for name in ROOT_BUILD_FILES: + for directory in (restore, source): + shutil.copy2(ROOT / name, directory / name) + config = destination / "config" config.mkdir() (config / ".keep").write_text("", encoding="utf-8") diff --git a/docs/deployment.md b/docs/deployment.md index abe67d5..4d50fa9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,253 +1,280 @@ -# 部署 - -节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(`libssl.so.3`)。启动时会自检,缺少时第一步就明确报错。 - -## 1. 构建与打包 - -```text -scripts/publish.sh → backend/artifacts/publish/// -scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) -``` - -构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MONITOR_TAG`、`MONITOR_COMMIT`、`MONITOR_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器的 Fastlane 构建用同名的 `--build-arg`);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。 - -| rid | 目标 | 构建方式 | -| --- | --- | --- | -| `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 | -| `linux-musl-x64`、`linux-musl-arm64` | Alpine、amd64 OpenWrt、aarch64 OpenWrt 路由器 | Windows CI 主机通过 AotAnywhere 交叉编译 | -| `osx-x64`、`osx-arm64` | Intel Mac、Apple Silicon | Windows CI 主机通过 AotAnywhere 交叉编译;发布包未做 Developer ID 签名或 Apple 公证 | -| `win-x64`、`win-arm64` | Windows x64、ARM64 | Windows CI 主机上的 Visual Studio C++ 工具链 | - -macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。通过浏览器下载时,macOS Gatekeeper 可能阻止启动;面向普通用户分发前需要 Developer ID 签名和 Apple 公证。MIPS 不在当前目标列表中(见 `project-layout.md`)。 - -仓库内的 `scripts/publish.sh` 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent、Probe 和 `fleet-enroll` 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。 - -## Backend 容器 - -Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/monitor/monitor-backend:` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器基于 .NET 10 ASP.NET noble-chiseled-extra 运行时镜像,以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。`extra` 变体带 IANA 时区数据,供按时区查询使用。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。 - -在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MONITOR_ADMIN_PASSWORD`(可选设置 `MONITOR_ADMIN_USER`、`MONITOR_VERSION`),然后启动: - -```sh -docker compose up -d -``` - -Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在 Docker 命名卷 `monitor-data`。首次启动成功创建管理员后,改密应在管理端完成。 - -健康检查端点为 `GET /healthz`。容器定义见 [`../packaging/container/Backend.Containerfile`](../packaging/container/Backend.Containerfile)。 - -## 节点二进制发布 - -Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。Linux glibc 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,支持 `linux/amd64` 和 `linux/arm64`;musl、macOS、Windows 仍通过对应平台归档分发。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../backend/agent/README.md) 与 [`../backend/probe/README.md`](../backend/probe/README.md)。 - -## CI 工作流 - -### Pull request 与 dev 验证 - -Pull request 和 `dev` push 会运行 Backend tests;通过后,独立的 `web-build` workflow 才会构建前端并检查 CSS。提交信息后缀 `[SKIP TESTS]` 只跳过 Backend tests,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。 - -### main 开发镜像 - -`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend、Agent、Probe 的 Linux/amd64 镜像会并行构建,并推送到同一个 Zot Registry 下独立的 `monitor-dev/` 仓库: - -| 镜像 | 滚动标签 | 固定提交标签 | -| --- | --- | --- | -| Backend | `registry.oeo.one/monitor-dev/monitor-backend:latest` | `registry.oeo.one/monitor-dev/monitor-backend:dev-<12位SHA>` | -| Agent | `registry.oeo.one/monitor-dev/monitor-agent:latest` | `registry.oeo.one/monitor-dev/monitor-agent:dev-<12位SHA>` | -| Probe | `registry.oeo.one/monitor-dev/monitor-probe:latest` | `registry.oeo.one/monitor-dev/monitor-probe:dev-<12位SHA>` | - -Dev 镜像目前仅支持 amd64。它们不创建 Gitea Release,也不修改正式版 `monitor/` 仓库中的标签。普通 main push 不会构建镜像。 - -### 正式版发布 - -推送匹配项目版本的 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release。正式镜像使用 `registry.oeo.one/monitor/` 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `monitor-dev/` 的 dev `latest` 相互独立。 - -Agent 和 Probe 镜像分别发布为 `registry.oeo.one/monitor/monitor-agent:` 与 `registry.oeo.one/monitor/monitor-probe:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/monitor/agent.json` 或 `/etc/monitor/probe.json`。运行容器时挂载对应配置目录;Probe 还会在该目录写入 `agents.json`,因此目录需可由 1654 用户写入。Agent、Probe 需使用宿主网络以保持节点端口和测量流量行为,并按需添加 `NET_RAW` 能力以执行 ICMP 测量: - -```sh -docker run -d --name monitor-agent --restart unless-stopped --network host \ - --cap-add NET_RAW -v "$PWD/agent-config:/etc/monitor" \ - registry.oeo.one/monitor/monitor-agent: - -docker run -d --name monitor-probe --restart unless-stopped --network host \ - --cap-add NET_RAW -v "$PWD/probe-config:/etc/monitor" \ - registry.oeo.one/monitor/monitor-probe: -``` - -宿主机配置目录应允许 UID/GID 1654 读写;Probe 需要写权限来更新 `agents.json`。也可通过 Docker Compose 配置相同的网络、卷和能力。 - -实测体积(`StackTraceSupport=false`、`IlcFoldIdenticalMethodBodies=true`、`OptimizationPreference=Size`): - -| 程序 | linux-musl-x64 | linux-x64 | win-x64 | -| --- | --- | --- | --- | -| monitor-probe | 7.7 MB | 7.7 MB | 5.6 MB | -| monitor-agent | 8.4 MB | 8.4 MB | 7.3 MB | -| fleet-enroll | 6.4 MB | 6.4 MB | — | - -Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。tar.gz 压缩后 Probe 约 3.6 MB。 - -## 2. 安装 - -解压 tar.gz,在解压目录里运行 `install.sh`(POSIX sh,root 运行;Debian、Ubuntu、Alpine、OpenWrt 都测试过): - -```sh -# Agent(服务器):注册并启动 -./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1 - -# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次) -./install.sh probe --probe-json ./probe.json - -./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册) -``` - -| 位置 | 内容 | 所有者 | -| --- | --- | --- | -| `/usr/lib/monitor/` | 二进制 | root,0755:采集进程不能改写自己的可执行文件 | -| `/etc/monitor/` | `agent.json` 或 `probe.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `monitor` 用户,0750 | - -安装脚本会:检查 OpenSSL、创建 `monitor` 用户(`useradd`、busybox `adduser`,OpenWrt 没有这些时直接写 `/etc/passwd`)、安装二进制和服务文件;缺少配置时只安装、不启动,并打印下一步。 - -### systemd(`packaging/systemd/*.service`) - -以 `monitor` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。 - -- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/monitor` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。 -- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。 - -### OpenWrt(`packaging/openwrt/`) - -只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/monitor-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/monitor`,sysupgrade 时保留 `/etc/monitor/`。 - -OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。闪存很小时注意:二进制 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。 - -## 3. 验证 - -`scripts/smoke-linux.sh` 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),`MODE=install` 用打包产物和 `install.sh` 安装、以 `monitor` 用户运行。`scripts/smoke-systemd.sh` 在 systemd 容器里验证服务单元。已经验证过: - -- Alpine 3.22(musl)、Debian 12(glibc):直接运行和 `install.sh` 安装都通; -- OpenWrt(官方 rootfs,busybox,无 `install`/`useradd`/`su`):安装、建用户、卸载、二进制启动和 OpenSSL 自检; -- systemd(Debian 12):两个服务 active,Agent 只有 `CAP_NET_RAW`、Probe 没有 capability,`NoNewPrivs`、seccomp 生效,Agent 无法写 `/etc/monitor`,没有重启和错误日志。 - -没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。 - -实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。 - -### 跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend) - -`scripts/smoke-wsl.py` 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 `tc netem` 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms): - -| 注入 | 期望 | 实测 | -| --- | --- | --- | -| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | `loss.down` 8.2%、`loss.up` 0;`jitter.down.p95` 8.7 ms、`jitter.up.p95` 0.18 ms;RTT +31 ms;TCP:`tcp.rtt.p99` 1.2 s、`tcp.slow` 28.8%、`tcp.retrans` 5.9%(Agent 一侧内核计数) | -| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | `loss.up` 9.4%、`loss.down` 0;`jitter.up.p95` 5.2 ms、`jitter.down.p95` 0 | -| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);`tcp.rtt.max` 9.5 s、`tcp.stall_max` 9.6 s | -| `hy2_ladder`(10/25/50/100 Mbit/s) | 无丢包 | `train.loss` 0.006%、`train.jitter.p95` 0.64 ms、可持续 100 Mbit/s | -| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 | - -验证中发现并修掉的两个问题: - -- **Probe 重启后,Agent 沿用了上一个会话的记录**:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里 `loss.up` 0、`loss.down` 7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。 -- **TCP 回显的几秒中断被记成丢包**:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间 `tcp.rtt.max` 只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。 - -没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。 - -### 跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS) - -2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。 - -```text -浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181) - 家宽机器: Probe ──WSS 到 https://<公网域名>──► Backend(身份、Agent 列表) - 家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent - VPS: Agent ──WSS 到 https://<公网域名>──► Backend -``` - -做法和要点: - -- **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。 -- **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。 -- **Probe 直连面板**:`probe.json` 里的 `server` 就是面板地址(取自 `Monitor__PublicUrl`,没配就用创建它时的请求地址),Probe 用它连面板,Agent 列表由面板下发;先注册还是先创建 Probe 都可以。 -- **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。 -- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 -- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。 - -**常驻(开机自启)**: - -- 家宽机器(没有 root 时):`~/.config/systemd/user/monitor-backend.service` 和 `monitor-probe.service`,`EnvironmentFile` 指向放配置的 `backend.env`,`Restart=always`,`systemctl --user enable --now …`,再用 `sudo loginctl enable-linger <用户>` 让它们不登录也能起来。Probe 的命令行是 `monitor-probe --config `(默认路径是 `/etc/monitor/probe.json`)。 -- VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。 -- 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。 - -**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。 - -**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。 - -实测(Probe 在广东移动家宽,Agent 在日本 IIJ): - -| 项目 | 结果 | -| --- | --- | -| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms | -| 丢包 | 上行 0%,下行 8.1%(`hy2_ladder` 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) | -| 抖动 p95(上 / 下) | 0.9 / 5.2 ms | -| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms | -| `reality_session` | 建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) | -| 评分 | REALITY 82(含按需测试)、Hy2 42 | -| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 | - -这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 - -## 容器节点的运行(podman + systemd) - -Agent 和 Probe 推荐在宿主机上用 root 的 podman 加 systemd 运行,这样宿主机更新器(下一节)才能重启它们。以 Agent 为例(Probe 把名字和目录换成 `monitor-probe`,配置文件换成 `probe.json`): - -```ini -# /etc/systemd/system/monitor-agent-container.service -[Unit] -Description=Monitor Agent (podman) -After=network-online.target -Wants=network-online.target - -[Service] -EnvironmentFile=/etc/monitor-update/agent.image # IMAGE=<仓库>@sha256:,更新器更新时改写它 -Restart=always -RestartSec=5 -TimeoutStopSec=30 -ExecStartPre=-/usr/bin/podman rm -f monitor-agent -ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --cap-drop all -v /etc/os-release:/etc/os-release:ro -v /opt/monitor/etc:/etc/monitor:Z ${IMAGE} -ExecStop=/usr/bin/podman stop -t 20 monitor-agent - -[Install] -WantedBy=multi-user.target -``` - -- **数据目录**(上面挂到容器 `/etc/monitor` 的 `/opt/monitor/etc`)归容器用户(UID/GID 1654),里面放 `agent.json`、身份目录(`fleet-enroll` 注册之后的 `agent-id/`),Probe 放 `probe.json` 和 `agents.json`。`agent.json` 里的 `fleet.identity_dir` 写容器里的路径(例如 `/etc/monitor/agent-id`)。 -- **首次注册 Agent**:`podman run --rm --network host -v <数据目录>:/etc/monitor --entrypoint /usr/local/bin/fleet-enroll <镜像> --server https://<面板> --join <加入令牌> --name <名字> --identity-dir /etc/monitor/agent-id`,再写 `agent.json`。Probe:在管理端创建(或轮换密钥)之后下载 `probe.json`,放进数据目录。 -- **拉取私有镜像**需要 Registry 的登录信息:用 `podman login --authfile /etc/monitor-update/registry-auth.json …` 生成一份只有 root 能读(600)的认证文件,更新器用它拉取,容器本身用不到。 -- **挂载宿主机的 `/etc/os-release`(只读)**:节点读系统名展示在面板上,不挂的话读到的是镜像里的 Ubuntu。 -- **ICMP 目标任务**要在 `podman run` 里加 `--cap-add NET_RAW`。 -- **经隧道注册之后检查 Agent 的 `public_host`**:面板看到的来源地址是隧道的出口(通常是内网地址),要在管理端改成这台机器自己的公网地址(`PATCH /api/admin/agents/{id}`),Probe 才连得上。 -- `packaging/compose/` 里的 compose 文件是不带宿主机更新器的简单方式:更新靠手动改版本再 `docker compose pull && up -d`,面板的更新按钮对它不起作用。 - -## 容器节点的更新(Agent、Probe) - -面板通知、节点验签、宿主机更新,**面板即使被攻破也装不上假镜像**: - -```text -CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 digest 的清单签名(ECDSA P-256,私钥只在 Woodpecker 密钥 release_signing_key 里) - └─ POST /api/admin/releases ──► 面板:只存下并原样转发(不验签)──► 在线的 Agent、Probe(连上时补发) - 节点进程:只把清单和签名写进数据目录的 update/,不执行任何命令 - │ systemd.path 监视到 offer.sig - ▼ - 宿主机 monitor-update(root):复制到临时目录 → 用本机的发布公钥验签 → 核对组件和镜像仓库 → seq 必须比已安装的大 - → status:available / invalid(写进 update-status/,节点随心跳报给面板,管理端「版本」页显示) - → 管理员点「更新」(或本机配置 POLICY=auto)→ podman pull <仓库>@sha256:(内容寻址,Registry 或链路上换不了) - → 写 IMAGE=… 到 /etc/monitor-update/<组件>.image,重启容器单元;20 秒内没有健康就换回旧镜像(rolled_back) -``` - -- **安装更新器**(每个容器节点一次,root):`packaging/update/install-updater.sh agent --data-dir /opt/monitor/etc --image-repo registry.oeo.one/monitor/monitor-agent --unit monitor-agent-container.service [--policy auto] [--authfile ]`。它装好 `monitor-update` 脚本和发布公钥,写 `/etc/monitor-update/<组件>.conf`,启用两个 systemd 路径单元。容器单元要用 `EnvironmentFile=/etc/monitor-update/<组件>.image` 和 `${IMAGE}` 启动容器。 -- **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。 -- **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。 -- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 -- **管理端**:「系统 → 版本」页显示最新发布的 tag、每个节点的更新状态(已是最新、有新版本、更新中、已回滚、清单无效),有新版本的节点有「更新」按钮,也可以「全部更新」。更新策略在节点本机配置:`/etc/monitor-update/<组件>.conf` 里 `POLICY=manual`(默认,等管理员请求)或 `auto`(验签通过就更新)。 -- **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。 -- 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。 - +# 部署 + +节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(`libssl.so.3`)。启动时会自检,缺少时第一步就明确报错。 + +## 1. 构建与打包 + +```text +scripts/publish.sh → backend/artifacts/publish/// +scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) +``` + +构建信息(程序里显示的 tag、提交、构建时间):`publish.sh` 会用 `git describe --tags --always --dirty`、`git rev-parse` 和当前时间自动设置环境变量 `MONITOR_TAG`、`MONITOR_COMMIT`、`MONITOR_BUILT_AT`,直接用 `dotnet publish` 或 CI 构建时自己设置它们(容器的 Fastlane 构建用同名的 `--build-arg`);不设置就是空,本地构建照常可重复。版本号取自 `backend/Directory.Build.props` 的 `Version`。前端的信息由 `web/build-info.mjs` 在构建时写入(版本取自 `web/package.json`)。 + +| rid | 目标 | 构建方式 | +| --- | --- | --- | +| `linux-x64`、`linux-arm64` | Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 | +| `linux-musl-x64`、`linux-musl-arm64` | Alpine、amd64 OpenWrt、aarch64 OpenWrt 路由器 | Windows CI 主机通过 AotAnywhere 交叉编译 | +| `osx-x64`、`osx-arm64` | Intel Mac、Apple Silicon | Windows CI 主机通过 AotAnywhere 交叉编译;发布包未做 Developer ID 签名或 Apple 公证 | +| `win-x64`、`win-arm64` | Windows x64、ARM64 | Windows CI 主机上的 Visual Studio C++ 工具链 | + +macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。通过浏览器下载时,macOS Gatekeeper 可能阻止启动;面向普通用户分发前需要 Developer ID 签名和 Apple 公证。MIPS 不在当前目标列表中(见 `project-layout.md`)。 + +仓库内的 `scripts/publish.sh` 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent、Probe 和 `fleet-enroll` 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。 + +## Backend 容器 + +Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/monitor/monitor-backend:` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 musl NativeAOT 单文件程序,基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine`,以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。 + +在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MONITOR_ADMIN_PASSWORD`(可选设置 `MONITOR_ADMIN_USER`、`MONITOR_VERSION`),然后启动: + +```sh +docker compose up -d +``` + +Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在 Docker 命名卷 `monitor-data`。首次启动成功创建管理员后,改密应在管理端完成。 + +Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。 + +健康检查端点为 `GET /healthz`。容器定义见 [`../packaging/container/Backend.Containerfile`](../packaging/container/Backend.Containerfile)。 + +## 节点二进制发布 + +Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。Linux musl 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine`,支持 `linux/amd64` 和 `linux/arm64`;所有平台仍提供对应归档。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../backend/agent/README.md) 与 [`../backend/probe/README.md`](../backend/probe/README.md)。 + +## CI 工作流 + +### Pull request 与 dev 验证 + +Pull request 和 `dev` push 会运行 Backend tests;通过后,独立的 `web-build` workflow 才会构建前端并检查 CSS。提交信息后缀 `[SKIP TESTS]` 只跳过 Backend tests,前端构建仍会运行。Gitea PR 事件的 `CI_COMMIT_MESSAGE` 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。 + +### main 开发镜像 + +`main` 上只有提交信息包含 `[CI]` 的 push 才构建镜像。合并 PR 时,请把 `[CI]` 放入 PR 标题或合并提交标题。Backend、Agent、Probe 的 Linux/amd64 镜像会并行构建,并推送到同一个 Zot Registry 下独立的 `monitor-dev/` 仓库: + +| 镜像 | 滚动标签 | 固定提交标签 | +| --- | --- | --- | +| Backend | `registry.oeo.one/monitor-dev/monitor-backend:latest` | `registry.oeo.one/monitor-dev/monitor-backend:dev-<12位SHA>` | +| Agent | `registry.oeo.one/monitor-dev/monitor-agent:latest` | `registry.oeo.one/monitor-dev/monitor-agent:dev-<12位SHA>` | +| Probe | `registry.oeo.one/monitor-dev/monitor-probe:latest` | `registry.oeo.one/monitor-dev/monitor-probe:dev-<12位SHA>` | + +Dev 镜像目前仅支持 amd64;Agent/Probe 在 `sdk:10.0-alpine-aot` 中构建 `linux-musl-x64` 产物,运行基底为 `runtime-deps:10.0-alpine`。它们不创建 Gitea Release,也不修改正式版 `monitor/` 仓库中的标签。普通 main push 不会构建镜像。 + +### 正式版发布 + +推送匹配项目版本的 `vMAJOR.MINOR.PATCH` tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release。正式镜像使用 `registry.oeo.one/monitor/` 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 `latest`。这与 `monitor-dev/` 的 dev `latest` 相互独立。 + +Agent 和 Probe 镜像分别发布为 `registry.oeo.one/monitor/monitor-agent:` 与 `registry.oeo.one/monitor/monitor-probe:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/monitor/agent.json` 或 `/etc/monitor/probe.json`。运行容器时挂载对应配置目录;Probe 还会在该目录写入 `agents.json`,因此目录需可由 1654 用户写入。Agent、Probe 需使用宿主网络以保持节点端口和测量流量行为,并按需添加 `NET_RAW` 能力以执行 ICMP 测量: + +```sh +docker run -d --name monitor-agent --restart unless-stopped --network host \ + --cap-add NET_RAW -v "$PWD/agent-config:/etc/monitor" \ + registry.oeo.one/monitor/monitor-agent: + +docker run -d --name monitor-probe --restart unless-stopped --network host \ + --cap-add NET_RAW -v "$PWD/probe-config:/etc/monitor" \ + registry.oeo.one/monitor/monitor-probe: +``` + +宿主机配置目录应允许 UID/GID 1654 读写;Probe 需要写权限来更新 `agents.json`。也可通过 Docker Compose 配置相同的网络、卷和能力。 + +Agent 和 Probe 的默认发布配置:NativeAOT、完整裁剪、`OptimizationPreference=Size`、符号剥离、相同方法体折叠、InvariantGlobalization;关闭 Server/Concurrent GC、异常栈、EventSource、调试、Hot Reload、HTTP tracing、HTTP/3 和 .NET Metrics。JSON 全部使用源码生成上下文,禁用反射序列化回退;LINQ 使用体积优化实现。HTTP/3 裁剪不影响现有 HTTP/WebSocket/TLS 连接,也不影响程序自身上报的监控指标。 + +`fleet-enroll` 使用同一套体积优化开关。Fleet 共享库已声明 AOT 兼容,由 Agent、Probe、Backend 或注册工具的最终发布一起裁剪;GC 和运行时功能开关配置在各可执行项目中。 + +历史实测体积(后续以各 RID 的 CI 产物为准): + +| 程序 | linux-musl-x64 | linux-x64 | win-x64 | +| --- | --- | --- | --- | +| monitor-probe | 7.7 MB | 7.7 MB | 5.6 MB | +| monitor-agent | 8.4 MB | 8.4 MB | 7.3 MB | +| fleet-enroll | 6.4 MB | 6.4 MB | — | + +Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。tar.gz 压缩后 Probe 约 3.6 MB。 + +## 2. 安装 + +解压 tar.gz,在解压目录里运行 `install.sh`(POSIX sh,root 运行;Debian、Ubuntu、Alpine、OpenWrt 都测试过): + +```sh +# Agent(服务器):注册并启动 +./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1 + +# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次) +./install.sh probe --probe-json ./probe.json + +./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册) +``` + +| 位置 | 内容 | 所有者 | +| --- | --- | --- | +| `/usr/lib/monitor/` | 二进制 | root,0755:采集进程不能改写自己的可执行文件 | +| `/etc/monitor/` | `agent.json` 或 `probe.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `monitor` 用户,0750 | + +安装脚本会:检查 OpenSSL、创建 `monitor` 用户(`useradd`、busybox `adduser`,OpenWrt 没有这些时直接写 `/etc/passwd`)、安装二进制和服务文件;缺少配置时只安装、不启动,并打印下一步。 + +### systemd(`packaging/systemd/*.service`) + +以 `monitor` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。 + +- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/monitor` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。 +- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。 + +### OpenWrt(`packaging/openwrt/`) + +只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/monitor-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/monitor`,sysupgrade 时保留 `/etc/monitor/`。 + +OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。闪存很小时注意:二进制 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。 + +## 3. 验证 + +`scripts/smoke-linux.sh` 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),`MODE=install` 用打包产物和 `install.sh` 安装、以 `monitor` 用户运行。`scripts/smoke-systemd.sh` 在 systemd 容器里验证服务单元。已经验证过: + +- Alpine 3.22(musl)、Debian 12(glibc):直接运行和 `install.sh` 安装都通; +- OpenWrt(官方 rootfs,busybox,无 `install`/`useradd`/`su`):安装、建用户、卸载、二进制启动和 OpenSSL 自检; +- systemd(Debian 12):两个服务 active,Agent 只有 `CAP_NET_RAW`、Probe 没有 capability,`NoNewPrivs`、seccomp 生效,Agent 无法写 `/etc/monitor`,没有重启和错误日志。 + +没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。 + +实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。 + +### 跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend) + +`scripts/smoke-wsl.py` 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 `tc netem` 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms): + +| 注入 | 期望 | 实测 | +| --- | --- | --- | +| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | `loss.down` 8.2%、`loss.up` 0;`jitter.down.p95` 8.7 ms、`jitter.up.p95` 0.18 ms;RTT +31 ms;TCP:`tcp.rtt.p99` 1.2 s、`tcp.slow` 28.8%、`tcp.retrans` 5.9%(Agent 一侧内核计数) | +| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | `loss.up` 9.4%、`loss.down` 0;`jitter.up.p95` 5.2 ms、`jitter.down.p95` 0 | +| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);`tcp.rtt.max` 9.5 s、`tcp.stall_max` 9.6 s | +| `hy2_ladder`(10/25/50/100 Mbit/s) | 无丢包 | `train.loss` 0.006%、`train.jitter.p95` 0.64 ms、可持续 100 Mbit/s | +| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 | + +验证中发现并修掉的两个问题: + +- **Probe 重启后,Agent 沿用了上一个会话的记录**:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里 `loss.up` 0、`loss.down` 7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。 +- **TCP 回显的几秒中断被记成丢包**:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间 `tcp.rtt.max` 只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。 + +没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。 + +### 跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS) + +2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。 + +```text +浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181) + 家宽机器: Probe ──WSS 到 https://<公网域名>──► Backend(身份、Agent 列表) + 家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent + VPS: Agent ──WSS 到 https://<公网域名>──► Backend +``` + +做法和要点: + +- **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。 +- **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。 +- **Probe 直连面板**:`probe.json` 里的 `server` 就是面板地址(取自 `Monitor__PublicUrl`,没配就用创建它时的请求地址),Probe 用它连面板,Agent 列表由面板下发;先注册还是先创建 Probe 都可以。 +- **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。 +- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 +- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。 + +**常驻(开机自启)**: + +- 家宽机器(没有 root 时):`~/.config/systemd/user/monitor-backend.service` 和 `monitor-probe.service`,`EnvironmentFile` 指向放配置的 `backend.env`,`Restart=always`,`systemctl --user enable --now …`,再用 `sudo loginctl enable-linger <用户>` 让它们不登录也能起来。Probe 的命令行是 `monitor-probe --config `(默认路径是 `/etc/monitor/probe.json`)。 +- VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。 +- 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。 + +**国家和运营商**:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(`Monitor__GeoLookupUrl`,缺省 `https://ip.007995.xyz`;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。 + +**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。 + +实测(Probe 在广东移动家宽,Agent 在日本 IIJ): + +| 项目 | 结果 | +| --- | --- | +| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms | +| 丢包 | 上行 0%,下行 8.1%(`hy2_ladder` 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) | +| 抖动 p95(上 / 下) | 0.9 / 5.2 ms | +| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms | +| `reality_session` | 建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) | +| 评分 | REALITY 82(含按需测试)、Hy2 42 | +| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 | + +这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 + +## 容器节点的运行(podman + systemd) + +Agent 和 Probe 推荐在宿主机上用 root 的 podman 加 systemd 运行,这样宿主机更新器(下一节)才能重启它们。以 Agent 为例(Probe 把名字和目录换成 `monitor-probe`,配置文件换成 `probe.json`): + +```ini +# /etc/systemd/system/monitor-agent-container.service +[Unit] +Description=Monitor Agent (podman) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/agent.image # IMAGE=<仓库>@sha256:,更新器更新时改写它 +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-agent +ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v /opt/monitor/etc:/etc/monitor ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-agent + +[Install] +WantedBy=multi-user.target +``` + +- **数据目录**(上面挂到容器 `/etc/monitor` 的 `/opt/monitor/etc`)归容器用户(UID/GID 1654),里面放 `agent.json`、身份目录(`fleet-enroll` 注册之后的 `agent-id/`),Probe 放 `probe.json` 和 `agents.json`。`agent.json` 里的 `fleet.identity_dir` 写容器里的路径(例如 `/etc/monitor/agent-id`)。 +- **首次注册 Agent**:`sudo podman run --rm --network host --security-opt label=disable -v <数据目录>:/etc/monitor --entrypoint /usr/local/bin/fleet-enroll <镜像> --server https://<面板> --join <加入令牌> --name <名字> --identity-dir /etc/monitor/agent-id`,再写 `agent.json`。Probe:在管理端创建(或轮换密钥)之后下载 `probe.json`,放进数据目录。 +- **拉取私有镜像**需要 Registry 的登录信息:用 `podman login --authfile /etc/monitor-update/registry-auth.json …` 生成一份只有 root 能读(600)的认证文件,更新器用它拉取,容器本身用不到。 +- **挂载宿主机的 `/etc/os-release`(只读)**:节点读系统名展示在面板上,不挂的话读到的是镜像里的 Alpine。 +- **ICMP 目标任务**要在 `podman run` 里加 `--cap-add NET_RAW`。 +- **经隧道注册之后检查 Agent 的 `public_host`**:面板看到的来源地址是隧道的出口(通常是内网地址),要在管理端改成这台机器自己的公网地址(`PATCH /api/admin/agents/{id}`),Probe 才连得上。 +- `packaging/compose/` 里的 compose 文件可用 `sudo podman compose up -d` 或 `docker compose up -d`;Podman Compose 需要安装 Compose provider。更新靠手动改版本再 `sudo podman compose pull && sudo podman compose up -d`,面板的更新按钮对它不起作用。 + +### Podman 的宿主指标与权限 + +这里使用 Linux 上的 **rootful Podman**(`sudo podman`),容器内仍以 UID/GID 1654 运行。配置目录需由 1654 用户读写;`label=disable` 不会改变 Unix 文件权限。Rootless Podman 的 UID 映射不同,不能直接沿用 `chown 1654:1654` 的准备步骤。 + +Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 示例也采用相同配置: + +| 宿主来源 | 容器路径 | 配置 | 指标 | +| --- | --- | --- | --- | +| `/proc` | `/host/proc` | `HOST_PROC=/host/proc` | CPU、内存、累计网络流量、瞬时收发速率、运行时长 | +| `/sys` | `/host/sys` | `HOST_SYS=/host/sys` | 网卡驱动报告的链路速率 | +| `/` | `/host/root` | `HOST_ROOT=/host/root` | 宿主根文件系统容量和已用空间 | +| `/etc/os-release` | `/etc/os-release` | 无 | 宿主发行版 | + +系统目录不加 `:z` 或 `:Z`,容器统一设置 `--security-opt label=disable`。读取失败的指标返回空值;配置了 `HOST_ROOT` 但没有挂载时不会改读容器 overlay 根分区。未设置这些环境变量时,原生进程继续读取本机默认路径。 + +磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。 + +Probe 保持现有功能,只上报系统、架构与节点状态,不上报 CPU、内存、磁盘或网卡指标。因此 Probe 的 Compose 模板只需要配置目录和 `/etc/os-release` 挂载;从 Agent systemd 示例改为 Probe 时,去掉 `/proc`、`/sys`、`/` 挂载及 `HOST_*` 环境变量。 + +可直接安装的 unit 模板见 `packaging/systemd/monitor-agent-container.service` 和 `packaging/systemd/monitor-probe-container.service`;它们与上述示例使用相同策略,分别读取 `/etc/monitor-update/agent.image` 与 `probe.image`。在 Linux 虚拟机上运行容器时,采集对象是该 Linux 虚拟机。 + +## 容器节点的更新(Agent、Probe) + +面板通知、节点验签、宿主机更新,**面板即使被攻破也装不上假镜像**: + +```text +CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 digest 的清单签名(ECDSA P-256,私钥只在 Woodpecker 密钥 release_signing_key 里) + └─ POST /api/admin/releases ──► 面板:只存下并原样转发(不验签)──► 在线的 Agent、Probe(连上时补发) + 节点进程:只把清单和签名写进数据目录的 update/,不执行任何命令 + │ systemd.path 监视到 offer.sig + ▼ + 宿主机 monitor-update(root):复制到临时目录 → 用本机的发布公钥验签 → 核对组件和镜像仓库 → seq 必须比已安装的大 + → status:available / invalid(写进 update-status/,节点随心跳报给面板,管理端「版本」页显示) + → 管理员点「更新」(或本机配置 POLICY=auto)→ podman pull <仓库>@sha256:(内容寻址,Registry 或链路上换不了) + → 写 IMAGE=… 到 /etc/monitor-update/<组件>.image,重启容器单元;20 秒内没有健康就换回旧镜像(rolled_back) +``` + +- **安装更新器**(每个容器节点一次,root):`packaging/update/install-updater.sh agent --data-dir /opt/monitor/etc --image-repo registry.oeo.one/monitor/monitor-agent --unit monitor-agent-container.service [--policy auto] [--authfile ]`。它装好 `monitor-update` 脚本和发布公钥,写 `/etc/monitor-update/<组件>.conf`,启用两个 systemd 路径单元。容器单元要用 `EnvironmentFile=/etc/monitor-update/<组件>.image` 和 `${IMAGE}` 启动容器。 +- **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。 +- **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。 +- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 +- **管理端**:「系统 → 版本」页显示最新发布的 tag、每个节点的更新状态(已是最新、有新版本、更新中、已回滚、清单无效),有新版本的节点有「更新」按钮,也可以「全部更新」。更新策略在节点本机配置:`/etc/monitor-update/<组件>.conf` 里 `POLICY=manual`(默认,等管理员请求)或 `auto`(验签通过就更新)。 +- **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。 +- 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。 + diff --git a/docs/project-layout.md b/docs/project-layout.md index 01485cf..e46b290 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -19,11 +19,13 @@ monitor/ ├─ packaging/ # install.sh、systemd 单元、OpenWrt 文件 ├─ web/ # 前端:Vite + React + TypeScript(自写),只依赖 backend/openapi/v1.json │ -├─ global.json Directory.Build.props Directory.Packages.props Monitor.slnx +├─ global.json Directory.Packages.props Monitor.slnx # SDK、NuGet 版本和全部 .NET 项目入口 ├─ .editorconfig BannedSymbols.txt # 风格与禁用 API(构建期强制) ├─ BannedSymbols.Node.txt # 仅用于常驻节点进程:纯采集边界 │ -└─ backend/ # 所有 C# 代码(构建产物在 backend/artifacts/) +├─ ci/Monitor.Tar/ # CI 归档工具,也加入根解决方案 +└─ backend/ # 服务和节点 C# 代码(构建产物在 backend/artifacts/) + ├─ Directory.Build.props Monitor.slnx # 后端编译设置和后端目录解决方案 ├─ fleet/ # 通用节点框架(不认识 Monitor,以后可以拆成独立仓库) │ ├─ src/ Fleet.Core Fleet.Identity Fleet.Rpc Fleet.Link Fleet.Hosting Fleet.Update │ │ Fleet.Enrollment.Client Fleet.Server.{Enrollment,Link,Releases,Sqlite} diff --git a/backend/global.json b/global.json similarity index 100% rename from backend/global.json rename to global.json diff --git a/packaging/compose/agent/compose.yaml b/packaging/compose/agent/compose.yaml index 3b641c5..488e45a 100644 --- a/packaging/compose/agent/compose.yaml +++ b/packaging/compose/agent/compose.yaml @@ -7,7 +7,8 @@ # --server "$MONITOR_SERVER" --join "$MONITOR_JOIN_TOKEN" --name "$MONITOR_AGENT_NAME" --identity-dir /etc/monitor/agent-id # 再在 .data/agent.json 里写(fleet.identity_dir 要和上面一致): # {"fleet":{"server":"https://","identity_dir":"/etc/monitor/agent-id"},"listen":{"bind":"0.0.0.0"},"limits":{"max_total_mbps":100,"max_total_pps":10000}} -# docker compose up -d +# sudo chown -R 1654:1654 .data +# sudo podman compose up -d # Linux rootful Podman;也可用 docker compose # # 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 # 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动,身份和历史都保留。 @@ -21,11 +22,20 @@ services: network_mode: host volumes: - ./.data:/etc/monitor - - /etc/os-release:/etc/os-release:ro # 让节点读到宿主机的系统名(镜像里是 Ubuntu) + - /etc/os-release:/etc/os-release:ro + # 系统目录只读,不加 :z/:Z;SELinux 隔离在容器级别禁用。 + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro + environment: + HOST_PROC: /host/proc + HOST_SYS: /host/sys + HOST_ROOT: /host/root read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 security_opt: + - label=disable - no-new-privileges:true cap_drop: - ALL diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index ea18454..09d7658 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -3,7 +3,8 @@ # 第一次使用: # mkdir -p .data && sudo chown 1654:1654 .data # 容器以 UID/GID 1654 运行,需要读写这个目录 # 在管理端创建 Probe,下载一次性身份配置,保存为 .data/probe.json(含私钥,权限 600,不要提交到版本控制) -# docker compose up -d +# sudo chown -R 1654:1654 .data # probe.json 的 600 权限要求文件也归容器用户所有 +# sudo podman compose up -d # Linux rootful Podman;也可用 docker compose # # 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 # 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动。 @@ -16,11 +17,12 @@ services: network_mode: host volumes: - ./.data:/etc/monitor - - /etc/os-release:/etc/os-release:ro # 让节点读到宿主机的系统名(镜像里是 Ubuntu) + - /etc/os-release:/etc/os-release:ro read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 security_opt: + - label=disable - no-new-privileges:true cap_drop: - ALL diff --git a/packaging/container/Agent.Containerfile b/packaging/container/Agent.Containerfile index 7b0e07b..32dd036 100644 --- a/packaging/container/Agent.Containerfile +++ b/packaging/container/Agent.Containerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine ARG APP_VERSION=unknown LABEL org.opencontainers.image.title="Monitor Agent" \ diff --git a/packaging/container/Agent.NativeBuild.Containerfile b/packaging/container/Agent.NativeBuild.Containerfile index c26898a..4f81658 100644 --- a/packaging/container/Agent.NativeBuild.Containerfile +++ b/packaging/container/Agent.NativeBuild.Containerfile @@ -1,10 +1,10 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build WORKDIR /src/backend COPY restore/backend/ ./ # restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 -RUN dotnet restore agent/src/Monitor.Agent -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts \ - && dotnet restore fleet/tools/fleet-enroll -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts +RUN dotnet restore agent/src/Monitor.Agent -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts \ + && dotnet restore fleet/tools/fleet-enroll -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts COPY source/backend/ ./ # 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) @@ -12,11 +12,11 @@ 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 -RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-x64 \ +RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-musl-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/agent \ - && dotnet publish fleet/tools/fleet-enroll -m:3 -c Release -r linux-x64 \ + && dotnet publish fleet/tools/fleet-enroll -m:3 -c Release -r linux-musl-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/enroll @@ -24,7 +24,7 @@ RUN dotnet publish agent/src/Monitor.Agent -m:3 -c Release -r linux-x64 \ RUN test -x /out/agent/monitor-agent && test -x /out/enroll/fleet-enroll \ && test "$(stat -c%s /out/agent/monitor-agent)" -gt 2000000 && test "$(stat -c%s /out/enroll/fleet-enroll)" -gt 2000000 -FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine ARG APP_VERSION=main LABEL org.opencontainers.image.title="Monitor Agent" \ diff --git a/packaging/container/Backend.Containerfile b/packaging/container/Backend.Containerfile index 2bde19a..26d6101 100644 --- a/packaging/container/Backend.Containerfile +++ b/packaging/container/Backend.Containerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine ARG APP_VERSION=unknown LABEL org.opencontainers.image.title="Monitor Backend" \ @@ -7,7 +7,7 @@ LABEL org.opencontainers.image.title="Monitor Backend" \ org.opencontainers.image.version="$APP_VERSION" WORKDIR /app -COPY --chown=1654:1654 app/ /app/ +COPY --chown=1654:1654 app/monitor-backend /app/monitor-backend COPY --chown=1654:1654 data/.keep /data/.keep ENV ASPNETCORE_HTTP_PORTS=8080 \ @@ -17,4 +17,4 @@ ENV ASPNETCORE_HTTP_PORTS=8080 \ USER 1654:1654 VOLUME ["/data"] EXPOSE 8080 -ENTRYPOINT ["dotnet", "/app/monitor-backend.dll"] +ENTRYPOINT ["/app/monitor-backend"] diff --git a/packaging/container/Probe.Containerfile b/packaging/container/Probe.Containerfile index cfeb418..8f198b7 100644 --- a/packaging/container/Probe.Containerfile +++ b/packaging/container/Probe.Containerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine ARG APP_VERSION=unknown LABEL org.opencontainers.image.title="Monitor Probe" \ diff --git a/packaging/container/Probe.NativeBuild.Containerfile b/packaging/container/Probe.NativeBuild.Containerfile index d5df013..76abe65 100644 --- a/packaging/container/Probe.NativeBuild.Containerfile +++ b/packaging/container/Probe.NativeBuild.Containerfile @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build WORKDIR /src/backend COPY restore/backend/ ./ # restore 也要带 PublishAot:不然不会还原 ILCompiler,之后 --no-restore 的发布就悄悄变成「裁剪过的框架依赖程序」 -RUN dotnet restore probe/src/Monitor.Probe -r linux-x64 -p:PublishAot=true --artifacts-path /artifacts +RUN dotnet restore probe/src/Monitor.Probe -r linux-musl-x64 -p:PublishAot=true --artifacts-path /artifacts COPY source/backend/ ./ # 构建信息:CI 通过 --build-arg 传入;不传就是空(放在 restore 之后,每次构建都变的时间不会让依赖还原的缓存失效) @@ -11,7 +11,7 @@ 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 -RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-x64 \ +RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-musl-x64 \ --self-contained true --no-restore -p:PublishAot=true -p:UseAotAnywhere=false \ -p:UseAppHost=true -p:StripSymbols=true \ -p:DebugType=none -p:DebugSymbols=false --artifacts-path /artifacts -o /out/probe @@ -19,7 +19,7 @@ RUN dotnet publish probe/src/Monitor.Probe -m:3 -c Release -r linux-x64 \ RUN test -x /out/probe/monitor-probe \ && test "$(stat -c%s /out/probe/monitor-probe)" -gt 2000000 -FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled-extra +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine ARG APP_VERSION=main LABEL org.opencontainers.image.title="Monitor Probe" \ diff --git a/packaging/systemd/monitor-agent-container.service b/packaging/systemd/monitor-agent-container.service new file mode 100644 index 0000000..dafa366 --- /dev/null +++ b/packaging/systemd/monitor-agent-container.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Agent (Podman container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/agent.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-agent +ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v /opt/monitor/etc:/etc/monitor ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-agent + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/monitor-probe-container.service b/packaging/systemd/monitor-probe-container.service new file mode 100644 index 0000000..28f8630 --- /dev/null +++ b/packaging/systemd/monitor-probe-container.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Probe (Podman container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/probe.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-probe +ExecStart=/usr/bin/podman run --rm --name monitor-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /opt/monitor-probe/etc:/etc/monitor ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-probe + +[Install] +WantedBy=multi-user.target diff --git a/scripts/publish.sh b/scripts/publish.sh index f5f18f9..963dc0c 100644 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -23,7 +23,11 @@ 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) +publish_options=() +if [[ "$project" == backend ]]; then + publish_options+=(-p:OpenApiGenerateDocuments=false) +fi +args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false "${publish_options[@]}" -o "/src/$out" --artifacts-path /artifacts -nologo -v q) case "$rid" in linux-musl-x64) @@ -39,8 +43,8 @@ case "$rid" in exit 3 ;; *) - cd "$root/backend" # global.json 在 backend/ 下 - dotnet publish "${csproj#backend/}" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false -o "$root/$out" -nologo -v q + cd "$root/backend" # 继承仓库根目录的 global.json + dotnet publish "${csproj#backend/}" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -p:DebugSymbols=false "${publish_options[@]}" -o "$root/$out" -nologo -v q ;; esac diff --git a/web/display/src/live.ts b/web/display/src/live.ts index b1ac44a..0c23d17 100644 --- a/web/display/src/live.ts +++ b/web/display/src/live.ts @@ -14,6 +14,7 @@ export interface Host { net_tx_total: number | null net_rx_bps: number | null net_tx_bps: number | null + net_link_mbps: number | null uptime_seconds: number | null at: string } diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index e4410b1..bba68b9 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -128,6 +128,10 @@ function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem ↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)} +
+ 链路速率 + {h.net_link_mbps ? `${h.net_link_mbps} Mbit/s` : '—'} +
运行时间 {fmtUptime(h.uptime_seconds)} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index a5175ff..d633c16 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1214,6 +1214,8 @@ export interface components { /** Format: int64 */ net_tx_bps: null | number | string; /** Format: int64 */ + net_link_mbps: null | number | string; + /** Format: int64 */ uptime_seconds: null | number | string; /** Format: date-time */ at: unknown;