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 a7827bd..a4e1f72 100644 --- a/TODO.md +++ b/TODO.md @@ -27,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 814032b..e8a497f 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -264,6 +264,7 @@ 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, @@ -292,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/openapi/v1.json b/backend/openapi/v1.json index 8d0221d..87bf05e 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -124,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": [ @@ -3917,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": [ @@ -4357,6 +4530,39 @@ "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" } } }, @@ -4435,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", @@ -4512,6 +4769,16 @@ "items": { "$ref": "#/components/schemas/NodeBuildDto" } + }, + "backend_update": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BackendUpdateDto" + } + ] } } }, @@ -4628,6 +4895,22 @@ } } }, + "ConfirmBackendUpdateRequest": { + "required": [ + "seq" + ], + "type": "object", + "properties": { + "seq": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + }, "ConnectedProbeDto": { "required": [ "probe_id", @@ -5424,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", @@ -6201,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", @@ -6815,6 +7178,16 @@ "items": { "$ref": "#/components/schemas/ProbeConnectionDto" } + }, + "ipv6": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Ipv6DiagnosticDto" + } + ] } } }, @@ -7112,6 +7485,10 @@ }, "digest": { "type": "string" + }, + "channel": { + "type": "string", + "default": "release" } } }, diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index d376c21..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, 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/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/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index e0e66a4..5907bd6 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -281,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; } } @@ -288,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; } } @@ -295,6 +301,9 @@ 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; } @@ -383,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; } @@ -554,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/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/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 2784074..acd06f8 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -41,7 +41,7 @@ public static class Mappers 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), c.IpVersion))]); + [.. 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( @@ -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.Ipv6Host, a.Ipv6Supported); + 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 0ad4407..d4bb481 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -39,7 +39,11 @@ public sealed record AgentDto( DateTimeOffset CreatedAt, DateTimeOffset? LastSeenAt, string Ipv6Host = "", - bool Ipv6Supported = false); + 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, string? Ipv6Host = null); @@ -59,7 +63,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? /// 某个 Probe 现在连着的一个 Agent。 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); @@ -139,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 da47c5e..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); diff --git a/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs index ad93ce1..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,6 +44,13 @@ 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? ip_version) => diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index b7dfbbe..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; @@ -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,7 +207,8 @@ 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, meta.Ipv6Supported), 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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index e69aca8..5647b65 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -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))!); } 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/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 1879be5..1b6153f 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -24,7 +24,8 @@ public sealed record AgentRecord( long? LastSeenAtMs, long RowVersion, string Ipv6Host = "", - bool Ipv6Supported = false); + bool Ipv6Supported = false, + string? Ipv6DiagnosticsJson = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -48,7 +49,8 @@ public sealed record AgentHello( byte[]? TlsSpki, int? ProbeTcpPort, IReadOnlyList? ProbeUdpPorts, - bool Ipv6Supported = false); + bool Ipv6Supported = false, + string? Ipv6DiagnosticsJson = null); /// 注册之后 MoShiro 的补充:公开地址和端口。 public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); @@ -64,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/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ea9a583..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); 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 625140d..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 = 6; + public const int CurrentVersion = 7; public static void Migrate(SqliteConnection connection) { @@ -101,6 +101,15 @@ internal static class Schema 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 e3e8d8f..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, ipv6_host, ipv6_supported"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics"; private readonly SqliteDatabase _db; @@ -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), ipv6_supported = @v6, 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)), ("@v6", hello.Ipv6Supported ? 1 : 0)); + ("@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) => @@ -219,7 +219,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.NullableLong(17), r.GetInt64(18), r.GetString(19), - r.GetInt64(20) != 0); + 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/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/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/deployment.md b/docs/deployment.md index f20281f..0c61639 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,6 +4,10 @@ ## 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 会话继续工作。 @@ -12,6 +16,8 @@ Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果 原来的 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) @@ -258,3 +264,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/protocol.md b/docs/protocol.md index 48f3a10..32cf03f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -99,6 +99,7 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen - 一个 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。 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/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/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 1cdaf10..a967a8c 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -7,6 +7,7 @@ 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() @@ -221,6 +222,16 @@ export function AgentDetail() {
{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 2c19aed..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,6 +205,7 @@ export function Probes() {
{p.name} + {p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'} {p.revoked ? ( 已吊销 ) : ( 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/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index f8f2a04..01ba734 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' -import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types' +import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types' import { fmtAgo } from '@shared/format' import { useQuery } from '@shared/hooks' import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui' @@ -9,7 +9,7 @@ import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../ import { Flag, IspIcon, useNodeInfos } from '@shared/nodes' import { OsIcon } from '@shared/osIcon' import { PageError } from '../lib' -import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions' +import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',') @@ -155,6 +155,7 @@ export function Overview() { // 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h) const [params, setParams] = useSearchParams() const ipVersion = ipVersionOf(params.get('ip_version')) + const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000) const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h' const setRange = (v: OverviewRange) => setParams( @@ -229,7 +230,8 @@ export function Overview() { {data ? ( cards.length === 0 ? (
- {ipVersion === 'ipv6' ? '还没有 IPv6 线路,请在管理端配置 Agent 的 IPv6 地址' : '还没有线路'} + {ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'} + {ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) =>

{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}

) : null}
) : (
diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index b9293c8..3c8f7fb 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/public/ipv6": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** IPv6 configuration and collector diagnostics without addresses */ + get: operations["ipv6_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/public/lines/{line_id}": { parameters: { query?: never; @@ -851,6 +868,41 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/backend/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current channel and host-verified backend update */ + get: operations["backend_update_get"]; + put?: never; + /** Confirm one verified backend release before the host restarts it */ + post: operations["backend_update_confirm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/backend/update/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Server-sent backend update notifications */ + get: operations["backend_update_live"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/links": { parameters: { query?: never; @@ -1011,6 +1063,13 @@ export interface components { ipv6_host: string; /** @default false */ ipv6_supported: boolean; + ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; + }; + AgentIpv6Dto: { + agent_id: string; + name: string; + online: boolean; + ipv6: components["schemas"]["Ipv6DiagnosticDto"]; }; AgentPatchRequest: { name: null | string; @@ -1030,6 +1089,15 @@ export interface components { /** Format: date-time */ revoked_at: unknown; }; + BackendUpdateDto: { + channel: string; + enabled: boolean; + state: string; + current_tag: null | string; + current_digest: null | string; + available: null | components["schemas"]["ReleaseDto"]; + error: null | string; + }; BlocksDto: { transport: string; pacer: string; @@ -1046,6 +1114,7 @@ export interface components { BuildInfoDto: { backend: components["schemas"]["BuildDto"]; nodes: components["schemas"]["NodeBuildDto"][]; + backend_update?: null | components["schemas"]["BackendUpdateDto"]; }; ChangePasswordRequest: { password: string; @@ -1074,6 +1143,10 @@ export interface components { [key: string]: null | number | string; }; }; + ConfirmBackendUpdateRequest: { + /** Format: int64 */ + seq: number | string; + }; ConnectedProbeDto: { probe_id: string; version: null | string; @@ -1253,6 +1326,18 @@ export interface components { macos: string; windows: string; }; + Ipv6DiagnosticDto: { + address_configured: null | boolean; + socket_supported: boolean; + listening: null | boolean; + has_global_address: null | boolean; + has_default_route: null | boolean; + state: string; + /** @default unknown */ + network_scope: string; + host_has_global_address?: null | boolean; + host_has_default_route?: null | boolean; + }; JoinTokenDto: { id: string; note: null | string; @@ -1426,6 +1511,10 @@ export interface components { items: components["schemas"]["AgentDto"][]; next_cursor: null | string; }; + PageDtoOfAgentIpv6Dto: { + items: components["schemas"]["AgentIpv6Dto"][]; + next_cursor: null | string; + }; PageDtoOfApiTokenDto: { items: components["schemas"]["ApiTokenDto"][]; next_cursor: null | string; @@ -1553,6 +1642,7 @@ export interface components { revoked: boolean; online: boolean; connections: components["schemas"]["ProbeConnectionDto"][]; + ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; }; ProbeIdentityDto: { probe_id: string; @@ -1624,6 +1714,8 @@ export interface components { tag: string; version: string; digest: string; + /** @default release */ + channel: string; }; ScoreDto: { /** Format: double */ @@ -1836,6 +1928,35 @@ export interface operations { }; }; }; + ipv6_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfAgentIpv6Dto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; lines_get: { parameters: { query?: never; @@ -4834,6 +4955,140 @@ export interface operations { }; }; }; + backend_update_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BackendUpdateDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + backend_update_confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmBackendUpdateRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + backend_update_live: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; links_list: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index d011294..2fc158f 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -7,6 +7,7 @@ type NarrowValue = unknown extends V ? string : number extends V ? Exclude = { [K in keyof T]: NarrowValue } export type AgentDto = Narrow +export type AgentIpv6Dto = Narrow export type ConnectedProbeDto = Narrow export type ProbeDto = Narrow export type ProbeConnectionDto = Narrow @@ -16,6 +17,7 @@ export type NodeInfoDto = Narrow export type AboutDto = Narrow export type BuildDto = Narrow export type BuildInfoDto = Narrow +export type BackendUpdateDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow export type ReleaseDto = Narrow diff --git a/web/shared/ipVersions.tsx b/web/shared/ipVersions.tsx index 4a0eb4f..598b3aa 100644 --- a/web/shared/ipVersions.tsx +++ b/web/shared/ipVersions.tsx @@ -5,6 +5,17 @@ export const ipVersionOf = (value: string | null | undefined): IpVersion => valu export const IP_VERSIONS: { value: IpVersion; label: string }[] = [{ value: 'ipv4', label: 'IPv4' }, { value: 'ipv6', label: 'IPv6' }] export const lineIdFor = (id: string, version: IpVersion): string => id.replace(/~ipv6$/, '') + (version === 'ipv6' ? '~ipv6' : '') +export const IPV6_STATES: Record = { + ready: 'IPv6 网络已就绪', + address_not_configured: '面板未配置 IPv6 地址', + unsupported: '运行环境不支持 IPv6', + no_global_address: '机器或容器未获得全局 IPv6 地址', + not_listening: 'Agent 只监听 IPv4,请检查 listen.bind', + no_default_route: '缺少 IPv6 默认路由', + unknown: '尚未取得 IPv6 诊断,请检查 Agent 版本', + container_network_not_ready: '宿主有 IPv6,但容器网络未就绪,请检查网络模式', +} + export function IpVersionPicker({ value, onChange }: { value: IpVersion; onChange: (value: IpVersion) => void }) { return }