diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..627e9f4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +ci/build-main-amd64.sh text eol=lf +ci/index-backend-image.sh text eol=lf +ci/publish-release-offer.sh text eol=lf +ci/release-linux.sh text eol=lf +packaging/update/*.sh text eol=lf diff --git a/.woodpecker/linux-release.yml b/.woodpecker/linux-release.yml index 9bc65d5..098ec4a 100644 --- a/.woodpecker/linux-release.yml +++ b/.woodpecker/linux-release.yml @@ -47,6 +47,11 @@ steps: ZOT_USERNAME: justme ZOT_SECRET: from_secret: zot_secret + RELEASE_API_URL: https://mo.oeo.one + RELEASE_SIGNING_KEY: + from_secret: release_signing_key + RELEASE_API_TOKEN: + from_secret: release_api_token commands: - bash ci/index-backend-image.sh depends_on: diff --git a/.woodpecker/main-amd64-build.yml b/.woodpecker/main-amd64-build.yml index 1b1beb9..b1a1f0a 100644 --- a/.woodpecker/main-amd64-build.yml +++ b/.woodpecker/main-amd64-build.yml @@ -24,6 +24,11 @@ steps: ZOT_USERNAME: justme ZOT_SECRET: from_secret: zot_secret + RELEASE_API_URL: https://mo.oeo.one + RELEASE_SIGNING_KEY: + from_secret: release_signing_key + RELEASE_API_TOKEN: + from_secret: release_api_token commands: - bash ci/build-main-amd64.sh backend depends_on: [] diff --git a/TODO.md b/TODO.md index bd00fd7..a4e1f72 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,6 @@ ## 测量 -- IPv6 - 路由跳数、路径漂移和 trace - 握手耗时:TCP 连接耗时,TLS / QUIC 握手耗时(QUIC 目前没有真实握手积木) - 网页长截图 @@ -28,7 +27,7 @@ - 更新遥测(现在面板只有每个节点的「当前更新状态」) - **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。 -- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证;Backend 自身的更新(Backend 目前没有更新器,要手动拉镜像)。 +- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。 - **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来: - 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。 - 本地说了算:更新策略(`auto` / `notify` / `pinned:`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。 diff --git a/backend/agent/README.md b/backend/agent/README.md index 3b13a4b..ea082c6 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -51,6 +51,8 @@ moshiro-agent --config /etc/moshiro/agent.json 不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 +IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为 `::`,覆盖整个双栈接口;已有配置如果仍写 `0.0.0.0`,可改为 `::` 后重启。Compose 与 systemd 容器部署使用宿主网络,诊断反映宿主 IPv6;自定义容器网络时,Agent 同时报告容器网络以及可读取到的宿主 `/proc` IPv6 状态。诊断不上传 IP 或接口名称。 + ## 设计说明 - 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。 diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 70d72b6..e8a497f 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -15,6 +15,7 @@ public sealed class AgentApp : IDisposable public const string Version = MoShiro.Protocol.BuildInfo.Version; private readonly AgentConfig _config; + private readonly bool _ipv6Supported; private readonly LoadedNodeIdentity _identity; private readonly TimeProvider _time; private readonly INodeLog _log; @@ -47,6 +48,12 @@ public sealed class AgentApp : IDisposable _uplink = new BackendUplink(time); IPAddress bind = IPAddress.Parse(config.Listen.Bind); + if (bind.Equals(IPAddress.IPv6Any) && !System.Net.Sockets.Socket.OSSupportsIPv6) + { + bind = IPAddress.Any; + } + + _ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6; _admission = new AgentAdmission(_streams); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data); @@ -257,6 +264,8 @@ public sealed class AgentApp : IDisposable { var meta = new AgentHelloMeta { + Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), + Ipv6Supported = _ipv6Supported, Platform = MoShiro.Measurement.PlatformDetector.Detect(), TlsSpki = _identity.File.TlsSpki, ProbeTcpPort = ControlPort, @@ -284,6 +293,7 @@ public sealed class AgentApp : IDisposable long packetsOut = _counters.PacketsOut; var extra = new AgentHeartbeatExtra { + Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), Probes = [.. _hub.ConnectedProbes()], Sessions = _sessions.All.Count, PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds, diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index cc89cb3..1331b8d 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -46,7 +46,7 @@ public sealed class FleetSection public sealed class ListenSection { [JsonPropertyName("bind")] - public string Bind { get; set; } = "0.0.0.0"; + public string Bind { get; set; } = "::"; /// Probe 控制通道(TLS,点对点会话)。 [JsonPropertyName("control_port")] diff --git a/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs b/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs index 21f050e..5753a42 100644 --- a/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs +++ b/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs @@ -44,6 +44,7 @@ public sealed class ChunkAssembler _ => null, }; header.ProbeId = new ProbeId(probe.ProbeId); + header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值 KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind); if (stream is null || spec is null || spec.Id != header.Kind) { diff --git a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs index 5703c4d..b467648 100644 --- a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs +++ b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs @@ -56,6 +56,10 @@ public sealed class ProbeSession : DataSession public string ProbeId => Info.PeerId; + public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4; + + public string PathKey => ProbeId + "~" + IpVersion; + /// Probe 的程序版本(握手里自报,只用于展示)。 public string? Version => Info.Version; @@ -72,7 +76,7 @@ public sealed class ProbeSession : DataSession get { SocketAddress? source = Volatile.Read(ref _udpSource); - return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source); + return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source); } } @@ -125,7 +129,7 @@ public sealed class ProbeSession : DataSession public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _); } -/// 当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。 +/// 每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。 public sealed class SessionRegistry : IDataSessions { private readonly object _gate = new(); @@ -143,11 +147,11 @@ public sealed class SessionRegistry : IDataSessions } } - public ProbeSession? Get(string probeId) + public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4) { lock (_gate) { - return _byProbe.GetValueOrDefault(probeId); + return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion); } } @@ -156,13 +160,13 @@ public sealed class SessionRegistry : IDataSessions { lock (_gate) { - _byProbe.TryGetValue(session.ProbeId, out ProbeSession? old); + _byProbe.TryGetValue(session.PathKey, out ProbeSession? old); if (old is not null) { _bySession.Remove(old.SessionId); } - _byProbe[session.ProbeId] = session; + _byProbe[session.PathKey] = session; _bySession[session.SessionId] = session; return old; } @@ -172,9 +176,9 @@ public sealed class SessionRegistry : IDataSessions { lock (_gate) { - if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session)) + if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session)) { - _byProbe.Remove(session.ProbeId); + _byProbe.Remove(session.PathKey); } if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session)) diff --git a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs index b390d6d..77acdd2 100644 --- a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs +++ b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs @@ -176,7 +176,7 @@ public sealed class PeerHub await _tasks.CloseSessionAsync(probe); _log.Info("probe disconnected", ("probe", info.PeerId)); OnProbeLost?.Invoke(probe); - await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId); + await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe)); } } @@ -208,7 +208,7 @@ public sealed class PeerHub /// 当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。 public IReadOnlyList 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, IpVersion = p.IpVersion }; public IReadOnlyList ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)]; @@ -218,13 +218,17 @@ public sealed class PeerHub /// Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。 public async ValueTask DisconnectAsync(string probeId) { - if (_sessions.Get(probeId)?.Rpc is not { } rpc) + bool disconnected = false; + foreach (ProbeSession probe in _sessions.All) { - return false; + if (probe.ProbeId == probeId && probe.Rpc is { } rpc) + { + await rpc.CloseAsync("disconnected", CancellationToken.None); + disconnected = true; + } } - await rpc.CloseAsync("disconnected", CancellationToken.None); - return true; + return disconnected; } /// 名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。 diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs index a5dbf29..5ee48b0 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs @@ -80,7 +80,7 @@ public sealed class TargetRunners Log = _log, Session = () => null, PeerAddress = address, - Fragments = new LocalSink(this, spec), + Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4), }; IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null))); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -132,11 +132,13 @@ public sealed class TargetRunners { private readonly TargetRunners _owner; private readonly TaskSpec _spec; + private readonly string _ipVersion; - public LocalSink(TargetRunners owner, TaskSpec spec) + public LocalSink(TargetRunners owner, TaskSpec spec, string ipVersion) { _owner = owner; _spec = spec; + _ipVersion = ipVersion; } public void Ship(string kind, FragmentScope scope, IReadOnlyList streams, IReadOnlyDictionary? parameters, long startUtcMs, int durationMs, int port) @@ -149,6 +151,7 @@ public sealed class TargetRunners TaskId = scope.Task, AgentId = _owner._agent, Target = _spec.Target, + IpVersion = _ipVersion, Params = parameters is null ? [] : new Dictionary(parameters), Port = port, StartUtcMs = startUtcMs, diff --git a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs index 23c2ddc..745a8bc 100644 --- a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs +++ b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs @@ -46,7 +46,12 @@ public sealed class TestRunner /// 校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 moshiro.test.status 回报。 public Result Start(TestRunParams run) { - if (_sessions.Get(run.ProbeId.Value) is not { } probe) + if (!IpVersions.IsValid(run.IpVersion)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6"); + } + + if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe) { return Result.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent"); } diff --git a/backend/fleet/src/Fleet.Link/Peer.cs b/backend/fleet/src/Fleet.Link/Peer.cs index e26dbcb..691b544 100644 --- a/backend/fleet/src/Fleet.Link/Peer.cs +++ b/backend/fleet/src/Fleet.Link/Peer.cs @@ -57,6 +57,11 @@ public sealed class PeerListener public async Task RunAsync(CancellationToken ct) { var listener = new TcpListener(_options.Listen); + if (_options.Listen.Address.Equals(IPAddress.IPv6Any)) + { + listener.Server.DualMode = true; + } + listener.Start(); Port = ((IPEndPoint)listener.LocalEndpoint).Port; Started.TrySetResult(); @@ -219,7 +224,8 @@ public static class PeerConnector public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) { - var client = new TcpClient { NoDelay = true }; + TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient(); + client.NoDelay = true; PeerSockets.EnableKeepAlive(client.Client); try { diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index fb33894..87bf05e 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -61,6 +61,13 @@ "schema": { "type": "string" } + }, + { + "name": "ip_version", + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -117,6 +124,37 @@ } } }, + "/api/public/ipv6": { + "get": { + "tags": [ + "public" + ], + "summary": "IPv6 configuration and collector diagnostics without addresses", + "operationId": "ipv6_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfAgentIpv6Dto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/public/lines/{line_id}": { "get": { "tags": [ @@ -706,6 +744,15 @@ ], "summary": "Server-sent events: nodes, overview, series and test topics", "operationId": "live_stream", + "parameters": [ + { + "name": "ip_version", + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" @@ -3901,6 +3948,148 @@ } } }, + "/api/admin/backend/update": { + "get": { + "tags": [ + "admin-build" + ], + "summary": "Current channel and host-verified backend update", + "operationId": "backend_update_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendUpdateDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-build" + ], + "summary": "Confirm one verified backend release before the host restarts it", + "operationId": "backend_update_confirm", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmBackendUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/backend/update/live": { + "get": { + "tags": [ + "admin-build" + ], + "summary": "Server-sent backend update notifications", + "operationId": "backend_update_live", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/admin/links": { "get": { "tags": [ @@ -4333,6 +4522,47 @@ }, "last_seen_at": { "format": "date-time" + }, + "ipv6_host": { + "type": "string", + "default": "" + }, + "ipv6_supported": { + "type": "boolean", + "default": false + }, + "ipv6": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Ipv6DiagnosticDto" + } + ] + } + } + }, + "AgentIpv6Dto": { + "required": [ + "agent_id", + "name", + "online", + "ipv6" + ], + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "ipv6": { + "$ref": "#/components/schemas/Ipv6DiagnosticDto" } } }, @@ -4371,6 +4601,12 @@ "null", "boolean" ] + }, + "ipv6_host": { + "type": [ + "null", + "string" + ] } } }, @@ -4405,6 +4641,57 @@ } } }, + "BackendUpdateDto": { + "required": [ + "channel", + "enabled", + "state", + "current_tag", + "current_digest", + "available", + "error" + ], + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "current_tag": { + "type": [ + "null", + "string" + ] + }, + "current_digest": { + "type": [ + "null", + "string" + ] + }, + "available": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReleaseDto" + } + ] + }, + "error": { + "type": [ + "null", + "string" + ] + } + } + }, "BlocksDto": { "required": [ "transport", @@ -4482,6 +4769,16 @@ "items": { "$ref": "#/components/schemas/NodeBuildDto" } + }, + "backend_update": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BackendUpdateDto" + } + ] } } }, @@ -4598,6 +4895,22 @@ } } }, + "ConfirmBackendUpdateRequest": { + "required": [ + "seq" + ], + "type": "object", + "properties": { + "seq": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + }, "ConnectedProbeDto": { "required": [ "probe_id", @@ -4624,6 +4937,10 @@ }, "connected_since": { "format": "date-time" + }, + "ip_version": { + "type": "string", + "default": "ipv4" } } }, @@ -5390,6 +5707,65 @@ } } }, + "Ipv6DiagnosticDto": { + "required": [ + "address_configured", + "socket_supported", + "listening", + "has_global_address", + "has_default_route", + "state" + ], + "type": "object", + "properties": { + "address_configured": { + "type": [ + "null", + "boolean" + ] + }, + "socket_supported": { + "type": "boolean" + }, + "listening": { + "type": [ + "null", + "boolean" + ] + }, + "has_global_address": { + "type": [ + "null", + "boolean" + ] + }, + "has_default_route": { + "type": [ + "null", + "boolean" + ] + }, + "state": { + "type": "string" + }, + "network_scope": { + "type": "string", + "default": "unknown" + }, + "host_has_global_address": { + "type": [ + "null", + "boolean" + ] + }, + "host_has_default_route": { + "type": [ + "null", + "boolean" + ] + } + } + }, "JoinTokenDto": { "required": [ "id", @@ -5555,6 +5931,14 @@ "$ref": "#/components/schemas/TestSummaryDto" } ] + }, + "ip_version": { + "type": "string", + "default": "ipv4" + }, + "ipv6_available": { + "type": "boolean", + "default": false } } }, @@ -6131,6 +6515,10 @@ "$ref": "#/components/schemas/TestSummaryDto" } ] + }, + "ip_version": { + "type": "string", + "default": "ipv4" } } }, @@ -6155,6 +6543,27 @@ } } }, + "PageDtoOfAgentIpv6Dto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentIpv6Dto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfApiTokenDto": { "required": [ "items", @@ -6727,6 +7136,10 @@ }, "connected_since": { "format": "date-time" + }, + "ip_version": { + "type": "string", + "default": "ipv4" } } }, @@ -6765,6 +7178,16 @@ "items": { "$ref": "#/components/schemas/ProbeConnectionDto" } + }, + "ipv6": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Ipv6DiagnosticDto" + } + ] } } }, @@ -7062,6 +7485,10 @@ }, "digest": { "type": "string" + }, + "channel": { + "type": "string", + "default": "release" } } }, @@ -7206,6 +7633,10 @@ "additionalProperties": { "type": "string" } + }, + "ip_version": { + "type": "string", + "default": "ipv4" } } }, @@ -7458,6 +7889,10 @@ "$ref": "#/components/schemas/TestResultDto" } ] + }, + "ip_version": { + "type": "string", + "default": "ipv4" } } }, diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs index 1a2e621..b574a36 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs @@ -74,8 +74,20 @@ public sealed class AgentConnection private async Task RunOnceAsync(Backoff backoff, CancellationToken ct) { + AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork; + using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time); + using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token); + IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal) + ? [literal] + : await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token); + IPAddress? address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6); + if (address is null) + { + throw new SocketException((int)SocketError.HostNotFound); + } + (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( - Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); + address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); var remote = (IPEndPoint)info.Remote!; var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []); diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index 4a59d2c..e41904f 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -67,7 +67,19 @@ public sealed class AgentDirectory /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 public void Apply(ProbeAgentList result) { - var next = result.Agents.Select(a => new KnownAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList(); + var next = new List(); + foreach (AgentEndpoint agent in result.Agents) + { + if (!string.IsNullOrWhiteSpace(agent.Host)) + { + next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki }); + } + + if (!string.IsNullOrWhiteSpace(agent.Ipv6Host)) + { + next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 }); + } + } lock (_gate) { if (result.Version < _version || (result.Version == _version && Same(next, _agents))) @@ -96,5 +108,5 @@ public sealed class AgentDirectory a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); public static bool Equal(KnownAgent a, KnownAgent b) => - a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki; + a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki; } diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 9ffb699..15d28df 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -39,8 +39,8 @@ public sealed class ProbeApp : IDisposable NodeId = new NodeId(file.ProbeId), NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base], - HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus() }, MoShiroJsonContext.Default.ProbeHeartbeatExtra), - HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect() }, MoShiroJsonContext.Default.ProbeHelloMeta), + HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra), + HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta), }; var list = new StateHandler( ProbeAgentList.StateType, @@ -70,19 +70,19 @@ public sealed class ProbeApp : IDisposable public AgentDirectory Directory => _env.Directory; /// 某个 Agent 的会话是否已经建立(测试和状态查看用)。 - public bool IsConnected(string agentId) + public bool IsConnected(string agentId, string ipVersion = IpVersions.V4) { lock (_connections) { - return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected; + return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected; } } - public AgentConnection? Connection(string agentId) + public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4) { lock (_connections) { - return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null; + return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null; } } @@ -121,7 +121,7 @@ public sealed class ProbeApp : IDisposable private async Task ReconcileAsync(IReadOnlyList agents, CancellationToken ct) { - var wanted = agents.ToDictionary(a => a.AgentId); + var wanted = agents.ToDictionary(a => a.PathKey); var stopped = new List(); lock (_connections) { diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 5bb9aa8..1057d90 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using MoShiro.Protocol; namespace MoShiro.Probe; @@ -25,6 +26,12 @@ public sealed class ProbeFile public sealed class KnownAgent { + [JsonPropertyName("ip_version")] + public string IpVersion { get; set; } = IpVersions.V4; + + [JsonIgnore] + public string PathKey => AgentId + "~" + IpVersion; + [JsonPropertyName("agent_id")] public string AgentId { get; set; } = ""; diff --git a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs b/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs index 5ff35e8..3b9825f 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs +++ b/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs @@ -6,6 +6,31 @@ namespace MoShiro.Probe.Tests; public sealed class ProbeTests { + [Fact] + public void AgentDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4() + { + string directory = NewDirectory(); + try + { + var agents = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance); + ProbeAgentList list = List(1, "agt_dual"); + list.Agents[0].Ipv6Host = "2001:db8::1"; + agents.Apply(list); + Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Agents.Select(a => a.IpVersion)); + var reloaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance); + Assert.Equal(2, reloaded.Agents.Count); + Assert.Equal("2001:db8::1", reloaded.Agents[1].Host); + list.Version = 2; + list.Agents[0].Ipv6Host = null; + reloaded.Apply(list); + Assert.Equal("ipv4", Assert.Single(reloaded.Agents).IpVersion); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip() { diff --git a/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs b/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs index 53379cb..f4591c3 100644 --- a/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs +++ b/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs @@ -69,6 +69,7 @@ public static class ChunkFinalizer Phase = source.Phase, AgentId = agent, ProbeId = source.ProbeId, + IpVersion = source.IpVersion, Target = null, Params = new Dictionary(resolvedParams), Port = port, diff --git a/backend/shared/MoShiro.Measurement/Net/Tcp.cs b/backend/shared/MoShiro.Measurement/Net/Tcp.cs index 30adafb..98e798b 100644 --- a/backend/shared/MoShiro.Measurement/Net/Tcp.cs +++ b/backend/shared/MoShiro.Measurement/Net/Tcp.cs @@ -39,7 +39,7 @@ public sealed class TcpLink : IDisposable /// public static async Task OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct) { - var client = new TcpClient { NoDelay = true }; + var client = new TcpClient(env.PeerAddress!.AddressFamily) { NoDelay = true }; try { long start = env.Clock.NowUs(); @@ -153,6 +153,11 @@ public sealed class TcpAcceptor foreach (int port in _ports) { var listener = new TcpListener(_bind, port); + if (_bind.Equals(IPAddress.IPv6Any)) + { + listener.Server.DualMode = true; + } + listener.Start(128); _listeners.Add(listener); bound.Add(((IPEndPoint)listener.LocalEndpoint).Port); diff --git a/backend/shared/MoShiro.Measurement/Platform/Ipv6Diagnostics.cs b/backend/shared/MoShiro.Measurement/Platform/Ipv6Diagnostics.cs new file mode 100644 index 0000000..5a798f8 --- /dev/null +++ b/backend/shared/MoShiro.Measurement/Platform/Ipv6Diagnostics.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using MoShiro.Protocol; + +namespace MoShiro.Measurement; + +/// 诊断采集进程所在的网络环境。宿主网络容器看到的是主机;桥接容器看到自己的网络环境。 +public static class Ipv6Diagnostics +{ + public static Ipv6Diagnostic Sample(bool? listening = null, string? networkScope = null, string? hostProcRoot = null) + { + bool supported = Socket.OSSupportsIPv6; + bool? address = null; + bool? route = null; + if (supported) + { + if (OperatingSystem.IsLinux()) + { + address = ParseAddresses(Read("/proc/net/if_inet6")); + route = ParseRoutes(Read("/proc/net/ipv6_route")); + } + else if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + { + try + { + address = false; + route = false; + foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + IPInterfaceProperties properties = adapter.GetIPProperties(); + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (IsGlobal(unicast.Address)) + { + address = true; + } + } + + foreach (GatewayIPAddressInformation gateway in properties.GatewayAddresses) + { + if (gateway.Address.AddressFamily == AddressFamily.InterNetworkV6 && !gateway.Address.Equals(IPAddress.IPv6Any)) + { + route = true; + } + } + } + } + catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException) + { + address = null; + route = null; + } + } + } + + string scope = networkScope is "host" or "container" ? networkScope + : File.Exists("/run/.containerenv") || File.Exists("/.dockerenv") ? "container" : "host"; + bool? hostAddress = scope == "host" ? address : null; + bool? hostRoute = scope == "host" ? route : null; + if (scope == "container" && OperatingSystem.IsLinux() && hostProcRoot is not null) + { + // /host/proc/net 通常指向当前进程的网络空间;宿主 PID 1 才是宿主网络。 + hostAddress = ParseAddresses(Read(Path.Combine(hostProcRoot, "1/net/if_inet6"))); + hostRoute = ParseRoutes(Read(Path.Combine(hostProcRoot, "1/net/ipv6_route"))); + } + + Ipv6Diagnostic result = Evaluate(supported, listening, address, route); + result.NetworkScope = scope; + result.HostHasGlobalAddress = hostAddress; + result.HostHasDefaultRoute = hostRoute; + if (scope == "container" && address == false && hostAddress == true) + { + result.State = "container_network_not_ready"; + } + + return result; + } + + public static Ipv6Diagnostic Evaluate(bool supported, bool? listening, bool? address, bool? route) => new() + { + SocketSupported = supported, + Listening = listening, + HasGlobalAddress = address, + HasDefaultRoute = route, + State = !supported ? "unsupported" : address == false ? "no_global_address" : listening == false ? "not_listening" + : route == false ? "no_default_route" : address is null || route is null ? "unknown" : "ready", + }; + + public static bool? ParseAddresses(string? text) + { + if (text is null) + { + return null; + } + + foreach (string line in text.Split('\n')) + { + string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 6 || fields[0].Length != 32 || fields[0][0] is not ('2' or '3') || fields[3] != "00") + { + continue; + } + + // tentative、DAD failed 和 deprecated 地址不能作为就绪的公网地址。 + if (uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) && (flags & 0x68) == 0) + { + return true; + } + } + + return false; + } + + public static bool? ParseRoutes(string? text) + { + if (text is null) + { + return null; + } + + foreach (string line in text.Split('\n')) + { + string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 10 && fields[0] == "00000000000000000000000000000000" && fields[1] == "00" && fields[9] != "lo" + && uint.TryParse(fields[8], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) && (flags & 1) != 0 && (flags & 0x200) == 0) + { + return true; + } + } + + return false; + } + + private static bool IsGlobal(IPAddress address) => address.AddressFamily == AddressFamily.InterNetworkV6 && (address.GetAddressBytes()[0] & 0xE0) == 0x20; + + private static string? Read(string path) + { + try + { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/backend/shared/MoShiro.Protocol/IpVersions.cs b/backend/shared/MoShiro.Protocol/IpVersions.cs new file mode 100644 index 0000000..a7880dc --- /dev/null +++ b/backend/shared/MoShiro.Protocol/IpVersions.cs @@ -0,0 +1,13 @@ +namespace MoShiro.Protocol; + +/// 测量路径的地址族。旧契约缺省为 IPv4;IPv6 使用独立的线路和序列。 +public static class IpVersions +{ + public const string V4 = "ipv4"; + public const string V6 = "ipv6"; + + public static bool IsValid(string? value) => value is V4 or V6; + + public static string LineSeries(string probeId, string ipVersion) => + RawChunkHeader.LinePrefix + probeId + (ipVersion == V6 ? ":ipv6" : ""); +} diff --git a/backend/shared/MoShiro.Protocol/Ipv6Diagnostic.cs b/backend/shared/MoShiro.Protocol/Ipv6Diagnostic.cs new file mode 100644 index 0000000..d19a926 --- /dev/null +++ b/backend/shared/MoShiro.Protocol/Ipv6Diagnostic.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace MoShiro.Protocol; + +/// 当前网络环境的 IPv6 诊断。仅上报状态,不传地址、接口名称或路由内容。 +public sealed class Ipv6Diagnostic +{ + [JsonPropertyName("socket_supported")] + public bool SocketSupported { get; set; } + + [JsonPropertyName("listening")] + public bool? Listening { get; set; } + + [JsonPropertyName("network_scope")] + public string NetworkScope { get; set; } = "unknown"; + + [JsonPropertyName("host_has_global_address")] + public bool? HostHasGlobalAddress { get; set; } + + [JsonPropertyName("host_has_default_route")] + public bool? HostHasDefaultRoute { get; set; } + + [JsonPropertyName("has_global_address")] + public bool? HasGlobalAddress { get; set; } + + [JsonPropertyName("has_default_route")] + public bool? HasDefaultRoute { get; set; } + + [JsonPropertyName("state")] + public string State { get; set; } = "unknown"; +} diff --git a/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs b/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs index 0255d66..e7c11ad 100644 --- a/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs +++ b/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs @@ -58,6 +58,10 @@ public sealed class StreamHeader /// 块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。 public sealed class RawChunkHeader { + [JsonPropertyName("ip_version")] + [JsonPropertyOrder(1)] + public string IpVersion { get; set; } = IpVersions.V4; + [JsonPropertyName("chunk_id")] public ChunkId ChunkId { get; set; } @@ -113,10 +117,10 @@ public sealed class RawChunkHeader /// /// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。 - /// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。 + /// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。 /// [JsonIgnore] - public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? ""); + public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(ProbeId?.Value ?? "", IpVersion); public const string LinePrefix = "line:"; } diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 83864cc..5907bd6 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -61,6 +61,9 @@ public sealed class TestPhase public sealed class TestRunParams { + [JsonPropertyName("ip_version")] + public string IpVersion { get; set; } = IpVersions.V4; + [JsonPropertyName("test_id")] public TestId TestId { get; set; } @@ -127,6 +130,9 @@ public sealed class AgentEventParams public sealed class AgentEndpoint { + [JsonPropertyName("ipv6_host")] + public string? Ipv6Host { get; set; } + [JsonPropertyName("agent_id")] public NodeId AgentId { get; set; } @@ -275,6 +281,9 @@ public sealed class UpdateStatus /// Probe 的 fleet.heartbeat 附加字段:更新器的状态。 public sealed class ProbeHeartbeatExtra { + [JsonPropertyName("ipv6")] + public Ipv6Diagnostic? Ipv6 { get; set; } + [JsonPropertyName("update")] public UpdateStatus? Update { get; set; } } @@ -282,6 +291,9 @@ public sealed class ProbeHeartbeatExtra /// Probe 的 fleet.hello 附加字段:系统、架构和构建信息(只用于展示和更新)。 public sealed class ProbeHelloMeta { + [JsonPropertyName("ipv6")] + public Ipv6Diagnostic? Ipv6 { get; set; } + [JsonPropertyName("platform")] public PlatformInfo? Platform { get; set; } } @@ -289,6 +301,13 @@ public sealed class ProbeHelloMeta /// fleet.hello 的附加字段。 public sealed class AgentHelloMeta { + [JsonPropertyName("ipv6")] + public Ipv6Diagnostic? Ipv6 { get; set; } + + /// 本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。 + [JsonPropertyName("ipv6_supported")] + public bool Ipv6Supported { get; set; } + [JsonPropertyName("platform")] public PlatformInfo? Platform { get; set; } @@ -353,6 +372,9 @@ public sealed class HostSnapshot /// Agent 眼中的一个已连接 Probe。 public sealed class ConnectedProbe { + [JsonPropertyName("ip_version")] + public string IpVersion { get; set; } = IpVersions.V4; + [JsonPropertyName("id")] public ProbeId Id { get; set; } @@ -370,6 +392,9 @@ public sealed class ConnectedProbe /// fleet.heartbeat 的附加字段:只有进程内的计数,没有主机信息。 public sealed class AgentHeartbeatExtra { + [JsonPropertyName("ipv6")] + public Ipv6Diagnostic? Ipv6 { get; set; } + /// 当前连着的 Probe 会话数(等于 的个数)。 [JsonPropertyName("sessions")] public int Sessions { get; set; } @@ -541,6 +566,7 @@ public static class FragmentKeys [JsonSerializable(typeof(AgentEnrollMeta))] [JsonSerializable(typeof(PlatformInfo))] [JsonSerializable(typeof(AgentHelloMeta))] +[JsonSerializable(typeof(Ipv6Diagnostic))] [JsonSerializable(typeof(AgentHeartbeatExtra))] [JsonSerializable(typeof(RawChunkHeader))] public sealed partial class MoShiroJsonContext : JsonSerializerContext; diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs new file mode 100644 index 0000000..f87462b --- /dev/null +++ b/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs @@ -0,0 +1,50 @@ +using MoShiro.Measurement; + +namespace MoShiro.Measurement.Tests; + +public sealed class Ipv6DiagnosticsTests +{ + [Fact] + public void Addresses_IgnoresLocalTentativeFailedAndDeprecatedAddresses() + { + Assert.Null(Ipv6Diagnostics.ParseAddresses(null)); + Assert.False(Ipv6Diagnostics.ParseAddresses("")); + Assert.False(Ipv6Diagnostics.ParseAddresses("00000000000000000000000000000001 01 80 10 80 lo\nfe800000000000000000000000000001 02 40 20 80 eth0")); + foreach (string flags in new[] { "40", "08", "20" }) + { + Assert.False(Ipv6Diagnostics.ParseAddresses($"20010db8000000000000000000000001 02 40 00 {flags} eth0")); + } + + Assert.True(Ipv6Diagnostics.ParseAddresses("20010db8000000000000000000000001 02 40 00 80 eth0")); + } + + [Fact] + public void Routes_RequiresAnActiveNonLoopbackDefaultRoute() + { + Assert.Null(Ipv6Diagnostics.ParseRoutes(null)); + Assert.False(Ipv6Diagnostics.ParseRoutes("")); + string prefix = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000000 00000000 "; + Assert.True(Ipv6Diagnostics.ParseRoutes(prefix + "00000003 eth0")); + Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000201 eth0")); + Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000001 lo")); + Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000000 eth0")); + } + + [Theory] + [InlineData(false, true, true, true, "unsupported")] + [InlineData(true, true, false, true, "no_global_address")] + [InlineData(true, false, true, true, "not_listening")] + [InlineData(true, true, true, false, "no_default_route")] + [InlineData(true, true, true, true, "ready")] + public void Evaluate_DistinguishesConfigurationFromAddressAndRouting(bool supported, bool listening, bool address, bool route, string expected) + { + Assert.Equal(expected, Ipv6Diagnostics.Evaluate(supported, listening, address, route).State); + } + + [Fact] + public void Probe_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown() + { + Assert.Equal("ready", Ipv6Diagnostics.Evaluate(true, null, true, true).State); + Assert.Equal("unknown", Ipv6Diagnostics.Evaluate(true, null, null, null).State); + } +} diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs index 1f659ec..06eb7e8 100644 --- a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs +++ b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs @@ -8,6 +8,21 @@ namespace MoShiro.Protocol.Tests; public sealed class RawChunkCodecTests { + [Fact] + public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries() + { + RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!; + Assert.Equal(IpVersions.V4, legacy.IpVersion); + Assert.Equal("line:prb_legacy", legacy.Series); + RawChunk chunk = SampleChunk(); + chunk.Header.ProbeId = new ProbeId("prb_dual"); + chunk.Header.IpVersion = IpVersions.V6; + RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value; + Assert.Equal("ipv6", decoded.Header.IpVersion); + Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series); + Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count); + } + private static readonly NodeId s_agent = new("agt_test"); private static RawChunk SampleChunk() diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 9360dd0..5aa566e 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -87,7 +87,7 @@ public static class AgentEndpoints private static async Task PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) { Result result = await agents.PatchAsync( - new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct); + new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct); if (!result.TryGet(out AgentView? view, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index e59949a..652f721 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Fleet.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -21,6 +22,12 @@ public static class BuildEndpoints .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403); group.MapPost("/nodes/{id}/update", RequestUpdateAsync) .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409); + group.MapGet("/backend/update", BackendUpdateAsync) + .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403); + group.MapPost("/backend/update", ConfirmBackendUpdateAsync) + .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412); + group.MapGet("/backend/update/live", LiveBackendUpdateAsync) + .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403); } /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 @@ -31,7 +38,7 @@ public static class BuildEndpoints .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); } - private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) { var items = (await nodes.ListAsync(ct)) .OrderBy(n => n.Kind, StringComparer.Ordinal) @@ -39,7 +46,7 @@ public static class BuildEndpoints .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))))) .ToList(); http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items), ApiJsonContext.Default.BuildInfoDto); + return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); } private static async Task ListReleasesAsync(ReleaseService releases, CancellationToken ct) => @@ -56,7 +63,43 @@ public static class BuildEndpoints private static async Task RequestUpdateAsync(HttpContext http, ReleaseService releases, string id, CancellationToken ct) => await releases.RequestUpdateAsync(new NodeId(id), ct) ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.AgentOffline, "the node is not connected"); - private static ReleaseDto ToDto(ReleaseOffer offer) => new(offer.Component, offer.Seq, offer.Tag, offer.Version, offer.Digest); + private static ReleaseDto ToDto(ReleaseOffer offer) => new(offer.Component, offer.Seq, offer.Tag, offer.Version, offer.Digest, offer.Channel); + + private static BackendUpdateDto ToDto(BackendUpdateView view) => + new(view.Channel, view.Enabled, view.State, view.CurrentTag, view.CurrentDigest, view.Available is { } release ? ToDto(release) : null, view.Error); + + private static async Task BackendUpdateAsync(BackendUpdates updates, CancellationToken ct) => + TypedResults.Json(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto); + + private static async Task ConfirmBackendUpdateAsync(HttpContext http, BackendUpdates updates, ConfirmBackendUpdateRequest request, CancellationToken ct) + { + Result result = await updates.ConfirmAsync(request.Seq, ct); + return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error); + } + + private static async Task LiveBackendUpdateAsync(HttpContext http, BackendUpdates updates, TimeProvider time, CancellationToken ct) + { + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-store"; + http.Response.Headers["X-Accel-Buffering"] = "no"; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2), time); + string? previous = null; + try + { + do + { + string json = JsonSerializer.Serialize(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto); + await http.Response.WriteAsync(json != previous ? "event: backend_update\ndata: " + json + "\n\n" : ": heartbeat\n\n", ct); + await http.Response.Body.FlushAsync(ct); + previous = json; + } + while (await timer.WaitForNextTickAsync(ct)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // 浏览器断开或后端正在升级重启 + } + } private static NodeUpdateDto? ToDto(UpdateStatus? status) => status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null); diff --git a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs index 66b8c41..f6fc4a1 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs @@ -109,7 +109,7 @@ public static class TaskEndpoints return Problems.From(http, lineError); } - Result result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct); + Result result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct); if (!result.TryGet(out TestRecord? test, out FleetError? error)) { return Problems.From(http, error); @@ -124,10 +124,12 @@ public static class TaskEndpoints // 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试 NodeId agent; ProbeId? probe = null; + string? ipVersion = null; if (LineKey.TryParse(line_id, out LineKey key)) { agent = key.Agent; probe = key.Probe; + ipVersion = key.IpVersion; } else { @@ -135,7 +137,7 @@ public static class TaskEndpoints } return TypedResults.Json( - new PageDto([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null), + new PageDto([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTestDto); } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 39fd43e..acd06f8 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -31,34 +31,34 @@ public static class Mappers public static OverviewLineDto ToDto(OverviewLine line) => new( line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels, - line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest)); + line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion); 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.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs)); - public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs)); + public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs), p.IpVersion); public static ProbeDto ToDto(ProbeView v) => new( v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, - [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs)))]); + [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe)); public static SeriesDto ToDto(SeriesResult r, bool live) => new( r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded, r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial), - [.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions); + [.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions, r.Line.IpVersion); public static TestDto ToDto(TestDetail detail) { TestRecord t = detail.Test; return new TestDto( t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger, - Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result)); + Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result), t.IpVersion); } public static TestDto ToDto(TestRecord t) => - new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null); + new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null, t.IpVersion); public static TestResultDto ToDto(TestResultView r) => new( @@ -103,7 +103,51 @@ public static class Mappers : null; return new AgentDto( a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible, - a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs)); + a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view)); + } + + public static Ipv6DiagnosticDto Ipv6Of(AgentView view) + { + Ipv6Diagnostic? diagnostic = view.Heartbeat?.Ipv6; + if (diagnostic is null && view.Agent.Ipv6DiagnosticsJson is { } json) + { + try + { + diagnostic = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic); + } + catch (JsonException) + { + // 旧节点或损坏的快照显示未知,不推断为没有 IPv6 + } + } + + bool configured = view.Agent.Ipv6Host.Length > 0; + string state = diagnostic?.State ?? "unknown"; + if (!configured && state is "ready" or "unknown") + { + state = "address_not_configured"; + } + + return new Ipv6DiagnosticDto(configured, diagnostic?.SocketSupported ?? view.Agent.Ipv6Supported, diagnostic?.Listening ?? view.Agent.Ipv6Supported, + diagnostic?.HasGlobalAddress, diagnostic?.HasDefaultRoute, state, diagnostic?.NetworkScope ?? "unknown", diagnostic?.HostHasGlobalAddress, diagnostic?.HostHasDefaultRoute); + } + + private static Ipv6DiagnosticDto? ProbeIpv6Of(ProbeRecord probe) + { + if (probe.Ipv6DiagnosticsJson is not { } json) + { + return null; + } + + try + { + Ipv6Diagnostic? d = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic); + return d is null ? null : new Ipv6DiagnosticDto(null, d.SocketSupported, null, d.HasGlobalAddress, d.HasDefaultRoute, d.State, d.NetworkScope, d.HostHasGlobalAddress, d.HostHasDefaultRoute); + } + catch (JsonException) + { + return null; + } } public static TaskDto ToDto(TaskRecord t) => diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index d01021e..d4bb481 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -37,10 +37,16 @@ public sealed record AgentDto( HeartbeatDto? Heartbeat, int EndpointChanges, DateTimeOffset CreatedAt, - DateTimeOffset? LastSeenAt); + DateTimeOffset? LastSeenAt, + string Ipv6Host = "", + bool Ipv6Supported = false, + Ipv6DiagnosticDto? Ipv6 = null); + +public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State, + string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null); /// merge-patch:缺省或为 null 的字段不修改。 -public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible); +public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null); public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt); @@ -52,12 +58,12 @@ public sealed record InstallCommandsDto(string Linux, string Macos, string Windo public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); /// Agent 眼中连着的一个 Probe(Agent 上报)。 -public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince); +public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); /// 某个 Probe 现在连着的一个 Agent。 -public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince); +public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections); +public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null); /// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected); @@ -137,8 +143,12 @@ public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? Os public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); /// 面板登记的最新发布清单(CI 签名的;验签在节点上)。 -public sealed record ReleaseDto(string Component, long Seq, string Tag, string Version, string Digest); +public sealed record ReleaseDto(string Component, long Seq, string Tag, string Version, string Digest, string Channel = "release"); public sealed record PublishReleaseRequest(string? Manifest, string? Signature); -public sealed record BuildInfoDto(BuildDto Backend, IReadOnlyList Nodes); +public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error); + +public sealed record ConfirmBackendUpdateRequest(long Seq); + +public sealed record BuildInfoDto(BuildDto Backend, IReadOnlyList Nodes, BackendUpdateDto? BackendUpdate = null); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 2ce9de8..beb9ee0 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -35,6 +35,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(LoginResponse))] [JsonSerializable(typeof(MeDto))] [JsonSerializable(typeof(AgentDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(AgentPatchRequest))] [JsonSerializable(typeof(LinkDto))] @@ -47,6 +48,8 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(NodeBuildDto))] [JsonSerializable(typeof(BuildInfoDto))] +[JsonSerializable(typeof(BackendUpdateDto))] +[JsonSerializable(typeof(ConfirmBackendUpdateRequest))] [JsonSerializable(typeof(NodeInfoDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(JoinTokenDto))] diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 4c1cdf0..7698bb5 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -1,5 +1,8 @@ namespace MoShiro.Backend.Api; +/// IPv6 诊断只含配置与网络状态,不包含任一种 IP 地址。 +public sealed record AgentIpv6Dto(string AgentId, string Name, bool Online, Ipv6DiagnosticDto Ipv6); + /// 汇总:整个时间段由累加器精确合并得出的值(不是各点的平均)。 public sealed record SummaryDto(IReadOnlyDictionary Metrics, IReadOnlyDictionary Samples, double Coverage); @@ -22,7 +25,8 @@ public sealed record OverviewLineDto( bool ProbeConnected, int EndpointChanges, SummaryDto Summary, - TestSummaryDto? LastTest); + TestSummaryDto? LastTest, + string IpVersion = "ipv4"); public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList Lines); @@ -43,7 +47,8 @@ public sealed record SeriesDto( EdgePartialDto EdgePartial, IReadOnlyList Gaps, IReadOnlyList Events, - IReadOnlyDictionary Versions); + IReadOnlyDictionary Versions, + string IpVersion = "ipv4"); public sealed record CompareDto( string Metric, @@ -81,7 +86,9 @@ public sealed record LineDto( bool ProbeConnected, DateTimeOffset? DataSince, IReadOnlyList Kinds, - TestSummaryDto? LastTest); + TestSummaryDto? LastTest, + string IpVersion = "ipv4", + bool Ipv6Available = false); public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score); @@ -109,7 +116,8 @@ public sealed record TestDto( DateTimeOffset RequestedAt, DateTimeOffset? StartedAt, DateTimeOffset? FinishedAt, - TestResultDto? Result); + TestResultDto? Result, + string IpVersion = "ipv4"); // ── 目录(由代码注册表生成) ────────────────────────────────────────── diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index b2de354..49ccad4 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -33,8 +33,14 @@ public static class LiveEndpoints private static async Task StreamAsync( HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, - BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime) + BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version) { + string ipVersion = ip_version ?? IpVersions.V4; + if (!IpVersions.IsValid(ipVersion)) + { + return Problems.From(http, ValidationError.Single("ip_version", "must be ipv4 or ipv6")); + } + StringValues raw = http.Request.Query["topics"]; Result> parsed = LiveTopic.ParseAll(raw.Where(v => v is not null)!, options.MaxSseTopics); if (!parsed.TryGet(out IReadOnlyList? topics, out FleetError? error)) @@ -78,7 +84,7 @@ public static class LiveEndpoints http.Response.Headers["X-Accel-Buffering"] = "no"; await http.Response.Body.FlushAsync(http.RequestAborted); - var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys); + var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion); using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping); await session.RunAsync(linked.Token); } @@ -101,6 +107,7 @@ public static class LiveEndpoints private readonly AgentService _agents; private readonly LineService _lines; private readonly IReadOnlyDictionary _lineKeys; + private readonly string _ipVersion; private readonly TestService _tests; private readonly QueryService _query; private readonly LiveHub _hub; @@ -117,12 +124,13 @@ public static class LiveEndpoints private long _lastWriteMs; private long _lastPeriodicMs; - public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys) + public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys, string ipVersion) { _http = http; _agents = agents; _lines = lines; _lineKeys = lineKeys; + _ipVersion = ipVersion; _tests = tests; _query = query; _hub = hub; @@ -297,7 +305,7 @@ public static class LiveEndpoints { MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value; IReadOnlyList visible = await _lines.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(a), ct); - Result result = await _query.OverviewAsync(visible, new RangeRequest(topic.Range, null, null, null, null), selection, ct); + Result result = await _query.OverviewAsync([.. visible.Where(l => l.IpVersion == _ipVersion)], new RangeRequest(topic.Range, null, null, null, null), selection, ct); if (result.TryGet(out OverviewResult? overview, out _)) { var dto = new OverviewDto( diff --git a/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs index 9c8be7d..e459f98 100644 --- a/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs @@ -21,6 +21,9 @@ public static class QueryEndpoints group.MapGet("/overview", OverviewAsync) .Api("overview_get", "Ranking of all lines over a time range", Tag) .Returns().ProducesProblems(403, 422, 429, 503); + group.MapGet("/ipv6", Ipv6Async) + .Api("ipv6_get", "IPv6 configuration and collector diagnostics without addresses", Tag) + .Returns>().ProducesProblems(403); group.MapGet("/lines/{line_id}", LineAsync) .Api("lines_get", "One line", Tag) .Returns().ProducesProblems(403, 404); @@ -41,11 +44,24 @@ public static class QueryEndpoints .Returns().ProducesProblems(403, 404); } + private static async Task Ipv6Async(HttpContext http, AgentService agents, CancellationToken ct) + { + IReadOnlyList views = await agents.ListAsync(ct); + return TypedResults.Json(new PageDto([.. views.Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && (v.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) + .Select(v => new AgentIpv6Dto(v.Agent.Id.Value, v.Agent.Name, v.Online, Mappers.Ipv6Of(v)))], null), ApiJsonContext.Default.PageDtoAgentIpv6Dto); + } + private static Task OverviewAsync( HttpContext http, LineService lineService, QueryService query, BackendOptions options, TimeProvider time, - string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order) => + string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order, string? ip_version) => QueryHelpers.GuardedAsync(http, async ct => { + string ipVersion = ip_version ?? IpVersions.V4; + if (!IpVersions.IsValid(ipVersion)) + { + return Problems.From(http, ValidationError.Single("ip_version", "must be ipv4 or ipv6")); + } + Result selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery); if (!selection.TryGet(out MetricSelection? sel, out FleetError? error)) { @@ -53,7 +69,7 @@ public static class QueryEndpoints } IReadOnlyList visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct); - Result result = await query.OverviewAsync(visible, new RangeRequest(range, from, to, null, null), sel, ct); + Result result = await query.OverviewAsync([.. visible.Where(l => l.IpVersion == ipVersion)], new RangeRequest(range, from, to, null, null), sel, ct); if (!result.TryGet(out OverviewResult? overview, out error)) { return Problems.From(http, error); @@ -81,12 +97,12 @@ public static class QueryEndpoints } AgentView view = line.Agent; - IReadOnlyList recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, ct); + IReadOnlyList recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct); TestRecord? last = recent.Count > 0 ? recent[0] : null; return TypedResults.Json( new LineDto( line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected, - Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last)), + Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0), ApiJsonContext.Default.LineDto); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs index 8f50d8b..aec1ee9 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs @@ -34,11 +34,11 @@ 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.IpVersion) : new ConnectedProbeInfo(e.Detail, null, null, at)); _nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote); break; case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail): - state.RemoveProbe(e.Detail); + state.RemoveProbe(e.Detail, e.Probe?.IpVersion ?? IpVersions.V4); break; case AgentEventKinds.EndpointChanged: state.CountEndpointChange(); diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs index 6fe7134..6471477 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs @@ -5,7 +5,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。 -public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs); +public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); /// Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。 public sealed class AgentRuntimeState @@ -19,22 +19,22 @@ public sealed class AgentRuntimeState public bool ProbeConnected => Probes.Count > 0; - public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId); + public bool IsConnected(string probeId, string ipVersion = IpVersions.V4) => Probes.Any(p => p.Id == probeId && p.IpVersion == ipVersion); /// 事件 probe_connected:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。 public void AddProbe(ConnectedProbeInfo info) { lock (_gate) { - Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id), info]); + Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]); } } - public void RemoveProbe(string id) + public void RemoveProbe(string id, string ipVersion = IpVersions.V4) { lock (_gate) { - Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]); + Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id || p.IpVersion != ipVersion)]); } } diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 5b09036..bb7f3ca 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Sockets; using System.Text.RegularExpressions; using Fleet.Core; using Fleet.Server; @@ -49,6 +51,16 @@ public sealed partial class AgentService return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host); } + private static bool ValidHost(string host, string ipVersion) + { + if (IPAddress.TryParse(host, out IPAddress? address)) + { + return !address.IsIPv4MappedToIPv6 && address.AddressFamily == (ipVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork); + } + + return host.Length <= 253 && Uri.CheckHostName(host) == UriHostNameType.Dns; + } + public async ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) { var errors = new Dictionary(); @@ -57,11 +69,16 @@ public sealed partial class AgentService errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'"; } - if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost)) + if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4)) { errors["public_host"] = "must be a host name or IP address"; } + if (patch.Ipv6Host is not null && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6)) + { + errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection"; + } + if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32))) { errors["labels"] = "at most 16 labels of 1-32 characters"; @@ -78,7 +95,7 @@ public sealed partial class AgentService return Result.Fail(error); } - if (patch.Name is not null || patch.PublicHost is not null) + if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null) { await AfterAgentSetChangedAsync(ct); } @@ -139,6 +156,7 @@ public sealed partial class AgentService AgentId = agent.Id, Name = agent.Name, Host = agent.PublicHost, + Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null, TcpPort = agent.ProbeTcpPort, UdpPorts = [.. agent.ProbeUdpPorts], Spki = Convert.ToBase64String(agent.TlsSpki), @@ -170,7 +188,4 @@ public sealed partial class AgentService [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] private static partial Regex NamePattern(); - - [GeneratedRegex(@"^[A-Za-z0-9.:\-\[\]]{1,253}$")] - private static partial Regex HostPattern(); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index 492bc1f..a2c6312 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -53,10 +53,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink private readonly AgentService _service; private readonly NodeInfoService _nodes; private readonly ReleaseService _releases; + private readonly IProbeStore _probes; - public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases) + public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, IProbeStore probes) { _releases = releases; + _probes = probes; _nodes = nodes; _agents = agents; _runtime = runtime; @@ -99,7 +101,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink _releases.RecordStatus(id, beat?.Update); 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.IpVersion))); foreach (ConnectedProbe p in beat.Probes) { _nodes.ObserveProbeInBackground(p.Id.Value, p.Remote); @@ -130,6 +132,10 @@ public sealed class MoShiroNodeEventSink : INodeEventSink { ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta); await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct); + if (meta?.Ipv6 is { } ipv6) + { + await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); + } } catch (JsonException) { @@ -146,7 +152,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra: try { - _releases.RecordStatus(id, extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra)?.Update); + ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra); + _releases.RecordStatus(id, heartbeat?.Update); + if (heartbeat?.Ipv6 is { } ipv6) + { + await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); + } } catch (JsonException) { @@ -196,14 +207,15 @@ public sealed class MoShiroNodeEventSink : INodeEventSink AgentRecord? before = await _agents.GetAsync(id, ct); string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); - await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts), ct); + string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic); + await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json), ct); // 端口或证书指纹变了,Probe 需要新的列表 AgentRecord? after = await _agents.GetAsync(id, ct); _nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform); await _releases.OfferToNodeAsync(id, ct); if (before is not null && after is not null - && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts)) + && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported) && after.Status == NodeStatus.Active) { await _service.AfterAgentSetChangedAsync(ct); diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index e47df2b..5647b65 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -13,7 +13,7 @@ public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps); public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits); /// 某个 Probe 现在连着的一个 Agent(Agent 上报)。 -public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs); +public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList Connections) { @@ -128,6 +128,8 @@ public sealed partial class ProbeService } await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + // 吊销会阻止未来认证;也断开当前直连 Backend 的长会话,不等待 Probe 心跳超时。 + await _gateway.DisconnectAsync(new NodeId(id), "probe revoked", ct); _gateway.NotifyAllTaskSetsChanged(); return Result.Ok((await GetAsync(id, ct))!); } @@ -178,7 +180,7 @@ public sealed partial class ProbeService AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId); if (agent is not null) { - connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs)); + connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs, info.IpVersion)); } } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 569f932..70eb75b 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(p => p.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 4fb34a2..9e6f0d7 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -6,6 +6,18 @@ public sealed class BackendOptions /// 数据目录:moshiro.db 与 raw/。 public string DataDirectory { get; set; } = "data"; + /// Backend 更新通道:auto 按镜像的构建 tag 识别 dev/release,Compose 可以显式覆盖。 + public string UpdateChannel { get; set; } = "auto"; + + /// 容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。 + public string ImageNamespace { get; set; } = ""; + + /// 容器内更新请求目录;留空使用数据目录下的 update/。 + public string? BackendUpdateDirectory { get; set; } + + /// 宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。 + public string? BackendUpdateStatusDirectory { get; set; } + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } diff --git a/backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs b/backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs index 5b6b8ae..1ec486c 100644 --- a/backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs +++ b/backend/src/MoShiro.Backend.Application/Ingest/IngestService.cs @@ -69,7 +69,8 @@ public sealed partial class IngestService RawChunkHeader header = chunk.Header; long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); string? problem = - header.AgentId != agent ? "agent_id does not match the authenticated node" + !IpVersions.IsValid(header.IpVersion) ? "unknown ip_version" + : header.AgentId != agent ? "agent_id does not match the authenticated node" : header.ChunkId.Value != msgId ? "chunk_id does not match the message id" : Kinds.Find(header.Kind) is null ? $"unknown kind {header.Kind}" : header.DurationMs is <= 0 or > MaxDurationMs ? "duration out of range" diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index b44c53e..1b6153f 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -22,7 +22,10 @@ public sealed record AgentRecord( string? RejectedJson, long CreatedAtMs, long? LastSeenAtMs, - long RowVersion); + long RowVersion, + string Ipv6Host = "", + bool Ipv6Supported = false, + string? Ipv6DiagnosticsJson = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -33,6 +36,8 @@ public sealed class AgentPatch public string? PublicHost { get; init; } + public string? Ipv6Host { get; init; } + public bool? PublicVisible { get; init; } } @@ -43,7 +48,9 @@ public sealed record AgentHello( string? LocalLimitsJson, byte[]? TlsSpki, int? ProbeTcpPort, - IReadOnlyList? ProbeUdpPorts); + IReadOnlyList? ProbeUdpPorts, + bool Ipv6Supported = false, + string? Ipv6DiagnosticsJson = null); /// 注册之后 MoShiro 的补充:公开地址和端口。 public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); @@ -59,4 +66,4 @@ public sealed record JoinTokenInfo( long? RevokedAtMs, long CreatedAtMs); -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null); diff --git a/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs b/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs index 6502332..03936cc 100644 --- a/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs @@ -30,7 +30,8 @@ public sealed record TestRecord( int PhaseCount, long RequestedAtMs, long? StartedAtMs, - long? FinishedAtMs); + long? FinishedAtMs, + string IpVersion = IpVersions.V4); /// 测试结果:指标与曲线都以 JSON 保存。 public sealed record TestResultRecord(TestId TestId, byte[] MetricsJson, byte[] SeriesJson); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index a126798..13a75b2 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -69,6 +69,8 @@ public interface IProbeStore /// 新增或整条更新(轮换密钥、改名都走这里)。 ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct); + ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct); + /// 吊销:保留记录,公钥立即从所有 Agent 的名单里移除。已经吊销的返回 false。 ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); @@ -108,6 +110,8 @@ public interface ITestStore ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct); + ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct); + ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct); ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Query/Lines.cs b/backend/src/MoShiro.Backend.Application/Query/Lines.cs index 8c07a0b..63d20a1 100644 --- a/backend/src/MoShiro.Backend.Application/Query/Lines.cs +++ b/backend/src/MoShiro.Backend.Application/Query/Lines.cs @@ -3,35 +3,35 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// 一条线路 = 一对(Agent,Probe)。存储里它的序列是 line:<probe_id>,对外的 id 是 <agent_id>~<probe_id>。 -public readonly record struct LineKey(NodeId Agent, ProbeId Probe) +/// 线路按(Agent,Probe,地址族)隔离。IPv4 保留 agent~probe 和 line:probe;IPv6 分别追加 ~ipv6、:ipv6。 +public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVersion = IpVersions.V4) { - public string Series => RawChunkHeader.LinePrefix + Probe.Value; + public string Series => IpVersions.LineSeries(Probe.Value, IpVersion); - public string Id => Agent.Value + "~" + Probe.Value; + public string Id => Agent.Value + "~" + Probe.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : ""); public static bool TryParse(string text, out LineKey key) { - int split = text.IndexOf('~', StringComparison.Ordinal); - if (split <= 0 || split == text.Length - 1) + string[] parts = text.Split('~'); + if (parts.Length is < 2 or > 3 || parts[0].Length == 0 || parts[1].Length == 0 || (parts.Length == 3 && parts[2] != IpVersions.V6)) { key = default; return false; } - key = new LineKey(new NodeId(text[..split]), new ProbeId(text[(split + 1)..])); + key = new LineKey(new NodeId(parts[0]), new ProbeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4); return true; } } /// 一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。 -public sealed record LineView(AgentView Agent, ProbeRecord Probe) +public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4) { - public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id)); + public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion); - public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id); + public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id && p.IpVersion == IpVersion); - public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id); + public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id && p.IpVersion == IpVersion); } /// 线路目录:把 id 解析成(Agent,Probe),列出所有线路。 @@ -54,7 +54,20 @@ public sealed class LineService IReadOnlyList agents = await _agents.ListAsync(ct); IReadOnlyList probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)]; LinkSnapshot links = await _links.SnapshotAsync(ct); - return [.. agents.Where(include).SelectMany(a => probes.Where(p => links.IsLinked(p.Id, a.Agent.Id.Value)).Select(p => new LineView(a, p)))]; + var result = new List(); + foreach (AgentView agent in agents.Where(include)) + { + foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value))) + { + result.Add(new LineView(agent, probe)); + if (agent.Agent.Ipv6Host.Length > 0) + { + result.Add(new LineView(agent, probe, IpVersions.V6)); + } + } + } + + return result; } /// @@ -64,6 +77,7 @@ public sealed class LineService { string agentPart = id; string? probePart = null; + string ipVersion = IpVersions.V4; if (id.Contains('~', StringComparison.Ordinal)) { if (!LineKey.TryParse(id, out LineKey key)) @@ -73,6 +87,7 @@ public sealed class LineService agentPart = key.Agent.Value; probePart = key.Probe.Value; + ipVersion = key.IpVersion; } AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct); @@ -102,6 +117,6 @@ public sealed class LineService probe = active[0]; } - return Result.Ok(new LineView(agent, probe)); + return Result.Ok(new LineView(agent, probe, ipVersion)); } } diff --git a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs index d484acb..624b0a8 100644 --- a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs +++ b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs @@ -167,7 +167,7 @@ public sealed class QueryService } IReadOnlyDictionary overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary(); - IReadOnlyList recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, ct); + IReadOnlyList recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, line.Key.IpVersion, ct); result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null)); } @@ -372,7 +372,7 @@ public sealed class QueryService long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day); var overlay = new Dictionary(StringComparer.Ordinal); var seenPlans = new HashSet(); - foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, ct)) + foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, line.IpVersion, ct)) { if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan)) { diff --git a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs index 4f29b51..56304b8 100644 --- a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs +++ b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs @@ -139,7 +139,8 @@ public sealed class RowLoader IReadOnlyList all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct); foreach (IGrouping group in all.GroupBy(r => r.AgentId)) { - result[new LineKey(group.Key, new ProbeId(bySeries.Key[RawChunkHeader.LinePrefix.Length..]))] = Decode([.. group]); + LineKey key = bySeries.First(l => l.Agent == group.Key); + result[key] = Decode([.. group]); } } diff --git a/backend/src/MoShiro.Backend.Application/Tests/TestService.cs b/backend/src/MoShiro.Backend.Application/Tests/TestService.cs index b0af831..ad5efa5 100644 --- a/backend/src/MoShiro.Backend.Application/Tests/TestService.cs +++ b/backend/src/MoShiro.Backend.Application/Tests/TestService.cs @@ -28,8 +28,16 @@ public sealed class TestService _time = time; } - public async ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) + public ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) => + StartAsync(agentId, probeId, request, trigger, IpVersions.V4, ct); + + public async ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct) { + if (!IpVersions.IsValid(ipVersion)) + { + return ValidationError.Fail("ip_version", "must be ipv4 or ipv6"); + } + PlanSpec? plan = Plans.Find(request.Plan); if (plan is null) { @@ -47,6 +55,11 @@ public sealed class TestService return Result.Fail(MoShiroErrors.AgentNotActive, "agent is not active"); } + if (ipVersion == IpVersions.V6 && !agent.Ipv6Supported) + { + return Result.Fail(FleetErrors.Unsupported, "this agent has not enabled IPv6 collection"); + } + // Agent 在 hello 里声明了它支持的采集方法:方案里有它不支持的就不下发(否则要等到阶段开始才失败) string? unsupported = plan.Phases.Select(p => p.Kind).FirstOrDefault(kind => !agent.Kinds.Contains(kind)); if (unsupported is not null) @@ -65,7 +78,7 @@ public sealed class TestService return Result.Fail(MoShiroErrors.AgentOffline, "agent is offline"); } - if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value))) + if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value, ipVersion))) { return Result.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent"); } @@ -78,10 +91,10 @@ public sealed class TestService var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time)); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary(request.Params), TestJson.Default.ParamMap); - var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null); + var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null, ipVersion); await _tests.CreateAsync(record, ct); - Result sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, Plan = plan.Id, Phases = phaseList }, ct); + Result sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, IpVersion = ipVersion, Plan = plan.Id, Phases = phaseList }, ct); if (!sent.TryGet(out _, out FleetError? sendError)) { await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct); @@ -149,6 +162,8 @@ public sealed class TestService public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct); + public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ipVersion, ct); + public static TestResultView ToView(TestResultRecord stored) { StoredTestMetrics metrics = JsonSerializer.Deserialize(stored.MetricsJson, TestJson.Default.StoredTestMetrics) ?? new StoredTestMetrics(); diff --git a/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs new file mode 100644 index 0000000..7af71ca --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs @@ -0,0 +1,118 @@ +using Fleet.Core; +using Microsoft.Extensions.Hosting; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// 宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。 +public interface IBackendUpdateFiles +{ + ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct); + + ValueTask ReadStatusAsync(CancellationToken ct); + + ValueTask ReadRequestAsync(CancellationToken ct); + + ValueTask RequestAsync(long seq, CancellationToken ct); +} + +public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error); + +/// Backend 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。 +public sealed class BackendUpdates : IHostedService +{ + private readonly ReleaseService _releases; + private readonly IBackendUpdateFiles _files; + private readonly BackendOptions _options; + + public BackendUpdates(ReleaseService releases, IBackendUpdateFiles files, BackendOptions options) + { + _releases = releases; + _files = files; + _options = options; + } + + public static string ChannelOf(BackendOptions options) + { + if (options.UpdateChannel is "dev" or "release") + { + return options.UpdateChannel; + } + + if (options.UpdateChannel != "auto") + { + throw new InvalidOperationException("UpdateChannel must be auto, dev or release"); + } + + string imageNamespace = options.ImageNamespace.Trim('/'); + if (imageNamespace == "moshiro-dev" || imageNamespace.EndsWith("/moshiro-dev", StringComparison.Ordinal)) + { + return "dev"; + } + + if (imageNamespace == "moshiro" || imageNamespace.EndsWith("/moshiro", StringComparison.Ordinal)) + { + return "release"; + } + + return BuildInfo.Tag is { } tag && (tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal)) ? "dev" : "release"; + } + + public async ValueTask GetAsync(CancellationToken ct) + { + string channel = ChannelOf(_options); + ReleaseOffer? offer = await _releases.GetBackendAsync(channel, ct); + UpdateStatus? status = await _files.ReadStatusAsync(ct); + bool installed = offer is not null && (status?.Current == offer.Digest || BuildInfo.Tag == offer.Tag); + // 只有更新器确认过同一份清单才允许按钮执行;旧状态不能被套到新的清单上。 + bool verified = offer is not null && status?.Available?.Seq == offer.Seq && status.Available.Digest == offer.Digest; + string state = status?.State ?? "unavailable"; + if (installed) + { + state = "up_to_date"; + } + else if (offer is not null && !verified && state is not ("invalid" or "failed" or "rolled_back")) + { + state = status is null ? "unavailable" : "checking"; + } + + if (verified && state == "available" && await _files.ReadRequestAsync(ct) == offer!.Seq) + { + state = "requested"; + } + + return new BackendUpdateView(channel, status is not null, state, BuildInfo.Tag, status?.Current, installed ? null : offer, status?.Error); + } + + public async ValueTask> ConfirmAsync(long seq, CancellationToken ct) + { + // 与 webhook 共用发布锁:确认之后即使又来了一个 tag,信箱中的请求仍只认本次确认的 seq。 + await _releases.BackendGate.WaitAsync(ct); + try + { + BackendUpdateView view = await GetAsync(ct); + if (!view.Enabled || view.State != "available" || view.Available?.Seq != seq) + { + return Result.Fail(MoShiroErrors.PreconditionFailed, "the offered release changed or has not been verified by the host updater; refresh before confirming"); + } + + await _files.RequestAsync(seq, ct); + return Result.Ok(true); + } + finally + { + _releases.BackendGate.Release(); + } + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + string channel = ChannelOf(_options); + if (await _releases.GetBackendAsync(channel, cancellationToken) is { } offer) + { + await _files.WriteOfferAsync(offer.Manifest, offer.Signature, cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index 39cc37b..0f88664 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -8,29 +8,38 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// 面板记下的、CI 签名的发布清单(只留每个组件最新的一份)。Manifest 是签名对象的原始文本,面板一个字节也不改。 -public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature); +public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature, string Channel = "release"); /// /// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。 /// 面板不验签、也不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// -public sealed partial class ReleaseService +public sealed partial class ReleaseService : IDisposable { public const int MaxManifestBytes = 4096; public const int MaxSignatureBytes = 512; - private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe]; + public const string BackendComponent = "backend"; + private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, BackendComponent]; private readonly ISettingsStore _settings; private readonly IAgentGateway _gateway; + private readonly IBackendUpdateFiles _backendFiles; + private readonly BackendOptions _options; private readonly ConcurrentDictionary _status = new(); - public ReleaseService(ISettingsStore settings, IAgentGateway gateway) + public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options) { _settings = settings; _gateway = gateway; + _backendFiles = backendFiles; + _options = options; } + internal SemaphoreSlim BackendGate { get; } = new(1, 1); + + public void Dispose() => BackendGate.Dispose(); + /// 登记一份新清单并推给在线的同类节点。只要格式对、seq 比现有的新就收下(签名由节点验证)。 public async ValueTask> PublishAsync(string manifest, string signature, CancellationToken ct) { @@ -39,20 +48,40 @@ public sealed partial class ReleaseService return ValidationError.Fail("manifest", problem); } - ReleaseOffer? current = await GetAsync(offer.Component, ct); - if (current is not null && offer.Seq <= current.Seq) + await BackendGate.WaitAsync(ct); + try { - return Result.Fail(MoShiroErrors.NameConflict, $"a release with seq {current.Seq} or newer is already registered"); - } + bool backend = offer.Component == BackendComponent; + ReleaseOffer? current = backend ? await GetBackendAsync(offer.Channel, ct) : await GetAsync(offer.Component, ct); + if (current is not null && offer.Seq <= current.Seq) + { + return Result.Fail(MoShiroErrors.NameConflict, $"a release with seq {current.Seq} or newer is already registered"); + } - await _settings.SetAsync(Key(offer.Component, "manifest"), offer.Manifest, ct); - await _settings.SetAsync(Key(offer.Component, "signature"), offer.Signature, ct); - foreach (NodeId node in OnlineOf(offer.Component)) + string componentKey = backend ? BackendComponent + "." + offer.Channel : offer.Component; + await _settings.SetAsync(Key(componentKey, "manifest"), offer.Manifest, ct); + await _settings.SetAsync(Key(componentKey, "signature"), offer.Signature, ct); + if (backend) + { + if (offer.Channel == BackendUpdates.ChannelOf(_options)) + { + await _backendFiles.WriteOfferAsync(offer.Manifest, offer.Signature, ct); + } + } + else + { + foreach (NodeId node in OnlineOf(offer.Component)) + { + await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct); + } + } + + return Result.Ok(offer); + } + finally { - await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct); + BackendGate.Release(); } - - return Result.Ok(offer); } public async ValueTask GetAsync(string component, CancellationToken ct) @@ -67,7 +96,8 @@ public sealed partial class ReleaseService var list = new List(); foreach (string component in s_components) { - if (await GetAsync(component, ct) is { } offer) + ReleaseOffer? offer = component == BackendComponent ? await GetBackendAsync(BackendUpdates.ChannelOf(_options), ct) : await GetAsync(component, ct); + if (offer is not null) { list.Add(offer); } @@ -76,6 +106,15 @@ public sealed partial class ReleaseService return list; } + public async ValueTask GetBackendAsync(string channel, CancellationToken ct) + { + string key = BackendComponent + "." + channel; + string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct); + string? signature = await _settings.GetAsync(Key(key, "signature"), ct); + return manifest is not null && signature is not null && TryParse(manifest, signature, out ReleaseOffer? offer, out _) + && offer.Component == BackendComponent && offer.Channel == channel ? offer : null; + } + /// 节点刚连上:把它这个组件最新的清单补发一份(节点离线时发布的也不会错过)。 public async ValueTask OfferToNodeAsync(NodeId node, CancellationToken ct) { @@ -129,7 +168,14 @@ public sealed partial class ReleaseService JsonElement root = document.RootElement; string component = Text(root, "component"); string digest = Text(root, "digest"); + string channel = Text(root, "channel"); + if (channel.Length == 0) + { + channel = "release"; // 老节点的清单没有 channel,保留原来的兼容性 + } + if (root.ValueKind != JsonValueKind.Object || Array.IndexOf(s_components, component) < 0 + || channel is not ("dev" or "release") || !root.TryGetProperty("seq", out JsonElement seq) || !seq.TryGetInt64(out long seqValue) || seqValue <= 0 || !Digest().IsMatch(digest) || Text(root, "image").Length is 0 or > 200) { @@ -137,7 +183,7 @@ public sealed partial class ReleaseService return false; } - offer = new ReleaseOffer(component, seqValue, Text(root, "tag"), Text(root, "version"), digest, manifest, signature); + offer = new ReleaseOffer(component, seqValue, Text(root, "tag"), Text(root, "version"), digest, manifest, signature, channel); return true; } catch (JsonException) diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs new file mode 100644 index 0000000..2b08f8b --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Storage.Sqlite; + +/// Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。 +public sealed class BackendUpdateFiles : IBackendUpdateFiles +{ + private readonly string _requests; + private readonly string _status; + + public BackendUpdateFiles(BackendOptions options) + { + _requests = Path.GetFullPath(options.BackendUpdateDirectory ?? Path.Combine(options.DataDirectory, "update")); + _status = Path.GetFullPath(options.BackendUpdateStatusDirectory ?? Path.Combine(options.DataDirectory, "update-status")); + } + + public async ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct) + { + await WriteAsync("offer.manifest", manifest, ct); + await WriteAsync("offer.sig", signature, ct); + } + + public async ValueTask ReadStatusAsync(CancellationToken ct) + { + string? json = await ReadAsync(Path.Combine(_status, "status.json"), 4096, ct); + if (json is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.UpdateStatus); + } + catch (JsonException) + { + return null; + } + } + + public async ValueTask ReadRequestAsync(CancellationToken ct) + { + string? request = await ReadAsync(Path.Combine(_requests, "apply.request"), 64, ct); + return long.TryParse(request, NumberStyles.None, CultureInfo.InvariantCulture, out long seq) ? seq : null; + } + + public ValueTask RequestAsync(long seq, CancellationToken ct) => WriteAsync("apply.request", seq.ToString(CultureInfo.InvariantCulture), ct); + + private async ValueTask WriteAsync(string name, string value, CancellationToken ct) + { + Directory.CreateDirectory(_requests); + string path = Path.Combine(_requests, name); + string temporary = Path.Combine(_requests, $".{name}.{Guid.NewGuid():N}"); + try + { + await File.WriteAllTextAsync(temporary, value, new UTF8Encoding(false), ct); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + private static async ValueTask ReadAsync(string path, int limit, CancellationToken ct) + { + try + { + var file = new FileInfo(path); + if (!file.Exists || file.LinkTarget is not null || file.Length > limit) + { + return null; + } + + return await File.ReadAllTextAsync(path, ct); + } + catch (IOException) + { + return null; // 更新器正做原子替换,下一次读取再试 + } + } +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 69b20f4..453018b 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 5; + public const int CurrentVersion = 7; public static void Migrate(SqliteConnection connection) { @@ -90,6 +90,26 @@ internal static class Schema connection.Exec("PRAGMA user_version = 5"); transaction.Commit(); } + + if (version < 6) + { + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host TEXT NOT NULL DEFAULT ''"); + connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_supported INTEGER NOT NULL DEFAULT 0"); + connection.Exec("ALTER TABLE tests ADD COLUMN ip_version TEXT NOT NULL DEFAULT 'ipv4'"); + connection.Exec("CREATE INDEX ix_tests_path ON tests (agent_id, probe_id, ip_version, requested_at)"); + connection.Exec("PRAGMA user_version = 6"); + transaction.Commit(); + } + + if (version < 7) + { + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_diagnostics TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN ipv6_diagnostics TEXT"); + connection.Exec("PRAGMA user_version = 7"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 1774315..c35f7e6 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics"; private readonly SqliteDatabase _db; @@ -61,10 +61,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "UPDATE agents SET name = @name, labels = @labels, public_host = @host, public_visible = @visible, row_version = row_version + 1 WHERE id = @id", + "UPDATE agents SET name = @name, labels = @labels, public_host = @host, ipv6_host = @v6, public_visible = @visible, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@name", patch.Name ?? current.Name), ("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)), - ("@host", patch.PublicHost ?? current.PublicHost), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0)); + ("@host", patch.PublicHost ?? current.PublicHost), ("@v6", patch.Ipv6Host ?? current.Ipv6Host), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0)); return Result.Ok(GetInternal(c, id)!); }, ct); @@ -117,9 +117,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { c.Exec( "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " + - "probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), row_version = row_version + 1 WHERE id = @id", + "probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson), - ("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts))); + ("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson)); }, ct); public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) => @@ -217,7 +217,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.NullableString(15), r.GetInt64(16), r.NullableLong(17), - r.GetInt64(18)); + r.GetInt64(18), + r.GetString(19), + r.GetInt64(20) != 0, + r.NullableString(21)); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index e62854b..900d43a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -50,14 +50,17 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4)), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5)), ("@id", id)), ct); + public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE probes SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id)), ct); + public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => _db.WriteAsync(c => c.Exec("UPDATE probes SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs index b763b41..eba95d9 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs @@ -7,7 +7,7 @@ namespace MoShiro.Backend.Storage.Sqlite; public sealed class SqliteTestStore : ITestStore { - private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id"; + private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id, ip_version"; private readonly SqliteDatabase _db; @@ -19,19 +19,22 @@ public sealed class SqliteTestStore : ITestStore public ValueTask CreateAsync(TestRecord test, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at)", + "INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, ip_version) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at, @ip)", ("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@probe", test.ProbeId.Value), ("@plan", test.Plan), ("@params", test.ParamsJson), ("@trigger", test.Trigger), - ("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs)), + ("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs), ("@ip", test.IpVersion)), ct); public ValueTask GetAsync(TestId id, CancellationToken ct) => _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM tests WHERE id = @id", Read, ("@id", id.Value)), ct); public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => + ListAsync(agentId, probeId, limit, null, ct); + + public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) => _db.ReadAsync>( c => c.Query( - $"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) ORDER BY requested_at DESC, id DESC LIMIT @limit", - Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit)), + $"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) AND (@ip IS NULL OR ip_version = @ip) ORDER BY requested_at DESC, id DESC LIMIT @limit", + Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit), ("@ip", ipVersion)), ct); public ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct) => @@ -88,5 +91,6 @@ public sealed class SqliteTestStore : ITestStore r.GetInt32(8), r.GetInt64(9), r.NullableLong(10), - r.NullableLong(11)); + r.NullableLong(11), + r.GetString(13)); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 290fda9..bf05c7f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs new file mode 100644 index 0000000..c57d772 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Tests; + +public sealed class BackendUpdateTests : IAsyncLifetime +{ + private const string Digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private readonly BackendFixture _backend = new(); + + public async ValueTask InitializeAsync() + { + await _backend.InitializeAsync(); + _backend.Services.GetRequiredService().UpdateChannel = "dev"; + } + + public ValueTask DisposeAsync() => _backend.DisposeAsync(); + + [Theory] + [InlineData("moshiro-dev", "dev")] + [InlineData("registry.example/team/moshiro-dev", "dev")] + [InlineData("moshiro", "release")] + [InlineData("registry.example/team/moshiro", "release")] + public void AutoChannel_FollowsTheConfiguredImageRepository(string imageNamespace, string expected) + { + Assert.Equal(expected, BackendUpdates.ChannelOf(new BackendOptions { UpdateChannel = "auto", ImageNamespace = imageNamespace })); + } + + private async Task PublishAsync(long seq, string channel = "dev") + { + string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}"""; + return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUC" })); + } + + private Task ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); + + private async Task VerifiedAsync(long seq) + { + string directory = Path.Combine(_backend.DataDirectory, "update-status"); + Directory.CreateDirectory(directory); + var status = new UpdateStatus + { + State = "available", + Current = "sha256:" + new string('b', 64), + Available = new UpdateAvailable { Seq = seq, Digest = Digest, Tag = "dev-" + seq, Version = "0.1.1" }, + }; + await File.WriteAllTextAsync(Path.Combine(directory, "status.json"), JsonSerializer.Serialize(status, MoShiroJsonContext.Default.UpdateStatus)); + } + + [Fact] + public async Task Webhook_PreservesChannels_AndNeverConfirmsAnUpdateByItself() + { + using HttpResponseMessage dev = await PublishAsync(1); + using HttpResponseMessage release = await PublishAsync(1, "release"); + Assert.Equal(HttpStatusCode.Created, dev.StatusCode); + Assert.Equal(HttpStatusCode.Created, release.StatusCode); + JsonNode view = await ViewAsync(); + Assert.Equal("dev", view["channel"]!.GetValue()); + Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue()); + string requestDirectory = Path.Combine(_backend.DataDirectory, "update"); + Assert.False(File.Exists(Path.Combine(requestDirectory, "apply.request"))); + Assert.Contains("\"channel\":\"dev\"", await File.ReadAllTextAsync(Path.Combine(requestDirectory, "offer.manifest")), StringComparison.Ordinal); + ReleaseOffer? stable = await _backend.Services.GetRequiredService().GetBackendAsync("release", default); + Assert.Equal("release-1", stable!.Tag); + using HttpResponseMessage replay = await PublishAsync(1); + Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode); + } + + [Fact] + public async Task Confirm_RequiresHostVerification_AndRejectsASupersededSequence() + { + using HttpResponseMessage first = await PublishAsync(10); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + using HttpResponseMessage unverified = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, unverified.StatusCode); + await VerifiedAsync(10); + using HttpResponseMessage accepted = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); + Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode); + string path = Path.Combine(_backend.DataDirectory, "update", "apply.request"); + Assert.Equal("10", await File.ReadAllTextAsync(path)); + Assert.Equal("requested", (await ViewAsync())["state"]!.GetValue()); + using HttpResponseMessage second = await PublishAsync(11); + Assert.Equal(HttpStatusCode.Created, second.StatusCode); + using HttpResponseMessage stale = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); + Assert.Equal("10", await File.ReadAllTextAsync(path)); // 新 webhook 不会把确认请求悄悄改成 11 + await VerifiedAsync(11); + using HttpResponseMessage newConfirmation = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 11 })); + Assert.Equal(HttpStatusCode.NoContent, newConfirmation.StatusCode); + Assert.Equal("11", await File.ReadAllTextAsync(path)); + } + + [Fact] + public async Task Live_PushesCurrentChannel_AndAnonymousCannotReadOrConfirm() + { + using HttpResponseMessage denied = await _backend.Anonymous.GetAsync("/api/admin/backend/update"); + Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); + using HttpResponseMessage cannotConfirm = await _backend.Anonymous.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 1 })); + Assert.Equal(HttpStatusCode.Unauthorized, cannotConfirm.StatusCode); + await using SseClient live = await SseClient.ConnectAsync(_backend.Admin, "/api/admin/backend/update/live"); + await live.WaitForAsync(e => e.Name == "backend_update"); + using HttpResponseMessage release = await PublishAsync(20); + Assert.Equal(HttpStatusCode.Created, release.StatusCode); + SseEvent updated = await live.WaitForAsync(e => e.Name == "backend_update" && e.Data?["available"]?["seq"]?.GetValue() == 20); + Assert.Equal("dev", updated.Data!["channel"]!.GetValue()); + Assert.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request"))); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/HostTests.cs b/backend/tests/MoShiro.Backend.Tests/HostTests.cs index ca4ea9b..75ccee3 100644 --- a/backend/tests/MoShiro.Backend.Tests/HostTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/HostTests.cs @@ -30,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), procRoot: "/proc", sysRoot: "/sys"); + var sampler = new HostSampler(time, path => path.Replace('\\', '/') 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); @@ -53,6 +53,7 @@ public sealed class HostSamplerTests var reads = new List(); var sampler = new HostSampler(TimeProvider.System, path => { + path = path.Replace('\\', '/'); // 测试里的 /proc 格式统一用斜杠,兼容 Windows 的 Path.Combine reads.Add(path); return path switch { diff --git a/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs new file mode 100644 index 0000000..9961347 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; +using MoShiro.Protocol; +using MoShiro.TestSupport; + +namespace MoShiro.Backend.Tests; + +/// 真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。 +[Collection("probe-e2e")] +public sealed class Ipv6Tests +{ + private readonly BackendFixture _backend; + + public Ipv6Tests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public async Task Queries_DifferentFamilyLoss_RemainsIndependentInHotPersistedAndCompareViews() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + long start = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / 60_000) - 4) * 60_000; + for (int k = 0; k < 6; k++) + { + foreach (string version in new[] { IpVersions.V4, IpVersions.V6 }) + { + RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", start + k * 10_000, 100, 100, + i => new EchoPacket(false, version == IpVersions.V6 && i % 2 == 0, 20_000, 20_000)); + chunk.Header.ProbeId = new ProbeId(_backend.DefaultProbeId); + chunk.Header.IpVersion = version; + Assert.True(await agent.SendChunkAsync(chunk)); + } + } + + string range = "from=" + DateTimeOffset.FromUnixTimeMilliseconds(start).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") + + "&to=" + DateTimeOffset.FromUnixTimeMilliseconds(start + 60_000).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + string v4 = _backend.Line(agent.NodeId); + string v6 = v4 + "~ipv6"; + foreach (string step in new[] { "1s", "10s", "1m" }) + { + foreach ((string line, double loss) in new[] { (v4, 0.0), (v6, 50.0) }) + { + JsonNode series = null!; + await BackendFixture.EventuallyAsync(async () => + { + series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?{range}&step={step}&preset=udp"); + return series["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600; + }); + Assert.Equal(loss, series["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + } + } + + JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={v4},{v6}&metric=loss.total&step=1m&{range}"); + Assert.Equal(0, compare["summary"]![v4]!.GetValue()); + Assert.Equal(50, compare["summary"]![v6]!.GetValue()); + + await using SseClient live = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{v6}:10s:udp"); + SseEvent snapshot = await live.WaitForAsync(e => e.Name == "series" && e.Data?["line_id"]?.GetValue() == v6); + Assert.Equal("ipv6", snapshot.Data!["ip_version"]!.GetValue()); + } + + [Fact] + public async Task Ipv6Configuration_RejectsWrongFamily_AndDoesNotArrangeIpv6ForALegacyAgent() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + using HttpResponseMessage invalid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value, + BackendFixture.Json(new JsonObject { ["ipv6_host"] = "127.0.0.1" })); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + Assert.Contains("ipv6_host", await invalid.Content.ReadAsStringAsync(), StringComparison.Ordinal); + using HttpResponseMessage valid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value, + BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" })); + Assert.True(valid.IsSuccessStatusCode); + ProbeAgentList endpoints = await _backend.Services.GetRequiredService().ProbeAgentsAsync(_backend.DefaultProbeId, default); + Assert.Null(Assert.Single(endpoints.Agents, a => a.AgentId == agent.NodeId).Ipv6Host); + JsonNode identity = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value); + Assert.Equal("::1", identity["ipv6_host"]!.GetValue()); + Assert.False(identity["ipv6_supported"]!.GetValue()); + } + + [Fact] + public async Task DualStack_CollectsBothFamilies_DisablingIpv6KeepsIpv4Running() + { + Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host"); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::"); + await agent.OverrideLineTasksAsync(rateHz: 30); + using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, + BackendFixture.Json(new JsonObject { ["public_host"] = "127.0.0.1", ["ipv6_host"] = "::1" })); + Assert.True(configured.IsSuccessStatusCode, await configured.Content.ReadAsStringAsync()); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id) && probe.App.IsConnected(agent.Id, IpVersions.V6))); + await BackendFixture.EventuallyAsync(async () => + { + JsonNode raw = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.Id); + return new[] { "line:" + probe.ProbeId, "line:" + probe.ProbeId + ":ipv6" }.All(series => + new[] { "udp_echo", "tcp_echo" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue() == series && c["kind"]!.GetValue() == kind))); + }, TimeSpan.FromSeconds(45)); + + string v4 = agent.Id + "~" + probe.ProbeId; + string v6 = v4 + "~ipv6"; + foreach (string step in new[] { "1s", "10s" }) + { + foreach ((string line, string family) in new[] { (v4, IpVersions.V4), (v6, IpVersions.V6) }) + { + JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?range=1m&step={step}&preset=udp,tcp"); + Assert.Equal(family, series["ip_version"]!.GetValue()); + Assert.True(series["summary"]!["samples"]!["udp_echo"]!.GetValue() > 0, series.ToJsonString()); + Assert.True(series["summary"]!["samples"]!["tcp_echo"]!.GetValue() > 0, series.ToJsonString()); + } + } + + JsonNode overview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=1m&ip_version=ipv6"); + Assert.Contains(overview["lines"]!.AsArray(), l => l!["id"]!.GetValue() == v6); + Assert.All(overview["lines"]!.AsArray(), l => Assert.Equal("ipv6", l!["ip_version"]!.GetValue())); + + var ipv4Connection = probe.App.Connection(agent.Id); + long previous = agent.App.Counters.Fragments; + using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "" })); + Assert.True(disabled.IsSuccessStatusCode); + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id, IpVersions.V6) is null)); + Assert.Same(ipv4Connection, probe.App.Connection(agent.Id)); + Assert.True(probe.App.IsConnected(agent.Id)); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.Fragments > previous), TimeSpan.FromSeconds(20)); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + } + + [Theory] + [InlineData("hy2_ladder")] + [InlineData("reality_session")] + public async Task Ipv6Test_UsesIpv6Session_AndDoesNotAppearInIpv4History(string plan) + { + Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host"); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::"); + await agent.OverrideLineTasksAsync(); + using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, + BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" })); + Assert.True(configured.IsSuccessStatusCode); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + string v4 = agent.Id + "~" + probe.ProbeId; + string v6 = v4 + "~ipv6"; + await BackendFixture.EventuallyAsync(async () => + (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probes"]!.AsArray() + .Any(p => p!["probe_id"]!.GetValue() == probe.ProbeId && p["ip_version"]!.GetValue() == "ipv6")); + var parameters = plan == "hy2_ladder" ? new JsonObject { ["mbps"] = 1, ["duration_s"] = 1 } : new JsonObject { ["duration_s"] = 1 }; + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{v6}/tests", BackendFixture.Json(new JsonObject { ["plan"] = plan, ["params"] = parameters })); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + string id = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + JsonNode result = null!; + await BackendFixture.EventuallyAsync(async () => + { + result = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id); + return result["status"]!.GetValue() is "failed" or "aborted" || result["result"] is not null; + }, TimeSpan.FromSeconds(100)); + Assert.Equal("succeeded", result["status"]!.GetValue()); + Assert.Equal("ipv6", result["ip_version"]!.GetValue()); + Assert.NotNull(result["result"]); + JsonNode history = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v4}/tests"); + Assert.Empty(history["items"]!.AsArray()); + JsonNode v6History = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v6}/tests"); + Assert.Contains(v6History["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs index 2d34f5a..03e7203 100644 --- a/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs @@ -54,8 +54,9 @@ public sealed class ProbeEndToEndTests // 吊销:面板立刻断开它,之后连不上 using HttpResponseMessage revoked = await _backend.Admin.PostAsync(path + "/revoke", null); revoked.EnsureSuccessStatusCode(); - await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue(), because: "a revoked probe is disconnected and cannot come back"); - Assert.False(probe.App.Link.IsConnected); + await BackendFixture.EventuallyAsync(async () => + !(await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue() && !probe.App.Link.IsConnected, + because: "a revoked probe is disconnected and cannot come back"); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 75c438b..7b41051 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -61,7 +61,7 @@ public sealed class UpdateTests long seq = NextSeq(); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("backend", seq))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("unknown", seq))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode); Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode); diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 649dc5d..4d96a25 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -31,6 +31,7 @@ dev_tag="dev-$short_sha" mkdir -p "$work" "$base_home" if [[ "$component" == backend ]]; then + export MOSHIRO_TAG="$dev_tag" BUILDAH_HOME="$base_home" \ ZOT_REGISTRY="$registry" \ ZOT_IMAGE_REPOSITORY="$repository" \ diff --git a/ci/index-backend-image.sh b/ci/index-backend-image.sh index 793d15b..e9ba88d 100644 --- a/ci/index-backend-image.sh +++ b/ci/index-backend-image.sh @@ -53,3 +53,8 @@ put_tag "$version" if [[ "$version" != *-* ]]; then put_tag latest fi + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +backend_manifest="$(manifest_head "$version")" +read -r backend_digest _ _ <<< "$backend_manifest" +bash "$root/ci/publish-release-offer.sh" backend "$registry/$image" "$backend_digest" "$CI_COMMIT_TAG" release "$root/.release/backend-release" diff --git a/ci/publish-release-offer.sh b/ci/publish-release-offer.sh new file mode 100644 index 0000000..4ede65e --- /dev/null +++ b/ci/publish-release-offer.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# CI 发布 webhook:镜像已推送成功后,签名并登记清单。面板不会因此自动重启 Backend。 +set -euo pipefail +component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}" +[[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; } +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir" +if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then + python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \ + "$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json" + response="$outdir/response.json" + status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/admin/releases" \ + -H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")" + if [[ "$status" != 2* ]]; then + if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then + echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers" + else + echo "release offer registration failed (HTTP $status)" >&2 + cat "$response" >&2 + exit 1 + fi + else + echo "registered the signed $component release with the panel ($channel)" + fi +fi diff --git a/ci/release-linux.sh b/ci/release-linux.sh index aa7c000..52b8b40 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -95,7 +95,7 @@ done push_tag() { local target_tag="$1" pushed=false for attempt in 1 2 3; do - if buildah push --tls-verify=true "$local_image" "docker://$image:$target_tag"; then + if buildah push --tls-verify=true --digestfile "$output/image.digest" "$local_image" "docker://$image:$target_tag"; then pushed=true break fi @@ -124,3 +124,7 @@ else push_tag "$version" fi fi + +if [[ -z "$tag" ]]; then + bash "$root/ci/publish-release-offer.sh" backend "$image" "$(cat "$output/image.digest")" "$MOSHIRO_TAG" dev "$output/release" +fi diff --git a/compose.yaml b/compose.yaml index d294fe7..00eb022 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: backend: - image: registry.oeo.one/moshiro/moshiro-backend:${MOSHIRO_VERSION:-0.1.1} + image: registry.oeo.one/${MOSHIRO_IMAGE_NAMESPACE:-moshiro}/moshiro-backend:${MOSHIRO_VERSION:-0.1.1} container_name: moshiro-backend restart: unless-stopped init: true @@ -9,10 +9,13 @@ services: environment: ASPNETCORE_HTTP_PORTS: "8080" MoShiro__DataDirectory: /data + MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto} + MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro} MoShiro__InitialAdminUser: ${MOSHIRO_ADMIN_USER:-admin} MoShiro__InitialAdminPassword: "${MOSHIRO_ADMIN_PASSWORD:?Set MOSHIRO_ADMIN_PASSWORD in the environment}" volumes: - moshiro-backend-data:/data + - moshiro-backend-status:/data/update-status:ro read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 @@ -25,3 +28,5 @@ services: volumes: moshiro-backend-data: name: moshiro-backend-data + moshiro-backend-status: + name: moshiro-backend-status diff --git a/docs/api.md b/docs/api.md index 6adfeb5..8449434 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,6 +4,14 @@ ## 1. 分层 +### IPv4 / IPv6 + +- `PATCH /api/admin/agents/{id}` 新增可选 `ipv6_host`:IPv6 地址或域名;空字符串关闭 IPv6,缺省或 null 不修改。原 `public_host` 用于 IPv4。 +- `GET /api/public/overview` 新增可选 `ip_version=ipv4|ipv6`,缺省 IPv4。SSE `/api/public/live` 的同名参数选择 overview 主题的地址族;series 主题仍按线路 id 定位。 +- IPv4 线路 id 保持 `agent~probe`,IPv6 为 `agent~probe~ipv6`。series、distribution、heatmap、compare 和管理端测试端点共用这个标识。测试记录与评分叠加独立。 +- 线路、时序、测试和连接 DTO 新增 `ip_version`;管理端 Agent DTO 新增 `ipv6_host`、`ipv6_supported`;线路详情新增 `ipv6_available`(是否配置了 IPv6 地址)。公开 DTO 不包含任一种 IP 地址。 +- API v1 采用新增字段和可选参数,现有 IPv4 请求及历史链接继续有效。前端类型从 OpenAPI 重新生成。 + ```text /api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌; public_visible = false 的 Agent 对匿名访客隐藏 diff --git a/docs/deployment.md b/docs/deployment.md index 4a7631d..4c4489b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,6 +4,20 @@ ## 1. 构建与打包 +### IPv6 状态诊断 + +Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括监听状态、全局地址、默认路由和网络命名空间。容器可提供只读宿主 `/proc` 挂载,附带宿主网络状态。只传布尔状态,不传 IP 或网卡名称。Docker 和 Podman 推荐宿主网络模式;自定义桥接网络时,需要启用 IPv6 并提供 `/proc` 宿主挂载,前端会提示宿主和容器网络状态是否不同。 + +### 启用 IPv6 测量 + +升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。 + +Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果显式配置了 `0.0.0.0`,需要在宿主机改为 `::` 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 `7443/tcp`、`7443/udp`、`7444/tcp`(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。 + +原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。 + +上线后,在管理端的 Probe / Agent 页面查看「IPv6 诊断」。它显示检测网络范围、全局地址和默认路由;机器有 IPv6 但容器没有时,检查是否使用宿主网络。没有填 Agent IPv6 测量地址时,诊断仍会上报,但面板不会建立该 Agent 的 IPv6 测量线路。 + ```text scripts/publish.sh → backend/artifacts/publish/// scripts/package.sh → backend/artifacts/packages/moshiro---.tar.gz(附 .sha256) @@ -193,13 +207,25 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。 +**用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 + +```sh +# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) +sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https:// --join --name +sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --probe-json ./probe.json +# 之后更新节点:不带参数 +sudo sh deploy-node.sh agent +``` + +节点上看不到公网 IPv6 地址,所以 Agent 的 `ipv6_host` 要在管理端手动填,Backend 才会让 Probe 去拨 IPv6。下面是不用脚本的手动步骤。 + 在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`): ```sh podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll agent \ --server https:// --join --name --identity-dir /etc/moshiro/agent-id podman compose run --rm -T --entrypoint /bin/sh agent -c 'cat > /etc/moshiro/agent.json' <<'JSON' -{"fleet":{"server":"https://","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"0.0.0.0"}} +{"fleet":{"server":"https://","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}} JSON podman compose up -d ``` @@ -250,3 +276,22 @@ sh packaging/update/install-updater.sh agent --user \ 更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 + +## Backend 签名更新 + +Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 + +`MOSHIRO_UPDATE_CHANNEL=auto` 时,`MOSHIRO_IMAGE_NAMESPACE=moshiro-dev` 选择 `dev`,`moshiro` 选择 `release`。CI 发布流水线使用对应 channel 签名并登记到 Backend webhook。旧版 Backend 不识别 Backend 清单,因此首次升级需按常规部署流程引导一次;该期间 CI 对这类旧版面板的明确“不认识 backend 组件”响应会提示引导信息,其他 webhook 错误仍会令发布失败。 + +更新器以手动策略运行,并通过独立状态 volume 将状态只读挂载回 Backend: + +```sh +sudo packaging/update/install-updater.sh backend --engine podman \ + --volume moshiro-backend-data --status-volume moshiro-backend-status \ + --helper-image registry.oeo.one/moshiro-dev/moshiro-backend: \ + --image-repo registry.oeo.one/moshiro-dev/moshiro-backend \ + --unit moshiro-backend.service --policy manual --channel dev \ + --health-url http://127.0.0.1:29181/healthz +``` + +Backend 服务需要将 `moshiro-backend-status` 挂载到 `/data/update-status:ro`,并将镜像引用文件 `/etc/moshiro-update/backend.image` 作为 `IMAGE_FILE` 提供给 systemd 单元。release 实例使用 `--channel release` 及 `registry.oeo.one/moshiro` 仓库。更新请求由管理端确认后写入状态接口,不要启用全局 Podman 自动更新 timer。 diff --git a/docs/measurement.md b/docs/measurement.md index f8f7cd6..0a56449 100644 --- a/docs/measurement.md +++ b/docs/measurement.md @@ -8,6 +8,8 @@ ## 2. 任务模型 +同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。 + ```text Task = { id, scope, kind, target, params, schedule, enabled } diff --git a/docs/protocol.md b/docs/protocol.md index 880285f..32cf03f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -94,6 +94,18 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen ## 4. 版本演进 +### IPv4 / IPv6 路径 + +- 一个 Probe 与 Agent 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。 +- `probe_agents.agents[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;Probe 按地址族解析,不跨地址族回退。 +- Agent 在 hello 的元数据里上报 `ipv6_supported`,只在本机监听 IPv6 时为 true。Backend 只向支持它的 Agent 安排 IPv6 路径,旧 Agent 缺省为 false。 +- Agent 和 Probe 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。 +- `ConnectedProbe`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 Probe 上报的值。 +- IPv4 的线路 id `agent~probe`、序列 `line:probe` 保持不变;IPv6 使用 `agent~probe~ipv6`、`line:probe:ipv6`。指标、重算、SSE 和测试结果按路径隔离。 +- 本次是 JSON 字段扩展,二进制包、列编码和容器布局没有变化,协议与 `container_version` 保持原版本。双栈采集需要升级 Agent 和 Probe;旧节点继续提供 IPv4。 + +### 通用规则 + - Fleet 层的兼容规则见 [../backend/fleet/README.md](../backend/fleet/README.md)。 - `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。 - RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。 diff --git a/packaging/compose/agent/compose.yaml b/packaging/compose/agent/compose.yaml index fa35451..57cb567 100644 --- a/packaging/compose/agent/compose.yaml +++ b/packaging/compose/agent/compose.yaml @@ -18,6 +18,7 @@ services: - /sys:/host/sys:ro - /:/host/root:ro environment: + MOSHIRO_NETWORK_SCOPE: host HOST_PROC: /host/proc HOST_SYS: /host/sys HOST_ROOT: /host/root diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index 962a153..7c20553 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -8,6 +8,8 @@ services: restart: unless-stopped init: true network_mode: host + environment: + MOSHIRO_NETWORK_SCOPE: host volumes: - probe-config:/etc/moshiro - probe-status:/etc/moshiro/update-status:ro diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh new file mode 100644 index 0000000..57f1c8e --- /dev/null +++ b/packaging/container/deploy-node.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# 部署或更新容器节点(Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、 +# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。 +# +# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1 +# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --probe-json ./probe.json +# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置) +# +# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。 +# rootless Podman 见 docs/deployment.md。 +set -eu + +usage() { + echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2 + echo " [--server URL --join TOKEN --name NAME] [--probe-json FILE] [--policy auto|manual] [--authfile F]" >&2 + exit 2 +} + +ROLE="${1:-}"; shift || true +case "$ROLE" in agent|probe) ;; *) usage ;; esac +ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; PROBE_JSON=""; POLICY=""; AUTHFILE="" +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --image-repo) REPO="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --image) IMAGE="$2"; shift 2 ;; + --server) SERVER="$2"; shift 2 ;; + --join) JOIN="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --probe-json) PROBE_JSON="$2"; shift 2 ;; + --policy) POLICY="$2"; shift 2 ;; + --authfile) AUTHFILE="$2"; shift 2 ;; + *) usage ;; + esac +done +[ "$(id -u)" -eq 0 ] || { echo "run as root (rootless Podman: see docs/deployment.md)" >&2; exit 1; } + +HERE="$(cd "$(dirname "$0")" && pwd)" +PACKAGING="$(cd "$HERE/.." && pwd)" +CONF_DIR=/etc/moshiro-update +CONF="$CONF_DIR/$ROLE.conf" + +# 重复运行时沿用上次的更新器配置(仓库、策略、认证文件、引擎) +conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head -n 1 || true; } +[ -n "$REPO" ] || REPO="$(conf_get IMAGE_REPO)" +[ -n "$POLICY" ] || POLICY="$(conf_get POLICY)" +[ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)" +[ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)" +[ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE" +[ -n "$POLICY" ] || POLICY=manual +if [ -z "$ENGINE" ]; then + if command -v podman >/dev/null 2>&1; then ENGINE=podman; elif command -v docker >/dev/null 2>&1; then ENGINE=docker; else echo "podman or docker is required" >&2; exit 1; fi +fi +case "$ENGINE" in + podman) UNIT="moshiro-$ROLE-container.service" ;; + docker) UNIT="moshiro-$ROLE-docker.service" ;; + *) echo "--engine must be podman or docker" >&2; exit 2 ;; +esac +V_CFG="moshiro-$ROLE-config" +V_STATUS="moshiro-$ROLE-status" +for f in "$PACKAGING/systemd/$UNIT" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do + [ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; } +done + +# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用 +if [ -z "$IMAGE" ]; then + if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi + if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$REPO:$TAG" >/dev/null + else "$ENGINE" pull -q "$REPO:$TAG" >/dev/null; fi + if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST" + else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi +fi +echo "image: $IMAGE" + +# 2. 卷。辅助容器以镜像自带的 app 用户(1654)读写,所以卷里的文件属主总是对的 +"$ENGINE" volume inspect "$V_CFG" >/dev/null 2>&1 || "$ENGINE" volume create "$V_CFG" >/dev/null +"$ENGINE" volume inspect "$V_STATUS" >/dev/null 2>&1 || "$ENGINE" volume create "$V_STATUS" >/dev/null +in_volume() { + "$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \ + -v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1" +} +in_volume 'mkdir -p /etc/moshiro/update' &2; exit 1; } + "$ENGINE" run --rm --pull=never -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \ + --server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir /etc/moshiro/agent-id + fi + if ! in_volume 'test -f /etc/moshiro/agent.json' &2; exit 1; } + printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}\n' "$SERVER" \ + | in_volume 'cat > /etc/moshiro/agent.json' + echo "config: created agent.json (listen.bind ::)" + elif in_volume 'grep -q "\"bind\" *: *\"0\.0\.0\.0\"" /etc/moshiro/agent.json' ::" + fi +else + if [ -n "$PROBE_JSON" ]; then + in_volume 'umask 077; cat > /etc/moshiro/probe.json' < "$PROBE_JSON" + echo "config: imported probe.json" + elif ! in_volume 'test -f /etc/moshiro/probe.json' &2; exit 1 + fi +fi + +# 4. 单元和更新器(保持原来的更新策略) +install -d -m 755 "$CONF_DIR" +printf 'IMAGE=%s\n' "$IMAGE" > "$CONF_DIR/$ROLE.image" +install -m 644 "$PACKAGING/systemd/$UNIT" "/etc/systemd/system/$UNIT" +set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \ + --image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable +[ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE" +sh "$PACKAGING/update/install-updater.sh" "$@" + +# 5. 启动 +systemctl daemon-reload +systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1 +systemctl restart "$UNIT" +systemctl start "moshiro-update-$ROLE.timer" +sleep 5 +systemctl is-active "$UNIT" diff --git a/packaging/install.sh b/packaging/install.sh index 569f9b5..3df42a8 100644 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -66,7 +66,7 @@ write_agent_config() { cat >"$CONFIG/agent.json" < [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac +case "$COMPONENT" in agent|probe|backend) ;; *) echo "usage: install-updater.sh [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY=manual; AUTHFILE=""; ENGINE=podman; SERVICE_SCOPE=system; ENABLE=true; DATA_VOLUME=""; STATUS_VOLUME=""; HELPER_IMAGE="" +CHANNEL=""; HEALTH_URL="" while [ $# -gt 0 ]; do case "$1" in --user) SERVICE_SCOPE=user; shift ;; @@ -18,6 +19,8 @@ while [ $# -gt 0 ]; do --image-repo) IMAGE_REPO="$2"; shift 2 ;; --unit) UNIT="$2"; shift 2 ;; --policy) POLICY="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --health-url) HEALTH_URL="$2"; shift 2 ;; --authfile) AUTHFILE="$2"; shift 2 ;; --no-enable) ENABLE=false; shift ;; *) echo "unknown option: $1" >&2; exit 2 ;; @@ -28,6 +31,10 @@ if [ -z "$IMAGE_REPO" ] || [ -z "$UNIT" ] || { [ -z "$DATA_DIR" ] && [ -z "$DATA fi case "$POLICY" in auto|manual) ;; *) echo "--policy must be auto or manual" >&2; exit 2 ;; esac case "$ENGINE" in podman|docker) ;; *) echo "--engine must be podman or docker" >&2; exit 2 ;; esac +if [ "$COMPONENT" = backend ]; then + [ "$POLICY" = manual ] && [ -n "$DATA_VOLUME" ] && [ -n "$HEALTH_URL" ] || { echo "Backend requires manual policy, a data volume and --health-url" >&2; exit 2; } + case "$CHANNEL" in dev|release) ;; *) echo "Backend requires --channel dev or release" >&2; exit 2 ;; esac +fi if [ -n "$DATA_VOLUME" ]; then [ -z "$DATA_DIR" ] || { echo "use --volume or --data-dir, not both" >&2; exit 2; } [ -n "$HELPER_IMAGE" ] || { echo "--volume needs --helper-image (an already pulled MoShiro image)" >&2; exit 2; } @@ -79,12 +86,13 @@ if [ -z "$DATA_VOLUME" ]; then install -d -m 755 "$DATA_DIR/update-status"; fi ENV_FILE="$CONF_DIR/$COMPONENT.image" PUBKEY="$CONF_DIR/release-key.pub.pem" { - for name in DATA_DIR IMAGE_REPO UNIT PUBKEY ENV_FILE STATE_DIR POLICY AUTHFILE ENGINE SERVICE_SCOPE DATA_VOLUME STATUS_VOLUME HELPER_IMAGE; do + for name in DATA_DIR IMAGE_REPO UNIT PUBKEY ENV_FILE STATE_DIR POLICY AUTHFILE ENGINE SERVICE_SCOPE DATA_VOLUME STATUS_VOLUME HELPER_IMAGE CHANNEL HEALTH_URL; do case "$name" in DATA_DIR) value="$DATA_DIR" ;; IMAGE_REPO) value="$IMAGE_REPO" ;; UNIT) value="$UNIT" ;; PUBKEY) value="$PUBKEY" ;; ENV_FILE) value="$ENV_FILE" ;; STATE_DIR) value="$STATE_DIR" ;; DATA_VOLUME) value="$DATA_VOLUME" ;; STATUS_VOLUME) value="$STATUS_VOLUME" ;; HELPER_IMAGE) value="$HELPER_IMAGE" ;; POLICY) value="$POLICY" ;; AUTHFILE) value="$AUTHFILE" ;; ENGINE) value="$ENGINE" ;; SERVICE_SCOPE) value="$SERVICE_SCOPE" ;; + CHANNEL) value="$CHANNEL" ;; HEALTH_URL) value="$HEALTH_URL" ;; esac printf '%s=' "$name"; shell_quote "$value"; printf '\n' done diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 107e7b2..094e7c4 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -10,11 +10,12 @@ # Shell snippets below execute inside helper containers. # shellcheck disable=SC2016 set -eu +umask 077 CMD="${1:-}" COMPONENT="${2:-}" -case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update " >&2; exit 2 ;; esac -case "$COMPONENT" in agent|probe) ;; *) echo "component must be agent or probe" >&2; exit 2 ;; esac +case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update " >&2; exit 2 ;; esac +case "$COMPONENT" in agent|probe|backend) ;; *) echo "component must be agent, probe or backend" >&2; exit 2 ;; esac if [ "$(id -u)" -eq 0 ]; then DEFAULT_CONF_DIR=/etc/moshiro-update @@ -34,6 +35,14 @@ ENGINE="${ENGINE:-podman}" SERVICE_SCOPE="${SERVICE_SCOPE:-system}" DATA_VOLUME="${DATA_VOLUME:-}" STATUS_VOLUME="${STATUS_VOLUME:-moshiro-$COMPONENT-status}" +CHANNEL="${CHANNEL:-}" +HEALTH_URL="${HEALTH_URL:-}" +if [ "$COMPONENT" = backend ]; then + [ "$POLICY" = manual ] || { echo "Backend updates require manual confirmation" >&2; exit 2; } + case "$CHANNEL" in dev|release) ;; *) echo "Backend needs CHANNEL=dev or release" >&2; exit 2 ;; esac + [ -n "$HEALTH_URL" ] || { echo "Backend needs a local HEALTH_URL" >&2; exit 2; } + [ -n "$DATA_VOLUME" ] || { echo "Backend backup needs DATA_VOLUME" >&2; exit 2; } +fi case "$ENGINE" in podman|docker) ;; *) echo "unsupported engine: $ENGINE" >&2; exit 2 ;; esac case "$SERVICE_SCOPE" in system|user) ;; *) echo "unsupported service scope: $SERVICE_SCOPE" >&2; exit 2 ;; esac service_ctl() { @@ -117,6 +126,37 @@ write_status() { # state available_json error current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; } installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; } +# Backend 的请求带用户确认的 seq;复制以后再验清单,避免后来到达的 tag 被自动套用。 +fetch_request() { + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/etc/moshiro:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then if [ "$1" = backend ]; then head -c 64 "$f"; else printf yes; fi; fi' sh "$COMPONENT" + elif [ -f "$UPDATE_DIR/apply.request" ] && [ ! -L "$UPDATE_DIR/apply.request" ]; then + if [ "$COMPONENT" = backend ]; then head -c 64 "$UPDATE_DIR/apply.request"; else printf yes; fi + fi +} + +backend_backup() { + BACKUP="$STATE_DIR/backend-before-$M_SEQ.tar.gz" + service_ctl stop "$UNIT" + if ! "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/data:ro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -czf - -C /data .' > "$BACKUP.tmp"; then + rm -f "$BACKUP.tmp" + service_ctl start "$UNIT" || true + write_status failed "$avail" "database backup failed" + return 1 + fi + chmod 600 "$BACKUP.tmp" + mv -f "$BACKUP.tmp" "$BACKUP" +} + +backend_restore() { + service_ctl stop "$UNIT" || true + "$ENGINE" run --pull=never --rm -i --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/data" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -xzf - --no-same-owner -C /data' < "$BACKUP" +} + # 从扁平的清单里取字段(清单由 CI 生成,只有字符串和整数,没有嵌套) field() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$OFFER_MANIFEST" | head -n 1; } num_field() { sed -n 's/.*"'"$1"'":\([0-9][0-9]*\).*/\1/p' "$OFFER_MANIFEST" | head -n 1; } @@ -131,6 +171,9 @@ verify_offer() { openssl dgst -sha256 -verify "$PUBKEY" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; } [ "$(field component)" = "$COMPONENT" ] || { ERR="manifest is for another component"; return 1; } [ "$(field image)" = "$IMAGE_REPO" ] || { ERR="manifest names another repository"; return 1; } + if [ -n "$CHANNEL" ]; then + [ "$(field channel)" = "$CHANNEL" ] || { ERR="manifest is for another channel"; return 1; } + fi M_DIGEST="$(field digest)" case "$M_DIGEST" in sha256:*) ;; *) ERR="bad digest"; return 1 ;; esac [ "${#M_DIGEST}" -eq 71 ] && [ -z "$(printf '%s' "${M_DIGEST#sha256:}" | tr -d '0-9a-f')" ] || { ERR="bad digest"; return 1; } @@ -162,6 +205,7 @@ do_check() { } do_apply() { + requested_seq="$(fetch_request)" if [ -n "$DATA_VOLUME" ]; then "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ -v "$DATA_VOLUME:/etc/moshiro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'rm -f /etc/moshiro/update/apply.request' @@ -170,6 +214,10 @@ do_apply() { fi fetch_offer verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; } + if [ "$COMPONENT" = backend ]; then + case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac + [ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; } + fi avail="$(available_json)" write_status applying "$avail" "" ref="$IMAGE_REPO@$M_DIGEST" @@ -177,6 +225,7 @@ do_apply() { write_status failed "$avail" "pull failed"; echo "pull failed: $ref" >&2; return 1 fi prev="$(cat "$ENV_FILE" 2>/dev/null || true)" + if [ "$COMPONENT" = backend ]; then backend_backup || return 1; fi printf 'IMAGE=%s\n' "$ref" > "$ENV_FILE.new" && mv -f "$ENV_FILE.new" "$ENV_FILE" service_ctl restart "$UNIT" || true # 健康确认:服务在跑,并且 20 秒内没有再被重启(崩溃循环算失败) @@ -184,13 +233,23 @@ do_apply() { before="$(service_ctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" sleep 15 after="$(service_ctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" - if service_ctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then + healthy=true + if [ -n "$HEALTH_URL" ]; then + if ! curl --fail --silent --show-error --max-time 5 "$HEALTH_URL" | grep -q '"status":"ok"'; then healthy=false; fi + fi + if [ "$healthy" = true ] && service_ctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED" write_status up_to_date null "" echo "updated to $M_TAG" return 0 fi echo "new version did not become healthy, rolling back" >&2 + if [ "$COMPONENT" = backend ]; then + if ! backend_restore; then + write_status failed "$avail" "database restore failed; backup retained" + return 1 + fi + fi if [ -n "$prev" ]; then printf '%s\n' "$prev" > "$ENV_FILE"; fi service_ctl restart "$UNIT" || true write_status rolled_back "$avail" "new version did not become healthy" @@ -200,10 +259,8 @@ do_apply() { case "$CMD" in check) if [ -n "$DATA_VOLUME" ] && [ "$POLICY" != auto ]; then - requested="$($ENGINE run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ - -v "$DATA_VOLUME:/etc/moshiro:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \ - -c '[ ! -f /etc/moshiro/update/apply.request ] || printf yes')" - if [ "$requested" = yes ]; then do_apply; else do_check; fi + requested="$(fetch_request)" + if [ -n "$requested" ]; then do_apply; else do_check; fi else do_check fi ;; diff --git a/web/README.md b/web/README.md index 448b3c9..ec8e0c3 100644 --- a/web/README.md +++ b/web/README.md @@ -22,6 +22,7 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css, 连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`MoShiro__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。 约定: +- 总览、线路详情、对比和管理端按需测试均可切换 IPv4 / IPv6;查询和测试结果独立。管理端 Agent 编辑页分别配置 IPv4、IPv6 地址,IPv6 留空只采 IPv4。展示端只显示地址族,不显示 IP。 - 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 - 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 - 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index cb61d56..64f14c3 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -4,6 +4,7 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom' import { activateSource, getBase, listSources } from '@shared/api/source' import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' import { useAuth } from './auth' +import { BackendUpdateNotice } from './BackendUpdateNotice' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' import { Probes } from './pages/Probes' @@ -162,6 +163,7 @@ export function App() {
+ } /> } /> diff --git a/web/admin/src/BackendUpdateNotice.tsx b/web/admin/src/BackendUpdateNotice.tsx new file mode 100644 index 0000000..f56006f --- /dev/null +++ b/web/admin/src/BackendUpdateNotice.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '@shared/api/client' +import { openLive } from '@shared/api/source' +import type { BackendUpdateDto } from '@shared/api/types' +import { Confirm, ErrorNotice, useAction } from '@shared/ui' +import { useAuth } from './auth' + +/** webhook 发布后显示当前通道的更新;宿主机验签完成且用户确认同一个 seq 后才升级。 */ +export function BackendUpdateNotice() { + const { me } = useAuth() + const [update, setUpdate] = useState() + const [confirming, setConfirming] = useState() + const [requested, setRequested] = useState(false) + const [actionError, setActionError] = useState() + const originalTag = useRef(undefined) + const { busy, run } = useAction() + + useEffect(() => { + if (me?.scope !== 'admin') return + let alive = true + const receive = (value: BackendUpdateDto) => { + if (!alive) return + setUpdate(value) + } + // 兼容尚未升级的 Backend:没有这个端点时隐藏提示;SSE 自动重连后再取得快照。 + api.get('/api/admin/backend/update').then(receive).catch(() => undefined) + const source = openLive('/api/admin/backend/update/live') + source.addEventListener('backend_update', (event) => receive(JSON.parse(event.data) as BackendUpdateDto)) + return () => { alive = false; source.close() } + }, [me?.scope]) + + useEffect(() => { + if (!requested || !update) return + if (update.state === 'up_to_date' || (update.current_tag && update.current_tag !== originalTag.current)) location.reload() + else if (['failed', 'rolled_back', 'invalid'].includes(update.state) || (update.state === 'available' && update.error)) setRequested(false) + }, [update, requested]) + + if (!update || (!update.available && !requested && update.state !== 'applying')) return null + const waiting = requested || update.state === 'requested' || update.state === 'applying' + const canUpdate = update.enabled && update.state === 'available' && update.available + const message = update.state === 'applying' ? 'Backend 正在升级重启,连接恢复后会自动刷新。' + : waiting ? '已确认 Backend 更新,正在等待宿主机更新器执行。' + : update.state === 'checking' ? '新版本已发布,正在等待宿主机验签。' + : !update.enabled ? '新版本已发布,需要在宿主机安装 Backend 更新器。' + : update.state === 'invalid' ? '发布清单未通过更新器校验。' + : update.state === 'rolled_back' ? '更新失败,已回滚。' + : update.state === 'failed' ? '更新失败。' : 'Backend 有新版本。' + + return ( +
+
+ {message} {update.available?.tag} ({update.channel}) + {canUpdate && !waiting ? : null} +
+ {update.error ?

{update.error}

: null} + + {confirming?.available ? ( + setConfirming(undefined)} onConfirm={async () => { + setActionError(undefined) + const ok = await run(async () => { + try { await api.post('/api/admin/backend/update', { seq: confirming.available!.seq }) } + catch (error) { setActionError(error); throw error } + }, '已确认更新') + if (ok) { + originalTag.current = update.current_tag + setRequested(true) + } + }}> + 将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。宿主机会先备份数据,再升级并重启,期间面板会短暂断开;恢复后自动刷新。如果发布清单已变化,需要重新确认。 + + ) : null} +
+ ) +} diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index 92d25bc..a967a8c 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -7,12 +7,14 @@ import { fmtAgo, fmtNum, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' import { AgentStatus } from './Agents' +import { IPV6_STATES } from '@shared/ipVersions' function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) { const { busy, run } = useAction() const [name, setName] = useState(agent.name) const [labels, setLabels] = useState(agent.labels.join(', ')) const [host, setHost] = useState(agent.public_host) + const [ipv6Host, setIpv6Host] = useState(agent.ipv6_host ?? '') const [visible, setVisible] = useState(agent.public_visible) return ( { const ok = await run( - () => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }), + () => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host.trim(), ipv6_host: ipv6Host.trim(), public_visible: visible }), '已保存', ) if (ok) { @@ -50,10 +52,15 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
- + setHost(e.target.value)} />
+
+ + setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" /> + +
setLabels(e.target.value)} /> @@ -167,9 +174,9 @@ export function AgentDetail() { {a.probes.map((p) => ( - + -
{probeName(p.probe_id)}
+
{probeName(p.probe_id)} {p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}
{p.probe_id}
{p.version ?? '—'} @@ -179,7 +186,7 @@ export function AgentDetail() { - + 测试 @@ -209,8 +216,22 @@ export function AgentDetail() {
ID
{a.id}
-
公开地址
+
IPv4 地址
{a.public_host || '—'}
+
IPv6 地址
+
{a.ipv6_host || '未配置'}
+
IPv6 监听
+
{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}
+
IPv6 诊断
+
{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}
+
检测网络
+
{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}
+
宿主全局 IPv6
+
{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}
+
全局 IPv6 地址
+
{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}
+
IPv6 默认路由
+
{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}
控制端口
{a.probe_tcp_port}/tcp
数据端口
diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 5629884..772a215 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -6,6 +6,7 @@ import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@ import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' +import { IPV6_STATES } from '@shared/ipVersions' import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' /** @@ -204,12 +205,13 @@ export function Probes() {
{p.name} + {p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'} {p.revoked ? ( 已吊销 ) : ( <> {p.online ? '已连面板' : '未连面板'} - 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'} + 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'} )} {p.id} @@ -252,10 +254,11 @@ export function Probes() { {p.connections.map((c) => ( - + {c.agent_name} + {c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'} {c.version ?? '—'} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 2ae8bb0..f47de95 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,5 +1,5 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' @@ -78,7 +78,7 @@ function Settings() { } /** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */ -function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) { +function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string; update?: ReactNode }) { const dash = — return ( @@ -91,6 +91,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { nam {commit || dash} {fmtBuiltAt(builtAt) || dash} {platform || dash} + {update ?? dash} ) } @@ -149,8 +150,8 @@ function Versions() { {(releases.data?.items ?? []).length === 0 ? '还没有(CI 构建后会自动登记)' : (releases.data?.items ?? []).map((r) => ( - - {r.component} {r.tag} + + {r.component} {r.tag} ({r.channel}) ))} @@ -174,7 +175,7 @@ function Versions() { - {back ? : null} + {back ? {info.data.backend_update.channel} · {info.data.backend_update.state === 'unavailable' ? '未安装更新器' : UPDATE_STATE[info.data.backend_update.state]?.text ?? info.data.backend_update.state} : undefined} /> : null} {nodes.map((n) => ( diff --git a/web/admin/src/pages/Tests.tsx b/web/admin/src/pages/Tests.tsx index 70c3da1..0dfb38f 100644 --- a/web/admin/src/pages/Tests.tsx +++ b/web/admin/src/pages/Tests.tsx @@ -1,5 +1,6 @@ import { FlaskConical, Square } from 'lucide-react' import { useEffect, useState } from 'react' +import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types' @@ -84,6 +85,7 @@ function Result({ test }: { test: TestDto }) { export function Tests() { const [params, setParams] = useSearchParams() + const ipVersion = ipVersionOf(params.get('ip_version')) const agents = useQuery(() => api.get>('/api/admin/agents'), [], 10_000) const probes = useQuery(() => api.get>('/api/admin/probes'), []) const plans = useQuery(() => api.get>('/api/public/catalog/plans'), []) @@ -92,16 +94,18 @@ export function Tests() { const active = (agents.data?.items ?? []).filter((a) => a.status === 'active') const agentId = params.get('agent') ?? active[0]?.id ?? '' const agent = active.find((a) => a.id === agentId) - const connected = agent?.probes ?? [] + const connected = (agent?.probes ?? []).filter((p) => ipVersionOf(p.ip_version) === ipVersion) const probeId = params.get('probe') ?? connected[0]?.probe_id ?? '' const [planId, setPlanId] = useState('') const [selected, setSelected] = useState() - const line = agentId && probeId ? `${agentId}~${probeId}` : '' + const line = agentId && probeId ? `${agentId}~${probeId}${ipVersion === 'ipv6' ? '~ipv6' : ''}` : '' useEffect(() => { if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id) }, [plans.data, planId]) + useEffect(() => { setSelected(undefined) }, [line]) + const tests = useQuery(() => (line ? api.get>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000) const detail = useQuery(() => (selected ? api.get(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000) const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id @@ -118,6 +122,7 @@ export function Tests() { <>

按需测试

+ set({ ip_version: v })} /> {agents.loading && !agents.data ? : null}
@@ -190,7 +195,7 @@ export function Tests() { ) : ( - {tests.data?.items.map((t) => ( + {tests.data?.items.filter((t) => t.agent_id === agentId && t.probe_id === probeId && ipVersionOf(t.ip_version) === ipVersion).map((t) => ( setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}>
{STATUS[t.status]?.label ?? t.status} @@ -221,7 +226,7 @@ export function Tests() {
结果
- {detail.data ? ( + {detail.data && detail.data.id === selected && ipVersionOf(detail.data.ip_version) === ipVersion ? ( <>

{detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status} diff --git a/web/display/src/App.tsx b/web/display/src/App.tsx index cd5165a..a9279dd 100644 --- a/web/display/src/App.tsx +++ b/web/display/src/App.tsx @@ -1,20 +1,28 @@ import { BuildFooter } from './BuildFooter' -import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom' import { SourceButton, ThemeToggle } from '@shared/ui' import { Compare } from './pages/Compare' import { Line } from './pages/Line' import { Overview } from './pages/Overview' +function LineRoute() { + const { id } = useParams() + return +} + export function App() { + const location = useLocation() + const ipv6 = location.pathname.endsWith('~ipv6') || new URLSearchParams(location.search).get('ip_version') === 'ipv6' + const familyQuery = ipv6 ? '?ip_version=ipv6' : '' return ( <>

} /> - } /> + } /> } /> } /> diff --git a/web/display/src/pages/Compare.tsx b/web/display/src/pages/Compare.tsx index 362b3f4..f070d78 100644 --- a/web/display/src/pages/Compare.tsx +++ b/web/display/src/pages/Compare.tsx @@ -1,5 +1,6 @@ import { Star } from 'lucide-react' import { useMemo } from 'react' +import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types' @@ -54,15 +55,16 @@ interface Cell { export function Compare() { const [params, setParams] = useSearchParams() + const ipVersion = ipVersionOf(params.get('ip_version')) const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d' const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0] - const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params]) + const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter((id) => id && id.endsWith('~ipv6') === (ipVersion === 'ipv6')), [params, ipVersion]) - const overview = useQuery(() => api.get('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000) + const overview = useQuery(() => api.get('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000) // 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好) const matrix = useMemo(() => { - const lines = overview.data?.lines ?? [] + const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion) const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) const byProbe = new Map() @@ -79,7 +81,7 @@ export function Compare() { byProbe.set(probeId, cells) } return { probes, agents, byProbe } - }, [overview.data, metric]) + }, [overview.data, metric, ipVersion]) // 默认选每个 Probe 里最好的那条线路(最多 6 条) const defaults = useMemo(() => { @@ -123,6 +125,7 @@ export function Compare() {

线路对比

同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。

+ update({ ip_version: v, lines: null })} /> ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />