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

@@ -10,8 +10,8 @@ public interface IObservationSink
}
/// <summary>
/// 原始块的列视图:按列名读取,并把 Probe / Agent 各自的单调时钟换算成 UTC。
/// Agent 时钟 → UTC 使用块头的锚点;Probe 时钟 → Agent 时钟使用估计的偏移。
/// 原始块的列视图:按列名读取,并把 拨号方 / 监听方 各自的单调时钟换算成 UTC。
/// Agent 时钟 → UTC 使用块头的锚点;拨号方 时钟 → Agent 时钟使用估计的偏移。
/// </summary>
public sealed class ChunkView
{
@@ -24,11 +24,11 @@ public sealed class ChunkView
public RawChunkHeader Header => Chunk.Header;
public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.AgentMonoUs, 1000);
public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.ListenerMonoUs, 1000);
public long ProbeToUtcMs(long probeUs) => AgentToUtcMs(probeUs + Header.Clock.ProbeOffsetUs);
public long DialerToUtcMs(long dialerUs) => AgentToUtcMs(dialerUs + Header.Clock.DialerOffsetUs);
public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsProbe ? ProbeToUtcMs(value) : AgentToUtcMs(value);
public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsDialer ? DialerToUtcMs(value) : AgentToUtcMs(value);
public double ParamNumber(string name, double fallback) =>
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback;
@@ -142,7 +142,7 @@ public static class TestEvaluator
step.JitterP95Ms = jh.Quantile(0.95);
}
// 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
// 阶段的时长:从发送时间戳算(块头里的时长是 拨号方 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0);
step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0;
steps.Add(step);
@@ -215,7 +215,7 @@ public static class TestEvaluator
continue;
}
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs);
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.ListenerMonoUs);
min = Math.Min(min, utcUs);
max = Math.Max(max, utcUs);
count++;
@@ -11,10 +11,10 @@ public static class LinkMetrics
[Stat.P50, Stat.P95, Stat.P99, Stat.Mean], good: 50, poor: 150);
public static readonly MetricDef LossUp = Metric.Ratio(
"loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the Probe and the Agent.", good: 0.5, poor: 3);
"loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the 拨号方 and the Agent.", good: 0.5, poor: 3);
public static readonly MetricDef LossDown = Metric.Ratio(
"loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the Probe, among packets that reached the Agent.", good: 0.5, poor: 3);
"loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the 拨号方, among packets that reached the Agent.", good: 0.5, poor: 3);
public static readonly MetricDef LossTotal = Metric.Ratio(
"loss.total", s_udpOrIcmpMsg, Ops.Loss(Dir.Total), "Loss (total)", "Packets whose reply never came back.", good: 0.5, poor: 3);
@@ -31,11 +31,11 @@ public static class LinkMetrics
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
public static readonly MetricDef LossRunUp = Metric.Max(
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.",
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive 拨号方-to-Agent Datagram loss run in each chunk; zero means no loss.",
Unit.Packets, "loss.run.up_max", good: 0, poor: 10);
public static readonly MetricDef LossRunDown = Metric.Max(
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-拨号方 Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
Unit.Packets, "loss.run.down_max", good: 0, poor: 10);
public static readonly MetricDef Reorder = Metric.Counter(
@@ -15,7 +15,7 @@ public enum GapOutput
MaxMs,
}
/// <summary>回显流(stream <c>msg</c>)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。</summary>
/// <summary>回显流(stream <c>msg</c>)的列:t1/t4 是 拨号方 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。</summary>
internal readonly struct EchoColumns
{
public EchoColumns(RawStream stream)
@@ -2,11 +2,11 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Analysis;
/// <summary>tcp_info 流的辅助:<c>side</c> 为 0 表示 Probe 一侧记录(Probe 时钟),否则为 Agent 一侧。</summary>
/// <summary>tcp_info 流的辅助:<c>side</c> 为 0 表示 拨号方 一侧记录(拨号方 时钟),否则为 Agent 一侧。</summary>
internal static class TcpInfoTime
{
public static long ToUtcMs(ChunkView view, RawColumn ts, RawColumn? side, int row) =>
side is not null && side.Values[row] == 0 ? view.ProbeToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]);
side is not null && side.Values[row] == 0 ? view.DialerToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]);
}
/// <summary>两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 (side, conn) 分别计算;旧数据没有 conn 时按 side 计算。</summary>
@@ -164,7 +164,7 @@ internal sealed class BulkRateOp : MetricOp
}
double mbps = total * 8 / (width * sampleMs / 1000.0) / 1_000_000;
sink.Emit(view.ProbeToUtcMs(bin * binUs), mbps);
sink.Emit(view.DialerToUtcMs(bin * binUs), mbps);
}
}
}
@@ -332,7 +332,7 @@ internal sealed class TrainLossOp : MetricOp
}
}
/// <summary>单向时延变化:t_recv(Probe) − t_send(Agent),去除偏移和漂移。</summary>
/// <summary>单向时延变化:t_recv(拨号方) − t_send(Agent),去除偏移和漂移。</summary>
internal sealed class TrainJitterOp : MetricOp
{
public override string Signature => "train_jitter/1";
@@ -8,27 +8,26 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。</summary>
/// <summary>节点管理:列表、修改、审批、启停、接管、删除;加入令牌。</summary>
public static class AgentEndpoints
{
private const string AgentsTag = "admin-agents";
private const string TokensTag = "admin-join-tokens";
private const string ProbeTag = "admin-probe";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/agents", ListAsync)
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
.Api("agents_list", "All nodes (agents) including pending and disabled ones; each says whether it listens, which dialers are connected to it and which listeners it dials", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/agents/{id}", GetAsync)
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapGet("/nodes/{id}/logs", ListNodeLogsAsync)
.Api("node_runtime_logs", "Recent Agent or Probe runtime logs", "admin-nodes").Returns<NodeRuntimeLogsDto>().ProducesProblems(401, 403)
.Api("node_runtime_logs", "Recent runtime logs of a node", "admin-nodes").Returns<NodeRuntimeLogsDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPatch("/agents/{id}", PatchAsync)
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
.Api("agents_update", "Update name, labels, public host, ISP or visibility (merge-patch, If-Match supported)", AgentsTag)
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
@@ -53,7 +52,7 @@ public static class AgentEndpoints
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPost("/join-tokens", CreateTokenAsync)
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
.Api("join_tokens_create", "Create a join token for an agent; the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
.Requires(Capability.FleetEnroll);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
@@ -61,25 +60,8 @@ public static class AgentEndpoints
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetEnroll);
group.MapGet("/probes", ListProbesAsync)
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/probes/{id}", GetProbeAsync)
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapPatch("/probes/{id}", PatchProbeAsync)
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRevoke);
group.MapDelete("/probes/{id}", DeleteProbeAsync)
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetRevoke);
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
group.MapPost("/agents/{id}/dialers/{dialer_id}/disconnect", DisconnectDialerAsync)
.Api("agents_dialer_disconnect", "Make a listening node drop one dialer's session (the dialer reconnects unless it is disabled)", AgentsTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
}
@@ -107,8 +89,13 @@ public static class AgentEndpoints
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
}
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, NodeInfoService nodes, AgentPatchRequest request, string id, CancellationToken ct)
{
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
{
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
}
Result<AgentView> result = await agents.PatchAsync(
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
if (!result.TryGet(out AgentView? view, out FleetError? error))
@@ -116,6 +103,11 @@ public static class AgentEndpoints
return Problems.From(http, error);
}
if (request.Isp is not null)
{
await nodes.SetIspAsync(id, request.Isp == "auto" ? null : request.Isp, ct);
}
Concurrency.SetETag(http, view.Agent.RowVersion);
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
}
@@ -182,62 +174,14 @@ public static class AgentEndpoints
return TypedResults.NoContent();
}
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
await probes.GetAsync(id, ct) is { } view
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
: Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
private static async Task<IResult> DisconnectDialerAsync(HttpContext http, AgentService agents, string id, string dialer_id, CancellationToken ct)
{
if (request.Name is null && request.Isp is null)
{
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
}
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
{
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
}
Result<ProbeView> result = request.Name is null
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
: await probes.RenameAsync(id, request.Name, ct);
if (!result.TryGet(out ProbeView? view, out FleetError? error))
{
return Problems.From(http, error);
}
if (request.Isp is not null)
{
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
}
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
{
Result<ProbeView> result = await probes.RevokeAsync(id, ct);
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
}
private static async Task<IResult> DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct)
{
await probes.DeleteAsync(id, ct);
return TypedResults.NoContent();
}
private static async Task<IResult> DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct)
{
Result<bool> result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct);
Result<bool> result = await agents.DisconnectDialerAsync(new NodeId(id), new NodeId(dialer_id), ct);
if (!result.TryGet(out bool dropped, out FleetError? error))
{
return Problems.From(http, error);
}
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent");
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.DialerOffline, "this dialer has no session with this node");
}
}
@@ -16,14 +16,14 @@ public static class BuildEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/build-info", GetAsync)
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every node", Tag)
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapGet("/releases", ListReleasesAsync)
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
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)
.Api("nodes_update", "Ask an online node to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
.Requires(Capability.UpdateConfirm);
group.MapGet("/backend/update", BackendUpdateAsync)
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
@@ -65,9 +65,8 @@ public static class BuildEndpoints
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
.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))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeBuildDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -1,4 +1,5 @@
using Fleet.Core;
using Fleet.Server;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -7,7 +8,10 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>;这里看每一对的结果,并且手动强制开、关或取消手动设置。</summary>
/// <summary>
/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点自动拨所有监听节点);
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</c>。
/// </summary>
public static class LinkEndpoints
{
private const string Tag = "admin-links";
@@ -15,110 +19,139 @@ public static class LinkEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/links", ListAsync)
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
.Api("links_list", "Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync)
.Api("links_set", "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404, 409, 422)
.Requires(Capability.FleetWrite);
group.MapPost("/links/{probe_id}/{agent_id}/retry", RetryAsync)
.Api("links_retry", "Enable and immediately retry the Probe-to-Agent connection", Tag).Returns<LinkRetryDto>().ProducesProblems(401, 403, 404)
group.MapPost("/links/{dialer_id}/{listener_id}/retry", RetryAsync)
.Api("links_retry", "Enable and immediately retry the dialer-to-listener connection", Tag).Returns<LinkRetryDto>().ProducesProblems(401, 403, 404, 409, 422)
.Requires(Capability.FleetWrite);
}
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, CancellationToken ct)
private static async Task<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
{
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
IReadOnlyList<AgentView> agentViews = await agents.ListAsync(ct);
AgentView[] nodes = [.. (await agents.ListAsync(ct)).Where(n => n.Agent.Status == NodeStatus.Active).OrderBy(n => n.Agent.Name, StringComparer.Ordinal)];
var items = new List<LinkDto>();
foreach (ProbeView probe in (await probes.ListAsync(ct)).Where(p => !p.Revoked).OrderBy(p => p.Probe.Name, StringComparer.Ordinal))
foreach (AgentView dialer in nodes)
{
foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal))
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && n.Agent.Id != dialer.Agent.Id))
{
items.Add(ToDto(snapshot, probe, agent, quic, time));
items.Add(ToDto(snapshot, dialer, listener, runtime, time));
}
}
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto);
LinkNodeDto[] nodeItems = [.. nodes.Select(n => new LinkNodeDto(n.Agent.Id.Value, n.Agent.Name, n.Agent.Listens, n.Online, n.RuntimeStatus))];
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, nodeItems, items), ApiJsonContext.Default.LinkMatrixDto);
}
private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct)
private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, SetLinkRequest request, CancellationToken ct)
{
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, request.Enabled == true, ct);
if (problem is not null)
{
return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
return problem;
}
if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
}
await links.SetAsync(probe_id, agent_id, request.Enabled, ct);
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe
await links.SetAsync(dialer_id, listener_id, request.Enabled, ct);
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:拨号方的拨号目标、监听方允许的拨号方
if (request.Enabled == true)
{
await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct);
}
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time), ApiJsonContext.Default.LinkDto);
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), dialer!, listener!, runtime, time), ApiJsonContext.Default.LinkDto);
}
private static async Task<IResult> RetryAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, CancellationToken ct)
private static async Task<IResult> RetryAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, CancellationToken ct)
{
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, requireListening: true, ct);
if (problem is not null)
{
return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
}
if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
return problem;
}
await links.SetAsync(probe_id, agent_id, true, ct);
await links.SetAsync(dialer_id, listener_id, true, ct);
await agents.AfterAgentSetChangedAsync(ct);
bool queued = await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
LinkDto link = ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time);
bool queued = await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct);
LinkDto link = ToDto(await links.SnapshotAsync(ct), dialer, listener, runtime, time);
return TypedResults.Json(new LinkRetryDto(queued, link), ApiJsonContext.Default.LinkRetryDto, statusCode: StatusCodes.Status202Accepted);
}
private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent, ProbeQuicRuntime quicRuntime, TimeProvider time)
/// <summary>两端都得是存在且激活的节点,不能连自己;开线路时监听方必须在监听。</summary>
private static async Task<(AgentView? Dialer, AgentView? Listener, IResult? Problem)> ResolveAsync(HttpContext http, AgentService agents, string dialerId, string listenerId, bool requireListening, CancellationToken ct)
{
bool linked = snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value);
ProbeQuicLinkSnapshot? quicSnapshot = quicRuntime.Get(probe.Probe.Id, agent.Agent.Id.Value, IpVersions.V4);
ProbeQuicLinkHealth? health = quicSnapshot?.Health;
bool fresh = quicSnapshot is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - quicSnapshot.AtMs <= 30_000;
if (dialerId == listenerId)
{
return (null, null, Problems.Create(http, MoShiroErrors.SelfLink, "a node cannot connect to itself"));
}
if (await agents.GetAsync(new NodeId(dialerId), ct) is not { } dialer)
{
return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "dialer not found"));
}
if (await agents.GetAsync(new NodeId(listenerId), ct) is not { } listener)
{
return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "listener not found"));
}
if (requireListening && !listener.Agent.Listens)
{
return (null, null, Problems.Create(http, MoShiroErrors.NotListening, "the listener does not accept connections: only nodes that listen can be connected to"));
}
return (dialer, listener, null);
}
private static LinkDto ToDto(LinkSnapshot snapshot, AgentView dialer, AgentView listener, LinkRuntime runtime, TimeProvider time)
{
bool linked = snapshot.IsLinked(dialer.Agent, listener.Agent);
ReportedLink? reported = runtime.Get(dialer.Agent.Id.Value, listener.Agent.Id.Value, IpVersions.V4);
LinkHealth? health = reported?.Health;
bool fresh = reported is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - reported.AtMs <= 30_000;
string state;
string? reason = null;
if (!linked) { state = "disabled"; reason = "线路策略已关闭"; }
else if (probe.RuntimeStatus != NodeRuntimeStatus.Running) { state = "probe_unavailable"; reason = $"Probe 状态:{probe.RuntimeStatus}"; }
else if (agent.RuntimeStatus != NodeRuntimeStatus.Running) { state = "agent_unavailable"; reason = $"Agent 状态:{agent.RuntimeStatus}"; }
else if (dialer.RuntimeStatus != NodeRuntimeStatus.Running) { state = "dialer_unavailable"; reason = $"拨号节点状态:{dialer.RuntimeStatus}"; }
else if (listener.RuntimeStatus != NodeRuntimeStatus.Running) { state = "listener_unavailable"; reason = $"监听节点状态:{listener.RuntimeStatus}"; }
else if (health is null || !fresh)
{
state = "waiting_report";
reason = health is null ? "Probe 尚未上报此线路的 QUIC 拨号状态" : "Probe 的线路状态上报已过期";
reason = health is null ? "拨号节点尚未上报此线路的状态" : "拨号节点的线路状态上报已过期";
}
else if (health.Connected && agent.Probes.Any(p => p.Id == probe.Probe.Id))
else if (health.Control.State == ControlState.Backoff)
{
state = "control_down";
reason = health.Control.LastError is { } fault ? $"与监听节点失联:{fault}" : "与监听节点失联,等待自动重试";
}
else if (health.Control.State != ControlState.Up || listener.Dialers.All(d => d.Id != dialer.Agent.Id.Value))
{
state = "control_connecting";
reason = "正在建立到监听节点的控制连接";
}
else if (health.Task.Quic.State == QuicState.Ready)
{
state = "connected";
}
else if (health.State == ProbeLinkAttemptState.RetryWait)
else if (health.Task.Quic.State == QuicState.Retrying)
{
state = "retry_wait";
reason = health.LastError is { Length: > 0 } error ? $"QUIC 连接失败:{error}" : "QUIC 连接失败,等待自动重试";
}
else if (agent.Probes.All(p => p.Id != probe.Probe.Id))
{
state = "control_disconnected";
reason = health.LastError ?? "Probe 尚未建立到 Agent 的测量控制会话";
state = "task_retrying";
reason = health.Task.Quic.LastError is { } fault ? $"控制连接正常,QUIC 数据通道重试中:{fault}" : "控制连接正常,QUIC 数据通道重试中";
}
else
{
state = "quic_connecting";
reason = health.LastError ?? "控制会话已建立,正在建立 QUIC 测量会话";
state = "task_connecting";
reason = "控制连接正常,正在建立 QUIC 数据通道";
}
bool connected = state == "connected";
return new LinkDto(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, linked,
snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value), connected, probe.RuntimeStatus, agent.RuntimeStatus,
state, reason, health?.LastError, health?.RetryAttempt ?? 0, health?.RetryInMs);
bool inBackoff = health?.Control.State == ControlState.Backoff;
return new LinkDto(dialer.Agent.Id.Value, dialer.Agent.Name, listener.Agent.Id.Value, listener.Agent.Name, linked,
snapshot.OverrideOf(dialer.Agent.Id.Value, listener.Agent.Id.Value), state == "connected", dialer.RuntimeStatus, listener.RuntimeStatus,
state, reason, (inBackoff ? health!.Control.LastError : health?.Task.Quic.LastError)?.ToString(),
health is null ? 0 : inBackoff ? health.Control.RetryAttempt : health.Task.Quic.RetryAttempt,
health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs);
}
}
@@ -94,7 +94,7 @@ public static class SystemEndpoints
if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink))
{
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 拨号方 的 Agent 列表和各 Agent 允许的 拨号方
}
return result.TryGet(out IReadOnlyList<SettingView>? views, out FleetError? error)
@@ -117,7 +117,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", line.IpVersion, ct);
Result<TestRecord> result = await tests.StartAsync(line.Key.Listener, line.Key.Dialer, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct);
if (!result.TryGet(out TestRecord? test, out FleetError? error))
{
return Problems.From(http, error);
@@ -129,14 +129,14 @@ public static class TaskEndpoints
private static async Task<IResult> ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct)
{
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
// 只给监听方 id(并且有多条线路)时列出这个节点上所有线路的测试
NodeId agent;
ProbeId? probe = null;
NodeId? dialer = null;
string? ipVersion = null;
if (LineKey.TryParse(line_id, out LineKey key))
{
agent = key.Agent;
probe = key.Probe;
agent = key.Listener;
dialer = key.Dialer;
ipVersion = key.IpVersion;
}
else
@@ -145,7 +145,7 @@ public static class TaskEndpoints
}
return TypedResults.Json(
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null),
new PageDto<TestDto>([.. (await tests.ListAsync(agent, dialer, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null),
ApiJsonContext.Default.PageDtoTestDto);
}
@@ -39,7 +39,7 @@ public static class TrustEndpoints
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
{
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
foreach (string component in new[] { ReleaseService.AgentComponent })
{
if (await releases.GetAsync(component, ct) is not { } offer)
{
@@ -30,43 +30,51 @@ 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.IpVersion, ToDto(line.Line.QuicHealth));
line.Line.Key.Id, line.Line.Listener.Agent.Id.Value, line.Line.Dialer.Id.Value, line.Line.Dialer.Name, line.Line.Listener.Agent.Name, line.Line.Listener.Agent.Labels,
line.Line.Listener.Online, line.Line.Connected, line.Line.Listener.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.Link));
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
public static QuicLineHealthDto? ToDto(ReportedLink? snapshot)
{
if (snapshot is null)
{
return null;
}
ProbeQuicLinkHealth health = snapshot.Health;
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null;
LinkHealth link = snapshot.Health;
QuicHealth quic = link.Task.Quic;
double? Rate(long count) => quic.HandshakeAttempts > 0 ? count * 100.0 / quic.HandshakeAttempts : null;
return new QuicLineHealthDto(
health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts,
Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount,
health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents,
health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs), health.State switch
link.Ready, quic.HandshakeAttempts, quic.HandshakeSuccesses, quic.HandshakeFailures, quic.HandshakeTimeouts,
Rate(quic.HandshakeSuccesses), Rate(quic.HandshakeFailures), Rate(quic.HandshakeTimeouts), quic.HandshakeP95Ms, quic.HandshakeSampleCount,
quic.TransportPacketLossRate, quic.TransportPacketsSent, quic.TransportCongestionEvents,
quic.TransportEcnCongestionEvents, quic.PathMtu, Time(snapshot.AtMs), quic.State switch
{
ProbeLinkAttemptState.Connected => "connected",
ProbeLinkAttemptState.RetryWait => "retry_wait",
QuicState.Ready => "connected",
QuicState.Retrying => "retry_wait",
QuicState.Idle => "idle",
_ => "connecting",
}, health.RetryAttempt, health.RetryInMs);
}, quic.RetryAttempt, quic.RetryInMs, ControlStateName(link.Control.State));
}
private static string ControlStateName(ControlState state) => state switch
{
ControlState.Up => "up",
ControlState.Backoff => "backoff",
ControlState.Syncing => "syncing",
ControlState.Authenticating => "authenticating",
_ => "dialing",
};
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), p.IpVersion);
public static ConnectedDialerDto ToDto(ConnectedDialerInfo d) => new(d.Id, d.Version, d.Remote, Time(d.SinceMs), d.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), c.IpVersion))], v.RuntimeStatus, ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
public static OutgoingConnectionDto ToDto(OutgoingConnection c) => new(c.ListenerId.Value, c.ListenerName, c.ListenerOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion);
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.Line.Id, r.Line.Listener.Value, r.Line.Dialer.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.Line.IpVersion);
@@ -74,12 +82,12 @@ public static class Mappers
{
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,
t.Id.Value, t.AgentId.Value, t.DialerId.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), 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, t.IpVersion);
new(t.Id.Value, t.AgentId.Value, t.DialerId.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(
@@ -120,10 +128,10 @@ public static class Mappers
}
HeartbeatDto? heartbeat = view.Heartbeat is { } h
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
? new HeartbeatDto(h.Dialers.Count, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: 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.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Id.Value, a.Name, StatusName(a.Status), view.Online, a.Listens, view.DialerConnected, [.. view.Dialers.Select(ToDto)], [.. view.Outgoing.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.TcpPort, a.QuicPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, view.RuntimeStatus, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
@@ -153,29 +161,11 @@ public static class Mappers
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) =>
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), t.CreatedBy);
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
@@ -224,8 +214,8 @@ public static class Mappers
private static string? RoleName(ColumnRole role) => role switch
{
ColumnRole.TsProbe => "ts_probe",
ColumnRole.TsAgent => "ts_agent",
ColumnRole.TsDialer => "ts_dialer",
ColumnRole.TsListener => "ts_listener",
ColumnRole.TsLocal => "ts_local",
_ => null,
};
@@ -48,13 +48,13 @@ public static class ErrorCodeHttpMap
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
Add(
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
Add(409, MoShiroErrors.NameConflict, MoShiroErrors.ProbeRevoked, MoShiroErrors.ProbeOffline, MoShiroErrors.TestBusy, MoShiroErrors.AgentNotActive, MoShiroErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline);
Add(409, MoShiroErrors.NameConflict, MoShiroErrors.NodeRevoked, MoShiroErrors.DialerOffline, MoShiroErrors.NotListening, MoShiroErrors.TestBusy, MoShiroErrors.AgentNotActive, MoShiroErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline);
Add(410, MoShiroErrors.RawExpired, MoShiroErrors.JoinTokenExpired, FleetErrors.JoinTokenExpired);
Add(412, MoShiroErrors.PreconditionFailed);
Add(413, MoShiroErrors.PayloadTooLarge);
Add(422, MoShiroErrors.ProbeRequired, MoShiroErrors.RangeTooLarge, MoShiroErrors.InvalidStep, MoShiroErrors.InvalidRange, MoShiroErrors.UnknownMetric, MoShiroErrors.UnknownPreset, MoShiroErrors.TooManyMetrics, FleetErrors.Unsupported);
Add(422, MoShiroErrors.DialerRequired, MoShiroErrors.SelfLink, MoShiroErrors.RangeTooLarge, MoShiroErrors.InvalidStep, MoShiroErrors.InvalidRange, MoShiroErrors.UnknownMetric, MoShiroErrors.UnknownPreset, MoShiroErrors.TooManyMetrics, FleetErrors.Unsupported);
Add(429, MoShiroErrors.TooManyRequests, MoShiroErrors.TooManyStreams);
Add(503, MoShiroErrors.StorageUnavailable, FleetErrors.Timeout);
map[NotImplemented.Value] = 501;
@@ -73,7 +73,7 @@ internal static class QueryHelpers
return false;
}
/// <summary>线路 id(<c>agent~probe</c>,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。</summary>
/// <summary>线路 id(<c>listener~dialer</c>,或只有一条线路时的监听方 id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。</summary>
public static async ValueTask<Result<LineView>> FindVisibleLineAsync(HttpContext http, LineService lines, string id, CancellationToken ct)
{
Result<LineView> found = await lines.ResolveAsync(id, ct);
@@ -82,7 +82,7 @@ internal static class QueryHelpers
return Result<LineView>.Fail(error);
}
return line.Agent.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Agent.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Probe.RevokedAtMs is null
return line.Listener.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Listener.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Dialer.Status == Fleet.Server.NodeStatus.Active
? found
: Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
@@ -41,13 +41,14 @@ public sealed record AgentDto(
string Name,
string Status,
bool Online,
bool ProbeConnected,
IReadOnlyList<ConnectedProbeDto> Probes,
bool Listens,
bool DialerConnected,
IReadOnlyList<ConnectedDialerDto> Dialers,
IReadOnlyList<OutgoingConnectionDto> Outgoing,
HostDto? Host,
string PublicHost,
int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts,
IReadOnlyList<int> ProbeQuicPorts,
int TcpPort,
IReadOnlyList<int> QuicPorts,
IReadOnlyList<string> Labels,
bool PublicVisible,
IReadOnlyList<string> Kinds,
@@ -73,10 +74,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
/// <param name="PublicHostAuto">true:撤销手填,回到自动识别(<c>ipv6_host_auto</c> 同理)。手填 <c>public_host</c> 或 <c>ipv6_host</c> 就是手动,自动识别不再覆盖。</param>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
/// <param name="Isp">telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</param>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null, string? Isp = null);
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string? CreatedBy = null);
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
@@ -123,24 +124,25 @@ public sealed record TrustStateDto(bool Initialized, string? Genesis, int Versio
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
/// <summary>监听方眼中连着的一个拨号方(监听方上报)。</summary>
public sealed record ConnectedDialerDto(string DialerId, 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, string IpVersion = "ipv4");
/// <summary>这个节点现在连着的一个监听节点(监听方上报)。</summary>
public sealed record OutgoingConnectionDto(string ListenerId, string ListenerName, bool ListenerOnline, 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, NodeRuntimeStatus RuntimeStatus, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
/// <summary>
/// 一对(拨号方,监听方)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。
/// <c>connection_state</c>:<c>disabled</c>、<c>dialer_unavailable</c>、<c>listener_unavailable</c>、<c>waiting_report</c>、<c>control_connecting</c>、<c>control_down</c>(失联)、<c>task_connecting</c>、<c>task_retrying</c>、<c>connected</c>。
/// </summary>
public sealed record LinkDto(string DialerId, string DialerName, string ListenerId, string ListenerName, bool Linked, bool? Override, bool Connected, NodeRuntimeStatus DialerRuntimeStatus, NodeRuntimeStatus ListenerRuntimeStatus, string ConnectionState = "control_connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = 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, NodeRuntimeStatus ProbeRuntimeStatus, NodeRuntimeStatus AgentRuntimeStatus, string ConnectionState = "connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null);
/// <summary>线路目录里的一个节点:<c>listens</c> 表示它可以作为监听方(被拖到的目标);任何激活的节点都可以作为拨号方。</summary>
public sealed record LinkNodeDto(string Id, string Name, bool Listens, bool Online, NodeRuntimeStatus RuntimeStatus);
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkNodeDto> Nodes, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
public sealed record TaskDto(
string Id,
string Scope,
@@ -210,7 +212,7 @@ public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, l
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
public sealed record NodeBuildDto(string Id, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
@@ -62,12 +62,9 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
[JsonSerializable(typeof(CreateJoinTokenRequest))]
[JsonSerializable(typeof(CreatedJoinTokenDto))]
[JsonSerializable(typeof(PatchProbeRequest))]
[JsonSerializable(typeof(ProbeDto))]
[JsonSerializable(typeof(PageDto<HostItemDto>))]
[JsonSerializable(typeof(HostSeriesDto))]
[JsonSerializable(typeof(LiveHostDto))]
[JsonSerializable(typeof(PageDto<ProbeDto>))]
[JsonSerializable(typeof(TaskDto))]
[JsonSerializable(typeof(PageDto<TaskDto>))]
[JsonSerializable(typeof(CreateTaskRequest))]
@@ -19,19 +19,19 @@ public sealed record TestSummaryDto(string Id, string Plan, string Status, DateT
public sealed record OverviewLineDto(
string Id,
string AgentId,
string ProbeId,
string ProbeName,
string DialerId,
string DialerName,
string Name,
IReadOnlyList<string> Labels,
bool Online,
bool ProbeConnected,
bool DialerConnected,
int EndpointChanges,
SummaryDto Summary,
TestSummaryDto? LastTest,
string IpVersion = "ipv4",
QuicLineHealthDto? QuicHealth = null);
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
/// <summary>Latest QUIC data-channel health reported by the dialing node, plus the control-layer state it runs on; cumulative counters reset when that node restarts.</summary>
public sealed record QuicLineHealthDto(
bool Connected,
long HandshakeAttempts,
@@ -51,7 +51,8 @@ public sealed record QuicLineHealthDto(
DateTimeOffset At,
string State = "connecting",
int RetryAttempt = 0,
long? RetryInMs = null);
long? RetryInMs = null,
string ControlState = "dialing");
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
@@ -59,7 +60,7 @@ public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffs
public sealed record SeriesDto(
string LineId,
string AgentId,
string ProbeId,
string DialerId,
DateTimeOffset From,
DateTimeOffset To,
string Step,
@@ -99,16 +100,16 @@ public sealed record HeatmapDto(
IReadOnlyList<double> Edges,
IReadOnlyList<IReadOnlyList<long>> Counts);
/// <summary>一条线路 = 一对(Agent,Probe);<c>id</c> 是 <c>&lt;agent_id&gt;~&lt;probe_id&gt;</c>。</summary>
/// <summary>一条线路 = 一对(监听方,拨号方);<c>agent_id</c> 是监听方,<c>id</c> 是 <c>&lt;agent_id&gt;~&lt;dialer_id&gt;</c>。</summary>
public sealed record LineDto(
string Id,
string AgentId,
string ProbeId,
string ProbeName,
string DialerId,
string DialerName,
string Name,
IReadOnlyList<string> Labels,
bool Online,
bool ProbeConnected,
bool DialerConnected,
DateTimeOffset? DataSince,
IReadOnlyList<string> Kinds,
TestSummaryDto? LastTest,
@@ -132,7 +133,7 @@ public sealed record TestResultDto(
public sealed record TestDto(
string Id,
string AgentId,
string ProbeId,
string DialerId,
string Plan,
IReadOnlyList<string> Phases,
string Status,
@@ -201,9 +202,9 @@ public sealed record HostDto(
long? UptimeSeconds,
DateTimeOffset At);
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>一个节点的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
public sealed record NodeInfoDto(string Id, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
@@ -211,10 +212,9 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record HostItemDto(string Id, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
public sealed record LiveHostDto(string Id, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -19,17 +19,17 @@ public static class InstallScripts
public static string Script(string resource) => Read(resource);
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
public static string? ComposeResource(string file) => file is "agent.yaml" or "backend.yaml" ? "compose-" + file : null;
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
/// <summary>某个角色(agent)的 compose 文件的 SHA-256;其他角色返回空。</summary>
public static string ComposeSha256(string? role) => role is "agent" ? Sha256("compose-" + role + ".yaml") : string.Empty;
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>、<c>probe.container</c>);不是这两个就是 null。</summary>
public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null;
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>);不是这两个就是 null。</summary>
public static string? QuadletResource(string file) => file is "agent.container" ? "quadlet-" + file : null;
/// <summary>某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty;
/// <summary>某个角色(agent)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
public static string QuadletSha256(string? role) => role is "agent" ? Sha256("quadlet-" + role + ".container") : string.Empty;
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
@@ -12,10 +12,8 @@
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-agent.container" LogicalName="quadlet-agent.container" />
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-probe.container" LogicalName="quadlet-probe.container" />
</ItemGroup>
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
+10 -11
View File
@@ -22,7 +22,7 @@ public static class OpsEndpoints
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript"))
.Api("install_openwrt", "The static native OpenWrt Probe installer and updater", Tag)
.Api("install_openwrt", "The static native OpenWrt installer and updater (Agent)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
@@ -31,30 +31,29 @@ public static class OpsEndpoints
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
.ExcludeFromDescription();
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: false))
.Api("install_manifest", "The signed release manifest an install code may fetch (exact signed bytes)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: true))
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
// compose 文件:Agent、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
.Api("install_compose", "The static compose file of the agent or the backend (agent.yaml, backend.yaml; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404);
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):统一 Agent 单元,静态,命令里先核对 SHA-256。
app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file"))
.Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag)
.Api("install_quadlet", "The static Podman Quadlet unit of the agent (agent.container; same bytes for everyone)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
}
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code, bool signature)
{
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
ReleaseOffer? offer = !string.IsNullOrEmpty(code) && await tokens.IsUsableAsync(code, http.RequestAborted) ? await releases.GetOrFetchAsync(ReleaseService.AgentComponent, http.RequestAborted) : null;
if (offer is null)
{
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
@@ -8,26 +8,24 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
/// 节点的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
/// 和别的公开接口一样:节点看得见才给(隐藏的只有登录后才看得见)。
/// </summary>
public static class HostEndpoints
{
private const string Tag = "public";
private const string AgentKind = "agent";
private const string ProbeKind = "probe";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/hosts", ListAsync)
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag)
.Api("hosts_list", "Latest host resource usage of every visible node (no hardware model, no addresses)", Tag)
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/hosts/{node_id}", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
.Api("hosts_history", "Host resource usage history of one node (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct)
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
{
var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct))
@@ -38,37 +36,16 @@ public static class HostEndpoints
}
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
}
foreach (ProbeView view in await probes.ListAsync(ct))
{
if (view.Revoked)
{
continue;
}
(HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id));
items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host)));
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
}
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
}
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
{
bool visible;
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal))
{
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http));
}
else
{
visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false };
}
if (!visible)
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found");
}
@@ -32,7 +32,7 @@ public static class LiveEndpoints
}
private static async Task<IResult> StreamAsync(
HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
HttpContext http, AgentService agents, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
{
string ipVersion = ip_version ?? IpVersions.V4;
@@ -84,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, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
var session = new LiveSession(http, agents, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
await session.RunAsync(linked.Token);
}
@@ -105,7 +105,6 @@ public static class LiveEndpoints
{
private readonly HttpContext _http;
private readonly AgentService _agents;
private readonly ProbeService _probes;
private readonly HostStatusService _hosts;
private readonly LineService _lines;
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
@@ -126,11 +125,10 @@ public static class LiveEndpoints
private long _lastWriteMs;
private long _lastPeriodicMs;
public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
public LiveSession(HttpContext http, AgentService agents, HostStatusService hosts, 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;
_probes = probes;
_hosts = hosts;
_lines = lines;
_lineKeys = lineKeys;
@@ -220,20 +218,10 @@ public static class LiveEndpoints
foreach (HostSignal host in hostSignals)
{
if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe)
{
await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
continue;
}
AgentView? view = await _agents.GetAsync(host.Node, ct);
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
{
await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
await WriteAsync("host", new LiveHostDto(host.Node.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
@@ -253,7 +241,7 @@ public static class LiveEndpoints
if (dirtyLines.Length > 0)
{
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Agent, _lineKeys[t.Raw].Series))))
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Listener, _lineKeys[t.Raw].Series))))
{
await PushSeriesAsync(topic, ct);
}
@@ -298,15 +286,7 @@ public static class LiveEndpoints
case LiveTopicKind.Hosts:
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
{
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
}
foreach (ProbeView view in await _probes.ListAsync(ct))
{
if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host)
{
await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct);
}
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
}
break;
@@ -16,7 +16,7 @@ public static class NodeEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/nodes", ListAsync)
.Api("nodes_list", "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", Tag)
.Api("nodes_list", "Display info of the visible nodes: system, architecture, country and ISP (never an address)", Tag)
.Returns<PageDto<NodeInfoDto>>().ProducesProblems(403);
group.MapGet("/about", AboutAsync)
.Api("about_get", "The backend's version, tag and build time, if the site shows build info", Tag)
@@ -36,9 +36,9 @@ public static class NodeEndpoints
return TypedResults.Json(new AboutDto(show, show ? Backend(withCommit: false) : null), ApiJsonContext.Default.AboutDto);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
{
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
// 和别的公开接口一样:看得见的节点(隐藏的只有登录后才看得见)
var visible = new HashSet<string>(StringComparer.Ordinal);
foreach (AgentView view in await agents.ListAsync(ct))
{
@@ -48,20 +48,12 @@ public static class NodeEndpoints
}
}
foreach (ProbeView probe in await probes.ListAsync(ct))
{
if (!probe.Revoked)
{
visible.Add(probe.Probe.Id);
}
}
// 版本和 tag 只在站点打开「显示构建信息」时才给访客;关着就是 null
bool showBuild = await settings.IsShowBuildAsync(ct);
var items = (await nodes.ListAsync(ct))
.Where(n => visible.Contains(n.NodeId))
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
.Select(n => new NodeInfoDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
@@ -96,13 +96,13 @@ public static class QueryEndpoints
return Problems.From(http, error);
}
AgentView view = line.Agent;
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct);
AgentView view = line.Listener;
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, line.Dialer.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), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
line.Key.Id, view.Agent.Id.Value, line.Dialer.Id.Value, line.Dialer.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), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.Link)),
ApiJsonContext.Default.LineDto);
}
@@ -244,7 +244,7 @@ public static class QueryEndpoints
string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault();
if (key is null)
{
return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal);
return lines.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal);
}
bool descending = order is not null
@@ -255,6 +255,6 @@ public static class QueryEndpoints
IOrderedEnumerable<OverviewLine> ordered = descending
? withValue.OrderByDescending(l => l.Summary.Values[key])
: withValue.OrderBy(l => l.Summary.Values[key]);
return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal));
return ordered.ThenBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal));
}
}
@@ -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}";
@@ -0,0 +1,35 @@
using System.Text.Json;
using Fleet.Rpc;
using Fleet.Server;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Gateway;
/// <summary>期望状态:为每个节点下发它要拨出的监听节点列表(地址、端口、证书公钥)。节点集合或线路变化时由 <c>NotifyAllChanged</c> 一起重算推送。</summary>
public sealed class DialTargetsStateProvider : IStateProvider
{
private readonly AgentService _agents;
private readonly IAgentStore _store;
public DialTargetsStateProvider(AgentService agents, IAgentStore store)
{
_agents = agents;
_store = store;
}
public string Type => DialTargets.StateType;
public async ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct)
{
if (await _store.GetAsync(node.Id, ct) is not { Status: NodeStatus.Active } dialer)
{
return null;
}
DialTargets targets = await _agents.DialTargetsAsync(dialer, ct);
return new StateDocument(targets.Version, JsonSerializer.SerializeToElement(targets, MoShiroJsonContext.Default.DialTargets));
}
public ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct) => ValueTask.CompletedTask;
}
@@ -50,10 +50,10 @@ public sealed class FleetGateway : IAgentGateway
await Connections.RequestAsync(
agent, MoShiroMethods.TestCancel, new TestCancelParams { TestId = test }, MoShiroJsonContext.Default.TestCancelParams, MoShiroJsonContext.Default.TestRunResult, ct);
public async ValueTask<Result<bool>> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct)
public async ValueTask<Result<bool>> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct)
{
Result<TestRunResult> result = await Connections.RequestAsync(
agent, MoShiroMethods.ProbeDisconnect, new ProbeDisconnectParams { ProbeId = probe }, MoShiroJsonContext.Default.ProbeDisconnectParams, MoShiroJsonContext.Default.TestRunResult, ct);
listener, MoShiroMethods.DialerDisconnect, new DialerDisconnectParams { DialerId = dialer }, MoShiroJsonContext.Default.DialerDisconnectParams, MoShiroJsonContext.Default.TestRunResult, ct);
if (!result.TryGet(out TestRunResult? done, out FleetError? error))
{
return Result<bool>.Fail(error.Code == FleetErrors.NodeOffline ? MoShiroErrors.AgentOffline : error.Code, error.Message);
@@ -64,8 +64,8 @@ public sealed class FleetGateway : IAgentGateway
public ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct) => Connections.DisconnectAsync(agent, reason, ct);
public ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) =>
Connections.NotifyAsync(probe, MoShiroMethods.ProbeLinkRetry, new ProbeLinkRetryParams { AgentId = agent }, MoShiroJsonContext.Default.ProbeLinkRetryParams, ct);
public ValueTask<bool> NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) =>
Connections.NotifyAsync(dialer, MoShiroMethods.LinkRetry, new LinkRetryParams { ListenerId = listener }, MoShiroJsonContext.Default.LinkRetryParams, ct);
public async ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct) =>
await Connections.NotifyAsync(node, MoShiroMethods.UpdateOffer, new UpdateOfferParams { Manifest = manifest, Signature = signature }, MoShiroJsonContext.Default.UpdateOfferParams, ct);
@@ -10,7 +10,7 @@ namespace MoShiro.Backend.Gateway;
public static class GatewayServiceCollectionExtensions
{
/// <summary>
/// 在 Fleet.Server 之上接入 MoShiro:期望状态(TaskSet)、RawChunk 收件箱、<c>moshiro.*</c> 方法、注册挂钩和节点事件。
/// 在 Fleet.Server 之上接入 MoShiro:期望状态(TaskSet、DialTargets)、RawChunk 收件箱、<c>moshiro.*</c> 方法、注册挂钩和节点事件。
/// 需要事先注册 <see cref="IFleetStore"/> 和 <see cref="TimeProvider"/>,并在 <c>AddMoShiroApplication</c> 之后调用。
/// </summary>
public static IServiceCollection AddMoShiroGateway(this IServiceCollection services)
@@ -18,15 +18,11 @@ public static class GatewayServiceCollectionExtensions
services.AddSingleton<IAgentGateway, FleetGateway>();
services.AddHostedService<ConnectionShutdown>();
services.AddFleetEnrollment();
services.AddFleetServer(options =>
{
options.NodeIdPrefix = AgentIds.Prefix;
options.NodeIdPrefixes[JoinTokenService.ProbeRole] = ProbeId.Prefix;
})
services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix)
.AddState<TaskSetStateProvider>()
.AddState<ProbeAgentsStateProvider>()
.AddState<DialTargetsStateProvider>()
.AddInbox<RawChunkInbox>()
.WithMethods(MoShiroMethodTable.Table, node => NodeRoles.Of(node.Id.Value))
.WithMethods(MoShiroMethodTable.Table)
.MapMethods(MapMoShiroMethods);
return services;
}
@@ -1,33 +0,0 @@
using System.Text.Json;
using Fleet.Rpc;
using Fleet.Server;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Gateway;
/// <summary>期望状态:为每个 Probe 下发要连接的 Agent 列表(地址、端口、证书指纹)。Agent 集合变化时由 <c>NotifyAllChanged</c> 一起重算推送。</summary>
public sealed class ProbeAgentsStateProvider : IStateProvider
{
private readonly AgentService _agents;
public ProbeAgentsStateProvider(AgentService agents)
{
_agents = agents;
}
public string Type => ProbeAgentList.StateType;
public async ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct)
{
if (!node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
return null;
}
ProbeAgentList list = await _agents.ProbeAgentsAsync(node.Id.Value, ct);
return new StateDocument(list.Version, JsonSerializer.SerializeToElement(list, MoShiroJsonContext.Default.ProbeAgentList));
}
public ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct) => ValueTask.CompletedTask;
}
@@ -8,7 +8,7 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Gateway;
/// <summary>
/// 期望状态:为每个 Agent 下发完整的 <see cref="TaskSet"/> 快照(有效任务集 + Probe 公钥)。
/// 期望状态:为每个 Agent 下发完整的 <see cref="TaskSet"/> 快照(有效任务集 + 允许拨入的拨号方公钥)。
/// 节点应用之后回传 <c>applied_version</c> 和 <c>rejected[]</c>(<c>unsupported</c>、<c>budget_exceeded</c>、<c>policy_denied</c>),面板上可以看到。
/// </summary>
public sealed class TaskSetStateProvider : IStateProvider
@@ -26,11 +26,6 @@ public sealed class TaskSetStateProvider : IStateProvider
public async ValueTask<StateDocument?> GetAsync(NodeRecord node, CancellationToken ct)
{
if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
{
return null; // Probe 不运行任务集
}
DesiredTaskSet? desired = await _planner.GetDesiredAsync(node.Id, ct);
return desired is null ? null : new StateDocument(desired.Version, JsonSerializer.SerializeToElement(desired.Document, MoShiroJsonContext.Default.TaskSet));
}
@@ -2,267 +2,36 @@ using Microsoft.Data.Sqlite;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
/// <summary>
/// 表结构。版本记录在 <c>PRAGMA user_version</c>。当前只有一个版本:不是这个版本的数据库(包括更早的构建留下的)不会被迁移,
/// 启动时报错,删除 <c>moshiro.db</c> 和 <c>raw/</c> 之后重新开始。以后的结构变更按版本号追加内置 SQL。
/// </summary>
internal static class Schema
{
public const int CurrentVersion = 16;
public const int CurrentVersion = 1;
public static void Migrate(SqliteConnection connection)
{
int version = connection.Scalar<int>("PRAGMA user_version");
if (version > CurrentVersion)
if (version == CurrentVersion)
{
throw new InvalidOperationException($"database schema version {version} is newer than this build supports ({CurrentVersion})");
return;
}
if (version < 1)
if (version != 0)
{
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec(V1);
connection.Exec("PRAGMA user_version = 1");
transaction.Commit();
throw new InvalidOperationException($"database schema version {version} is not supported by this build (expected {CurrentVersion}); delete moshiro.db and the raw/ directory to start over");
}
if (version < 2)
{
// 测试属于一条线路(Probe,Agent):旧测试没有 Probe,留空
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE tests ADD COLUMN probe_id TEXT NOT NULL DEFAULT ''");
connection.Exec("CREATE INDEX ix_tests_line ON tests (agent_id, probe_id, requested_at)");
connection.Exec("PRAGMA user_version = 2");
transaction.Commit();
}
if (version < 3)
{
// 主机资源占用的历史(每个 Agent 每分钟一行)
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("""
CREATE TABLE host_samples (
agent_id TEXT NOT NULL,
at INTEGER NOT NULL,
cpu REAL,
mem_used INTEGER,
mem_total INTEGER,
disk_used INTEGER,
disk_total INTEGER,
net_rx_bps INTEGER,
net_tx_bps INTEGER,
uptime INTEGER,
PRIMARY KEY (agent_id, at)
) WITHOUT ROWID;
""");
connection.Exec("PRAGMA user_version = 3");
transaction.Commit();
}
if (version < 4)
{
// 节点的展示信息:系统、架构、国家、运营商;geo_host 是查询用的地址,只在 Backend 内部用
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("""
CREATE TABLE node_meta (
node_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
os TEXT,
os_name TEXT,
arch TEXT,
country TEXT,
isp_key TEXT,
isp_name TEXT,
geo_host TEXT,
geo_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
) WITHOUT ROWID
""");
connection.Exec("PRAGMA user_version = 4");
transaction.Commit();
}
if (version < 5)
{
// 节点自报的构建信息:版本、tag、提交、构建时间
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE node_meta ADD COLUMN version TEXT");
connection.Exec("ALTER TABLE node_meta ADD COLUMN tag TEXT");
connection.Exec("ALTER TABLE node_meta ADD COLUMN commit_id TEXT");
connection.Exec("ALTER TABLE node_meta ADD COLUMN built_at TEXT");
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();
}
if (version < 8)
{
// Probe 用加入令牌注册:令牌带种类;Probe 有两把独立的密钥,对等面公钥单独存
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE join_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''");
connection.Exec("ALTER TABLE probes ADD COLUMN peer_pubkey BLOB");
connection.Exec("PRAGMA user_version = 8");
transaction.Commit();
}
if (version < 9)
{
// 对外地址自动识别:手填的标记为 manual,自动识别不会覆盖;Agent 上报的地址存下来,撤销手填时马上能重新识别
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE agents ADD COLUMN public_host_manual INTEGER NOT NULL DEFAULT 0");
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host_manual INTEGER NOT NULL DEFAULT 0");
connection.Exec("ALTER TABLE agents ADD COLUMN reported_addresses TEXT NOT NULL DEFAULT '[]'");
connection.Exec("PRAGMA user_version = 9");
transaction.Commit();
}
if (version < 10)
{
// 预握手敲门:Probe 需要 Agent 的证书公钥(不只是指纹)来派生配对密钥,由 Agent 在 hello 里上报
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE agents ADD COLUMN tls_pubkey BLOB");
connection.Exec("PRAGMA user_version = 10");
transaction.Commit();
}
if (version < 11)
{
// 权限从三个作用域改成细粒度的能力。破坏性变更,不做兼容:旧的 API 令牌全部作废,需要重新创建
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("DROP TABLE api_tokens");
connection.Exec("""
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities TEXT NOT NULL,
token_sha256 BLOB NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER,
revoked_at INTEGER
)
""");
connection.Exec("PRAGMA user_version = 11");
transaction.Commit();
}
if (version < 12)
{
// passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("""
CREATE TABLE passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
last_used_at INTEGER
)
""");
connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)");
connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'");
connection.Exec("PRAGMA user_version = 12");
transaction.Commit();
}
if (version < 13)
{
// 归属:加入码是谁创建的,节点是用哪个加入码(也就是谁)加进来的;审计日志
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT");
connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT");
connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT");
connection.Exec("""
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
user_id TEXT,
actor TEXT NOT NULL,
method TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
remote TEXT,
success INTEGER NOT NULL
)
""");
connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)");
connection.Exec(
"""
CREATE TABLE operator_sets (
version INTEGER PRIMARY KEY,
statement TEXT NOT NULL,
credential_id TEXT NOT NULL,
authenticator_data TEXT NOT NULL,
client_data_json TEXT NOT NULL,
signature TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""");
connection.Exec("PRAGMA user_version = 13");
transaction.Commit();
}
if (version < 14)
{
// 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec(
"""
CREATE TABLE node_bindings (
node_id TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
seq INTEGER NOT NULL,
signed INTEGER NOT NULL,
bound_at INTEGER NOT NULL,
violation TEXT,
violation_at INTEGER
)
""");
connection.Exec("PRAGMA user_version = 14");
transaction.Commit();
}
if (version < 15)
{
// 运行时二进制绑定整体移除(节点自报的哈希不可信,来源证明在安装时):旧版本留下的表不再使用
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("DROP TABLE IF EXISTS node_bindings");
connection.Exec("PRAGMA user_version = 15");
transaction.Commit();
}
if (version < 16)
{
// 运营商可以手动指定(自动识别会错,例如 CN2 线路):手动的不会被后面的 IP 归属查询覆盖
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE node_meta ADD COLUMN isp_manual INTEGER NOT NULL DEFAULT 0");
connection.Exec("PRAGMA user_version = 16");
transaction.Commit();
}
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec(V1);
connection.Exec("PRAGMA user_version = 1");
transaction.Commit();
}
/// <summary>
/// 新增 Kind 或指标都不需要改表结构:指标是 <c>metric_*.metrics</c> 里的一个键,Kind 只是 <c>raw_chunks.kind</c> 的一个取值。
/// 节点只有一张表(<c>agents</c>):监听的节点有地址和端口,不监听的节点只拨出。线路 <c>(agent_id, series)</c> 里的 agent_id 是监听方,series 里是拨号方。
/// </summary>
private const string V1 = """
CREATE TABLE agents (
@@ -271,9 +40,17 @@ internal static class Schema
status TEXT NOT NULL,
pubkey BLOB NOT NULL,
tls_spki BLOB NOT NULL,
tls_pubkey BLOB NOT NULL,
listens INTEGER NOT NULL DEFAULT 0,
public_host TEXT NOT NULL DEFAULT '',
probe_tcp_port INTEGER NOT NULL DEFAULT 7443,
probe_udp_ports TEXT NOT NULL DEFAULT '[7443]',
tcp_port INTEGER NOT NULL DEFAULT 0,
quic_ports TEXT NOT NULL DEFAULT '[]',
ipv6_host TEXT NOT NULL DEFAULT '',
ipv6_supported INTEGER NOT NULL DEFAULT 0,
ipv6_diagnostics TEXT,
public_host_manual INTEGER NOT NULL DEFAULT 0,
ipv6_host_manual INTEGER NOT NULL DEFAULT 0,
reported_addresses TEXT NOT NULL DEFAULT '[]',
labels TEXT NOT NULL DEFAULT '[]',
public_visible INTEGER NOT NULL DEFAULT 1,
kinds TEXT NOT NULL DEFAULT '[]',
@@ -284,12 +61,67 @@ internal static class Schema
rejected TEXT,
task_hash TEXT,
meta TEXT,
added_by TEXT,
created_at INTEGER NOT NULL,
last_seen_at INTEGER,
row_version INTEGER NOT NULL DEFAULT 0
);
-- 同名节点只允许一个已建立的(pending 的可以重名,等待管理员接管或改名)
CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending';
CREATE TABLE analysis_queue (
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
window_start INTEGER NOT NULL,
not_before INTEGER NOT NULL,
reason TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (agent_id, series, window_start)
) WITHOUT ROWID;
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities TEXT NOT NULL,
token_sha256 BLOB NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER,
revoked_at INTEGER
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
user_id TEXT,
actor TEXT NOT NULL,
method TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
remote TEXT,
success INTEGER NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY,
agent_id TEXT,
kind TEXT NOT NULL,
at INTEGER NOT NULL,
detail TEXT
);
CREATE TABLE host_samples (
agent_id TEXT NOT NULL,
at INTEGER NOT NULL,
cpu REAL,
mem_used INTEGER,
mem_total INTEGER,
disk_used INTEGER,
disk_total INTEGER,
net_rx_bps INTEGER,
net_tx_bps INTEGER,
uptime INTEGER,
PRIMARY KEY (agent_id, at)
) WITHOUT ROWID;
CREATE TABLE join_tokens (
id TEXT PRIMARY KEY,
@@ -301,41 +133,105 @@ internal static class Schema
uses INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL,
revoked_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE probes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
pubkey BLOB NOT NULL,
created_at INTEGER NOT NULL,
revoked_at INTEGER
kind TEXT NOT NULL DEFAULT '',
created_by TEXT
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
password_salt BLOB NOT NULL,
iterations INTEGER NOT NULL,
CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_versions (
metric_id TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
recompute_from INTEGER,
recompute_cursor INTEGER
);
CREATE TABLE node_meta (
node_id TEXT PRIMARY KEY,
os TEXT,
os_name TEXT,
arch TEXT,
country TEXT,
isp_key TEXT,
isp_name TEXT,
isp_manual INTEGER NOT NULL DEFAULT 0,
geo_host TEXT,
geo_at INTEGER NOT NULL DEFAULT 0,
version TEXT,
tag TEXT,
commit_id TEXT,
built_at TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
) WITHOUT ROWID;
CREATE TABLE operator_sets (
version INTEGER PRIMARY KEY,
statement TEXT NOT NULL,
credential_id TEXT NOT NULL,
authenticator_data TEXT NOT NULL,
client_data_json TEXT NOT NULL,
signature TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE raw_chunks (
chunk_id TEXT PRIMARY KEY,
segment_id INTEGER NOT NULL REFERENCES raw_segments(id),
offset INTEGER NOT NULL,
length INTEGER NOT NULL,
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
kind TEXT NOT NULL,
task_id TEXT,
test_id TEXT,
phase INTEGER,
start_ts INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
partial INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE raw_segments (
id INTEGER PRIMARY KEY,
day TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
bytes INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE rollup_dirty (
level INTEGER NOT NULL,
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
bucket_start INTEGER NOT NULL,
PRIMARY KEY (level, agent_id, series, bucket_start)
) WITHOUT ROWID;
CREATE TABLE sessions (
id_sha256 BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token BLOB NOT NULL,
auth_method TEXT NOT NULL DEFAULT 'password',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
scope TEXT NOT NULL,
token_sha256 BLOB NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE tasks (
@@ -352,9 +248,17 @@ internal static class Schema
row_version INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE test_results (
test_id TEXT PRIMARY KEY,
metrics BLOB NOT NULL,
series BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE tests (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
dialer_id TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'ipv4',
plan TEXT NOT NULL,
params TEXT NOT NULL,
trigger TEXT NOT NULL,
@@ -366,79 +270,42 @@ internal static class Schema
started_at INTEGER,
finished_at INTEGER
);
CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at);
CREATE TABLE test_results (
test_id TEXT PRIMARY KEY,
metrics BLOB NOT NULL,
series BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE raw_segments (
id INTEGER PRIMARY KEY,
day TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
bytes INTEGER NOT NULL DEFAULT 0
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
password_salt BLOB NOT NULL,
iterations INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE raw_chunks (
chunk_id TEXT PRIMARY KEY,
segment_id INTEGER NOT NULL REFERENCES raw_segments(id),
offset INTEGER NOT NULL,
length INTEGER NOT NULL,
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
kind TEXT NOT NULL,
task_id TEXT,
test_id TEXT,
phase INTEGER,
start_ts INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
partial INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts);
CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL;
CREATE INDEX ix_raw_segment ON raw_chunks (segment_id);
CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID;
CREATE INDEX ix_audit_time ON audit_log (at);
CREATE INDEX ix_events_agent ON events (agent_id, at);
CREATE INDEX ix_events_time ON events (at);
CREATE INDEX ix_metric_10s_time ON metric_10s (window_start);
CREATE INDEX ix_metric_1m_time ON metric_1m (window_start);
CREATE INDEX ix_metric_5m_time ON metric_5m (window_start);
CREATE TABLE metric_versions (
metric_id TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
recompute_from INTEGER,
recompute_cursor INTEGER
);
CREATE INDEX ix_passkeys_user ON passkeys (user_id);
CREATE TABLE analysis_queue (
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
window_start INTEGER NOT NULL,
not_before INTEGER NOT NULL,
reason TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (agent_id, series, window_start)
) WITHOUT ROWID;
CREATE INDEX ix_queue_due ON analysis_queue (not_before);
CREATE TABLE rollup_dirty (
level INTEGER NOT NULL,
agent_id TEXT NOT NULL,
series TEXT NOT NULL,
bucket_start INTEGER NOT NULL,
PRIMARY KEY (level, agent_id, series, bucket_start)
) WITHOUT ROWID;
CREATE TABLE events (
id INTEGER PRIMARY KEY,
agent_id TEXT,
kind TEXT NOT NULL,
at INTEGER NOT NULL,
detail TEXT
);
CREATE INDEX ix_events_time ON events (at);
CREATE INDEX ix_events_agent ON events (agent_id, at);
CREATE INDEX ix_raw_segment ON raw_chunks (segment_id);
CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts);
CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL;
CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at);
CREATE INDEX ix_tests_path ON tests (agent_id, dialer_id, ip_version, requested_at);
CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending';
""";
}
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Storage.Sqlite;
public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{
private const string AgentColumns =
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by";
"id, name, status, pubkey, tls_spki, public_host, tcp_port, quic_ports, labels, public_visible, kinds, agent_version, local_limits, " +
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, listens";
private readonly SqliteDatabase _db;
@@ -35,10 +35,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
_db.ExecuteAsync(c =>
{
c.Exec(
"UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, probe_tcp_port = @tcp, probe_udp_ports = @udp, " +
"UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, tcp_port = @tcp, quic_ports = @quic, listens = @listens, " +
"kinds = CASE WHEN kinds = '[]' THEN @kinds ELSE kinds END, row_version = row_version + 1 WHERE id = @id",
("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.ProbeTcpPort),
("@udp", StorageJson.ToJson(enrollment.ProbeUdpPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds)));
("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.TcpPort), ("@listens", enrollment.TcpPort > 0 ? 1 : 0),
("@quic", StorageJson.ToJson(enrollment.QuicPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds)));
}, ct);
public ValueTask<Result<AgentRecord>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) =>
@@ -111,8 +111,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
// 旧记录换成新公钥,历史数据保留;待审批记录删除
c.Exec("DELETE FROM agents WHERE id = @id", ("@id", pendingId.Value));
c.Exec(
"UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id",
("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki));
"UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, tls_pubkey = @peer, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id",
("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki), ("@peer", pending.TlsPublicKey));
return Result<AgentRecord>.Ok(GetInternal(c, new NodeId(existingId))!);
}, ct);
@@ -122,13 +122,15 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
public ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
ListenInfo? listen = hello.Listen;
c.Exec(
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), tls_pubkey = COALESCE(@pub, tls_pubkey), " +
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " +
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), listens = @listens, " +
"tcp_port = COALESCE(@tcp, tcp_port), quic_ports = COALESCE(@quic, quic_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " +
"reported_addresses = COALESCE(@addr, reported_addresses), 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), ("@pub", hello.TlsPublicKey), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson),
("@addr", hello.Addresses is null ? null : StorageJson.ToJson(hello.Addresses)));
("@listens", listen is null ? 0 : 1), ("@tcp", listen?.TcpPort), ("@quic", listen is null ? null : StorageJson.ToJson(listen.QuicPorts)),
("@v6", listen?.Ipv6Supported == true ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson),
("@addr", listen?.Addresses is null ? null : StorageJson.ToJson(listen.Addresses)));
}, ct);
public ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct) =>
@@ -141,6 +143,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
("@id", id.Value), ("@host", publicHost), ("@v6", ipv6Host));
}, ct);
public ValueTask SetIpv6DiagnosticsAsync(NodeId id, string diagnosticsJson, CancellationToken ct) =>
_db.ExecuteAsync(c => c.Exec("UPDATE agents SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id.Value)), ct);
public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) =>
_db.ExecuteAsync(c => c.Exec("UPDATE agents SET task_version = @v, task_hash = @h WHERE id = @id", ("@id", id.Value), ("@v", version), ("@h", taskHash)), ct);
@@ -173,53 +178,20 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
public ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct) =>
_db.WriteAsync(c =>
{
if (node.Kind == JoinTokenService.ProbeRole)
{
return CreateProbe(c, node);
}
if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id))
{
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a node with this name already exists");
}
c.Exec(
"INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)",
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki),
"INSERT INTO agents (id, name, status, pubkey, tls_spki, tls_pubkey, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @peer, @labels, @meta, @version, @now, @by)",
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), ("@peer", node.TlsPublicKey),
("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy));
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
}, ct);
/// <summary>Probe 用加入令牌注册:直接进 probes 表,有令牌就算批准(Probe 没有 pending 状态);两把公钥都是它自己生成的。</summary>
private static Result<NodeRecord> CreateProbe(SqliteConnection c, NewNode node)
{
if (node.TlsPublicKey is not { Length: > 0 } peerKey)
{
return Result<NodeRecord>.Fail(FleetErrors.InvalidParams, "a probe needs its peer key");
}
if (c.Scalar<long>("SELECT COUNT(*) FROM probes WHERE name = @name COLLATE NOCASE", ("@name", node.Name)) > 0)
{
return Result<NodeRecord>.Fail(FleetErrors.NameConflict, "a probe with this name already exists");
}
c.Exec(
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)",
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy));
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
}
/// <summary>Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。</summary>
async ValueTask<NodeRecord?> IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) =>
await GetAsync(id, ct) is { } agent
? ToNode(agent, null)
: await _db.ReadAsync(
c => c.QueryOne(
"SELECT id, name, pubkey, created_at, revoked_at FROM probes WHERE id = @id",
r => new NodeRecord(
new NodeId(r.GetString(0)), r.GetString(1), r.IsDBNull(4) ? NodeStatus.Active : NodeStatus.Disabled, r.Bytes(2), [], [], null, null, r.GetInt64(3), null),
("@id", id.Value)),
ct);
ValueTask<NodeRecord?> IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) =>
_db.ReadAsync(c => GetInternal(c, id) is { } agent ? ToNode(agent, null) : null, ct);
public ValueTask<NodeRecord?> FindEstablishedNodeByNameAsync(string name, CancellationToken ct) =>
_db.ReadAsync(
@@ -268,7 +240,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
r.GetInt64(23) != 0,
StorageJson.Strings(r.GetString(24)),
r.NullableBytes(25),
r.NullableString(26));
r.NullableString(26),
r.GetInt64(27) != 0);
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);
@@ -47,44 +47,6 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct);
}
public sealed class SqliteProbeStore : IProbeStore
{
private readonly SqliteDatabase _db;
public SqliteProbeStore(SqliteDatabase db)
{
_db = db;
}
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by 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), r.NullableBytes(6), r.NullableString(7))),
ct);
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by 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), r.NullableBytes(6), r.NullableString(7)), ("@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);
public ValueTask<bool> DeleteAsync(string id, CancellationToken ct) =>
_db.WriteAsync(c => c.Exec("DELETE FROM probes WHERE id = @id", ("@id", id)) == 1, ct);
public ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct) =>
_db.ExecuteAsync(c =>
{
c.Exec(
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @peer, @at, @revoked) " +
"ON CONFLICT(id) DO UPDATE SET name = excluded.name, pubkey = excluded.pubkey, peer_pubkey = excluded.peer_pubkey, created_at = excluded.created_at, revoked_at = excluded.revoked_at",
("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@peer", probe.PeerPublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs));
}, ct);
}
public sealed class SqliteSettingsStore : ISettingsStore
{
private readonly SqliteDatabase _db;
@@ -14,18 +14,18 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<NodeMeta>>(
c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read),
c => c.Query("SELECT node_id, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read),
ct);
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) =>
_db.ExecuteAsync(
c => c.Exec(
"INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " +
"VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " +
"kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " +
"INSERT INTO node_meta (node_id, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " +
"VALUES (@id, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " +
"os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " +
"isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " +
"version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at, isp_manual = excluded.isp_manual",
("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country),
("@id", meta.Info.NodeId), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country),
("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs),
("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt), ("@im", meta.Info.IspManual ? 1 : 0)),
ct);
@@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore
private static NodeMeta Read(SqliteDataReader r)
{
static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i);
return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14), r.GetInt64(15) != 0), S(r, 8), r.GetInt64(9), r.GetInt64(10));
return new NodeMeta(new NodeInfo(r.GetString(0), S(r, 1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 10), S(r, 11), S(r, 12), S(r, 13), r.GetInt64(14) != 0), S(r, 7), r.GetInt64(8), r.GetInt64(9));
}
}
@@ -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, ip_version";
private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, dialer_id, ip_version";
private readonly SqliteDatabase _db;
@@ -19,22 +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, 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),
"INSERT INTO tests (id, agent_id, dialer_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, ip_version) VALUES (@id, @agent, @dialer, @plan, @params, @trigger, @status, @err, @phase, @count, @at, @ip)",
("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@dialer", test.DialerId.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), ("@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, NodeId? dialerId, int limit, CancellationToken ct) =>
ListAsync(agentId, dialerId, limit, null, ct);
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) =>
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, NodeId? dialerId, 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) 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)),
$"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@dialer IS NULL OR dialer_id = @dialer) AND (@ip IS NULL OR ip_version = @ip) ORDER BY requested_at DESC, id DESC LIMIT @limit",
Read, ("@agent", agentId?.Value), ("@dialer", dialerId?.Value), ("@limit", limit), ("@ip", ipVersion)),
ct);
public ValueTask<bool> UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct) =>
@@ -49,8 +49,8 @@ public sealed class SqliteTestStore : ITestStore
public ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct) =>
_db.ExecuteAsync(c => c.Exec("UPDATE tests SET phase_count = @n WHERE id = @id", ("@id", id.Value), ("@n", phaseCount)), ct);
public ValueTask<bool> HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct) =>
_db.ReadAsync(c => c.Scalar<long>("SELECT COUNT(*) FROM tests WHERE agent_id = @a AND probe_id = @p AND status IN ('queued', 'running')", ("@a", agentId.Value), ("@p", probeId.Value)) > 0, ct);
public ValueTask<bool> HasActiveAsync(NodeId agentId, NodeId dialerId, CancellationToken ct) =>
_db.ReadAsync(c => c.Scalar<long>("SELECT COUNT(*) FROM tests WHERE agent_id = @a AND dialer_id = @p AND status IN ('queued', 'running')", ("@a", agentId.Value), ("@p", dialerId.Value)) > 0, ct);
public ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct) =>
_db.ExecuteAsync(
@@ -81,7 +81,7 @@ public sealed class SqliteTestStore : ITestStore
private static TestRecord Read(SqliteDataReader r) => new(
new TestId(r.GetString(0)),
new NodeId(r.GetString(1)),
new ProbeId(r.GetString(12)),
new NodeId(r.GetString(12)),
r.GetString(2),
r.GetString(3),
r.GetString(4),
@@ -14,7 +14,6 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<IAgentStore>(p => p.GetRequiredService<SqliteAgentStore>());
services.AddSingleton<IFleetStore>(p => p.GetRequiredService<SqliteAgentStore>());
services.AddSingleton<IJoinTokenStore, SqliteJoinTokenStore>();
services.AddSingleton<IProbeStore, SqliteProbeStore>();
services.AddSingleton<ISettingsStore, SqliteSettingsStore>();
services.AddSingleton<ITaskStore, SqliteTaskStore>();
services.AddSingleton<ITestStore, SqliteTestStore>();
+1 -1
View File
@@ -38,7 +38,7 @@ public static class BackendHost
IServiceCollection services = builder.Services;
services.AddSingleton(time ?? TimeProvider.System);
services.AddMoShiroSqlite(options.DataDirectory);
// 国家和运营商:Backend 用节点的来源地址查一次(Probe、Agent 不访问任何外部服务);地址留空就不查
// 国家和运营商:Backend 用节点的来源地址查一次(拨号方、Agent 不访问任何外部服务);地址留空就不查
if (string.IsNullOrWhiteSpace(options.GeoLookupUrl))
{
services.AddSingleton<IGeoLookup, NoGeoLookup>();
+1 -1
View File
@@ -1,7 +1,7 @@
using MoShiro.Backend;
WebApplication app = BackendHost.Build(args);
// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent、Probe 启动时打的是同一组)
// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent 启动时打的是同一组)
if (app.Logger.IsEnabled(LogLevel.Information))
{
app.Logger.LogInformation("moshiro backend starting: version {BuildInfo}", MoShiro.Protocol.BuildInfo.Summary);