backend: confirm signed channel updates (#16) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Publish signed, channel-matched Backend update offers and host IPv6 diagnostics.
This commit was merged in pull request #16.
This commit is contained in:
commit
1b052dcaef
62 files changed
+1747
-70
No files matched your search
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
- 更新遥测(现在面板只有每个节点的「当前更新状态」)
|
||||
- **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。
|
||||
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证;Backend 自身的更新(Backend 目前没有更新器,要手动拉镜像)。
|
||||
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。
|
||||
- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来:
|
||||
- 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。
|
||||
- 本地说了算:更新策略(`auto` / `notify` / `pinned:<version>`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。
|
||||
|
||||
@@ -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 断开时对应的会话资源会释放。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>(
|
||||
ProbeAgentList.StateType,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>诊断采集进程所在的网络环境。宿主网络容器看到的是主机;桥接容器看到自己的网络环境。</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>当前网络环境的 IPv6 诊断。仅上报状态,不传地址、接口名称或路由内容。</summary>
|
||||
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";
|
||||
}
|
||||
@@ -281,6 +281,9 @@ public sealed class UpdateStatus
|
||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||
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
|
||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||
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
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
/// <summary>本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。</summary>
|
||||
[JsonPropertyName("ipv6_supported")]
|
||||
public bool Ipv6Supported { get; set; }
|
||||
@@ -383,6 +392,9 @@ public sealed class ConnectedProbe
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
public sealed class AgentHeartbeatExtra
|
||||
{
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
|
||||
[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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<PageDto<ReleaseDto>>().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<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403);
|
||||
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
||||
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(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);
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||
@@ -31,7 +38,7 @@ public static class BuildEndpoints
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, CancellationToken ct)
|
||||
private static async Task<IResult> 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<IResult> ListReleasesAsync(ReleaseService releases, CancellationToken ct) =>
|
||||
@@ -56,7 +63,43 @@ public static class BuildEndpoints
|
||||
private static async Task<IResult> 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<IResult> BackendUpdateAsync(BackendUpdates updates, CancellationToken ct) =>
|
||||
TypedResults.Json(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto);
|
||||
|
||||
private static async Task<IResult> ConfirmBackendUpdateAsync(HttpContext http, BackendUpdates updates, ConfirmBackendUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
Result<bool> 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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
|
||||
@@ -59,7 +63,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||
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<ProbeConnectionDto> Connections);
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
|
||||
|
||||
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
|
||||
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);
|
||||
|
||||
/// <summary>面板登记的最新发布清单(CI 签名的;验签在节点上)。</summary>
|
||||
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<NodeBuildDto> 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<NodeBuildDto> Nodes, BackendUpdateDto? BackendUpdate = null);
|
||||
@@ -35,6 +35,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(LoginResponse))]
|
||||
[JsonSerializable(typeof(MeDto))]
|
||||
[JsonSerializable(typeof(AgentDto))]
|
||||
[JsonSerializable(typeof(PageDto<AgentIpv6Dto>))]
|
||||
[JsonSerializable(typeof(PageDto<AgentDto>))]
|
||||
[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<NodeInfoDto>))]
|
||||
[JsonSerializable(typeof(JoinTokenDto))]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>IPv6 诊断只含配置与网络状态,不包含任一种 IP 地址。</summary>
|
||||
public sealed record AgentIpv6Dto(string AgentId, string Name, bool Online, Ipv6DiagnosticDto Ipv6);
|
||||
|
||||
/// <summary>汇总:整个时间段由累加器精确合并得出的值(不是各点的平均)。</summary>
|
||||
public sealed record SummaryDto(IReadOnlyDictionary<string, double?> Metrics, IReadOnlyDictionary<string, long> Samples, double Coverage);
|
||||
|
||||
|
||||
@@ -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<OverviewDto>().ProducesProblems(403, 422, 429, 503);
|
||||
group.MapGet("/ipv6", Ipv6Async)
|
||||
.Api("ipv6_get", "IPv6 configuration and collector diagnostics without addresses", Tag)
|
||||
.Returns<PageDto<AgentIpv6Dto>>().ProducesProblems(403);
|
||||
group.MapGet("/lines/{line_id}", LineAsync)
|
||||
.Api("lines_get", "One line", Tag)
|
||||
.Returns<LineDto>().ProducesProblems(403, 404);
|
||||
@@ -41,6 +44,13 @@ public static class QueryEndpoints
|
||||
.Returns<TestDto>().ProducesProblems(403, 404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Ipv6Async(HttpContext http, AgentService agents, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AgentView> views = await agents.ListAsync(ct);
|
||||
return TypedResults.Json(new PageDto<AgentIpv6Dto>([.. 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<IResult> 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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<AuthService>();
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<BackendUpdates>();
|
||||
services.AddHostedService(p => p.GetRequiredService<BackendUpdates>());
|
||||
services.AddSingleton<JoinTokenService>();
|
||||
services.AddSingleton<RawBrowserService>();
|
||||
services.AddSingleton<EventService>();
|
||||
|
||||
@@ -6,6 +6,18 @@ public sealed class BackendOptions
|
||||
/// <summary>数据目录:<c>moshiro.db</c> 与 <c>raw/</c>。</summary>
|
||||
public string DataDirectory { get; set; } = "data";
|
||||
|
||||
/// <summary>Backend 更新通道:auto 按镜像的构建 tag 识别 dev/release,Compose 可以显式覆盖。</summary>
|
||||
public string UpdateChannel { get; set; } = "auto";
|
||||
|
||||
/// <summary>容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。</summary>
|
||||
public string ImageNamespace { get; set; } = "";
|
||||
|
||||
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
|
||||
public string? BackendUpdateDirectory { get; set; }
|
||||
|
||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||
public string? BackendUpdateStatusDirectory { get; set; }
|
||||
|
||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed record AgentRecord(
|
||||
long? LastSeenAtMs,
|
||||
long RowVersion,
|
||||
string Ipv6Host = "",
|
||||
bool Ipv6Supported = false);
|
||||
bool Ipv6Supported = false,
|
||||
string? Ipv6DiagnosticsJson = null);
|
||||
|
||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||
public sealed class AgentPatch
|
||||
@@ -48,7 +49,8 @@ public sealed record AgentHello(
|
||||
byte[]? TlsSpki,
|
||||
int? ProbeTcpPort,
|
||||
IReadOnlyList<int>? ProbeUdpPorts,
|
||||
bool Ipv6Supported = false);
|
||||
bool Ipv6Supported = false,
|
||||
string? Ipv6DiagnosticsJson = null);
|
||||
|
||||
/// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary>
|
||||
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> 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);
|
||||
@@ -69,6 +69,8 @@ public interface IProbeStore
|
||||
/// <summary>新增或整条更新(轮换密钥、改名都走这里)。</summary>
|
||||
ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct);
|
||||
|
||||
ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct);
|
||||
|
||||
/// <summary>吊销:保留记录,公钥立即从所有 Agent 的名单里移除。已经吊销的返回 false。</summary>
|
||||
ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。</summary>
|
||||
public interface IBackendUpdateFiles
|
||||
{
|
||||
ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct);
|
||||
|
||||
ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<long?> 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);
|
||||
|
||||
/// <summary>Backend 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。</summary>
|
||||
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<BackendUpdateView> 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<Result<bool>> 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<bool>.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<bool>.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;
|
||||
}
|
||||
@@ -8,29 +8,38 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>面板记下的、CI 签名的发布清单(只留每个组件最新的一份)。<c>Manifest</c> 是签名对象的原始文本,面板一个字节也不改。</summary>
|
||||
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");
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。
|
||||
/// <b>面板不验签、也不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
|
||||
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
|
||||
/// </summary>
|
||||
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<string, UpdateStatus> _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();
|
||||
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。只要格式对、<c>seq</c> 比现有的新就收下(签名由节点验证)。</summary>
|
||||
public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
|
||||
{
|
||||
@@ -39,20 +48,40 @@ public sealed partial class ReleaseService
|
||||
return ValidationError.Fail<ReleaseOffer>("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<ReleaseOffer>.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<ReleaseOffer>.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<ReleaseOffer>.Ok(offer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct);
|
||||
BackendGate.Release();
|
||||
}
|
||||
|
||||
return Result<ReleaseOffer>.Ok(offer);
|
||||
}
|
||||
|
||||
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
|
||||
@@ -67,7 +96,8 @@ public sealed partial class ReleaseService
|
||||
var list = new List<ReleaseOffer>();
|
||||
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<ReleaseOffer?> 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;
|
||||
}
|
||||
|
||||
/// <summary>节点刚连上:把它这个组件最新的清单补发一份(节点离线时发布的也不会错过)。</summary>
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。</summary>
|
||||
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<UpdateStatus?> 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<long?> 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<string?> 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; // 更新器正做原子替换,下一次读取再试
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -50,14 +50,17 @@ public sealed class SqliteProbeStore : IProbeStore
|
||||
|
||||
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
|
||||
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<ProbeRecord?> 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<bool> 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);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||
services.AddSingleton<IBackendUpdateFiles, BackendUpdateFiles>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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<BackendOptions>().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<HttpResponseMessage> 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<JsonNode> 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<string>());
|
||||
Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue<string>());
|
||||
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<ReleaseService>().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<string>());
|
||||
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<long>() == 20);
|
||||
Assert.Equal("dev", updated.Data!["channel"]!.GetValue<string>());
|
||||
Assert.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request")));
|
||||
}
|
||||
}
|
||||
@@ -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<bool>(), 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<bool>() && !probe.App.Link.IsConnected,
|
||||
because: "a revoked probe is disconnected and cannot come back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+5
-1
@@ -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
|
||||
+6
-1
@@ -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
|
||||
@@ -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 <agent|probe|backend|fleet-enroll> <rid> → backend/artifacts/publish/<project>/<rid>/
|
||||
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/moshiro-<role>-<version>-<rid>.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:<version> \
|
||||
--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。
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,7 +66,7 @@ write_agent_config() {
|
||||
cat >"$CONFIG/agent.json" <<JSON
|
||||
{
|
||||
"fleet": { "server": "$1", "identity_dir": "$CONFIG" },
|
||||
"listen": { "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
||||
"listen": { "bind": "::", "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
||||
"limits": { "max_total_mbps": 200, "max_total_pps": 20000, "allowed_target_cidrs": [], "max_concurrent_tests": 1 }
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/podman rm -f moshiro-agent
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e MOSHIRO_NETWORK_SCOPE=host -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/podman stop -t 20 moshiro-agent
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/docker rm -f moshiro-agent
|
||||
ExecStart=/usr/bin/docker run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/docker run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e MOSHIRO_NETWORK_SCOPE=host -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/docker stop -t 20 moshiro-agent
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/podman rm -f moshiro-agent
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e MOSHIRO_NETWORK_SCOPE=host -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v moshiro-agent-config:/etc/moshiro -v moshiro-agent-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/podman stop -t 20 moshiro-agent
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=MoShiro Backend (signed container updates)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=/etc/moshiro-update/backend.image
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/podman rm -f moshiro-backend
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-backend --network host --read-only --tmpfs /tmp --security-opt no-new-privileges --cap-drop all --env-file /etc/moshiro/backend.env -v moshiro-backend-data:/data -v moshiro-backend-status:/data/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/podman stop -t 20 moshiro-backend
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/podman rm -f moshiro-probe
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -e MOSHIRO_NETWORK_SCOPE=host -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/podman stop -t 20 moshiro-probe
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/docker rm -f moshiro-probe
|
||||
ExecStart=/usr/bin/docker run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/docker run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -e MOSHIRO_NETWORK_SCOPE=host -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/docker stop -t 20 moshiro-probe
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -9,7 +9,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
ExecStartPre=-/usr/bin/podman rm -f moshiro-probe
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStart=/usr/bin/podman run --rm --name moshiro-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -e MOSHIRO_NETWORK_SCOPE=host -v /etc/os-release:/etc/os-release:ro -v moshiro-probe-config:/etc/moshiro -v moshiro-probe-status:/etc/moshiro/update-status:ro ${IMAGE}
|
||||
ExecStop=/usr/bin/podman stop -t 20 moshiro-probe
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
set -eu
|
||||
|
||||
COMPONENT="${1:-}"; shift || true
|
||||
case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh <agent|probe> [--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 <agent|probe|backend> [--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
|
||||
|
||||
@@ -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 <check|apply> <agent|probe>" >&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 <check|apply> <agent|probe|backend>" >&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 ;;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</aside>
|
||||
<div className="main">
|
||||
<BackendUpdateNotice />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/agents" replace />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
|
||||
@@ -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<BackendUpdateDto>()
|
||||
const [confirming, setConfirming] = useState<BackendUpdateDto>()
|
||||
const [requested, setRequested] = useState(false)
|
||||
const [actionError, setActionError] = useState<unknown>()
|
||||
const originalTag = useRef<string | null | undefined>(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<BackendUpdateDto>('/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 (
|
||||
<div className="notice" style={{ marginBottom: 16 }}>
|
||||
<div className="row wrap">
|
||||
<span>{message} <span className="mono">{update.available?.tag}</span> <span className="muted">({update.channel})</span></span>
|
||||
{canUpdate && !waiting ? <button type="button" className="btn small primary right" disabled={busy} onClick={() => setConfirming(update)}>更新并重启</button> : null}
|
||||
</div>
|
||||
{update.error ? <p className="muted">{update.error}</p> : null}
|
||||
<ErrorNotice error={actionError} />
|
||||
{confirming?.available ? (
|
||||
<Confirm title="更新并重启 Backend" confirmText="确认更新并重启" onClose={() => 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})。宿主机会先备份数据,再升级并重启,期间面板会短暂断开;恢复后自动刷新。如果发布清单已变化,需要重新确认。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<dd className="mono">{a.ipv6_host || '未配置'}</dd>
|
||||
<dt>IPv6 监听</dt>
|
||||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||||
<dt>IPv6 诊断</dt>
|
||||
<dd>{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}</dd>
|
||||
<dt>检测网络</dt>
|
||||
<dd>{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}</dd>
|
||||
<dt>宿主全局 IPv6</dt>
|
||||
<dd>{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}</dd>
|
||||
<dt>全局 IPv6 地址</dt>
|
||||
<dd>{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}</dd>
|
||||
<dt>IPv6 默认路由</dt>
|
||||
<dd>{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}</dd>
|
||||
<dt>控制端口</dt>
|
||||
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
|
||||
<dt>数据端口</dt>
|
||||
|
||||
@@ -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() {
|
||||
<div className="row wrap">
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
|
||||
<NodeTag info={infos.get(p.id)} />
|
||||
<Chip tone={p.ipv6?.state === 'ready' ? 'ok' : 'warn'}>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'}</Chip>
|
||||
{p.revoked ? (
|
||||
<Chip tone="bad">已吊销</Chip>
|
||||
) : (
|
||||
|
||||
@@ -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 = <span className="faint">—</span>
|
||||
return (
|
||||
<tr>
|
||||
@@ -91,6 +91,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { nam
|
||||
<td className="mono">{commit || dash}</td>
|
||||
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
|
||||
<td className="muted">{platform || dash}</td>
|
||||
<td>{update ?? dash}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -149,8 +150,8 @@ function Versions() {
|
||||
{(releases.data?.items ?? []).length === 0
|
||||
? '还没有(CI 构建后会自动登记)'
|
||||
: (releases.data?.items ?? []).map((r) => (
|
||||
<span key={r.component} style={{ marginRight: 12 }}>
|
||||
{r.component} <span className="mono">{r.tag}</span>
|
||||
<span key={`${r.component}:${r.channel}`} style={{ marginRight: 12 }}>
|
||||
{r.component} <span className="mono">{r.tag}</span> <span className="faint">({r.channel})</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
@@ -174,7 +175,7 @@ function Versions() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} /> : null}
|
||||
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} update={info.data?.backend_update ? <Chip tone={info.data.backend_update.state === 'available' ? 'warn' : ''}>{info.data.backend_update.channel} · {info.data.backend_update.state === 'unavailable' ? '未安装更新器' : UPDATE_STATE[info.data.backend_update.state]?.text ?? info.data.backend_update.state}</Chip> : undefined} /> : null}
|
||||
<BuildRow name="前端" kind="web" version={frontendBuild.version} tag={frontendBuild.tag} commit={frontendBuild.commit} builtAt={frontendBuild.builtAt} />
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
|
||||
@@ -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<Page<AgentIpv6Dto>>('/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 ? (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路,请在管理端配置 Agent 的 IPv6 地址' : '还没有线路'}</Empty>
|
||||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
|
||||
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cards">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V,
|
||||
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
|
||||
|
||||
export type AgentDto = Narrow<S['AgentDto']>
|
||||
export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
|
||||
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
|
||||
export type ProbeDto = Narrow<S['ProbeDto']>
|
||||
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
|
||||
@@ -16,6 +17,7 @@ export type NodeInfoDto = Narrow<S['NodeInfoDto']>
|
||||
export type AboutDto = Narrow<S['AboutDto']>
|
||||
export type BuildDto = Narrow<S['BuildDto']>
|
||||
export type BuildInfoDto = Narrow<S['BuildInfoDto']>
|
||||
export type BackendUpdateDto = Narrow<S['BackendUpdateDto']>
|
||||
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
|
||||
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
|
||||
export type ReleaseDto = Narrow<S['ReleaseDto']>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 <Segmented options={IP_VERSIONS} value={value} onChange={onChange} />
|
||||
}
|
||||
Reference in new issue
Block a user