Files
MoShiro/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs
T
justme ad5efc62ff 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.
2026-10-21 11:55:36 +08:00

273 lines
12 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using Fleet.Core;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <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>至少有一个拨号方连着。</summary>
public bool DialerConnected => Dialers.Count > 0;
public NodeRuntimeStatus RuntimeStatus
{
get
{
if (Agent.Status == NodeStatus.Disabled)
{
return NodeRuntimeStatus.Maintenance;
}
if (Agent.Status == NodeStatus.Pending)
{
return NodeRuntimeStatus.Initializing;
}
if (!Online)
{
if (LastDisconnectReason == "updating")
{
return NodeRuntimeStatus.Updating;
}
if (LastDisconnectReason == "stopping")
{
return Heartbeat?.Update?.State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Stopping;
}
return NodeRuntimeStatus.Offline;
}
if (Heartbeat?.Update?.State == "applying")
{
return NodeRuntimeStatus.Updating;
}
if (Heartbeat is null)
{
return NodeRuntimeStatus.Initializing;
}
return Heartbeat.RuntimeStatus;
}
}
}
/// <summary>节点的管理:列表、修改、审批、启停、接管、删除,以及每个节点要拨出的目标列表。</summary>
public sealed partial class AgentService
{
private readonly IAgentStore _agents;
private readonly AgentRuntime _runtime;
private readonly IAgentGateway _gateway;
private readonly TaskPlanner _planner;
private readonly ISettingsStore _settings;
private readonly NodeInfoService _nodes;
private readonly LinkService _links;
private readonly TrustService _trust;
private readonly NodeOwnerService _owners;
public AgentService(NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
{
_owners = owners;
_trust = trust;
_links = links;
_nodes = nodes;
_agents = agents;
_runtime = runtime;
_gateway = gateway;
_planner = planner;
_settings = settings;
}
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
{
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
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, await _agents.ListAsync(ct)) : null;
public AgentView View(AgentRecord agent, IReadOnlyList<AgentRecord> all)
{
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
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);
}
/// <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)
{
if (IPAddress.TryParse(host, out IPAddress? address))
{
return !address.IsIPv4MappedToIPv6 && address.AddressFamily == (ipVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork);
}
return host.Length <= 253 && Uri.CheckHostName(host) == UriHostNameType.Dns;
}
public async ValueTask<Result<AgentView>> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (patch.Name is not null && !NamePattern().IsMatch(patch.Name))
{
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
}
if (patch.PublicHost is not null && patch.PublicHostAuto != true && !ValidHost(patch.PublicHost, IpVersions.V4))
{
errors["public_host"] = "must be a host name or IP address";
}
if (patch.Ipv6Host is not null && patch.Ipv6HostAuto != true && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
{
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
}
if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32)))
{
errors["labels"] = "at most 16 labels of 1-32 characters";
}
if (errors.Count > 0)
{
return ValidationError.Fail<AgentView>(errors);
}
Result<AgentRecord> result = await _agents.PatchAsync(id, patch, expectedRowVersion, ct);
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
{
return Result<AgentView>.Fail(error);
}
if (patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
// 撤销手填:马上按已经上报的地址重新识别
(string? host, string? v6) = AgentAddressPolicy.Choose(agent, agent.ReportedAddresses ?? [], null);
await _agents.ApplyAutoAddressesAsync(id, host, v6, ct);
agent = await _agents.GetAsync(id, ct) ?? agent;
}
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null || patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
await AfterAgentSetChangedAsync(ct);
}
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public ValueTask<Result<AgentView>> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
public ValueTask<Result<AgentView>> EnableAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
public async ValueTask<Result<AgentView>> DisableAsync(NodeId id, CancellationToken ct)
{
Result<AgentView> result = await ChangeStatusAsync(id, NodeStatus.Disabled, ct);
if (result.IsSuccess)
{
await _gateway.DisconnectAsync(id, "disabled", ct);
}
return result;
}
public async ValueTask<Result<AgentView>> TakeoverAsync(NodeId pendingId, CancellationToken ct)
{
Result<AgentRecord> result = await _agents.TakeoverAsync(pendingId, ct);
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
{
return Result<AgentView>.Fail(error);
}
// 公钥换了,旧连接使用的是旧密钥
await _gateway.DisconnectAsync(agent.Id, "key replaced", ct);
await AfterAgentSetChangedAsync(ct);
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public async ValueTask DeleteAsync(NodeId id, CancellationToken ct)
{
await _gateway.DisconnectAsync(id, "deleted", ct);
await _agents.DeleteAsync(id, ct);
await _nodes.ForgetAsync(id.Value, ct);
_owners.Forget(id.Value);
await _links.ForgetNodeAsync(id.Value, ct);
_runtime.Remove(id);
await AfterAgentSetChangedAsync(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.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.Targets.Add(new DialTarget
{
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!),
});
}
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
result.TrustChain = chain.Count == 0 ? null : [.. chain];
return result;
}
/// <summary>节点集合或线路变了:递增拨号目标版本,重算各节点的期望状态(任务集和拨号目标都是期望状态,一起通知)。</summary>
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
{
await _planner.BumpDialTargetsVersionAsync(ct);
_gateway.NotifyAllTaskSetsChanged();
}
private async ValueTask<Result<AgentView>> ChangeStatusAsync(NodeId id, NodeStatus status, CancellationToken ct)
{
Result<AgentRecord> result = await _agents.SetStatusAsync(id, status, ct);
if (!result.TryGet(out AgentRecord? agent, out FleetError? error))
{
return Result<AgentView>.Fail(error);
}
await AfterAgentSetChangedAsync(ct);
_gateway.NotifyTaskSetChanged(id);
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
private static partial Regex NamePattern();
}