ci: sync main into dev [CI SKIP]
This commit is contained in:
commit
450748f3bc
101 files changed
+2572
-195
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: []
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
## 测量
|
||||
|
||||
- IPv6
|
||||
- 路由跳数、路径漂移和 trace
|
||||
- 握手耗时:TCP 连接耗时,TLS / QUIC 握手耗时(QUIC 目前没有真实握手积木)
|
||||
- 网页长截图
|
||||
@@ -28,7 +27,7 @@
|
||||
|
||||
- 更新遥测(现在面板只有每个节点的「当前更新状态」)
|
||||
- **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。
|
||||
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证;Backend 自身的更新(Backend 目前没有更新器,要手动拉镜像)。
|
||||
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。
|
||||
- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来:
|
||||
- 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。
|
||||
- 本地说了算:更新策略(`auto` / `notify` / `pinned:<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 断开时对应的会话资源会释放。
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class AgentApp : IDisposable
|
||||
public const string Version = MoShiro.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly bool _ipv6Supported;
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
@@ -47,6 +48,12 @@ public sealed class AgentApp : IDisposable
|
||||
_uplink = new BackendUplink(time);
|
||||
|
||||
IPAddress bind = IPAddress.Parse(config.Listen.Bind);
|
||||
if (bind.Equals(IPAddress.IPv6Any) && !System.Net.Sockets.Socket.OSSupportsIPv6)
|
||||
{
|
||||
bind = IPAddress.Any;
|
||||
}
|
||||
|
||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||
_admission = new AgentAdmission(_streams);
|
||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
|
||||
@@ -257,6 +264,8 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Ipv6Supported = _ipv6Supported,
|
||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||
TlsSpki = _identity.File.TlsSpki,
|
||||
ProbeTcpPort = ControlPort,
|
||||
@@ -284,6 +293,7 @@ public sealed class AgentApp : IDisposable
|
||||
long packetsOut = _counters.PacketsOut;
|
||||
var extra = new AgentHeartbeatExtra
|
||||
{
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Probes = [.. _hub.ConnectedProbes()],
|
||||
Sessions = _sessions.All.Count,
|
||||
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class FleetSection
|
||||
public sealed class ListenSection
|
||||
{
|
||||
[JsonPropertyName("bind")]
|
||||
public string Bind { get; set; } = "0.0.0.0";
|
||||
public string Bind { get; set; } = "::";
|
||||
|
||||
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
|
||||
[JsonPropertyName("control_port")]
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class ChunkAssembler
|
||||
_ => null,
|
||||
};
|
||||
header.ProbeId = new ProbeId(probe.ProbeId);
|
||||
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
|
||||
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
|
||||
if (stream is null || spec is null || spec.Id != header.Kind)
|
||||
{
|
||||
|
||||
@@ -56,6 +56,10 @@ public sealed class ProbeSession : DataSession
|
||||
|
||||
public string ProbeId => Info.PeerId;
|
||||
|
||||
public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4;
|
||||
|
||||
public string PathKey => ProbeId + "~" + IpVersion;
|
||||
|
||||
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
|
||||
public string? Version => Info.Version;
|
||||
|
||||
@@ -72,7 +76,7 @@ public sealed class ProbeSession : DataSession
|
||||
get
|
||||
{
|
||||
SocketAddress? source = Volatile.Read(ref _udpSource);
|
||||
return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source);
|
||||
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ public sealed class ProbeSession : DataSession
|
||||
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
|
||||
}
|
||||
|
||||
/// <summary>当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。</summary>
|
||||
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
|
||||
public sealed class SessionRegistry : IDataSessions
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
@@ -143,11 +147,11 @@ public sealed class SessionRegistry : IDataSessions
|
||||
}
|
||||
}
|
||||
|
||||
public ProbeSession? Get(string probeId)
|
||||
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _byProbe.GetValueOrDefault(probeId);
|
||||
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,13 +160,13 @@ public sealed class SessionRegistry : IDataSessions
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byProbe.TryGetValue(session.ProbeId, out ProbeSession? old);
|
||||
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
|
||||
if (old is not null)
|
||||
{
|
||||
_bySession.Remove(old.SessionId);
|
||||
}
|
||||
|
||||
_byProbe[session.ProbeId] = session;
|
||||
_byProbe[session.PathKey] = session;
|
||||
_bySession[session.SessionId] = session;
|
||||
return old;
|
||||
}
|
||||
@@ -172,9 +176,9 @@ public sealed class SessionRegistry : IDataSessions
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session))
|
||||
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
|
||||
{
|
||||
_byProbe.Remove(session.ProbeId);
|
||||
_byProbe.Remove(session.PathKey);
|
||||
}
|
||||
|
||||
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
|
||||
|
||||
@@ -176,7 +176,7 @@ public sealed class PeerHub
|
||||
await _tasks.CloseSessionAsync(probe);
|
||||
_log.Info("probe disconnected", ("probe", info.PeerId));
|
||||
OnProbeLost?.Invoke(probe);
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId);
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ public sealed class PeerHub
|
||||
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs };
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
@@ -218,13 +218,17 @@ public sealed class PeerHub
|
||||
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
|
||||
public async ValueTask<bool> DisconnectAsync(string probeId)
|
||||
{
|
||||
if (_sessions.Get(probeId)?.Rpc is not { } rpc)
|
||||
bool disconnected = false;
|
||||
foreach (ProbeSession probe in _sessions.All)
|
||||
{
|
||||
return false;
|
||||
if (probe.ProbeId == probeId && probe.Rpc is { } rpc)
|
||||
{
|
||||
await rpc.CloseAsync("disconnected", CancellationToken.None);
|
||||
disconnected = true;
|
||||
}
|
||||
}
|
||||
|
||||
await rpc.CloseAsync("disconnected", CancellationToken.None);
|
||||
return true;
|
||||
return disconnected;
|
||||
}
|
||||
|
||||
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
|
||||
|
||||
@@ -80,7 +80,7 @@ public sealed class TargetRunners
|
||||
Log = _log,
|
||||
Session = () => null,
|
||||
PeerAddress = address,
|
||||
Fragments = new LocalSink(this, spec),
|
||||
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
|
||||
};
|
||||
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -132,11 +132,13 @@ public sealed class TargetRunners
|
||||
{
|
||||
private readonly TargetRunners _owner;
|
||||
private readonly TaskSpec _spec;
|
||||
private readonly string _ipVersion;
|
||||
|
||||
public LocalSink(TargetRunners owner, TaskSpec spec)
|
||||
public LocalSink(TargetRunners owner, TaskSpec spec, string ipVersion)
|
||||
{
|
||||
_owner = owner;
|
||||
_spec = spec;
|
||||
_ipVersion = ipVersion;
|
||||
}
|
||||
|
||||
public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port)
|
||||
@@ -149,6 +151,7 @@ public sealed class TargetRunners
|
||||
TaskId = scope.Task,
|
||||
AgentId = _owner._agent,
|
||||
Target = _spec.Target,
|
||||
IpVersion = _ipVersion,
|
||||
Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters),
|
||||
Port = port,
|
||||
StartUtcMs = startUtcMs,
|
||||
|
||||
@@ -46,7 +46,12 @@ public sealed class TestRunner
|
||||
/// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>moshiro.test.status</c> 回报。</summary>
|
||||
public Result<TestRunResult> Start(TestRunParams run)
|
||||
{
|
||||
if (_sessions.Get(run.ProbeId.Value) is not { } probe)
|
||||
if (!IpVersions.IsValid(run.IpVersion))
|
||||
{
|
||||
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6");
|
||||
}
|
||||
|
||||
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
|
||||
{
|
||||
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ public sealed class PeerListener
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var listener = new TcpListener(_options.Listen);
|
||||
if (_options.Listen.Address.Equals(IPAddress.IPv6Any))
|
||||
{
|
||||
listener.Server.DualMode = true;
|
||||
}
|
||||
|
||||
listener.Start();
|
||||
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
Started.TrySetResult();
|
||||
@@ -219,7 +224,8 @@ public static class PeerConnector
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
var client = new TcpClient { NoDelay = true };
|
||||
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
|
||||
client.NoDelay = true;
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -61,6 +61,13 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_version",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -117,6 +124,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/ipv6": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "IPv6 configuration and collector diagnostics without addresses",
|
||||
"operationId": "ipv6_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PageDtoOfAgentIpv6Dto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/public/lines/{line_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -706,6 +744,15 @@
|
||||
],
|
||||
"summary": "Server-sent events: nodes, overview, series and test topics",
|
||||
"operationId": "live_stream",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ip_version",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
@@ -3901,6 +3948,148 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/backend/update": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Current channel and host-verified backend update",
|
||||
"operationId": "backend_update_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BackendUpdateDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Confirm one verified backend release before the host restarts it",
|
||||
"operationId": "backend_update_confirm",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConfirmBackendUpdateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/backend/update/live": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Server-sent backend update notifications",
|
||||
"operationId": "backend_update_live",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/links": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4333,6 +4522,47 @@
|
||||
},
|
||||
"last_seen_at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"ipv6_host": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"ipv6_supported": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"ipv6": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Ipv6DiagnosticDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentIpv6Dto": {
|
||||
"required": [
|
||||
"agent_id",
|
||||
"name",
|
||||
"online",
|
||||
"ipv6"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"online": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ipv6": {
|
||||
"$ref": "#/components/schemas/Ipv6DiagnosticDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4371,6 +4601,12 @@
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"ipv6_host": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4405,6 +4641,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BackendUpdateDto": {
|
||||
"required": [
|
||||
"channel",
|
||||
"enabled",
|
||||
"state",
|
||||
"current_tag",
|
||||
"current_digest",
|
||||
"available",
|
||||
"error"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
},
|
||||
"current_tag": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"current_digest": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"available": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ReleaseDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BlocksDto": {
|
||||
"required": [
|
||||
"transport",
|
||||
@@ -4482,6 +4769,16 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeBuildDto"
|
||||
}
|
||||
},
|
||||
"backend_update": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/BackendUpdateDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4598,6 +4895,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmBackendUpdateRequest": {
|
||||
"required": [
|
||||
"seq"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seq": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectedProbeDto": {
|
||||
"required": [
|
||||
"probe_id",
|
||||
@@ -4624,6 +4937,10 @@
|
||||
},
|
||||
"connected_since": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5390,6 +5707,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ipv6DiagnosticDto": {
|
||||
"required": [
|
||||
"address_configured",
|
||||
"socket_supported",
|
||||
"listening",
|
||||
"has_global_address",
|
||||
"has_default_route",
|
||||
"state"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address_configured": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"socket_supported": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"listening": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"has_global_address": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"has_default_route": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
},
|
||||
"network_scope": {
|
||||
"type": "string",
|
||||
"default": "unknown"
|
||||
},
|
||||
"host_has_global_address": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"host_has_default_route": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"JoinTokenDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -5555,6 +5931,14 @@
|
||||
"$ref": "#/components/schemas/TestSummaryDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
},
|
||||
"ipv6_available": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6131,6 +6515,10 @@
|
||||
"$ref": "#/components/schemas/TestSummaryDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6155,6 +6543,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfAgentIpv6Dto": {
|
||||
"required": [
|
||||
"items",
|
||||
"next_cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AgentIpv6Dto"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PageDtoOfApiTokenDto": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -6727,6 +7136,10 @@
|
||||
},
|
||||
"connected_since": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6765,6 +7178,16 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProbeConnectionDto"
|
||||
}
|
||||
},
|
||||
"ipv6": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Ipv6DiagnosticDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7062,6 +7485,10 @@
|
||||
},
|
||||
"digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"default": "release"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7206,6 +7633,10 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7458,6 +7889,10 @@
|
||||
"$ref": "#/components/schemas/TestResultDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"default": "ipv4"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,8 +74,20 @@ public sealed class AgentConnection
|
||||
|
||||
private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
|
||||
{
|
||||
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
|
||||
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
|
||||
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
|
||||
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
|
||||
? [literal]
|
||||
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
|
||||
IPAddress? address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6);
|
||||
if (address is null)
|
||||
{
|
||||
throw new SocketException((int)SocketError.HostNotFound);
|
||||
}
|
||||
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
||||
|
||||
@@ -67,7 +67,19 @@ public sealed class AgentDirectory
|
||||
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
|
||||
public void Apply(ProbeAgentList result)
|
||||
{
|
||||
var next = result.Agents.Select(a => new KnownAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList();
|
||||
var next = new List<KnownAgent>();
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 });
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
|
||||
@@ -96,5 +108,5 @@ public sealed class AgentDirectory
|
||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||
|
||||
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
||||
a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -70,19 +70,19 @@ public sealed class ProbeApp : IDisposable
|
||||
public AgentDirectory Directory => _env.Directory;
|
||||
|
||||
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
|
||||
public bool IsConnected(string agentId)
|
||||
public bool IsConnected(string agentId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected;
|
||||
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
|
||||
}
|
||||
}
|
||||
|
||||
public AgentConnection? Connection(string agentId)
|
||||
public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null;
|
||||
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed class ProbeApp : IDisposable
|
||||
|
||||
private async Task ReconcileAsync(IReadOnlyList<KnownAgent> agents, CancellationToken ct)
|
||||
{
|
||||
var wanted = agents.ToDictionary(a => a.AgentId);
|
||||
var wanted = agents.ToDictionary(a => a.PathKey);
|
||||
var stopped = new List<Running>();
|
||||
lock (_connections)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
@@ -25,6 +26,12 @@ public sealed class ProbeFile
|
||||
|
||||
public sealed class KnownAgent
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonIgnore]
|
||||
public string PathKey => AgentId + "~" + IpVersion;
|
||||
|
||||
[JsonPropertyName("agent_id")]
|
||||
public string AgentId { get; set; } = "";
|
||||
|
||||
|
||||
@@ -6,6 +6,31 @@ namespace MoShiro.Probe.Tests;
|
||||
|
||||
public sealed class ProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
try
|
||||
{
|
||||
var agents = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
ProbeAgentList list = List(1, "agt_dual");
|
||||
list.Agents[0].Ipv6Host = "2001:db8::1";
|
||||
agents.Apply(list);
|
||||
Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Agents.Select(a => a.IpVersion));
|
||||
var reloaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
Assert.Equal(2, reloaded.Agents.Count);
|
||||
Assert.Equal("2001:db8::1", reloaded.Agents[1].Host);
|
||||
list.Version = 2;
|
||||
list.Agents[0].Ipv6Host = null;
|
||||
reloaded.Apply(list);
|
||||
Assert.Equal("ipv4", Assert.Single(reloaded.Agents).IpVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip()
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ public static class ChunkFinalizer
|
||||
Phase = source.Phase,
|
||||
AgentId = agent,
|
||||
ProbeId = source.ProbeId,
|
||||
IpVersion = source.IpVersion,
|
||||
Target = null,
|
||||
Params = new Dictionary<string, JsonElement>(resolvedParams),
|
||||
Port = port,
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class TcpLink : IDisposable
|
||||
/// </summary>
|
||||
public static async Task<TcpLink> OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct)
|
||||
{
|
||||
var client = new TcpClient { NoDelay = true };
|
||||
var client = new TcpClient(env.PeerAddress!.AddressFamily) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
long start = env.Clock.NowUs();
|
||||
@@ -153,6 +153,11 @@ public sealed class TcpAcceptor
|
||||
foreach (int port in _ports)
|
||||
{
|
||||
var listener = new TcpListener(_bind, port);
|
||||
if (_bind.Equals(IPAddress.IPv6Any))
|
||||
{
|
||||
listener.Server.DualMode = true;
|
||||
}
|
||||
|
||||
listener.Start(128);
|
||||
_listeners.Add(listener);
|
||||
bound.Add(((IPEndPoint)listener.LocalEndpoint).Port);
|
||||
|
||||
@@ -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,13 @@
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>测量路径的地址族。旧契约缺省为 IPv4;IPv6 使用独立的线路和序列。</summary>
|
||||
public static class IpVersions
|
||||
{
|
||||
public const string V4 = "ipv4";
|
||||
public const string V6 = "ipv6";
|
||||
|
||||
public static bool IsValid(string? value) => value is V4 or V6;
|
||||
|
||||
public static string LineSeries(string probeId, string ipVersion) =>
|
||||
RawChunkHeader.LinePrefix + probeId + (ipVersion == V6 ? ":ipv6" : "");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -58,6 +58,10 @@ public sealed class StreamHeader
|
||||
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
|
||||
public sealed class RawChunkHeader
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("chunk_id")]
|
||||
public ChunkId ChunkId { get; set; }
|
||||
|
||||
@@ -113,10 +117,10 @@ public sealed class RawChunkHeader
|
||||
|
||||
/// <summary>
|
||||
/// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。
|
||||
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。
|
||||
/// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? "");
|
||||
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(ProbeId?.Value ?? "", IpVersion);
|
||||
|
||||
public const string LinePrefix = "line:";
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ public sealed class TestPhase
|
||||
|
||||
public sealed class TestRunParams
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("test_id")]
|
||||
public TestId TestId { get; set; }
|
||||
|
||||
@@ -127,6 +130,9 @@ public sealed class AgentEventParams
|
||||
|
||||
public sealed class AgentEndpoint
|
||||
{
|
||||
[JsonPropertyName("ipv6_host")]
|
||||
public string? Ipv6Host { get; set; }
|
||||
|
||||
[JsonPropertyName("agent_id")]
|
||||
public NodeId AgentId { get; set; }
|
||||
|
||||
@@ -275,6 +281,9 @@ public sealed class UpdateStatus
|
||||
/// <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; }
|
||||
}
|
||||
@@ -282,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; }
|
||||
}
|
||||
@@ -289,6 +301,13 @@ 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; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
@@ -353,6 +372,9 @@ public sealed class HostSnapshot
|
||||
/// <summary>Agent 眼中的一个已连接 Probe。</summary>
|
||||
public sealed class ConnectedProbe
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public ProbeId Id { get; set; }
|
||||
|
||||
@@ -370,6 +392,9 @@ public sealed class ConnectedProbe
|
||||
/// <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; }
|
||||
@@ -541,6 +566,7 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(Ipv6Diagnostic))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
public sealed partial class MoShiroJsonContext : JsonSerializerContext;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,21 @@ namespace MoShiro.Protocol.Tests;
|
||||
|
||||
public sealed class RawChunkCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
|
||||
{
|
||||
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
|
||||
Assert.Equal(IpVersions.V4, legacy.IpVersion);
|
||||
Assert.Equal("line:prb_legacy", legacy.Series);
|
||||
RawChunk chunk = SampleChunk();
|
||||
chunk.Header.ProbeId = new ProbeId("prb_dual");
|
||||
chunk.Header.IpVersion = IpVersions.V6;
|
||||
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value;
|
||||
Assert.Equal("ipv6", decoded.Header.IpVersion);
|
||||
Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series);
|
||||
Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count);
|
||||
}
|
||||
|
||||
private static readonly NodeId s_agent = new("agt_test");
|
||||
|
||||
private static RawChunk SampleChunk()
|
||||
|
||||
@@ -87,7 +87,7 @@ public static class AgentEndpoints
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await agents.PatchAsync(
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -109,7 +109,7 @@ public static class TaskEndpoints
|
||||
return Problems.From(http, lineError);
|
||||
}
|
||||
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct);
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct);
|
||||
if (!result.TryGet(out TestRecord? test, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
@@ -124,10 +124,12 @@ public static class TaskEndpoints
|
||||
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
|
||||
NodeId agent;
|
||||
ProbeId? probe = null;
|
||||
string? ipVersion = null;
|
||||
if (LineKey.TryParse(line_id, out LineKey key))
|
||||
{
|
||||
agent = key.Agent;
|
||||
probe = key.Probe;
|
||||
ipVersion = key.IpVersion;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -135,7 +137,7 @@ public static class TaskEndpoints
|
||||
}
|
||||
|
||||
return TypedResults.Json(
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null),
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null),
|
||||
ApiJsonContext.Default.PageDtoTestDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,34 +31,34 @@ public static class Mappers
|
||||
public static OverviewLineDto ToDto(OverviewLine line) =>
|
||||
new(
|
||||
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
|
||||
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest));
|
||||
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
|
||||
|
||||
public static HostDto ToDto(HostSnapshot h) =>
|
||||
new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs));
|
||||
|
||||
public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs));
|
||||
public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs), p.IpVersion);
|
||||
|
||||
public static ProbeDto ToDto(ProbeView v) =>
|
||||
new(
|
||||
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
|
||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs)))]);
|
||||
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe));
|
||||
|
||||
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
||||
new(
|
||||
r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded,
|
||||
r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial),
|
||||
[.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions);
|
||||
[.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions, r.Line.IpVersion);
|
||||
|
||||
public static TestDto ToDto(TestDetail detail)
|
||||
{
|
||||
TestRecord t = detail.Test;
|
||||
return new TestDto(
|
||||
t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger,
|
||||
Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result));
|
||||
Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result), t.IpVersion);
|
||||
}
|
||||
|
||||
public static TestDto ToDto(TestRecord t) =>
|
||||
new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null);
|
||||
new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null, t.IpVersion);
|
||||
|
||||
public static TestResultDto ToDto(TestResultView r) =>
|
||||
new(
|
||||
@@ -103,7 +103,51 @@ public static class Mappers
|
||||
: null;
|
||||
return new AgentDto(
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs));
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view));
|
||||
}
|
||||
|
||||
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
||||
{
|
||||
Ipv6Diagnostic? diagnostic = view.Heartbeat?.Ipv6;
|
||||
if (diagnostic is null && view.Agent.Ipv6DiagnosticsJson is { } json)
|
||||
{
|
||||
try
|
||||
{
|
||||
diagnostic = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 旧节点或损坏的快照显示未知,不推断为没有 IPv6
|
||||
}
|
||||
}
|
||||
|
||||
bool configured = view.Agent.Ipv6Host.Length > 0;
|
||||
string state = diagnostic?.State ?? "unknown";
|
||||
if (!configured && state is "ready" or "unknown")
|
||||
{
|
||||
state = "address_not_configured";
|
||||
}
|
||||
|
||||
return new Ipv6DiagnosticDto(configured, diagnostic?.SocketSupported ?? view.Agent.Ipv6Supported, diagnostic?.Listening ?? view.Agent.Ipv6Supported,
|
||||
diagnostic?.HasGlobalAddress, diagnostic?.HasDefaultRoute, state, diagnostic?.NetworkScope ?? "unknown", diagnostic?.HostHasGlobalAddress, diagnostic?.HostHasDefaultRoute);
|
||||
}
|
||||
|
||||
private static Ipv6DiagnosticDto? ProbeIpv6Of(ProbeRecord probe)
|
||||
{
|
||||
if (probe.Ipv6DiagnosticsJson is not { } json)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Ipv6Diagnostic? d = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
return d is null ? null : new Ipv6DiagnosticDto(null, d.SocketSupported, null, d.HasGlobalAddress, d.HasDefaultRoute, d.State, d.NetworkScope, d.HostHasGlobalAddress, d.HostHasDefaultRoute);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static TaskDto ToDto(TaskRecord t) =>
|
||||
|
||||
@@ -37,10 +37,16 @@ public sealed record AgentDto(
|
||||
HeartbeatDto? Heartbeat,
|
||||
int EndpointChanges,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastSeenAt);
|
||||
DateTimeOffset? LastSeenAt,
|
||||
string Ipv6Host = "",
|
||||
bool Ipv6Supported = false,
|
||||
Ipv6DiagnosticDto? Ipv6 = null);
|
||||
|
||||
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
|
||||
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
||||
|
||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible);
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
|
||||
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -52,12 +58,12 @@ public sealed record InstallCommandsDto(string Linux, string Macos, string Windo
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince);
|
||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<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);
|
||||
@@ -137,8 +143,12 @@ public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? Os
|
||||
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -22,7 +25,8 @@ public sealed record OverviewLineDto(
|
||||
bool ProbeConnected,
|
||||
int EndpointChanges,
|
||||
SummaryDto Summary,
|
||||
TestSummaryDto? LastTest);
|
||||
TestSummaryDto? LastTest,
|
||||
string IpVersion = "ipv4");
|
||||
|
||||
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
|
||||
|
||||
@@ -43,7 +47,8 @@ public sealed record SeriesDto(
|
||||
EdgePartialDto EdgePartial,
|
||||
IReadOnlyList<GapDto> Gaps,
|
||||
IReadOnlyList<EventDto> Events,
|
||||
IReadOnlyDictionary<string, string> Versions);
|
||||
IReadOnlyDictionary<string, string> Versions,
|
||||
string IpVersion = "ipv4");
|
||||
|
||||
public sealed record CompareDto(
|
||||
string Metric,
|
||||
@@ -81,7 +86,9 @@ public sealed record LineDto(
|
||||
bool ProbeConnected,
|
||||
DateTimeOffset? DataSince,
|
||||
IReadOnlyList<string> Kinds,
|
||||
TestSummaryDto? LastTest);
|
||||
TestSummaryDto? LastTest,
|
||||
string IpVersion = "ipv4",
|
||||
bool Ipv6Available = false);
|
||||
|
||||
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
|
||||
|
||||
@@ -109,7 +116,8 @@ public sealed record TestDto(
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
TestResultDto? Result);
|
||||
TestResultDto? Result,
|
||||
string IpVersion = "ipv4");
|
||||
|
||||
// ── 目录(由代码注册表生成) ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -33,8 +33,14 @@ public static class LiveEndpoints
|
||||
|
||||
private static async Task<IResult> StreamAsync(
|
||||
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime)
|
||||
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
|
||||
{
|
||||
string ipVersion = ip_version ?? IpVersions.V4;
|
||||
if (!IpVersions.IsValid(ipVersion))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("ip_version", "must be ipv4 or ipv6"));
|
||||
}
|
||||
|
||||
StringValues raw = http.Request.Query["topics"];
|
||||
Result<IReadOnlyList<LiveTopic>> parsed = LiveTopic.ParseAll(raw.Where(v => v is not null)!, options.MaxSseTopics);
|
||||
if (!parsed.TryGet(out IReadOnlyList<LiveTopic>? topics, out FleetError? error))
|
||||
@@ -78,7 +84,7 @@ public static class LiveEndpoints
|
||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await http.Response.Body.FlushAsync(http.RequestAborted);
|
||||
|
||||
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys);
|
||||
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
|
||||
await session.RunAsync(linked.Token);
|
||||
}
|
||||
@@ -101,6 +107,7 @@ public static class LiveEndpoints
|
||||
private readonly AgentService _agents;
|
||||
private readonly LineService _lines;
|
||||
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
||||
private readonly string _ipVersion;
|
||||
private readonly TestService _tests;
|
||||
private readonly QueryService _query;
|
||||
private readonly LiveHub _hub;
|
||||
@@ -117,12 +124,13 @@ public static class LiveEndpoints
|
||||
private long _lastWriteMs;
|
||||
private long _lastPeriodicMs;
|
||||
|
||||
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys)
|
||||
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
|
||||
{
|
||||
_http = http;
|
||||
_agents = agents;
|
||||
_lines = lines;
|
||||
_lineKeys = lineKeys;
|
||||
_ipVersion = ipVersion;
|
||||
_tests = tests;
|
||||
_query = query;
|
||||
_hub = hub;
|
||||
@@ -297,7 +305,7 @@ public static class LiveEndpoints
|
||||
{
|
||||
MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value;
|
||||
IReadOnlyList<LineView> visible = await _lines.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(a), ct);
|
||||
Result<OverviewResult> result = await _query.OverviewAsync(visible, new RangeRequest(topic.Range, null, null, null, null), selection, ct);
|
||||
Result<OverviewResult> result = await _query.OverviewAsync([.. visible.Where(l => l.IpVersion == _ipVersion)], new RangeRequest(topic.Range, null, null, null, null), selection, ct);
|
||||
if (result.TryGet(out OverviewResult? overview, out _))
|
||||
{
|
||||
var dto = new OverviewDto(
|
||||
|
||||
@@ -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,11 +44,24 @@ 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? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order, string? ip_version) =>
|
||||
QueryHelpers.GuardedAsync(http, async ct =>
|
||||
{
|
||||
string ipVersion = ip_version ?? IpVersions.V4;
|
||||
if (!IpVersions.IsValid(ipVersion))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("ip_version", "must be ipv4 or ipv6"));
|
||||
}
|
||||
|
||||
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery);
|
||||
if (!selection.TryGet(out MetricSelection? sel, out FleetError? error))
|
||||
{
|
||||
@@ -53,7 +69,7 @@ public static class QueryEndpoints
|
||||
}
|
||||
|
||||
IReadOnlyList<LineView> visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct);
|
||||
Result<OverviewResult> result = await query.OverviewAsync(visible, new RangeRequest(range, from, to, null, null), sel, ct);
|
||||
Result<OverviewResult> result = await query.OverviewAsync([.. visible.Where(l => l.IpVersion == ipVersion)], new RangeRequest(range, from, to, null, null), sel, ct);
|
||||
if (!result.TryGet(out OverviewResult? overview, out error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
@@ -81,12 +97,12 @@ public static class QueryEndpoints
|
||||
}
|
||||
|
||||
AgentView view = line.Agent;
|
||||
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, ct);
|
||||
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct);
|
||||
TestRecord? last = recent.Count > 0 ? recent[0] : null;
|
||||
return TypedResults.Json(
|
||||
new LineDto(
|
||||
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
|
||||
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last)),
|
||||
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0),
|
||||
ApiJsonContext.Default.LineDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ public sealed class AgentEventService
|
||||
switch (e.Kind)
|
||||
{
|
||||
case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.IpVersion) : new ConnectedProbeInfo(e.Detail, null, null, at));
|
||||
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote);
|
||||
break;
|
||||
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
|
||||
state.RemoveProbe(e.Detail);
|
||||
state.RemoveProbe(e.Detail, e.Probe?.IpVersion ?? IpVersions.V4);
|
||||
break;
|
||||
case AgentEventKinds.EndpointChanged:
|
||||
state.CountEndpointChange();
|
||||
|
||||
@@ -5,7 +5,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs);
|
||||
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
||||
|
||||
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
|
||||
public sealed class AgentRuntimeState
|
||||
@@ -19,22 +19,22 @@ public sealed class AgentRuntimeState
|
||||
|
||||
public bool ProbeConnected => Probes.Count > 0;
|
||||
|
||||
public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId);
|
||||
public bool IsConnected(string probeId, string ipVersion = IpVersions.V4) => Probes.Any(p => p.Id == probeId && p.IpVersion == ipVersion);
|
||||
|
||||
/// <summary>事件 <c>probe_connected</c>:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。</summary>
|
||||
public void AddProbe(ConnectedProbeInfo info)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id), info]);
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveProbe(string id)
|
||||
public void RemoveProbe(string id, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]);
|
||||
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id || p.IpVersion != ipVersion)]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
@@ -49,6 +51,16 @@ public sealed partial class AgentService
|
||||
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host);
|
||||
}
|
||||
|
||||
private static bool ValidHost(string host, string ipVersion)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out IPAddress? address))
|
||||
{
|
||||
return !address.IsIPv4MappedToIPv6 && address.AddressFamily == (ipVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork);
|
||||
}
|
||||
|
||||
return host.Length <= 253 && Uri.CheckHostName(host) == UriHostNameType.Dns;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<AgentView>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
@@ -57,11 +69,16 @@ public sealed partial class AgentService
|
||||
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
|
||||
}
|
||||
|
||||
if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost))
|
||||
if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4))
|
||||
{
|
||||
errors["public_host"] = "must be a host name or IP address";
|
||||
}
|
||||
|
||||
if (patch.Ipv6Host is not null && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
|
||||
{
|
||||
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
|
||||
}
|
||||
|
||||
if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32)))
|
||||
{
|
||||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
||||
@@ -78,7 +95,7 @@ public sealed partial class AgentService
|
||||
return Result<AgentView>.Fail(error);
|
||||
}
|
||||
|
||||
if (patch.Name is not null || patch.PublicHost is not null)
|
||||
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null)
|
||||
{
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
}
|
||||
@@ -139,6 +156,7 @@ public sealed partial class AgentService
|
||||
AgentId = agent.Id,
|
||||
Name = agent.Name,
|
||||
Host = agent.PublicHost,
|
||||
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
||||
TcpPort = agent.ProbeTcpPort,
|
||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||
Spki = Convert.ToBase64String(agent.TlsSpki),
|
||||
@@ -170,7 +188,4 @@ public sealed partial class AgentService
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9.:\-\[\]]{1,253}$")]
|
||||
private static partial Regex HostPattern();
|
||||
}
|
||||
@@ -53,10 +53,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
private readonly AgentService _service;
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly ReleaseService _releases;
|
||||
private readonly IProbeStore _probes;
|
||||
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases)
|
||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, IProbeStore probes)
|
||||
{
|
||||
_releases = releases;
|
||||
_probes = probes;
|
||||
_nodes = nodes;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
@@ -99,7 +101,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
_releases.RecordStatus(id, beat?.Update);
|
||||
if (beat is not null)
|
||||
{
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs)));
|
||||
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.IpVersion)));
|
||||
foreach (ConnectedProbe p in beat.Probes)
|
||||
{
|
||||
_nodes.ObserveProbeInBackground(p.Id.Value, p.Remote);
|
||||
@@ -130,6 +132,10 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
{
|
||||
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
|
||||
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
|
||||
if (meta?.Ipv6 is { } ipv6)
|
||||
{
|
||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -146,7 +152,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
_releases.RecordStatus(id, extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra)?.Update);
|
||||
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
||||
_releases.RecordStatus(id, heartbeat?.Update);
|
||||
if (heartbeat?.Ipv6 is { } ipv6)
|
||||
{
|
||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -196,14 +207,15 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
|
||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts), ct);
|
||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json), ct);
|
||||
|
||||
// 端口或证书指纹变了,Probe 需要新的列表
|
||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||
await _releases.OfferToNodeAsync(id, ct);
|
||||
if (before is not null && after is not null
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts))
|
||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported)
|
||||
&& after.Status == NodeStatus.Active)
|
||||
{
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
|
||||
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits);
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs);
|
||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
||||
|
||||
public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<ProbeConnection> Connections)
|
||||
{
|
||||
@@ -128,6 +128,8 @@ public sealed partial class ProbeService
|
||||
}
|
||||
|
||||
await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
// 吊销会阻止未来认证;也断开当前直连 Backend 的长会话,不等待 Probe 心跳超时。
|
||||
await _gateway.DisconnectAsync(new NodeId(id), "probe revoked", ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
@@ -178,7 +180,7 @@ public sealed partial class ProbeService
|
||||
AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId);
|
||||
if (agent is not null)
|
||||
{
|
||||
connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs));
|
||||
connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs, info.IpVersion));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ public sealed partial class IngestService
|
||||
RawChunkHeader header = chunk.Header;
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string? problem =
|
||||
header.AgentId != agent ? "agent_id does not match the authenticated node"
|
||||
!IpVersions.IsValid(header.IpVersion) ? "unknown ip_version"
|
||||
: header.AgentId != agent ? "agent_id does not match the authenticated node"
|
||||
: header.ChunkId.Value != msgId ? "chunk_id does not match the message id"
|
||||
: Kinds.Find(header.Kind) is null ? $"unknown kind {header.Kind}"
|
||||
: header.DurationMs is <= 0 or > MaxDurationMs ? "duration out of range"
|
||||
|
||||
@@ -22,7 +22,10 @@ public sealed record AgentRecord(
|
||||
string? RejectedJson,
|
||||
long CreatedAtMs,
|
||||
long? LastSeenAtMs,
|
||||
long RowVersion);
|
||||
long RowVersion,
|
||||
string Ipv6Host = "",
|
||||
bool Ipv6Supported = false,
|
||||
string? Ipv6DiagnosticsJson = null);
|
||||
|
||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||
public sealed class AgentPatch
|
||||
@@ -33,6 +36,8 @@ public sealed class AgentPatch
|
||||
|
||||
public string? PublicHost { get; init; }
|
||||
|
||||
public string? Ipv6Host { get; init; }
|
||||
|
||||
public bool? PublicVisible { get; init; }
|
||||
}
|
||||
|
||||
@@ -43,7 +48,9 @@ public sealed record AgentHello(
|
||||
string? LocalLimitsJson,
|
||||
byte[]? TlsSpki,
|
||||
int? ProbeTcpPort,
|
||||
IReadOnlyList<int>? ProbeUdpPorts);
|
||||
IReadOnlyList<int>? ProbeUdpPorts,
|
||||
bool Ipv6Supported = false,
|
||||
string? Ipv6DiagnosticsJson = null);
|
||||
|
||||
/// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary>
|
||||
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> Kinds);
|
||||
@@ -59,4 +66,4 @@ public sealed record JoinTokenInfo(
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs);
|
||||
|
||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs);
|
||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null);
|
||||
@@ -30,7 +30,8 @@ public sealed record TestRecord(
|
||||
int PhaseCount,
|
||||
long RequestedAtMs,
|
||||
long? StartedAtMs,
|
||||
long? FinishedAtMs);
|
||||
long? FinishedAtMs,
|
||||
string IpVersion = IpVersions.V4);
|
||||
|
||||
/// <summary>测试结果:指标与曲线都以 JSON 保存。</summary>
|
||||
public sealed record TestResultRecord(TestId TestId, byte[] MetricsJson, byte[] SeriesJson);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -108,6 +110,8 @@ public interface ITestStore
|
||||
|
||||
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct);
|
||||
|
||||
@@ -3,35 +3,35 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>一条线路 = 一对(Agent,Probe)。存储里它的序列是 <c>line:<probe_id></c>,对外的 id 是 <c><agent_id>~<probe_id></c>。</summary>
|
||||
public readonly record struct LineKey(NodeId Agent, ProbeId Probe)
|
||||
/// <summary>线路按(Agent,Probe,地址族)隔离。IPv4 保留 <c>agent~probe</c> 和 <c>line:probe</c>;IPv6 分别追加 <c>~ipv6</c>、<c>:ipv6</c>。</summary>
|
||||
public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVersion = IpVersions.V4)
|
||||
{
|
||||
public string Series => RawChunkHeader.LinePrefix + Probe.Value;
|
||||
public string Series => IpVersions.LineSeries(Probe.Value, IpVersion);
|
||||
|
||||
public string Id => Agent.Value + "~" + Probe.Value;
|
||||
public string Id => Agent.Value + "~" + Probe.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : "");
|
||||
|
||||
public static bool TryParse(string text, out LineKey key)
|
||||
{
|
||||
int split = text.IndexOf('~', StringComparison.Ordinal);
|
||||
if (split <= 0 || split == text.Length - 1)
|
||||
string[] parts = text.Split('~');
|
||||
if (parts.Length is < 2 or > 3 || parts[0].Length == 0 || parts[1].Length == 0 || (parts.Length == 3 && parts[2] != IpVersions.V6))
|
||||
{
|
||||
key = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
key = new LineKey(new NodeId(text[..split]), new ProbeId(text[(split + 1)..]));
|
||||
key = new LineKey(new NodeId(parts[0]), new ProbeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe)
|
||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4)
|
||||
{
|
||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id));
|
||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
|
||||
|
||||
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id);
|
||||
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
|
||||
|
||||
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id);
|
||||
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
|
||||
}
|
||||
|
||||
/// <summary>线路目录:把 id 解析成(Agent,Probe),列出所有线路。</summary>
|
||||
@@ -54,7 +54,20 @@ public sealed class LineService
|
||||
IReadOnlyList<AgentView> agents = await _agents.ListAsync(ct);
|
||||
IReadOnlyList<ProbeRecord> probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)];
|
||||
LinkSnapshot links = await _links.SnapshotAsync(ct);
|
||||
return [.. agents.Where(include).SelectMany(a => probes.Where(p => links.IsLinked(p.Id, a.Agent.Id.Value)).Select(p => new LineView(a, p)))];
|
||||
var result = new List<LineView>();
|
||||
foreach (AgentView agent in agents.Where(include))
|
||||
{
|
||||
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
|
||||
{
|
||||
result.Add(new LineView(agent, probe));
|
||||
if (agent.Agent.Ipv6Host.Length > 0)
|
||||
{
|
||||
result.Add(new LineView(agent, probe, IpVersions.V6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,6 +77,7 @@ public sealed class LineService
|
||||
{
|
||||
string agentPart = id;
|
||||
string? probePart = null;
|
||||
string ipVersion = IpVersions.V4;
|
||||
if (id.Contains('~', StringComparison.Ordinal))
|
||||
{
|
||||
if (!LineKey.TryParse(id, out LineKey key))
|
||||
@@ -73,6 +87,7 @@ public sealed class LineService
|
||||
|
||||
agentPart = key.Agent.Value;
|
||||
probePart = key.Probe.Value;
|
||||
ipVersion = key.IpVersion;
|
||||
}
|
||||
|
||||
AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct);
|
||||
@@ -102,6 +117,6 @@ public sealed class LineService
|
||||
probe = active[0];
|
||||
}
|
||||
|
||||
return Result<LineView>.Ok(new LineView(agent, probe));
|
||||
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion));
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ public sealed class QueryService
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, double> overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary<string, double>();
|
||||
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, ct);
|
||||
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, line.Key.IpVersion, ct);
|
||||
result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null));
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ public sealed class QueryService
|
||||
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day);
|
||||
var overlay = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
var seenPlans = new HashSet<string>();
|
||||
foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, ct))
|
||||
foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, line.IpVersion, ct))
|
||||
{
|
||||
if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan))
|
||||
{
|
||||
|
||||
@@ -139,7 +139,8 @@ public sealed class RowLoader
|
||||
IReadOnlyList<MetricRow> all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct);
|
||||
foreach (IGrouping<NodeId, MetricRow> group in all.GroupBy(r => r.AgentId))
|
||||
{
|
||||
result[new LineKey(group.Key, new ProbeId(bySeries.Key[RawChunkHeader.LinePrefix.Length..]))] = Decode([.. group]);
|
||||
LineKey key = bySeries.First(l => l.Agent == group.Key);
|
||||
result[key] = Decode([.. group]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,16 @@ public sealed class TestService
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct)
|
||||
public ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) =>
|
||||
StartAsync(agentId, probeId, request, trigger, IpVersions.V4, ct);
|
||||
|
||||
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct)
|
||||
{
|
||||
if (!IpVersions.IsValid(ipVersion))
|
||||
{
|
||||
return ValidationError.Fail<TestRecord>("ip_version", "must be ipv4 or ipv6");
|
||||
}
|
||||
|
||||
PlanSpec? plan = Plans.Find(request.Plan);
|
||||
if (plan is null)
|
||||
{
|
||||
@@ -47,6 +55,11 @@ public sealed class TestService
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentNotActive, "agent is not active");
|
||||
}
|
||||
|
||||
if (ipVersion == IpVersions.V6 && !agent.Ipv6Supported)
|
||||
{
|
||||
return Result<TestRecord>.Fail(FleetErrors.Unsupported, "this agent has not enabled IPv6 collection");
|
||||
}
|
||||
|
||||
// Agent 在 hello 里声明了它支持的采集方法:方案里有它不支持的就不下发(否则要等到阶段开始才失败)
|
||||
string? unsupported = plan.Phases.Select(p => p.Kind).FirstOrDefault(kind => !agent.Kinds.Contains(kind));
|
||||
if (unsupported is not null)
|
||||
@@ -65,7 +78,7 @@ public sealed class TestService
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentOffline, "agent is offline");
|
||||
}
|
||||
|
||||
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value)))
|
||||
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value, ipVersion)))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent");
|
||||
}
|
||||
@@ -78,10 +91,10 @@ public sealed class TestService
|
||||
var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time));
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary<string, JsonElement>(request.Params), TestJson.Default.ParamMap);
|
||||
var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null);
|
||||
var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null, ipVersion);
|
||||
await _tests.CreateAsync(record, ct);
|
||||
|
||||
Result<bool> sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, Plan = plan.Id, Phases = phaseList }, ct);
|
||||
Result<bool> sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, IpVersion = ipVersion, Plan = plan.Id, Phases = phaseList }, ct);
|
||||
if (!sent.TryGet(out _, out FleetError? sendError))
|
||||
{
|
||||
await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct);
|
||||
@@ -149,6 +162,8 @@ public sealed class TestService
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ipVersion, ct);
|
||||
|
||||
public static TestResultView ToView(TestResultRecord stored)
|
||||
{
|
||||
StoredTestMetrics metrics = JsonSerializer.Deserialize(stored.MetricsJson, TestJson.Default.StoredTestMetrics) ?? new StoredTestMetrics();
|
||||
|
||||
@@ -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 = 5;
|
||||
public const int CurrentVersion = 7;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -90,6 +90,26 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 5");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 6)
|
||||
{
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host TEXT NOT NULL DEFAULT ''");
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_supported INTEGER NOT NULL DEFAULT 0");
|
||||
connection.Exec("ALTER TABLE tests ADD COLUMN ip_version TEXT NOT NULL DEFAULT 'ipv4'");
|
||||
connection.Exec("CREATE INDEX ix_tests_path ON tests (agent_id, probe_id, ip_version, requested_at)");
|
||||
connection.Exec("PRAGMA user_version = 6");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 7)
|
||||
{
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_diagnostics TEXT");
|
||||
connection.Exec("ALTER TABLE probes ADD COLUMN ipv6_diagnostics TEXT");
|
||||
connection.Exec("PRAGMA user_version = 7");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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";
|
||||
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
@@ -61,10 +61,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
}
|
||||
|
||||
c.Exec(
|
||||
"UPDATE agents SET name = @name, labels = @labels, public_host = @host, public_visible = @visible, row_version = row_version + 1 WHERE id = @id",
|
||||
"UPDATE agents SET name = @name, labels = @labels, public_host = @host, ipv6_host = @v6, public_visible = @visible, row_version = row_version + 1 WHERE id = @id",
|
||||
("@id", id.Value), ("@name", patch.Name ?? current.Name),
|
||||
("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)),
|
||||
("@host", patch.PublicHost ?? current.PublicHost), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0));
|
||||
("@host", patch.PublicHost ?? current.PublicHost), ("@v6", patch.Ipv6Host ?? current.Ipv6Host), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0));
|
||||
return Result<AgentRecord>.Ok(GetInternal(c, id)!);
|
||||
}, ct);
|
||||
|
||||
@@ -117,9 +117,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
{
|
||||
c.Exec(
|
||||
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " +
|
||||
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), row_version = row_version + 1 WHERE id = @id",
|
||||
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, row_version = row_version + 1 WHERE id = @id",
|
||||
("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson),
|
||||
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)));
|
||||
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson));
|
||||
}, ct);
|
||||
|
||||
public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) =>
|
||||
@@ -217,7 +217,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
||||
r.NullableString(15),
|
||||
r.GetInt64(16),
|
||||
r.NullableLong(17),
|
||||
r.GetInt64(18));
|
||||
r.GetInt64(18),
|
||||
r.GetString(19),
|
||||
r.GetInt64(20) != 0,
|
||||
r.NullableString(21));
|
||||
|
||||
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
|
||||
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
|
||||
public sealed class SqliteTestStore : ITestStore
|
||||
{
|
||||
private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id";
|
||||
private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id, ip_version";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
@@ -19,19 +19,22 @@ public sealed class SqliteTestStore : ITestStore
|
||||
public ValueTask CreateAsync(TestRecord test, CancellationToken ct) =>
|
||||
_db.ExecuteAsync(
|
||||
c => c.Exec(
|
||||
"INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at)",
|
||||
"INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, ip_version) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at, @ip)",
|
||||
("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@probe", test.ProbeId.Value), ("@plan", test.Plan), ("@params", test.ParamsJson), ("@trigger", test.Trigger),
|
||||
("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs)),
|
||||
("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs), ("@ip", test.IpVersion)),
|
||||
ct);
|
||||
|
||||
public ValueTask<TestRecord?> GetAsync(TestId id, CancellationToken ct) =>
|
||||
_db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM tests WHERE id = @id", Read, ("@id", id.Value)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) =>
|
||||
ListAsync(agentId, probeId, limit, null, ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<TestRecord>>(
|
||||
c => c.Query(
|
||||
$"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) ORDER BY requested_at DESC, id DESC LIMIT @limit",
|
||||
Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit)),
|
||||
$"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) AND (@ip IS NULL OR ip_version = @ip) ORDER BY requested_at DESC, id DESC LIMIT @limit",
|
||||
Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit), ("@ip", ipVersion)),
|
||||
ct);
|
||||
|
||||
public ValueTask<bool> UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct) =>
|
||||
@@ -88,5 +91,6 @@ public sealed class SqliteTestStore : ITestStore
|
||||
r.GetInt32(8),
|
||||
r.GetInt64(9),
|
||||
r.NullableLong(10),
|
||||
r.NullableLong(11));
|
||||
r.NullableLong(11),
|
||||
r.GetString(13));
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public sealed class HostSamplerTests
|
||||
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
|
||||
string stat = Stat1;
|
||||
string dev = Dev1;
|
||||
var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
|
||||
var sampler = new HostSampler(time, path => path.Replace('\\', '/') switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
|
||||
HostSnapshot first = sampler.Sample();
|
||||
Assert.Null(first.CpuPercent); // 第一次没有基线
|
||||
Assert.Null(first.NetRxBps);
|
||||
@@ -53,6 +53,7 @@ public sealed class HostSamplerTests
|
||||
var reads = new List<string>();
|
||||
var sampler = new HostSampler(TimeProvider.System, path =>
|
||||
{
|
||||
path = path.Replace('\\', '/'); // 测试里的 /proc 格式统一用斜杠,兼容 Windows 的 Path.Combine
|
||||
reads.Add(path);
|
||||
return path switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class Ipv6Tests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public Ipv6Tests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queries_DifferentFamilyLoss_RemainsIndependentInHotPersistedAndCompareViews()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
long start = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / 60_000) - 4) * 60_000;
|
||||
for (int k = 0; k < 6; k++)
|
||||
{
|
||||
foreach (string version in new[] { IpVersions.V4, IpVersions.V6 })
|
||||
{
|
||||
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", start + k * 10_000, 100, 100,
|
||||
i => new EchoPacket(false, version == IpVersions.V6 && i % 2 == 0, 20_000, 20_000));
|
||||
chunk.Header.ProbeId = new ProbeId(_backend.DefaultProbeId);
|
||||
chunk.Header.IpVersion = version;
|
||||
Assert.True(await agent.SendChunkAsync(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
string range = "from=" + DateTimeOffset.FromUnixTimeMilliseconds(start).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
|
||||
+ "&to=" + DateTimeOffset.FromUnixTimeMilliseconds(start + 60_000).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
string v4 = _backend.Line(agent.NodeId);
|
||||
string v6 = v4 + "~ipv6";
|
||||
foreach (string step in new[] { "1s", "10s", "1m" })
|
||||
{
|
||||
foreach ((string line, double loss) in new[] { (v4, 0.0), (v6, 50.0) })
|
||||
{
|
||||
JsonNode series = null!;
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?{range}&step={step}&preset=udp");
|
||||
return series["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600;
|
||||
});
|
||||
Assert.Equal(loss, series["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={v4},{v6}&metric=loss.total&step=1m&{range}");
|
||||
Assert.Equal(0, compare["summary"]![v4]!.GetValue<double>());
|
||||
Assert.Equal(50, compare["summary"]![v6]!.GetValue<double>());
|
||||
|
||||
await using SseClient live = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{v6}:10s:udp");
|
||||
SseEvent snapshot = await live.WaitForAsync(e => e.Name == "series" && e.Data?["line_id"]?.GetValue<string>() == v6);
|
||||
Assert.Equal("ipv6", snapshot.Data!["ip_version"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ipv6Configuration_RejectsWrongFamily_AndDoesNotArrangeIpv6ForALegacyAgent()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
using HttpResponseMessage invalid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value,
|
||||
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "127.0.0.1" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
Assert.Contains("ipv6_host", await invalid.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
using HttpResponseMessage valid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value,
|
||||
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" }));
|
||||
Assert.True(valid.IsSuccessStatusCode);
|
||||
ProbeAgentList endpoints = await _backend.Services.GetRequiredService<AgentService>().ProbeAgentsAsync(_backend.DefaultProbeId, default);
|
||||
Assert.Null(Assert.Single(endpoints.Agents, a => a.AgentId == agent.NodeId).Ipv6Host);
|
||||
JsonNode identity = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
|
||||
Assert.Equal("::1", identity["ipv6_host"]!.GetValue<string>());
|
||||
Assert.False(identity["ipv6_supported"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DualStack_CollectsBothFamilies_DisablingIpv6KeepsIpv4Running()
|
||||
{
|
||||
Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host");
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::");
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id,
|
||||
BackendFixture.Json(new JsonObject { ["public_host"] = "127.0.0.1", ["ipv6_host"] = "::1" }));
|
||||
Assert.True(configured.IsSuccessStatusCode, await configured.Content.ReadAsStringAsync());
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id) && probe.App.IsConnected(agent.Id, IpVersions.V6)));
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
JsonNode raw = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.Id);
|
||||
return new[] { "line:" + probe.ProbeId, "line:" + probe.ProbeId + ":ipv6" }.All(series =>
|
||||
new[] { "udp_echo", "tcp_echo" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue<string>() == series && c["kind"]!.GetValue<string>() == kind)));
|
||||
}, TimeSpan.FromSeconds(45));
|
||||
|
||||
string v4 = agent.Id + "~" + probe.ProbeId;
|
||||
string v6 = v4 + "~ipv6";
|
||||
foreach (string step in new[] { "1s", "10s" })
|
||||
{
|
||||
foreach ((string line, string family) in new[] { (v4, IpVersions.V4), (v6, IpVersions.V6) })
|
||||
{
|
||||
JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?range=1m&step={step}&preset=udp,tcp");
|
||||
Assert.Equal(family, series["ip_version"]!.GetValue<string>());
|
||||
Assert.True(series["summary"]!["samples"]!["udp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
|
||||
Assert.True(series["summary"]!["samples"]!["tcp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode overview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=1m&ip_version=ipv6");
|
||||
Assert.Contains(overview["lines"]!.AsArray(), l => l!["id"]!.GetValue<string>() == v6);
|
||||
Assert.All(overview["lines"]!.AsArray(), l => Assert.Equal("ipv6", l!["ip_version"]!.GetValue<string>()));
|
||||
|
||||
var ipv4Connection = probe.App.Connection(agent.Id);
|
||||
long previous = agent.App.Counters.Fragments;
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "" }));
|
||||
Assert.True(disabled.IsSuccessStatusCode);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id, IpVersions.V6) is null));
|
||||
Assert.Same(ipv4Connection, probe.App.Connection(agent.Id));
|
||||
Assert.True(probe.App.IsConnected(agent.Id));
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.Fragments > previous), TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hy2_ladder")]
|
||||
[InlineData("reality_session")]
|
||||
public async Task Ipv6Test_UsesIpv6Session_AndDoesNotAppearInIpv4History(string plan)
|
||||
{
|
||||
Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host");
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::");
|
||||
await agent.OverrideLineTasksAsync();
|
||||
using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id,
|
||||
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" }));
|
||||
Assert.True(configured.IsSuccessStatusCode);
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
string v4 = agent.Id + "~" + probe.ProbeId;
|
||||
string v6 = v4 + "~ipv6";
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probes"]!.AsArray()
|
||||
.Any(p => p!["probe_id"]!.GetValue<string>() == probe.ProbeId && p["ip_version"]!.GetValue<string>() == "ipv6"));
|
||||
var parameters = plan == "hy2_ladder" ? new JsonObject { ["mbps"] = 1, ["duration_s"] = 1 } : new JsonObject { ["duration_s"] = 1 };
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{v6}/tests", BackendFixture.Json(new JsonObject { ["plan"] = plan, ["params"] = parameters }));
|
||||
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
|
||||
string id = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
JsonNode result = null!;
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
result = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id);
|
||||
return result["status"]!.GetValue<string>() is "failed" or "aborted" || result["result"] is not null;
|
||||
}, TimeSpan.FromSeconds(100));
|
||||
Assert.Equal("succeeded", result["status"]!.GetValue<string>());
|
||||
Assert.Equal("ipv6", result["ip_version"]!.GetValue<string>());
|
||||
Assert.NotNull(result["result"]);
|
||||
JsonNode history = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v4}/tests");
|
||||
Assert.Empty(history["items"]!.AsArray());
|
||||
JsonNode v6History = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v6}/tests");
|
||||
Assert.Contains(v6History["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
|
||||
## 1. 分层
|
||||
|
||||
### IPv4 / IPv6
|
||||
|
||||
- `PATCH /api/admin/agents/{id}` 新增可选 `ipv6_host`:IPv6 地址或域名;空字符串关闭 IPv6,缺省或 null 不修改。原 `public_host` 用于 IPv4。
|
||||
- `GET /api/public/overview` 新增可选 `ip_version=ipv4|ipv6`,缺省 IPv4。SSE `/api/public/live` 的同名参数选择 overview 主题的地址族;series 主题仍按线路 id 定位。
|
||||
- IPv4 线路 id 保持 `agent~probe`,IPv6 为 `agent~probe~ipv6`。series、distribution、heatmap、compare 和管理端测试端点共用这个标识。测试记录与评分叠加独立。
|
||||
- 线路、时序、测试和连接 DTO 新增 `ip_version`;管理端 Agent DTO 新增 `ipv6_host`、`ipv6_supported`;线路详情新增 `ipv6_available`(是否配置了 IPv6 地址)。公开 DTO 不包含任一种 IP 地址。
|
||||
- API v1 采用新增字段和可选参数,现有 IPv4 请求及历史链接继续有效。前端类型从 OpenAPI 重新生成。
|
||||
|
||||
```text
|
||||
/api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌;
|
||||
public_visible = false 的 Agent 对匿名访客隐藏
|
||||
|
||||
+46
-1
@@ -4,6 +4,20 @@
|
||||
|
||||
## 1. 构建与打包
|
||||
|
||||
### IPv6 状态诊断
|
||||
|
||||
Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括监听状态、全局地址、默认路由和网络命名空间。容器可提供只读宿主 `/proc` 挂载,附带宿主网络状态。只传布尔状态,不传 IP 或网卡名称。Docker 和 Podman 推荐宿主网络模式;自定义桥接网络时,需要启用 IPv6 并提供 `/proc` 宿主挂载,前端会提示宿主和容器网络状态是否不同。
|
||||
|
||||
### 启用 IPv6 测量
|
||||
|
||||
升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
|
||||
|
||||
Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果显式配置了 `0.0.0.0`,需要在宿主机改为 `::` 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 `7443/tcp`、`7443/udp`、`7444/tcp`(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。
|
||||
|
||||
原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。
|
||||
|
||||
上线后,在管理端的 Probe / Agent 页面查看「IPv6 诊断」。它显示检测网络范围、全局地址和默认路由;机器有 IPv6 但容器没有时,检查是否使用宿主网络。没有填 Agent IPv6 测量地址时,诊断仍会上报,但面板不会建立该 Agent 的 IPv6 测量线路。
|
||||
|
||||
```text
|
||||
scripts/publish.sh <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)
|
||||
@@ -193,13 +207,25 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2
|
||||
|
||||
镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro-<role>-status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。
|
||||
|
||||
**用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
|
||||
|
||||
```sh
|
||||
# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起)
|
||||
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <name>
|
||||
sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --probe-json ./probe.json
|
||||
# 之后更新节点:不带参数
|
||||
sudo sh deploy-node.sh agent
|
||||
```
|
||||
|
||||
节点上看不到公网 IPv6 地址,所以 Agent 的 `ipv6_host` 要在管理端手动填,Backend 才会让 Probe 去拨 IPv6。下面是不用脚本的手动步骤。
|
||||
|
||||
在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`):
|
||||
|
||||
```sh
|
||||
podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll agent \
|
||||
--server https://<backend> --join <token> --name <name> --identity-dir /etc/moshiro/agent-id
|
||||
podman compose run --rm -T --entrypoint /bin/sh agent -c 'cat > /etc/moshiro/agent.json' <<'JSON'
|
||||
{"fleet":{"server":"https://<backend>","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"0.0.0.0"}}
|
||||
{"fleet":{"server":"https://<backend>","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}
|
||||
JSON
|
||||
podman compose up -d
|
||||
```
|
||||
@@ -250,3 +276,22 @@ sh packaging/update/install-updater.sh agent --user \
|
||||
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。
|
||||
|
||||
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
||||
|
||||
## Backend 签名更新
|
||||
|
||||
Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。
|
||||
|
||||
`MOSHIRO_UPDATE_CHANNEL=auto` 时,`MOSHIRO_IMAGE_NAMESPACE=moshiro-dev` 选择 `dev`,`moshiro` 选择 `release`。CI 发布流水线使用对应 channel 签名并登记到 Backend webhook。旧版 Backend 不识别 Backend 清单,因此首次升级需按常规部署流程引导一次;该期间 CI 对这类旧版面板的明确“不认识 backend 组件”响应会提示引导信息,其他 webhook 错误仍会令发布失败。
|
||||
|
||||
更新器以手动策略运行,并通过独立状态 volume 将状态只读挂载回 Backend:
|
||||
|
||||
```sh
|
||||
sudo packaging/update/install-updater.sh backend --engine podman \
|
||||
--volume moshiro-backend-data --status-volume moshiro-backend-status \
|
||||
--helper-image registry.oeo.one/moshiro-dev/moshiro-backend:<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。
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
## 2. 任务模型
|
||||
|
||||
同一个 Probe 与 Agent 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 和 Probe 的本机策略执行。两个地址族分别使用有界发件箱(Probe 每路径 8 MB,Agent 的共同上传队列 64 MB)。
|
||||
|
||||
```text
|
||||
Task = { id, scope, kind, target, params, schedule, enabled }
|
||||
|
||||
|
||||
@@ -94,6 +94,18 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
|
||||
|
||||
## 4. 版本演进
|
||||
|
||||
### IPv4 / IPv6 路径
|
||||
|
||||
- 一个 Probe 与 Agent 可以同时建立 IPv4、IPv6 两个会话,共用身份公钥,各自使用独立的会话密钥、流、时钟估计与发件箱。重连只替换同地址族的会话。
|
||||
- `probe_agents.agents[]` 保留 `host`(IPv4 地址或 A 记录域名),新增可选 `ipv6_host`(IPv6 地址或 AAAA 记录域名)。未配置时不建立 IPv6 会话;Probe 按地址族解析,不跨地址族回退。
|
||||
- Agent 在 hello 的元数据里上报 `ipv6_supported`,只在本机监听 IPv6 时为 true。Backend 只向支持它的 Agent 安排 IPv6 路径,旧 Agent 缺省为 false。
|
||||
- Agent 和 Probe 会在 hello 与心跳中上报 IPv6 套接字、监听、全局地址、默认路由及网络命名空间状态。Podman/Docker 宿主网络部署里,采样反映宿主接口;桥接网络容器会分别报告容器网络和能读取到的宿主 IPv6。诊断只有布尔值和粗粒度状态,不包含地址和接口名称。
|
||||
- `ConnectedProbe`、`TestRunParams` 和 RawChunk 块头新增 `ip_version`,取值 `ipv4` / `ipv6`,缺省 `ipv4`。Agent 合并列块时以认证过的控制会话地址族为准,覆盖 Probe 上报的值。
|
||||
- IPv4 的线路 id `agent~probe`、序列 `line:probe` 保持不变;IPv6 使用 `agent~probe~ipv6`、`line:probe:ipv6`。指标、重算、SSE 和测试结果按路径隔离。
|
||||
- 本次是 JSON 字段扩展,二进制包、列编码和容器布局没有变化,协议与 `container_version` 保持原版本。双栈采集需要升级 Agent 和 Probe;旧节点继续提供 IPv4。
|
||||
|
||||
### 通用规则
|
||||
|
||||
- Fleet 层的兼容规则见 [../backend/fleet/README.md](../backend/fleet/README.md)。
|
||||
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/bin/sh
|
||||
# 部署或更新容器节点(Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
|
||||
# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。
|
||||
#
|
||||
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
|
||||
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --probe-json ./probe.json
|
||||
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
|
||||
#
|
||||
# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。
|
||||
# rootless Podman 见 docs/deployment.md。
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
echo "usage: deploy-node.sh <agent|probe> [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2
|
||||
echo " [--server URL --join TOKEN --name NAME] [--probe-json FILE] [--policy auto|manual] [--authfile F]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ROLE="${1:-}"; shift || true
|
||||
case "$ROLE" in agent|probe) ;; *) usage ;; esac
|
||||
ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; PROBE_JSON=""; POLICY=""; AUTHFILE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--engine) ENGINE="$2"; shift 2 ;;
|
||||
--image-repo) REPO="$2"; shift 2 ;;
|
||||
--tag) TAG="$2"; shift 2 ;;
|
||||
--image) IMAGE="$2"; shift 2 ;;
|
||||
--server) SERVER="$2"; shift 2 ;;
|
||||
--join) JOIN="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--probe-json) PROBE_JSON="$2"; shift 2 ;;
|
||||
--policy) POLICY="$2"; shift 2 ;;
|
||||
--authfile) AUTHFILE="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run as root (rootless Podman: see docs/deployment.md)" >&2; exit 1; }
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
PACKAGING="$(cd "$HERE/.." && pwd)"
|
||||
CONF_DIR=/etc/moshiro-update
|
||||
CONF="$CONF_DIR/$ROLE.conf"
|
||||
|
||||
# 重复运行时沿用上次的更新器配置(仓库、策略、认证文件、引擎)
|
||||
conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head -n 1 || true; }
|
||||
[ -n "$REPO" ] || REPO="$(conf_get IMAGE_REPO)"
|
||||
[ -n "$POLICY" ] || POLICY="$(conf_get POLICY)"
|
||||
[ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)"
|
||||
[ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)"
|
||||
[ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE"
|
||||
[ -n "$POLICY" ] || POLICY=manual
|
||||
if [ -z "$ENGINE" ]; then
|
||||
if command -v podman >/dev/null 2>&1; then ENGINE=podman; elif command -v docker >/dev/null 2>&1; then ENGINE=docker; else echo "podman or docker is required" >&2; exit 1; fi
|
||||
fi
|
||||
case "$ENGINE" in
|
||||
podman) UNIT="moshiro-$ROLE-container.service" ;;
|
||||
docker) UNIT="moshiro-$ROLE-docker.service" ;;
|
||||
*) echo "--engine must be podman or docker" >&2; exit 2 ;;
|
||||
esac
|
||||
V_CFG="moshiro-$ROLE-config"
|
||||
V_STATUS="moshiro-$ROLE-status"
|
||||
for f in "$PACKAGING/systemd/$UNIT" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do
|
||||
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用
|
||||
if [ -z "$IMAGE" ]; then
|
||||
if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi
|
||||
if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$REPO:$TAG" >/dev/null
|
||||
else "$ENGINE" pull -q "$REPO:$TAG" >/dev/null; fi
|
||||
if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST"
|
||||
else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi
|
||||
fi
|
||||
echo "image: $IMAGE"
|
||||
|
||||
# 2. 卷。辅助容器以镜像自带的 app 用户(1654)读写,所以卷里的文件属主总是对的
|
||||
"$ENGINE" volume inspect "$V_CFG" >/dev/null 2>&1 || "$ENGINE" volume create "$V_CFG" >/dev/null
|
||||
"$ENGINE" volume inspect "$V_STATUS" >/dev/null 2>&1 || "$ENGINE" volume create "$V_STATUS" >/dev/null
|
||||
in_volume() {
|
||||
"$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \
|
||||
-v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1"
|
||||
}
|
||||
in_volume 'mkdir -p /etc/moshiro/update' </dev/null
|
||||
|
||||
# 3. 配置
|
||||
if [ "$ROLE" = agent ]; then
|
||||
if ! in_volume 'test -f /etc/moshiro/agent-id/node.json' </dev/null; then
|
||||
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&2; exit 1; }
|
||||
"$ENGINE" run --rm --pull=never -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \
|
||||
--server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir /etc/moshiro/agent-id
|
||||
fi
|
||||
if ! in_volume 'test -f /etc/moshiro/agent.json' </dev/null; then
|
||||
[ -n "$SERVER" ] || { echo "agent.json is missing; pass --server to create it" >&2; exit 1; }
|
||||
printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}\n' "$SERVER" \
|
||||
| in_volume 'cat > /etc/moshiro/agent.json'
|
||||
echo "config: created agent.json (listen.bind ::)"
|
||||
elif in_volume 'grep -q "\"bind\" *: *\"0\.0\.0\.0\"" /etc/moshiro/agent.json' </dev/null; then
|
||||
# 旧配置只监听 IPv4:改成 ::(双栈,IPv4 照常可用)
|
||||
in_volume 'sed -i "s/\"bind\" *: *\"0\.0\.0\.0\"/\"bind\": \"::\"/" /etc/moshiro/agent.json' </dev/null
|
||||
echo "config: listen.bind 0.0.0.0 -> ::"
|
||||
fi
|
||||
else
|
||||
if [ -n "$PROBE_JSON" ]; then
|
||||
in_volume 'umask 077; cat > /etc/moshiro/probe.json' < "$PROBE_JSON"
|
||||
echo "config: imported probe.json"
|
||||
elif ! in_volume 'test -f /etc/moshiro/probe.json' </dev/null; then
|
||||
echo "first deployment needs --probe-json (download it from the panel)" >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 单元和更新器(保持原来的更新策略)
|
||||
install -d -m 755 "$CONF_DIR"
|
||||
printf 'IMAGE=%s\n' "$IMAGE" > "$CONF_DIR/$ROLE.image"
|
||||
install -m 644 "$PACKAGING/systemd/$UNIT" "/etc/systemd/system/$UNIT"
|
||||
set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \
|
||||
--image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable
|
||||
[ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE"
|
||||
sh "$PACKAGING/update/install-updater.sh" "$@"
|
||||
|
||||
# 5. 启动
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1
|
||||
systemctl restart "$UNIT"
|
||||
systemctl start "moshiro-update-$ROLE.timer"
|
||||
sleep 5
|
||||
systemctl is-active "$UNIT"
|
||||
@@ -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 -e ASPNETCORE_URLS=http://0.0.0.0:29181 --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 ;;
|
||||
|
||||
@@ -22,6 +22,7 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css,
|
||||
连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`MoShiro__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。
|
||||
|
||||
约定:
|
||||
- 总览、线路详情、对比和管理端按需测试均可切换 IPv4 / IPv6;查询和测试结果独立。管理端 Agent 编辑页分别配置 IPv4、IPv6 地址,IPv6 留空只采 IPv4。展示端只显示地址族,不显示 IP。
|
||||
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
|
||||
- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。
|
||||
- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
|
||||
|
||||
@@ -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,12 +7,14 @@ import { fmtAgo, fmtNum, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
||||
import { AgentStatus } from './Agents'
|
||||
import { IPV6_STATES } from '@shared/ipVersions'
|
||||
|
||||
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
|
||||
const { busy, run } = useAction()
|
||||
const [name, setName] = useState(agent.name)
|
||||
const [labels, setLabels] = useState(agent.labels.join(', '))
|
||||
const [host, setHost] = useState(agent.public_host)
|
||||
const [ipv6Host, setIpv6Host] = useState(agent.ipv6_host ?? '')
|
||||
const [visible, setVisible] = useState(agent.public_visible)
|
||||
return (
|
||||
<Dialog
|
||||
@@ -29,7 +31,7 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const ok = await run(
|
||||
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }),
|
||||
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host.trim(), ipv6_host: ipv6Host.trim(), public_visible: visible }),
|
||||
'已保存',
|
||||
)
|
||||
if (ok) {
|
||||
@@ -50,10 +52,15 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="公开地址" hint="Probe 用它连接这个 Agent:域名或 IP">
|
||||
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)">
|
||||
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录);留空只采集 IPv4">
|
||||
<input className="input" value={ipv6Host} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="标签" hint="逗号分隔">
|
||||
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} />
|
||||
@@ -167,9 +174,9 @@ export function AgentDetail() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.probes.map((p) => (
|
||||
<tr key={p.probe_id}>
|
||||
<tr key={`${p.probe_id}~${p.ip_version}`}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)}</div>
|
||||
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)} <Chip>{p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></div>
|
||||
<div className="mono faint">{p.probe_id}</div>
|
||||
</td>
|
||||
<td>{p.version ?? '—'}</td>
|
||||
@@ -179,7 +186,7 @@ export function AgentDetail() {
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||||
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}`}>
|
||||
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}&ip_version=${p.ip_version}`}>
|
||||
<FlaskConical size={14} />
|
||||
测试
|
||||
</Link>
|
||||
@@ -209,8 +216,22 @@ export function AgentDetail() {
|
||||
<dl className="kv">
|
||||
<dt>ID</dt>
|
||||
<dd className="mono">{a.id}</dd>
|
||||
<dt>公开地址</dt>
|
||||
<dt>IPv4 地址</dt>
|
||||
<dd className="mono">{a.public_host || '—'}</dd>
|
||||
<dt>IPv6 地址</dt>
|
||||
<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,12 +205,13 @@ 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>
|
||||
) : (
|
||||
<>
|
||||
<Chip tone={p.online ? 'ok' : ''}>{p.online ? '已连面板' : '未连面板'}</Chip>
|
||||
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>
|
||||
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'}</Chip>
|
||||
</>
|
||||
)}
|
||||
<span className="mono faint">{p.id}</span>
|
||||
@@ -252,10 +254,11 @@ export function Probes() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.connections.map((c) => (
|
||||
<tr key={c.agent_id}>
|
||||
<tr key={`${c.agent_id}~${c.ip_version}`}>
|
||||
<td>
|
||||
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
|
||||
{c.agent_name}
|
||||
<Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{c.version ?? '—'}</td>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { FlaskConical, Square } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types'
|
||||
@@ -84,6 +85,7 @@ function Result({ test }: { test: TestDto }) {
|
||||
|
||||
export function Tests() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
|
||||
const plans = useQuery(() => api.get<Page<PlanDto>>('/api/public/catalog/plans'), [])
|
||||
@@ -92,16 +94,18 @@ export function Tests() {
|
||||
const active = (agents.data?.items ?? []).filter((a) => a.status === 'active')
|
||||
const agentId = params.get('agent') ?? active[0]?.id ?? ''
|
||||
const agent = active.find((a) => a.id === agentId)
|
||||
const connected = agent?.probes ?? []
|
||||
const connected = (agent?.probes ?? []).filter((p) => ipVersionOf(p.ip_version) === ipVersion)
|
||||
const probeId = params.get('probe') ?? connected[0]?.probe_id ?? ''
|
||||
const [planId, setPlanId] = useState('')
|
||||
const [selected, setSelected] = useState<string>()
|
||||
const line = agentId && probeId ? `${agentId}~${probeId}` : ''
|
||||
const line = agentId && probeId ? `${agentId}~${probeId}${ipVersion === 'ipv6' ? '~ipv6' : ''}` : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id)
|
||||
}, [plans.data, planId])
|
||||
|
||||
useEffect(() => { setSelected(undefined) }, [line])
|
||||
|
||||
const tests = useQuery(() => (line ? api.get<Page<TestDto>>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000)
|
||||
const detail = useQuery(() => (selected ? api.get<TestDto>(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000)
|
||||
const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id
|
||||
@@ -118,6 +122,7 @@ export function Tests() {
|
||||
<>
|
||||
<header>
|
||||
<h1>按需测试</h1>
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => set({ ip_version: v })} />
|
||||
<span className="right">{agents.loading && !agents.data ? <Spinner /> : null}</span>
|
||||
</header>
|
||||
<ErrorNotice error={agents.error ?? plans.error ?? tests.error} />
|
||||
@@ -190,7 +195,7 @@ export function Tests() {
|
||||
) : (
|
||||
<table className="table hover">
|
||||
<tbody>
|
||||
{tests.data?.items.map((t) => (
|
||||
{tests.data?.items.filter((t) => t.agent_id === agentId && t.probe_id === probeId && ipVersionOf(t.ip_version) === ipVersion).map((t) => (
|
||||
<tr key={t.id} onClick={() => setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}>
|
||||
<td>
|
||||
<Chip tone={STATUS[t.status]?.tone ?? ''}>{STATUS[t.status]?.label ?? t.status}</Chip>
|
||||
@@ -221,7 +226,7 @@ export function Tests() {
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">结果</div>
|
||||
{detail.data ? (
|
||||
{detail.data && detail.data.id === selected && ipVersionOf(detail.data.ip_version) === ipVersion ? (
|
||||
<>
|
||||
<p className="faint" style={{ marginBottom: 12 }}>
|
||||
{detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status}
|
||||
|
||||
+12
-4
@@ -1,20 +1,28 @@
|
||||
import { BuildFooter } from './BuildFooter'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
|
||||
import { SourceButton, ThemeToggle } from '@shared/ui'
|
||||
import { Compare } from './pages/Compare'
|
||||
import { Line } from './pages/Line'
|
||||
import { Overview } from './pages/Overview'
|
||||
|
||||
function LineRoute() {
|
||||
const { id } = useParams()
|
||||
return <Line key={id} />
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const location = useLocation()
|
||||
const ipv6 = location.pathname.endsWith('~ipv6') || new URLSearchParams(location.search).get('ip_version') === 'ipv6'
|
||||
const familyQuery = ipv6 ? '?ip_version=ipv6' : ''
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="inner">
|
||||
<nav className="topnav">
|
||||
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
<NavLink to={`/${familyQuery}`} end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
总览
|
||||
</NavLink>
|
||||
<NavLink to="/compare" className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
<NavLink to={`/compare${familyQuery}`} className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
对比
|
||||
</NavLink>
|
||||
<a href="/admin/">管理</a>
|
||||
@@ -25,7 +33,7 @@ export function App() {
|
||||
</header>
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/line/:id" element={<Line />} />
|
||||
<Route path="/line/:id" element={<LineRoute />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
@@ -54,15 +55,16 @@ interface Cell {
|
||||
|
||||
export function Compare() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d'
|
||||
const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0]
|
||||
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params])
|
||||
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter((id) => id && id.endsWith('~ipv6') === (ipVersion === 'ipv6')), [params, ipVersion])
|
||||
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000)
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000)
|
||||
|
||||
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
|
||||
const matrix = useMemo(() => {
|
||||
const lines = overview.data?.lines ?? []
|
||||
const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||||
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const byProbe = new Map<string, Cell[]>()
|
||||
@@ -79,7 +81,7 @@ export function Compare() {
|
||||
byProbe.set(probeId, cells)
|
||||
}
|
||||
return { probes, agents, byProbe }
|
||||
}, [overview.data, metric])
|
||||
}, [overview.data, metric, ipVersion])
|
||||
|
||||
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
|
||||
const defaults = useMemo(() => {
|
||||
@@ -123,6 +125,7 @@ export function Compare() {
|
||||
<h1>线路对比</h1>
|
||||
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
|
||||
<div className="toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => update({ ip_version: v, lines: null })} />
|
||||
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
|
||||
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
|
||||
{METRICS.map((x) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LineDto, TestDto } from '@shared/api/types'
|
||||
@@ -245,7 +246,7 @@ export function Line() {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="crumbs">
|
||||
<Link to="/" className="row">
|
||||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||||
<ChevronLeft size={16} />
|
||||
总览
|
||||
</Link>
|
||||
@@ -256,6 +257,18 @@ export function Line() {
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
{IP_VERSIONS.map((v) => {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('ip_version', v.value)
|
||||
return (
|
||||
<Link key={v.value} className={`btn small${(id.endsWith('~ipv6') ? 'ipv6' : 'ipv4') === v.value ? ' primary' : ''}`} to={`/line/${encodeURIComponent(lineIdFor(id, v.value))}?${query.toString()}`}>
|
||||
{v.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
||||
</div>
|
||||
{line.data ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
|
||||
@@ -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,6 +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 { 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(',')
|
||||
@@ -153,6 +154,8 @@ export function Overview() {
|
||||
const infos = useNodeInfos()
|
||||
// 近多久的汇总和走势: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(
|
||||
@@ -164,12 +167,13 @@ export function Overview() {
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
|
||||
|
||||
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
|
||||
const cards = useMemo(() => {
|
||||
const byAgent = new Map<string, AgentCard>()
|
||||
for (const l of data?.lines ?? []) {
|
||||
if (ipVersionOf(l.ip_version) !== ipVersion) continue
|
||||
if (probe && l.probe_name !== probe) continue
|
||||
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
|
||||
card.lines.push(l)
|
||||
@@ -190,7 +194,7 @@ export function Overview() {
|
||||
if (y === null) return -1
|
||||
return x - y
|
||||
})
|
||||
}, [data, probe, sort])
|
||||
}, [data, probe, sort, ipVersion])
|
||||
|
||||
const online = cards.filter((c) => c.online).length
|
||||
|
||||
@@ -203,6 +207,7 @@ export function Overview() {
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
</p>
|
||||
<div className="toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setProbe(''); setParams(next, { replace: true }) }} />
|
||||
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
|
||||
<span className="right row">
|
||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
||||
@@ -225,7 +230,8 @@ export function Overview() {
|
||||
{data ? (
|
||||
cards.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<Empty>还没有线路</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">
|
||||
|
||||
+278
-1
@@ -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;
|
||||
@@ -1007,12 +1059,24 @@ export interface components {
|
||||
created_at: unknown;
|
||||
/** Format: date-time */
|
||||
last_seen_at: unknown;
|
||||
/** @default */
|
||||
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;
|
||||
labels: null | string[];
|
||||
public_host: null | string;
|
||||
public_visible: null | boolean;
|
||||
ipv6_host?: null | string;
|
||||
};
|
||||
ApiTokenDto: {
|
||||
id: string;
|
||||
@@ -1025,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;
|
||||
@@ -1041,6 +1114,7 @@ export interface components {
|
||||
BuildInfoDto: {
|
||||
backend: components["schemas"]["BuildDto"];
|
||||
nodes: components["schemas"]["NodeBuildDto"][];
|
||||
backend_update?: null | components["schemas"]["BackendUpdateDto"];
|
||||
};
|
||||
ChangePasswordRequest: {
|
||||
password: string;
|
||||
@@ -1069,12 +1143,18 @@ export interface components {
|
||||
[key: string]: null | number | string;
|
||||
};
|
||||
};
|
||||
ConfirmBackendUpdateRequest: {
|
||||
/** Format: int64 */
|
||||
seq: number | string;
|
||||
};
|
||||
ConnectedProbeDto: {
|
||||
probe_id: string;
|
||||
version: null | string;
|
||||
remote: null | string;
|
||||
/** Format: date-time */
|
||||
connected_since: unknown;
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
CreateApiTokenRequest: {
|
||||
name: string;
|
||||
@@ -1246,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;
|
||||
@@ -1285,6 +1377,10 @@ export interface components {
|
||||
data_since: unknown;
|
||||
kinds: string[];
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
/** @default false */
|
||||
ipv6_available: boolean;
|
||||
};
|
||||
LinkDto: {
|
||||
probe_id: string;
|
||||
@@ -1408,11 +1504,17 @@ export interface components {
|
||||
endpoint_changes: number | string;
|
||||
summary: components["schemas"]["SummaryDto"];
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
PageDtoOfAgentDto: {
|
||||
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;
|
||||
@@ -1527,6 +1629,8 @@ export interface components {
|
||||
remote: null | string;
|
||||
/** Format: date-time */
|
||||
connected_since: unknown;
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
ProbeDto: {
|
||||
id: string;
|
||||
@@ -1538,6 +1642,7 @@ export interface components {
|
||||
revoked: boolean;
|
||||
online: boolean;
|
||||
connections: components["schemas"]["ProbeConnectionDto"][];
|
||||
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
|
||||
};
|
||||
ProbeIdentityDto: {
|
||||
probe_id: string;
|
||||
@@ -1609,6 +1714,8 @@ export interface components {
|
||||
tag: string;
|
||||
version: string;
|
||||
digest: string;
|
||||
/** @default release */
|
||||
channel: string;
|
||||
};
|
||||
ScoreDto: {
|
||||
/** Format: double */
|
||||
@@ -1642,6 +1749,8 @@ export interface components {
|
||||
versions: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
SetLinkRequest: {
|
||||
enabled: null | boolean;
|
||||
@@ -1702,6 +1811,8 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
finished_at: unknown;
|
||||
result: null | components["schemas"]["TestResultDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
TestResultDto: {
|
||||
metrics: {
|
||||
@@ -1762,6 +1873,7 @@ export interface operations {
|
||||
metrics?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
ip_version?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -1816,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;
|
||||
@@ -2191,7 +2332,9 @@ export interface operations {
|
||||
};
|
||||
live_stream: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
ip_version?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
@@ -4812,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']>
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user