POST /api/admin/links/reset clears every manual link setting and turns automatic linking on. In the default rules a listening node in CN ranks below a listening node elsewhere, so the CN side dials; the links are pushed again when a node's country is first resolved or changes.
275 lines
12 KiB
C#
275 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;
|
|
// 线路的默认方向看国家(国内的节点默认只拨出):国家查到或变了就重新下发
|
|
_nodes.OnCountryChanged = () => AfterAgentSetChangedAsync(CancellationToken.None);
|
|
}
|
|
|
|
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();
|
|
}
|