agent: unify listening and dialing with directed mutual measurements

Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -4,10 +4,10 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
/// <param name="Role">节点安装角色,统一为 <c>agent</c>,默认监听并拨号。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows, string Openwrt = "");
/// <summary>
@@ -20,7 +20,7 @@ public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controll
/// <summary>
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;监听模式的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
/// </summary>
public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
@@ -36,12 +36,6 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst
public sealed class JoinTokenService
{
public const string AgentRole = "agent";
public const string ProbeRole = "probe";
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
private readonly IJoinTokenStore _store;
private readonly BackendOptions _options;
@@ -57,9 +51,9 @@ public sealed class JoinTokenService
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (request.Role is not (AgentRole or ProbeRole))
if (request.Role != AgentRole)
{
errors["role"] = "must be agent or probe";
errors["role"] = "must be agent";
}
if (request.MaxUses is < 1 or > 100_000)
@@ -90,7 +84,7 @@ public sealed class JoinTokenService
string plaintext = TokenHash.NewToken("jt");
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, "",
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Quadlet(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
@@ -110,7 +104,7 @@ public sealed class JoinTokenService
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)",
role == ProbeRole && plan.Genesis is not null
plan.Genesis is not null
? $"f=$(mktemp) && (curl -fsS '{baseUrl}/install/openwrt.sh' -o \"$f\" || uclient-fetch -q -O \"$f\" '{baseUrl}/install/openwrt.sh') && echo \"{plan.OpenwrtSha256} $f\" | sha256sum -c - >/dev/null && {env} MOSHIRO_CHANNEL='{plan.NativeChannel}' sh \"$f\" install"
: "");
}
@@ -136,7 +130,7 @@ public sealed class JoinTokenService
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
}
lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}");
lines.Add($"MOSHIRO_IMAGE={ImageRepository()}");
if (desktop)
{
// Docker Desktop 没有宿主网络
@@ -149,14 +143,8 @@ public sealed class JoinTokenService
string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n"
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d";
string macos = role == ProbeRole
? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n"
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d"
: "";
string windows = role == ProbeRole
? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n"
+ $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d"
: "";
string macos = "";
string windows = "";
return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows);
}
@@ -184,36 +172,34 @@ public sealed class JoinTokenService
string unit = $"$HOME/.config/containers/systemd/moshiro-{role}.container";
string command = $"mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-{role}.container.d && cat > ~/.config/moshiro/{role}.env <<'EOF'\n{env}\nEOF\n"
+ $"chmod 600 ~/.config/moshiro/{role}.env && curl -fsSL '{url}' -o \"{unit}\" && echo \"{plan.QuadletSha256} {unit}\" | sha256sum -c - "
+ $"&& printf '[Container]\\nImage={ImageRepository(role)}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf "
+ $"&& printf '[Container]\\nImage={ImageRepository()}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf "
+ $"&& systemctl --user daemon-reload && systemctl --user start moshiro-{role}";
return new QuadletInstall(env, url, plan.QuadletSha256, command);
}
/// <summary>这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
private string ImageRepository(string role)
/// <summary>节点镜像的仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
private string ImageRepository()
{
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}";
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-agent";
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
/// <summary>
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
/// 这个一次性码现在还能用吗:安装脚本据此向面板取已签名的发布清单。
/// 代码不存在、已吊销、已过期或用完了返回 false(不消耗它)。
/// </summary>
public async ValueTask<string?> RoleOfCodeAsync(string code, CancellationToken ct)
public async ValueTask<bool> IsUsableAsync(string code, CancellationToken ct)
{
if (code.Length is 0 or > 200)
{
return null;
return false;
}
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
? null
: RoleOf(token.Kind);
return token is not null && token.RevokedAtMs is null && token.ExpiresAtMs > _time.GetUtcNow().ToUnixTimeMilliseconds() && token.Uses < token.MaxUses;
}
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
@@ -46,9 +46,9 @@ public sealed class SettingsService
private static IReadOnlyList<SettingSpec> Known(BackendOptions o) =>
[
new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."),
new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend, agents and probes."),
new(AutoLink, "bool", "true", 0, 1, "Whether every probe connects to every agent automatically; when off, only the links chosen by hand are connected."),
new(RestartEnabled, "bool", "true", 0, 1, "Whether agents and probes restart themselves once a day (they exit in order and the service manager starts them again); a local restart_at on the node wins."),
new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend and the agents."),
new(AutoLink, "bool", "true", 0, 1, "Whether every node that does not listen connects to every listening node automatically; when off, only the links chosen by hand are connected. Links between listening nodes are always manual."),
new(RestartEnabled, "bool", "true", 0, 1, "Whether agents restart themselves once a day (they exit in order and the service manager starts them again); a local restart_at on the node wins."),
new(RestartHour, "int", "4", 0, 23, "Hour of the daily restart, in the time zone below."),
new(RestartMinute, "int", "30", 0, 59, "Minute of the daily restart."),
new(RestartOffsetMinutes, "int", "480", -840, 840, "Time zone of the daily restart as minutes east of UTC (480 = Beijing)."),
@@ -3,7 +3,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary><c>moshiro.event</c>:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。</summary>
/// <summary><c>moshiro.event</c>:节点报告的事件(拨号方连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。</summary>
public sealed class AgentEventService
{
private readonly AgentRuntime _runtime;
@@ -33,12 +33,12 @@ public sealed class AgentEventService
AgentRuntimeState state = _runtime.Get(agent);
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, p.IpVersion) : new ConnectedProbeInfo(e.Detail, null, null, at));
_nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote);
case AgentEventKinds.DialerConnected when !string.IsNullOrEmpty(e.Detail):
state.AddDialer(e.Dialer is { } p ? new ConnectedDialerInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.IpVersion) : new ConnectedDialerInfo(e.Detail, null, null, at));
_nodes.ObserveDialerInBackground(e.Detail, e.Dialer?.Remote);
break;
case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail):
state.RemoveProbe(e.Detail, e.Probe?.IpVersion ?? IpVersions.V4);
case AgentEventKinds.DialerDisconnected when !string.IsNullOrEmpty(e.Detail):
state.RemoveDialer(e.Detail, e.Dialer?.IpVersion ?? IpVersions.V4);
break;
case AgentEventKinds.EndpointChanged:
state.CountEndpointChange();
@@ -4,58 +4,58 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。</summary>
public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
/// <summary>监听方眼中的一个已连接拨号方(监听方在事件和心跳里上报)。</summary>
public sealed record ConnectedDialerInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
/// <summary>Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。</summary>
/// <summary>节点的运行时状态(只在内存中):连着它的拨号方、最近的心跳、进程内计数、端点变化次数。</summary>
public sealed class AgentRuntimeState
{
private readonly object _gate = new();
private int _endpointChanges;
private IReadOnlyList<ConnectedProbeInfo> _probes = [];
private IReadOnlyList<ConnectedDialerInfo> _dialers = [];
private string? _lastDisconnectReason;
/// <summary>当前连着这个 Agent 的 Probe(每个 Probe 一项)。</summary>
public IReadOnlyList<ConnectedProbeInfo> Probes => Volatile.Read(ref _probes);
/// <summary>当前连着这个节点的拨号方(每个拨号方每个 IP 族一项)。</summary>
public IReadOnlyList<ConnectedDialerInfo> Dialers => Volatile.Read(ref _dialers);
public bool ProbeConnected => Probes.Count > 0;
public bool DialerConnected => Dialers.Count > 0;
public bool IsConnected(string probeId, string ipVersion = IpVersions.V4) => Probes.Any(p => p.Id == probeId && p.IpVersion == ipVersion);
public bool IsConnected(string dialerId, string ipVersion = IpVersions.V4) => Dialers.Any(p => p.Id == dialerId && p.IpVersion == ipVersion);
/// <summary>事件 <c>probe_connected</c>:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。</summary>
public void AddProbe(ConnectedProbeInfo info)
/// <summary>事件 <c>dialer_connected</c>:事件里带着拨号方的版本和地址;心跳里的完整快照随后会再校正一次。</summary>
public void AddDialer(ConnectedDialerInfo info)
{
lock (_gate)
{
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]);
Volatile.Write(ref _dialers, [.. _dialers.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]);
}
}
public void RemoveProbe(string id, string ipVersion = IpVersions.V4)
public void RemoveDialer(string id, string ipVersion = IpVersions.V4)
{
lock (_gate)
{
Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id || p.IpVersion != ipVersion)]);
Volatile.Write(ref _dialers, [.. _dialers.Where(p => p.Id != id || p.IpVersion != ipVersion)]);
}
}
/// <summary>心跳里的完整快照:覆盖事件推算出的结果。</summary>
public void ReplaceProbes(IEnumerable<ConnectedProbeInfo> probes)
public void ReplaceDialers(IEnumerable<ConnectedDialerInfo> dialers)
{
lock (_gate)
{
Volatile.Write(ref _probes, [.. probes.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]);
Volatile.Write(ref _dialers, [.. dialers.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]);
}
}
public void ClearProbes() => ReplaceProbes([]);
public void ClearDialers() => ReplaceDialers([]);
/// <summary>最新的主机资源占用快照,以及收到它的时间。</summary>
public HostSnapshot? Host { get; set; }
public long HostReceivedAtMs { get; set; }
public AgentHeartbeatExtra? LastHeartbeat { get; set; }
public NodeHeartbeatExtra? LastHeartbeat { get; set; }
public long LastHeartbeatAtMs { get; set; }
@@ -85,17 +85,17 @@ public sealed class AgentRuntime
public void Remove(NodeId agent) => _states.TryRemove(agent, out _);
/// <summary>某个 Probe 现在连着哪些 Agent。</summary>
public IReadOnlyList<(NodeId Agent, ConnectedProbeInfo Probe)> ConnectionsOf(string probeId)
/// <summary>某个拨号方现在连着哪些监听节点。</summary>
public IReadOnlyList<(NodeId Listener, ConnectedDialerInfo Dialer)> ConnectionsOf(string dialerId)
{
var result = new List<(NodeId, ConnectedProbeInfo)>();
foreach ((NodeId agent, AgentRuntimeState state) in _states)
var result = new List<(NodeId, ConnectedDialerInfo)>();
foreach ((NodeId listener, AgentRuntimeState state) in _states)
{
foreach (ConnectedProbeInfo probe in state.Probes)
foreach (ConnectedDialerInfo dialer in state.Dialers)
{
if (probe.Id == probeId)
if (dialer.Id == dialerId)
{
result.Add((agent, probe));
result.Add((listener, dialer));
}
}
}
@@ -103,12 +103,12 @@ public sealed class AgentRuntime
return result;
}
/// <summary>Agent 断开时,它对 Probe 会话的认知也随之失效。</summary>
/// <summary>节点断开时,它对拨号方会话的认知也随之失效。</summary>
public void MarkOffline(NodeId agent)
{
if (_states.TryGetValue(agent, out AgentRuntimeState? state))
{
state.ClearProbes();
state.ClearDialers();
}
}
}
@@ -7,10 +7,15 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null)
/// <summary>本节点拨出的一条连接:监听方现在认的这个拨号方会话(由监听方上报)。</summary>
public sealed record OutgoingConnection(NodeId ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
/// <param name="Dialers">现在连着这个节点的拨号方(监听方上报)。</param>
/// <param name="Outgoing">这个节点现在连着的监听节点(监听方上报)。</param>
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedDialerInfo> Dialers, IReadOnlyList<OutgoingConnection> Outgoing, NodeHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null)
{
/// <summary>至少有一个 Probe 连着。</summary>
public bool ProbeConnected => Probes.Count > 0;
/// <summary>至少有一个拨号方连着。</summary>
public bool DialerConnected => Dialers.Count > 0;
public NodeRuntimeStatus RuntimeStatus
{
@@ -49,7 +54,7 @@ public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<Con
}
}
/// <summary>Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。</summary>
/// <summary>节点的管理:列表、修改、审批、启停、接管、删除,以及每个节点要拨出的目标列表。</summary>
public sealed partial class AgentService
{
private readonly IAgentStore _agents;
@@ -80,20 +85,40 @@ public sealed partial class AgentService
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
{
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
return [.. agents.Select(View)];
return [.. agents.Select(a => View(a, agents))];
}
public async ValueTask<AgentView?> GetAsync(NodeId id, CancellationToken ct) =>
await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null;
await _agents.GetAsync(id, ct) is { } agent ? View(agent, await _agents.ListAsync(ct)) : null;
public AgentView View(AgentRecord agent)
public AgentView View(AgentRecord agent, IReadOnlyList<AgentRecord> all)
{
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason);
var outgoing = new List<OutgoingConnection>();
foreach ((NodeId listenerId, ConnectedDialerInfo info) in _runtime.ConnectionsOf(agent.Id.Value))
{
if (all.FirstOrDefault(a => a.Id == listenerId) is { } listener)
{
outgoing.Add(new OutgoingConnection(listenerId, listener.Name, _gateway.IsOnline(listenerId), info.Version, info.Remote, info.SinceMs, info.IpVersion));
}
}
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Dialers ?? [], [.. outgoing.OrderBy(c => c.ListenerName, StringComparer.Ordinal)], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason);
}
public ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) =>
_gateway.NotifyProbeLinkRetryAsync(probe, agent, ct);
/// <summary>让监听方断开某个拨号方的会话(拨号方按退避重连,除非它被禁用)。</summary>
public async ValueTask<Result<bool>> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct)
{
if (await _agents.GetAsync(dialer, ct) is null || await _agents.GetAsync(listener, ct) is null)
{
return Result<bool>.Fail(MoShiroErrors.AgentNotFound, "node not found");
}
return await _gateway.DisconnectDialerAsync(listener, dialer, ct);
}
public ValueTask<bool> NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) =>
_gateway.NotifyLinkRetryAsync(dialer, listener, ct);
private static bool ValidHost(string host, string ipVersion)
{
@@ -152,7 +177,7 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
}
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public ValueTask<Result<AgentView>> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
@@ -181,7 +206,7 @@ public sealed partial class AgentService
// 公钥换了,旧连接使用的是旧密钥
await _gateway.DisconnectAsync(agent.Id, "key replaced", ct);
await AfterAgentSetChangedAsync(ct);
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public async ValueTask DeleteAsync(NodeId id, CancellationToken ct)
@@ -195,26 +220,24 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
}
/// <summary>这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。</summary>
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(string probeId, CancellationToken ct)
/// <summary>这个节点要拨出的监听节点列表(激活、可拨号,并且线路是开着的)。版本号在列表变化时递增。</summary>
public async ValueTask<DialTargets> DialTargetsAsync(AgentRecord dialer, CancellationToken ct)
{
LinkSnapshot links = await _links.SnapshotAsync(ct);
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
var result = new ProbeAgentList { Version = version };
// 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value)))
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.DialTargetsVersionKey, ct), out long v) ? v : 0;
var result = new DialTargets { Version = version };
foreach (AgentRecord listener in agents.Where(a => a.Dialable && links.IsLinked(dialer, a)))
{
result.Agents.Add(new AgentEndpoint
result.Targets.Add(new DialTarget
{
AgentId = agent.Id,
Name = agent.Name,
Host = agent.PublicHost,
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
QuicPorts = [.. agent.ProbeUdpPorts],
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
NodeId = listener.Id,
Name = listener.Name,
Host = listener.PublicHost,
Ipv6Host = listener.Ipv6Supported && listener.Ipv6Host.Length > 0 ? listener.Ipv6Host : null,
TcpPort = listener.TcpPort,
QuicPorts = [.. listener.QuicPorts],
PublicKey = Convert.ToBase64String(listener.TlsPublicKey!),
});
}
@@ -224,10 +247,10 @@ public sealed partial class AgentService
return result;
}
/// <summary>Agent 集合变了:递增列表版本,重算各 Agent 的期望状态,并把新的 Agent 列表推给 Probe(都是期望状态,一起通知)。</summary>
/// <summary>节点集合或线路变了:递增拨号目标版本,重算各节点的期望状态(任务集和拨号目标都是期望状态,一起通知)。</summary>
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
{
await _planner.BumpProbeAgentsVersionAsync(ct);
await _planner.BumpDialTargetsVersionAsync(ct);
_gateway.NotifyAllTaskSetsChanged();
}
@@ -241,7 +264,7 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
_gateway.NotifyTaskSetChanged(id);
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
@@ -4,7 +4,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// <c>moshiro.host</c>:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
/// <c>moshiro.host</c>:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。
/// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。
/// </summary>
public sealed class HostStatusService
@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record ReportedLink(LinkHealth Health, long AtMs);
/// <summary>Latest per-line control and task health reported by dialing nodes. Volatile runtime state, not historical metrics.</summary>
public sealed class LinkRuntime
{
private readonly ConcurrentDictionary<(string Dialer, string Listener, string IpVersion), ReportedLink> _links = new();
public ReportedLink? Get(string dialerId, string listenerId, string ipVersion) =>
_links.TryGetValue((dialerId, listenerId, ipVersion), out ReportedLink? snapshot) ? snapshot : null;
public void Replace(string dialerId, long atMs, IReadOnlyList<LinkHealth> links)
{
var current = new HashSet<(string Dialer, string Listener, string IpVersion)>();
foreach (LinkHealth link in links)
{
var key = (dialerId, link.ListenerId, link.IpVersion);
current.Add(key);
_links[key] = new ReportedLink(link, atMs);
}
foreach ((string Dialer, string Listener, string IpVersion) key in _links.Keys.Where(k => k.Dialer == dialerId && !current.Contains(k)))
{
_links.TryRemove(key, out _);
}
}
/// <summary>拨号方与 Backend 的连接中断:它到各监听方的线路状态不再可知,按控制层退避显示,保留最后一次的统计。</summary>
public void MarkDialerOffline(string dialerId, long atMs)
{
foreach (KeyValuePair<(string Dialer, string Listener, string IpVersion), ReportedLink> pair in _links.ToArray().Where(p => p.Key.Dialer == dialerId))
{
LinkHealth old = pair.Value.Health;
old.Control.State = ControlState.Backoff;
old.Control.SinceMs = atMs;
old.Control.LastError = new LinkFault { Phase = "backend", Kind = "node_offline", Message = "节点与 Backend 的连接中断" };
old.Control.RetryInMs = null;
old.Task.Quic.State = QuicState.Idle;
old.Task.Quic.RetryInMs = null;
_links[pair.Key] = new ReportedLink(old, atMs);
}
}
}
@@ -1,30 +1,31 @@
namespace MoShiro.Backend.Application;
/// <summary>某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。</summary>
/// <summary>某一时刻的「线路」开关:自动连接的总开关,加上每一对(拨号方,监听方)的手动设置。</summary>
public sealed class LinkSnapshot
{
private readonly Dictionary<(string Probe, string Agent), bool> _overrides;
private readonly Dictionary<(string Dialer, string Listener), bool> _overrides;
public LinkSnapshot(bool auto, Dictionary<(string Probe, string Agent), bool> overrides)
public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides)
{
Auto = auto;
_overrides = overrides;
}
/// <summary>自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。</summary>
/// <summary>自动连接:开着时,所有节点自动拨其他监听节点;关着时,只有手动开启的才连。两个监听节点默认双向互连。</summary>
public bool Auto { get; }
/// <summary>手动设置:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 没设置(跟随自动连接)。</summary>
public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null;
public bool? OverrideOf(string dialerId, string listenerId) => _overrides.TryGetValue((dialerId, listenerId), out bool value) ? value : null;
/// <summary>这一对最终连不连:手动设置优先,没有就看自动连接。</summary>
public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto;
public bool IsLinked(AgentRecord dialer, AgentRecord listener) =>
dialer.Id != listener.Id && (OverrideOf(dialer.Id.Value, listener.Id.Value) ?? Auto);
}
/// <summary>
/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认),
/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(<c>link.&lt;probe&gt;.&lt;agent&gt;</c>,值 1 或 0,空表示没设置)。
/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:自动连接开着时,所有节点拨其他监听节点(默认),
/// 关着时只连手动选中的;每一对还可以手动强制开或关,每个方向的线路独立设置。手动设置存在设置表里(<c>link.&lt;拨号方&gt;.&lt;监听方&gt;</c>,值 1 或 0,空表示没设置)。
/// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(<c>peers</c>)和线路目录里;改动之后要调用 <see cref="AgentService.AfterAgentSetChangedAsync"/> 重新下发。
/// </summary>
public sealed class LinkService
{
@@ -54,10 +55,10 @@ public sealed class LinkService
}
/// <summary>手动设置一对线路:<c>true</c> 强制连、<c>false</c> 强制不连、<c>null</c> 取消手动设置。</summary>
public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) =>
_settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
public ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct) =>
_settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct);
/// <summary>Probe 或 Agent 被删除:清掉所有涉及它的手动设置。</summary>
/// <summary>节点被删除:清掉所有涉及它的手动设置。</summary>
public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct)
{
foreach (string key in (await _settings.GetAllAsync(ct)).Keys.Where(k => k.StartsWith(Prefix, StringComparison.Ordinal) && k.Split('.').Skip(1).Contains(nodeId)))
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口。</summary>
/// <summary>MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口(不监听的节点没有)。</summary>
public sealed class MoShiroEnrollmentHook : IEnrollmentHook
{
private readonly IAgentStore _agents;
@@ -20,19 +20,12 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
{
if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
// Probe:新的对等面公钥要进每个 Agent 的名单,Probe 自己也要拿到 Agent 列表
await _service.AfterAgentSetChangedAsync(ct);
return;
}
AgentEnrollMeta meta = new();
NodeEnrollMeta meta = new();
if (context.ProductMeta is { } raw)
{
try
{
meta = raw.Deserialize(MoShiroJsonContext.Default.AgentEnrollMeta) ?? meta;
meta = raw.Deserialize(MoShiroJsonContext.Default.NodeEnrollMeta) ?? meta;
}
catch (JsonException)
{
@@ -41,10 +34,11 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
}
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
string host = meta.TcpPort <= 0 ? ""
: !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct);
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.TcpPort, meta.QuicPorts, meta.Kinds), ct);
if (meta.TcpPort > 0 && await _agents.GetAsync(node.Id, ct) is { } enrolled)
{
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
await _agents.ApplyAutoAddressesAsync(node.Id, null, observedV6, ct);
@@ -57,7 +51,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
}
}
/// <summary>节点上线、下线、hello、心跳:更新运行时状态,补全 Agent 信息,并推送给 SSE。</summary>
/// <summary>节点上线、下线、hello、心跳:更新运行时状态,补全节点信息,并推送给 SSE。</summary>
public sealed class MoShiroNodeEventSink : INodeEventSink
{
private readonly IAgentStore _agents;
@@ -68,21 +62,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly NodeInfoService _nodes;
private readonly ReleaseService _releases;
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
private readonly ProbeQuicRuntime _quicRuntime;
private readonly ProbeRuntime _probeRuntime;
private readonly LinkRuntime _linkRuntime;
private readonly NodeRuntimeLogService _nodeLogs;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime, ProbeRuntime probeRuntime, NodeRuntimeLogService nodeLogs)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, NodeOwnerService owners, LinkRuntime linkRuntime, NodeRuntimeLogService nodeLogs)
{
_nodeLogs = nodeLogs;
_owners = owners;
_quicRuntime = quicRuntime;
_probeRuntime = probeRuntime;
_linkRuntime = linkRuntime;
_releases = releases;
_schedule = schedule;
_probes = probes;
_nodes = nodes;
_agents = agents;
_runtime = runtime;
@@ -94,12 +84,6 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct)
{
NodeId id = nodeEvent.Node.Id;
if (id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
await OnProbeEventAsync(id, nodeEvent, ct);
return;
}
switch (nodeEvent.Kind)
{
case NodeEventKind.Online:
@@ -111,6 +95,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
_runtime.Get(id).MarkOffline(nodeEvent.Reason);
_runtime.MarkOffline(id);
_releases.RecordStatus(id, null);
_linkRuntime.MarkDialerOffline(id.Value, nodeEvent.AtMs);
_live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs));
await _events.AddAsync(id, "agent_offline", nodeEvent.AtMs, null, ct);
break;
@@ -118,112 +103,60 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
await ApplyHelloAsync(id, hello, nodeEvent.RemoteIp, ct);
break;
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
await _nodes.SetVersionAsync(id.Value, NodeKinds.Agent, nodeEvent.Node.NodeVersion, ct);
if (_nodes.ShouldRetryGeo(id.Value) && await _agents.GetAsync(id, ct) is { } unlocated)
{
_nodes.ObserveAgentInBackground(id.Value, GeoHostOf(unlocated, nodeEvent.RemoteIp), null);
}
try
{
AgentRuntimeState state = _runtime.Get(id);
AgentHeartbeatExtra? beat = extra.Deserialize(MoShiroJsonContext.Default.AgentHeartbeatExtra);
state.LastHeartbeat = beat;
state.LastHeartbeatAtMs = nodeEvent.AtMs;
_releases.RecordStatus(id, beat?.Update);
if (beat is not null)
{
_nodeLogs.Append(id, beat.RuntimeLogs, beat.DroppedRuntimeLogs ?? 0);
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);
}
}
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
await ApplyHeartbeatAsync(id, nodeEvent, extra, ct);
break;
}
}
private async ValueTask ApplyHeartbeatAsync(NodeId id, NodeEvent nodeEvent, System.Text.Json.JsonElement extra, CancellationToken ct)
{
await _nodes.SetVersionAsync(id.Value, nodeEvent.Node.NodeVersion, ct);
if (_nodes.ShouldRetryGeo(id.Value) && await _agents.GetAsync(id, ct) is { } unlocated)
{
_nodes.ObserveAgentInBackground(id.Value, GeoHostOf(unlocated, nodeEvent.RemoteIp), null);
}
try
{
AgentRuntimeState state = _runtime.Get(id);
NodeHeartbeatExtra? beat = extra.Deserialize(MoShiroJsonContext.Default.NodeHeartbeatExtra);
state.LastHeartbeat = beat;
state.LastHeartbeatAtMs = nodeEvent.AtMs;
_releases.RecordStatus(id, beat?.Update);
if (beat is null)
{
return;
}
_nodeLogs.Append(id, beat.RuntimeLogs, beat.DroppedRuntimeLogs ?? 0);
state.ReplaceDialers(beat.Dialers.Select(d => new ConnectedDialerInfo(d.Id.Value, d.Version, d.Remote, d.SinceUtcMs, d.IpVersion)));
foreach (ConnectedDialer dialer in beat.Dialers)
{
_nodes.ObserveDialerInBackground(dialer.Id.Value, dialer.Remote);
}
if (beat.Links is { } links)
{
_linkRuntime.Replace(id.Value, nodeEvent.AtMs, links);
}
if (beat.Ipv6 is { } ipv6)
{
await _agents.SetIpv6DiagnosticsAsync(id, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
}
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
}
/// <summary>查国家和运营商用哪个地址:对外地址优先,其次 IPv6 地址,都没有(NAT 后面、或者没有上报公网地址)才用这条连接的来源地址。</summary>
private static string? GeoHostOf(AgentRecord? agent, string? remoteIp) =>
!string.IsNullOrEmpty(agent?.PublicHost) ? agent.PublicHost
: !string.IsNullOrEmpty(agent?.Ipv6Host) ? agent.Ipv6Host
: remoteIp;
/// <summary>Probe 自己连上来了:hello 里带着它的系统、架构和构建信息;上线下线只推给 SSE,不记事件。</summary>
private async ValueTask OnProbeEventAsync(NodeId id, NodeEvent nodeEvent, CancellationToken ct)
{
switch (nodeEvent.Kind)
{
case NodeEventKind.Online:
_probeRuntime.MarkOnline(id.Value);
_live.Publish(new NodeSignal("probe", id.Value, true, nodeEvent.AtMs));
break;
case NodeEventKind.Hello:
if (nodeEvent.Hello?.Meta is { } raw)
{
try
{
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
_owners.Report(id.Value, meta?.Owners);
if (meta?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
}
}
catch (JsonException)
{
// 格式不对就忽略
}
}
await _nodes.SetVersionAsync(id.Value, NodeKinds.Probe, nodeEvent.Node.NodeVersion, ct);
await _releases.OfferToNodeAsync(id, ct);
await _schedule.SendToNodeAsync(id, ct);
break;
case NodeEventKind.Offline:
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
_releases.RecordStatus(id, null);
_probeRuntime.MarkOffline(id.Value, nodeEvent.Reason);
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
break;
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
await _nodes.SetVersionAsync(id.Value, NodeKinds.Probe, nodeEvent.Node.NodeVersion, ct);
try
{
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
_releases.RecordStatus(id, heartbeat?.Update);
if (heartbeat is not null)
{
_nodeLogs.Append(id, heartbeat.RuntimeLogs, heartbeat.DroppedRuntimeLogs ?? 0);
_probeRuntime.RecordHeartbeat(id.Value, nodeEvent.AtMs, heartbeat);
}
if (heartbeat?.QuicLinks is { } quicLinks)
{
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
}
if (heartbeat?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
}
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
break;
}
}
private async ValueTask ApplyHelloAsync(NodeId id, HelloParams hello, string? remoteIp, CancellationToken ct)
{
var kinds = new List<string>();
@@ -235,12 +168,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
AgentHelloMeta meta = new();
NodeHelloMeta meta = new();
if (hello.Meta is { } raw)
{
try
{
meta = raw.Deserialize(MoShiroJsonContext.Default.AgentHelloMeta) ?? meta;
meta = raw.Deserialize(MoShiroJsonContext.Default.NodeHelloMeta) ?? meta;
}
catch (JsonException)
{
@@ -248,53 +181,33 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
// Agent 上报 TLS 证书的公钥:指纹由面板自己算,不信节点报的指纹
byte[]? tlsKey = null;
byte[]? spki = null;
if (meta.TlsPublicKey is not null)
{
try
{
byte[] candidate = Convert.FromBase64String(meta.TlsPublicKey);
if (Fleet.Identity.NodeIdentity.IsValidPublicKey(candidate))
{
tlsKey = candidate;
spki = Fleet.Identity.NodeIdentity.Fingerprint(candidate);
}
}
catch (FormatException)
{
// 忽略
}
}
_owners.Report(id.Value, meta.Owners);
if (meta.Platform is { } platform)
{
platform.Version = hello.NodeVersion;
}
await _nodes.SetVersionAsync(id.Value, NodeKinds.Agent, hello.NodeVersion, ct);
await _nodes.SetVersionAsync(id.Value, hello.NodeVersion, ct);
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
IReadOnlyList<int>? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts;
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, meta.Listen, ipv6Json), ct);
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
// 自动识别对外地址:监听的节点上报的公网地址;手填的不动
if (meta.Listen?.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
{
(string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Addresses, null);
(string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Listen.Addresses, null);
await _agents.ApplyAutoAddressesAsync(id, publicHost, ipv6Host, ct);
}
// 端口、证书指纹或对外地址变了,Probe 需要新的列表
AgentRecord? after = await _agents.GetAsync(id, ct);
_nodes.ObserveAgentInBackground(id.Value, GeoHostOf(after, remoteIp), meta.Platform);
await _releases.OfferToNodeAsync(id, ct);
await _schedule.SendToNodeAsync(id, ct);
if (before is not null && after is not null
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || !(before.TlsPublicKey ?? []).AsSpan().SequenceEqual(after.TlsPublicKey ?? []) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host)
&& after.Status == NodeStatus.Active)
// 监听状态、端口或对外地址变了,拨号方需要新的目标列表
if (before is not null && after is not null && after.Status == NodeStatus.Active
&& (before.Listens != after.Listens || before.TcpPort != after.TcpPort || !before.QuicPorts.SequenceEqual(after.QuicPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host))
{
await _service.AfterAgentSetChangedAsync(ct);
}
@@ -6,7 +6,7 @@ namespace MoShiro.Backend.Application;
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
/// <summary>
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
/// 节点的属主:Agent 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
/// 在内存里:节点每次连上来都会重新报。
@@ -8,7 +8,7 @@ public sealed record NodeRuntimeLog(long Id, long AtUtcMs, string Level, string
public sealed record NodeRuntimeLogSnapshot(IReadOnlyList<NodeRuntimeLog> Items, long Dropped);
/// <summary>Bounded in-memory recent logs uploaded by authenticated Agent and Probe heartbeats.</summary>
/// <summary>Bounded in-memory recent logs uploaded by authenticated Agent and 拨号方 heartbeats.</summary>
public sealed class NodeRuntimeLogService(TimeProvider time)
{
private const int CapacityPerNode = 256;
@@ -1,60 +0,0 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs);
/// <summary>Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics.</summary>
public sealed class ProbeQuicRuntime
{
private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new();
public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) =>
_links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null;
public void Replace(string probeId, long atMs, IReadOnlyList<ProbeQuicLinkHealth> links)
{
var current = new HashSet<(string Probe, string Agent, string IpVersion)>();
foreach (ProbeQuicLinkHealth link in links)
{
var key = (probeId, link.AgentId, link.IpVersion);
current.Add(key);
_links[key] = new ProbeQuicLinkSnapshot(link, atMs);
}
foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k)))
{
_links.TryRemove(key, out _);
}
}
public void MarkProbeOffline(string probeId, long atMs)
{
foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId))
{
ProbeQuicLinkHealth old = pair.Value.Health;
_links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth
{
AgentId = old.AgentId,
IpVersion = old.IpVersion,
Connected = false,
State = ProbeLinkAttemptState.RetryWait,
LastError = "Probe 与 Backend 的连接中断",
RetryAttempt = old.RetryAttempt,
RetryInMs = old.RetryInMs,
HandshakeAttempts = old.HandshakeAttempts,
HandshakeSuccesses = old.HandshakeSuccesses,
HandshakeFailures = old.HandshakeFailures,
HandshakeTimeouts = old.HandshakeTimeouts,
HandshakeP95Ms = old.HandshakeP95Ms,
HandshakeSampleCount = old.HandshakeSampleCount,
TransportPacketLossRate = old.TransportPacketLossRate,
TransportPacketsSent = old.TransportPacketsSent,
TransportCongestionEvents = old.TransportCongestionEvents,
TransportEcnCongestionEvents = old.TransportEcnCongestionEvents,
PathMtu = old.PathMtu,
}, atMs);
}
}
}
@@ -1,22 +0,0 @@
using System.Collections.Concurrent;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>Volatile runtime status reported by each Probe's heartbeat and Fleet connection lifecycle.</summary>
public sealed class ProbeRuntime
{
private readonly ConcurrentDictionary<string, ProbeRuntimeSnapshot> _probes = new(StringComparer.Ordinal);
public ProbeRuntimeSnapshot? Get(string probeId) => _probes.TryGetValue(probeId, out ProbeRuntimeSnapshot? snapshot) ? snapshot : null;
public void RecordHeartbeat(string probeId, long atMs, ProbeHeartbeatExtra heartbeat) =>
_probes[probeId] = new ProbeRuntimeSnapshot(heartbeat, atMs, null);
public void MarkOnline(string probeId) => _probes[probeId] = new ProbeRuntimeSnapshot(null, 0, null);
public void MarkOffline(string probeId, string? reason) =>
_probes.AddOrUpdate(probeId, _ => new ProbeRuntimeSnapshot(null, 0, reason), (_, current) => current with { DisconnectReason = reason });
}
public sealed record ProbeRuntimeSnapshot(ProbeHeartbeatExtra? Heartbeat, long AtMs, string? DisconnectReason);
@@ -1,175 +0,0 @@
using System.Text.RegularExpressions;
using Fleet.Core;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
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, ProbeRuntimeSnapshot? Runtime = null)
{
public bool Revoked => Probe.RevokedAtMs is not null;
public NodeRuntimeStatus RuntimeStatus
{
get
{
if (Revoked)
{
return NodeRuntimeStatus.Maintenance;
}
if (!Online)
{
if (Runtime?.DisconnectReason == "updating")
{
return NodeRuntimeStatus.Updating;
}
if (Runtime?.DisconnectReason == "stopping")
{
return Runtime.Heartbeat?.Update?.State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Stopping;
}
return NodeRuntimeStatus.Offline;
}
if (Runtime?.Heartbeat?.Update?.State == "applying")
{
return NodeRuntimeStatus.Updating;
}
if (Runtime?.Heartbeat is null)
{
return NodeRuntimeStatus.Initializing;
}
return Runtime.Heartbeat.RuntimeStatus;
}
}
}
/// <summary>
/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。
/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。
/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。
/// 一条线路是一对(Probe,Agent)。
/// </summary>
public sealed partial class ProbeService
{
private readonly IProbeStore _probes;
private readonly IAgentStore _agents;
private readonly AgentRuntime _runtime;
private readonly IAgentGateway _gateway;
private readonly TimeProvider _time;
private readonly NodeInfoService _nodes;
private readonly LinkService _links;
private readonly ProbeRuntime _probeRuntime;
private readonly NodeOwnerService _owners;
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links, ProbeRuntime probeRuntime)
{
_owners = owners;
_links = links;
_probeRuntime = probeRuntime;
_nodes = nodes;
_probes = probes;
_agents = agents;
_runtime = runtime;
_gateway = gateway;
_time = time;
}
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
{
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
return [.. probes.Select(p => View(p, agents))];
}
public async ValueTask<ProbeView?> GetAsync(string id, CancellationToken ct) =>
await _probes.GetAsync(id, ct) is { } probe ? View(probe, await _agents.ListAsync(ct)) : null;
public async ValueTask<Result<ProbeView>> RenameAsync(string id, string name, CancellationToken ct)
{
if (await _probes.GetAsync(id, ct) is not { } probe)
{
return Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
}
string trimmed = name.Trim();
if (!NamePattern().IsMatch(trimmed))
{
return ValidationError.Fail<ProbeView>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
}
if ((await _probes.ListAsync(ct)).Any(p => p.Id != id && string.Equals(p.Name, trimmed, StringComparison.OrdinalIgnoreCase)))
{
return Result<ProbeView>.Fail(MoShiroErrors.NameConflict, $"a probe named {trimmed} already exists");
}
await _probes.SaveAsync(probe with { Name = trimmed }, ct);
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
}
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
{
if (await _probes.GetAsync(id, ct) is null)
{
return Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
}
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))!);
}
/// <summary>删除:Probe 身份和它的线路从面板消失;已经入库的数据按保留策略自然过期。</summary>
public async ValueTask<bool> DeleteAsync(string id, CancellationToken ct)
{
bool deleted = await _probes.DeleteAsync(id, ct);
if (deleted)
{
await _nodes.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged();
}
return deleted;
}
/// <summary>让某个 Agent 断开这个 Probe(Probe 会按退避重连,除非它已经被吊销)。</summary>
public async ValueTask<Result<bool>> DisconnectAsync(string probeId, NodeId agentId, CancellationToken ct)
{
if (await _probes.GetAsync(probeId, ct) is null)
{
return Result<bool>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
}
if (await _agents.GetAsync(agentId, ct) is null)
{
return Result<bool>.Fail(MoShiroErrors.AgentNotFound, "agent not found");
}
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
}
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
{
var connections = new List<ProbeConnection>();
foreach ((NodeId agentId, ConnectedProbeInfo info) in _runtime.ConnectionsOf(probe.Id))
{
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, info.IpVersion));
}
}
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)], _probeRuntime.Get(probe.Id));
}
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
private static partial Regex NamePattern();
}
@@ -16,9 +16,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddHostedService<StartupTasks>();
services.AddSingleton<AgentRuntime>();
services.AddSingleton<ProbeRuntime>();
services.AddSingleton<NodeRuntimeLogService>();
services.AddSingleton<ProbeQuicRuntime>();
services.AddSingleton<LinkRuntime>();
services.AddSingleton<LiveHub>();
services.AddSingleton<HotLayer>();
services.AddSingleton<BackendCounters>();
@@ -31,7 +30,6 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AgentEventService>();
services.AddSingleton<HostStatusService>();
services.AddSingleton<NodeInfoService>();
services.AddSingleton<ProbeService>();
services.AddSingleton<LinkService>();
services.AddSingleton<LineService>();
services.AddSingleton<TestService>();
@@ -12,17 +12,17 @@ public sealed class BackendOptions
/// <summary>容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。</summary>
public string ImageNamespace { get; set; } = "";
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。</summary>
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent」生成的容器命令用它拼镜像名)。</summary>
public string NodeImageRegistry { get; set; } = "registry.oeo.one";
/// <summary>
/// 更新方式:<c>restart</c>(环境变量 <c>MOSHIRO_UPDATE=restart</c>,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent、Probe 一样;
/// 更新方式:<c>restart</c>(环境变量 <c>MOSHIRO_UPDATE=restart</c>,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent 一样;
/// 留空就不能从管理端更新(别的部署方式退出之后不会重新拉取)。
/// </summary>
public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE");
/// <summary>
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
/// 每个 Agent 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
/// </summary>
public bool RequireOwner { get; set; } = true;
@@ -98,7 +98,7 @@ public sealed class BackendOptions
public double PhaseDurationScale { get; set; } = 1.0;
/// <summary>
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商,
/// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 拨号方、Agent 的来源地址查一次国家和运营商,
/// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。
/// </summary>
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
@@ -1,5 +1,6 @@
using Fleet.Core;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
@@ -10,8 +11,8 @@ public sealed record AgentRecord(
byte[] PublicKey,
byte[] TlsSpki,
string PublicHost,
int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts,
int TcpPort,
IReadOnlyList<int> QuicPorts,
IReadOnlyList<string> Labels,
bool PublicVisible,
IReadOnlyList<string> Kinds,
@@ -30,7 +31,12 @@ public sealed record AgentRecord(
bool Ipv6HostManual = false,
IReadOnlyList<string>? ReportedAddresses = null,
byte[]? TlsPublicKey = null,
string? AddedBy = null);
string? AddedBy = null,
bool Listens = false)
{
/// <summary>可以作为拨号目标:激活、会监听、有公开地址和证书公钥(拨号方需要它固定证书、派生敲门密钥)。</summary>
public bool Dialable => Status == NodeStatus.Active && Listens && PublicHost.Length > 0 && TlsPublicKey is { Length: > 0 };
}
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
public sealed class AgentPatch
@@ -51,21 +57,16 @@ public sealed class AgentPatch
public bool? PublicVisible { get; init; }
}
/// <summary>来自 <c>fleet.hello</c> 的信息。</summary>
/// <summary>来自 <c>fleet.hello</c> 的信息。不监听的节点没有 <see cref="Listen"/>。</summary>
public sealed record AgentHello(
IReadOnlyList<string> Kinds,
string? AgentVersion,
string? LocalLimitsJson,
byte[]? TlsSpki,
int? ProbeTcpPort,
IReadOnlyList<int>? ProbeUdpPorts,
bool Ipv6Supported = false,
string? Ipv6DiagnosticsJson = null,
IReadOnlyList<string>? Addresses = null,
byte[]? TlsPublicKey = null);
ListenInfo? Listen,
string? Ipv6DiagnosticsJson = null);
/// <summary>注册之后 MoShiro 的补充:公开地址和端口。</summary>
public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList<int> ProbeUdpPorts, IReadOnlyList<string> Kinds);
/// <summary>注册之后 MoShiro 的补充:公开地址和端口(不监听的节点没有端口)。</summary>
public sealed record AgentEnrollment(string PublicHost, int TcpPort, IReadOnlyList<int> QuicPorts, IReadOnlyList<string> Kinds);
public sealed record JoinTokenInfo(
string Id,
@@ -79,9 +80,3 @@ public sealed record JoinTokenInfo(
long CreatedAtMs,
string Kind = "",
string? CreatedBy = null);
/// <summary>
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 <c>peers</c> 里放的是后者。
/// </summary>
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null);
@@ -20,7 +20,7 @@ public sealed record TaskRecord(
public sealed record TestRecord(
TestId Id,
NodeId AgentId,
ProbeId ProbeId,
NodeId DialerId,
string Plan,
string ParamsJson,
string Trigger,
@@ -1,12 +1,11 @@
namespace MoShiro.Backend.Application;
/// <summary>
/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
/// 一个节点用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。
/// <b>不含任何 IP 地址、城市、ASN 或经纬度</b>——这些只在查询的那一刻用到,不留下,也不对外。
/// </summary>
public sealed record NodeInfo(
string NodeId,
string Kind,
string? Os,
string? OsName,
string? Arch,
@@ -25,11 +24,5 @@ public sealed record NodeInfo(
/// </summary>
public sealed record NodeMeta(NodeInfo Info, string? GeoHost, long GeoAtMs, long UpdatedAtMs);
public static class NodeKinds
{
public const string Agent = "agent";
public const string Probe = "probe";
}
/// <summary>IP 归属查询的结果(只取需要的几项)。</summary>
public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate);
@@ -8,7 +8,7 @@ namespace MoShiro.Backend.Application;
/// <summary>
/// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。
/// <list type="bullet">
/// <item>Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
/// <item>拨号方、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。</item>
/// <item>只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。</item>
/// <item>查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。</item>
/// </list>
@@ -36,7 +36,7 @@ public sealed class NodeInfoService
_time = time;
}
/// <summary>所有节点的展示信息(Agent 和 Probe)。</summary>
/// <summary>所有节点的展示信息(Agent)。</summary>
public async ValueTask<IReadOnlyList<NodeInfo>> ListAsync(CancellationToken ct)
{
await EnsureLoadedAsync(ct);
@@ -47,10 +47,10 @@ public sealed class NodeInfoService
/// 手动指定节点的运营商(telecom / unicom / mobile / broadnet / other);传 null 回到自动识别(下次节点出现时重新查)。
/// 返回 false 表示取值不认识。
/// </summary>
public async ValueTask<bool> SetIspAsync(string nodeId, string kind, string? key, CancellationToken ct)
public async ValueTask<bool> SetIspAsync(string nodeId, string? key, CancellationToken ct)
{
await EnsureLoadedAsync(ct);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId);
if (key is null)
{
// 清掉手动值,并让下一次心跳重新查询
@@ -69,7 +69,7 @@ public sealed class NodeInfoService
}
/// <summary>节点自报的系统、架构和构建信息;没有变化就不写库。</summary>
public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct)
public async ValueTask SetPlatformAsync(string nodeId, PlatformInfo? platform, CancellationToken ct)
{
if (platform is null)
{
@@ -77,7 +77,7 @@ public sealed class NodeInfoService
}
await EnsureLoadedAsync(ct);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId);
string? os = Clean(platform.Os, 16);
string? name = Clean(platform.Name, 40);
string? arch = Clean(platform.Arch, 16);
@@ -95,7 +95,7 @@ public sealed class NodeInfoService
}
/// <summary>Keep the displayed build version aligned with the authenticated Fleet hello, even if platform metadata is absent.</summary>
public async ValueTask SetVersionAsync(string nodeId, string kind, string? version, CancellationToken ct)
public async ValueTask SetVersionAsync(string nodeId, string? version, CancellationToken ct)
{
string? clean = Clean(version, 32);
if (clean is null)
@@ -104,7 +104,7 @@ public sealed class NodeInfoService
}
await EnsureLoadedAsync(ct);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId);
if (current.Info.Version == clean)
{
return;
@@ -117,7 +117,7 @@ public sealed class NodeInfoService
/// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做;
/// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。
/// </summary>
public async ValueTask ObserveAddressAsync(string nodeId, string kind, string? host, CancellationToken ct)
public async ValueTask ObserveAddressAsync(string nodeId, string? host, CancellationToken ct)
{
host = host?.Trim();
_lastTry[nodeId] = Now();
@@ -129,7 +129,7 @@ public sealed class NodeInfoService
try
{
await EnsureLoadedAsync(ct);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind);
NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId);
long now = Now();
bool same = string.Equals(current.GeoHost, host, StringComparison.OrdinalIgnoreCase);
bool fresh = current.Info.Country is not null && now - current.GeoAtMs < s_geoTtl.TotalMilliseconds;
@@ -175,14 +175,14 @@ public sealed class NodeInfoService
return !_lastTry.TryGetValue(nodeId, out long at) || Now() - at >= s_retryAfter.TotalMilliseconds;
}
/// <summary>Agent 报告某个 Probe 连着它:在后台用 Probe 的来源地址查国家和运营商(系统信息是 Probe 自己连上面板时报的)。出错只吞掉。</summary>
public void ObserveProbeInBackground(string probeId, string? remote) =>
/// <summary>Agent 报告某个 拨号方 连着它:在后台用 拨号方 的来源地址查国家和运营商(系统信息是 拨号方 自己连上面板时报的)。出错只吞掉。</summary>
public void ObserveDialerInBackground(string dialerId, string? remote) =>
_ = Task.Run(async () =>
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await ObserveAddressAsync(probeId, NodeKinds.Probe, remote, cts.Token);
await ObserveAddressAsync(dialerId, remote, cts.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
{
@@ -197,8 +197,8 @@ public sealed class NodeInfoService
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await SetPlatformAsync(agentId, NodeKinds.Agent, platform, cts.Token);
await ObserveAddressAsync(agentId, NodeKinds.Agent, publicHost, cts.Token);
await SetPlatformAsync(agentId, platform, cts.Token);
await ObserveAddressAsync(agentId, publicHost, cts.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException)
{
@@ -272,7 +272,7 @@ public sealed class NodeInfoService
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
private static NodeMeta Empty(string nodeId, string kind) => new(new NodeInfo(nodeId, kind, null, null, null, null, null, null), null, 0, 0);
private static NodeMeta Empty(string nodeId) => new(new NodeInfo(nodeId, null, null, null, null, null, null), null, 0, 0);
/// <summary>节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。</summary>
private static string? Clean(string? text, int max)
@@ -10,7 +10,7 @@ public interface IAgentGateway
{
IReadOnlyCollection<NodeId> OnlineAgents { get; }
/// <summary>所有在线的节点(Agent 和 Probe)。</summary>
/// <summary>所有在线的节点。</summary>
IReadOnlyCollection<NodeId> OnlineNodes { get; }
bool IsOnline(NodeId agent);
@@ -20,18 +20,18 @@ public interface IAgentGateway
void NotifyAllTaskSetsChanged();
/// <summary>失败时错误码为 <c>probe_offline</c>、<c>test_busy</c>、<c>agent_offline</c> 等。</summary>
/// <summary>失败时错误码为 <c>dialer_offline</c>、<c>test_busy</c>、<c>agent_offline</c> 等。</summary>
ValueTask<Result<bool>> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct);
ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct);
/// <summary>让 Agent 断开某个 Probe 的会话。返回 Agent 是否真的有这个会话。</summary>
ValueTask<Result<bool>> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct);
/// <summary>让监听方断开某个拨号方的会话。返回监听方是否真的有这个会话。</summary>
ValueTask<Result<bool>> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct);
ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct);
/// <summary>Notify an online Probe to immediately retry all IP paths to an Agent.</summary>
ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) => ValueTask.FromResult(false);
/// <summary>Notify an online dialer to immediately retry all IP paths to a listener.</summary>
ValueTask<bool> NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) => ValueTask.FromResult(false);
/// <summary>把发布清单和签名原样交给节点(通知,不等回复)。</summary>
ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct);
@@ -29,6 +29,8 @@ public interface IAgentStore
/// <summary>写入自动识别出的地址;管理员手填的那一项不动。为 null 的项不改。</summary>
ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct);
ValueTask SetIpv6DiagnosticsAsync(NodeId id, string diagnosticsJson, CancellationToken ct);
ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct);
ValueTask<string?> GetTaskHashAsync(NodeId id, CancellationToken ct);
@@ -66,23 +68,6 @@ public interface IJoinTokenStore
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
}
public interface IProbeStore
{
ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct);
ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct);
/// <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);
ValueTask<bool> DeleteAsync(string id, CancellationToken ct);
}
public interface ISettingsStore
{
ValueTask<string?> GetAsync(string key, CancellationToken ct);
@@ -114,15 +99,15 @@ public interface ITestStore
ValueTask<TestRecord?> GetAsync(TestId id, CancellationToken ct);
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct);
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, CancellationToken ct);
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct);
ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, NodeId? dialerId, 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);
ValueTask<bool> HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct);
ValueTask<bool> HasActiveAsync(NodeId agentId, NodeId dialerId, CancellationToken ct);
ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct);
@@ -1,14 +1,15 @@
using Fleet.Core;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <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)
/// <summary>线路按(监听方,拨号方,地址族)隔离。IPv4 是 <c>listener~dialer</c> 和 <c>line:dialer</c>;IPv6 分别追加 <c>~ipv6</c>、<c>:ipv6</c>。</summary>
public readonly record struct LineKey(NodeId Listener, NodeId Dialer, string IpVersion = IpVersions.V4)
{
public string Series => IpVersions.LineSeries(Probe.Value, IpVersion);
public string Series => IpVersions.LineSeries(Dialer.Value, IpVersion);
public string Id => Agent.Value + "~" + Probe.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : "");
public string Id => Listener.Value + "~" + Dialer.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : "");
public static bool TryParse(string text, out LineKey key)
{
@@ -19,52 +20,49 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
return false;
}
key = new LineKey(new NodeId(parts[0]), new ProbeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4);
key = new LineKey(new NodeId(parts[0]), new NodeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4);
return true;
}
}
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null)
/// <summary>一条线路的展示视图:监听方的状态、拨号方记录,以及这一对现在是否连着。</summary>
public sealed record LineView(AgentView Listener, AgentRecord Dialer, string IpVersion = IpVersions.V4, ReportedLink? Link = null)
{
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
public LineKey Key => new(Listener.Agent.Id, Dialer.Id, IpVersion);
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
public bool Connected => Listener.Dialers.Any(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion);
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
public ConnectedDialerInfo? Connection => Listener.Dialers.FirstOrDefault(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion);
}
/// <summary>线路目录:把 id 解析成(Agent,Probe),列出所有线路。</summary>
/// <summary>线路目录:把 id 解析成(监听方,拨号方),列出所有线路。</summary>
public sealed class LineService
{
private readonly AgentService _agents;
private readonly IProbeStore _probes;
private readonly LinkService _links;
private readonly ProbeQuicRuntime _quicRuntime;
private readonly LinkRuntime _linkRuntime;
public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime)
public LineService(AgentService agents, LinkService links, LinkRuntime linkRuntime)
{
_links = links;
_agents = agents;
_probes = probes;
_quicRuntime = quicRuntime;
_linkRuntime = linkRuntime;
}
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
/// <summary>所有线路:每个监听节点和每个激活的节点配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
public async ValueTask<IReadOnlyList<LineView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
{
IReadOnlyList<AgentView> agents = await _agents.ListAsync(ct);
IReadOnlyList<ProbeRecord> probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)];
IReadOnlyList<AgentView> nodes = await _agents.ListAsync(ct);
LinkSnapshot links = await _links.SnapshotAsync(ct);
var result = new List<LineView>();
foreach (AgentView agent in agents.Where(include))
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && include(n)))
{
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
foreach (AgentRecord dialer in nodes.Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent)))
{
result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4)));
if (agent.Agent.Ipv6Host.Length > 0)
result.Add(new LineView(listener, dialer, IpVersions.V4, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V4)));
if (listener.Agent.Ipv6Host.Length > 0)
{
result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6)));
result.Add(new LineView(listener, dialer, IpVersions.V6, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V6)));
}
}
}
@@ -73,12 +71,12 @@ public sealed class LineService
}
/// <summary>
/// 线路 id 是 <c>agent~probe</c>。只给 Agent id 时:恰好有一个未吊销的 Probe 就用它,否则要求明确指定(<c>probe_required</c>)。
/// 线路 id 是 <c>listener~dialer</c>。只给监听方 id 时:当前恰好有一条开着的线路就用它,否则要求明确指定(<c>dialer_required</c>)。
/// </summary>
public async ValueTask<Result<LineView>> ResolveAsync(string id, CancellationToken ct)
{
string agentPart = id;
string? probePart = null;
string listenerPart = id;
string? dialerPart = null;
string ipVersion = IpVersions.V4;
if (id.Contains('~', StringComparison.Ordinal))
{
@@ -87,38 +85,38 @@ public sealed class LineService
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
agentPart = key.Agent.Value;
probePart = key.Probe.Value;
listenerPart = key.Listener.Value;
dialerPart = key.Dialer.Value;
ipVersion = key.IpVersion;
}
AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct);
if (agent is null)
AgentView? listener = await _agents.GetAsync(new NodeId(listenerPart), ct);
if (listener is null)
{
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
ProbeRecord? probe;
if (probePart is not null)
AgentRecord? dialer;
if (dialerPart is not null)
{
probe = probes.FirstOrDefault(p => p.Id == probePart);
if (probe is null)
dialer = (await _agents.ListAsync(ct)).Select(n => n.Agent).FirstOrDefault(a => a.Id.Value == dialerPart);
if (dialer is null)
{
return Result<LineView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "dialer not found");
}
}
else
{
ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)];
if (active.Length != 1)
LinkSnapshot links = await _links.SnapshotAsync(ct);
AgentRecord[] linked = [.. (await _agents.ListAsync(ct)).Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent))];
if (linked.Length != 1)
{
return Result<LineView>.Fail(MoShiroErrors.ProbeRequired, active.Length == 0 ? "there is no active probe" : "several probes exist: use <agent_id>~<probe_id> as the line id");
return Result<LineView>.Fail(MoShiroErrors.DialerRequired, linked.Length == 0 ? "there is no line to this node" : "several lines exist: use <listener_id>~<dialer_id> as the line id");
}
probe = active[0];
dialer = linked[0];
}
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion)));
return Result<LineView>.Ok(new LineView(listener, dialer, ipVersion, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, ipVersion)));
}
}
@@ -58,9 +58,9 @@ public sealed class QueryService
// ───────────────────────── 单条线路的时序 ─────────────────────────
/// <summary>这条线路现在是不是连着(Agent 报告了这个 Probe 的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。</summary>
/// <summary>这条线路现在是不是连着(监听方报告了这个拨号方的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。</summary>
private bool IsConnected(LineKey line) =>
_runtime.TryGet(line.Agent, out AgentRuntimeState? state) && state.Probes.Any(p => p.Id == line.Probe.Value && p.IpVersion == line.IpVersion);
_runtime.TryGet(line.Listener, out AgentRuntimeState? state) && state.Dialers.Any(p => p.Id == line.Dialer.Value && p.IpVersion == line.IpVersion);
public async ValueTask<Result<SeriesResult>> SeriesAsync(LineKey line, RangeRequest request, MetricSelection selection, CancellationToken ct)
{
@@ -70,7 +70,7 @@ public sealed class QueryService
&& !IsConnected(line))
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (await _loader.LatestSampleMsAsync(line.Agent, line.Series, now, ct) is { } latest && latest < now - 5000)
if (await _loader.LatestSampleMsAsync(line.Listener, line.Series, now, ct) is { } latest && latest < now - 5000)
{
request = request with { Range = null, From = DateTimeOffset.FromUnixTimeMilliseconds(latest - span), To = DateTimeOffset.FromUnixTimeMilliseconds(latest) };
}
@@ -82,7 +82,7 @@ public sealed class QueryService
return Result<SeriesResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<SeriesResult>.Fail(error);
@@ -124,7 +124,7 @@ public sealed class QueryService
SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, overlay);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
IReadOnlyList<EventRecord> events = await _events.ListAsync(line.Agent, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct);
IReadOnlyList<EventRecord> events = await _events.ListAsync(line.Listener, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct);
IReadOnlyList<GapInfo> gaps = FindGaps(bucketed, p.Range, nowMs, events);
var versions = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string id in selection.ExportIds)
@@ -140,7 +140,7 @@ public sealed class QueryService
/// <summary>这条线路最早的数据(Unix 毫秒);没有数据返回 null。</summary>
public ValueTask<long?> EarliestAsync(LineKey line, CancellationToken ct) =>
_loader.EarliestSampleMsAsync(line.Agent, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
_loader.EarliestSampleMsAsync(line.Listener, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
// ───────────────────────── 首页排行 ─────────────────────────
@@ -174,7 +174,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, line.Key.IpVersion, ct);
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Listener, line.Key.Dialer, 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));
}
@@ -245,7 +245,7 @@ public sealed class QueryService
return Result<DistributionResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<DistributionResult>.Fail(error);
@@ -289,7 +289,7 @@ public sealed class QueryService
return Result<HeatmapResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<HeatmapResult>.Fail(error);
@@ -379,7 +379,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, line.IpVersion, ct))
foreach (TestRecord test in await _tests.ListAsync(line.Listener, line.Dialer, 20, line.IpVersion, ct))
{
if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan))
{
@@ -422,7 +422,7 @@ public sealed class QueryService
{
long from = bucketed.T[start];
long to = bucketed.T[i - 1] + range.StepMs;
EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "probe_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated");
EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "dialer_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated");
gaps.Add(new GapInfo(from, to, near?.Kind ?? "no_data"));
}
}
@@ -130,7 +130,7 @@ public sealed class RowLoader
{
foreach (LineKey line in lines)
{
Result<IReadOnlyList<SourceRow>> one = await LoadAsync(plan, range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> one = await LoadAsync(plan, range, line.Listener, line.Series, ct);
if (!one.TryGet(out IReadOnlyList<SourceRow>? rows, out FleetError? error))
{
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Fail(error);
@@ -142,14 +142,14 @@ public sealed class RowLoader
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Ok(result);
}
// 每个 Probe 一个序列:按序列分组,每组一次查询
// 每个拨号方一个序列:按序列分组,每组一次查询
foreach (IGrouping<string, LineKey> bySeries in lines.GroupBy(l => l.Series))
{
NodeId[] agents = [.. bySeries.Select(l => l.Agent).Distinct()];
NodeId[] agents = [.. bySeries.Select(l => l.Listener).Distinct()];
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))
{
LineKey key = bySeries.First(l => l.Agent == group.Key);
LineKey key = bySeries.First(l => l.Listener == group.Key);
result[key] = Decode([.. group]);
}
}
@@ -9,28 +9,26 @@ namespace MoShiro.Backend.Application;
public sealed record DesiredTaskSet(long Version, TaskSet Document);
/// <summary>
/// 为每个 Agent 计算期望状态(<see cref="TaskSet"/>):有效任务集 + 允许连接的 Probe 公钥。
/// 为每个 Agent 计算期望状态(<see cref="TaskSet"/>):有效任务集 + 允许拨入的拨号方公钥。
/// 只有内容真的变了才递增版本号,所以断线重连后 Agent 收到的永远是当前版本的完整快照。
/// </summary>
public sealed class TaskPlanner : IDisposable
{
public const string ProbeAgentsVersionKey = "probe_agents_version";
public const string DialTargetsVersionKey = "dial_targets_version";
private readonly ITaskStore _tasks;
private readonly IAgentStore _agents;
private readonly IProbeStore _probes;
private readonly ISettingsStore _settings;
private readonly LinkService _links;
private readonly TrustService _trust;
private readonly SemaphoreSlim _gate = new(1, 1);
public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust)
public TaskPlanner(ITaskStore tasks, IAgentStore agents, ISettingsStore settings, LinkService links, TrustService trust)
{
_trust = trust;
_links = links;
_tasks = tasks;
_agents = agents;
_probes = probes;
_settings = settings;
}
@@ -44,7 +42,7 @@ public sealed class TaskPlanner : IDisposable
}
IReadOnlyList<TaskRecord> all = await _tasks.ListAsync(ct);
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
IReadOnlyList<AgentRecord> nodes = await _agents.ListAsync(ct);
LinkSnapshot links = await _links.SnapshotAsync(ct);
var document = new TaskSet();
@@ -62,9 +60,12 @@ public sealed class TaskPlanner : IDisposable
});
}
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
if (agent.Listens)
{
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
foreach (AgentRecord dialer in nodes.Where(n => n.Status == NodeStatus.Active && n.TlsPublicKey is { Length: > 0 } && links.IsLinked(n, agent)))
{
document.Peers.Add(new PeerSpec { NodeId = dialer.Id, PublicKey = Convert.ToBase64String(dialer.TlsPublicKey!) });
}
}
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
@@ -96,5 +97,5 @@ public sealed class TaskPlanner : IDisposable
public void Dispose() => _gate.Dispose();
public ValueTask<long> BumpProbeAgentsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(ProbeAgentsVersionKey, ct);
public ValueTask<long> BumpDialTargetsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(DialTargetsVersionKey, ct);
}
@@ -7,7 +7,7 @@ namespace MoShiro.Backend.Application;
public sealed record PhaseSpec(string Kind, IReadOnlyDictionary<string, JsonElement> Overrides, int DurationSeconds);
/// <summary>
/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Probe、Measurement)不认识它。
/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Measurement)不认识它。
/// 测试方案:多个阶段的 once 任务组合(例如 hy2_ladder 在 10、25、50、100 Mbps 下各跑 10 秒)。</summary>
public sealed record PlanSpec(string Id, string Title, string Description, IReadOnlyList<PhaseSpec> Phases);
@@ -28,10 +28,10 @@ public sealed class TestService
_time = time;
}
public ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) =>
StartAsync(agentId, probeId, request, trigger, IpVersions.V4, ct);
public ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, NodeId dialerId, StartTestRequest request, string trigger, CancellationToken ct) =>
StartAsync(agentId, dialerId, request, trigger, IpVersions.V4, ct);
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct)
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, NodeId dialerId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct)
{
if (!IpVersions.IsValid(ipVersion))
{
@@ -78,12 +78,12 @@ public sealed class TestService
return Result<TestRecord>.Fail(MoShiroErrors.AgentOffline, "agent is offline");
}
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value, ipVersion)))
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(dialerId.Value, ipVersion)))
{
return Result<TestRecord>.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent");
return Result<TestRecord>.Fail(MoShiroErrors.DialerOffline, $"dialer {dialerId.Value} has no session with this node");
}
if (await _tests.HasActiveAsync(agentId, probeId, ct))
if (await _tests.HasActiveAsync(agentId, dialerId, ct))
{
return Result<TestRecord>.Fail(MoShiroErrors.TestBusy, "another test is already running on this line");
}
@@ -91,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, ipVersion);
var record = new TestRecord(testId, agentId, dialerId, 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, IpVersion = ipVersion, Plan = plan.Id, Phases = phaseList }, ct);
Result<bool> sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, DialerId = dialerId, 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);
@@ -160,9 +160,9 @@ public sealed class TestService
return new TestDetail(test, plan?.Phases.Select(p => p.Kind).ToList() ?? [], stored is null ? null : ToView(stored));
}
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, NodeId? dialerId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, dialerId, 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 ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, string? ipVersion, CancellationToken ct) => _tests.ListAsync(agentId, dialerId, limit, ipVersion, ct);
public static TestResultView ToView(TestResultRecord stored)
{
@@ -240,9 +240,9 @@ public sealed class TestService
}
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.QuicPorts.Count > 0)
{
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
merged["port"] = JsonSerializer.SerializeToElement(agent.QuicPorts[0], PlanPortJson.Default.Int32);
}
ParamResolution resolved = ParamResolver.Resolve(kind.Params, merged);
@@ -21,7 +21,7 @@ public interface IDatabaseBackup
public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error);
/// <summary>
/// Backend 的发布通知和确认,和 Agent、Probe 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出,
/// Backend 的发布通知和确认,和 Agent 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出,
/// Quadlet 单元每次启动都先拉最新镜像(<c>Pull=always</c>),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。
/// 只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c>(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。
/// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。
@@ -5,14 +5,14 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 定时去镜像仓库取各组件(Agent、Probe、Backend 自己)最新的签名清单:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告),
/// 定时去镜像仓库取各组件(Agent、Backend 自己)最新的签名清单:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告),
/// 面板自己也去仓库取,Backend 的更新通知和节点的更新建议就不依赖那一步。取回来的清单要用发布公钥验过签才登记。
/// </summary>
public sealed partial class ReleasePoller : BackgroundService
{
private static readonly TimeSpan s_startupDelay = TimeSpan.FromSeconds(20);
private static readonly TimeSpan s_interval = TimeSpan.FromMinutes(5);
private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, ReleaseService.BackendComponent];
private static readonly string[] s_components = [ReleaseService.AgentComponent, ReleaseService.BackendComponent];
private readonly ReleaseService _releases;
private readonly TimeProvider _time;
@@ -11,7 +11,7 @@ namespace MoShiro.Backend.Application;
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(节点连上时再补发一次)。
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的节点(节点连上时再补发一次)。
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
/// </summary>
@@ -20,7 +20,8 @@ public sealed partial class ReleaseService : IDisposable
public const int MaxManifestBytes = 4096;
public const int MaxSignatureBytes = 512;
public const string BackendComponent = "backend";
private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, BackendComponent];
public const string AgentComponent = "agent";
private static readonly string[] s_components = [AgentComponent, BackendComponent];
private readonly ISettingsStore _settings;
private readonly IAgentGateway _gateway;
@@ -116,7 +117,7 @@ public sealed partial class ReleaseService : IDisposable
/// <summary>不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。</summary>
public async ValueTask PurgeUnverifiedAsync(CancellationToken ct)
{
foreach (string key in new[] { NodeKinds.Agent, NodeKinds.Probe, BackendComponent + ".dev", BackendComponent + ".release" })
foreach (string key in new[] { AgentComponent, BackendComponent + ".dev", BackendComponent + ".release" })
{
string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct);
string? signature = await _settings.GetAsync(Key(key, "signature"), ct);
@@ -188,9 +189,7 @@ public sealed partial class ReleaseService : IDisposable
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
internal static string? ComponentOf(NodeId node) =>
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
: null;
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? AgentComponent : null;
private static string Key(string component, string part) => $"release.{component}.{part}";