using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; using Fleet.Core; using Fleet.Server; using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// 本节点拨出的一条连接:监听方现在认的这个拨号方会话(由监听方上报)。 public sealed record OutgoingConnection(NodeId ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); /// 现在连着这个节点的拨号方(监听方上报)。 /// 这个节点现在连着的监听节点(监听方上报)。 public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList Dialers, IReadOnlyList Outgoing, NodeHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null) { /// 至少有一个拨号方连着。 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; } } } /// 节点的管理:列表、修改、审批、启停、接管、删除,以及每个节点要拨出的目标列表。 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> ListAsync(CancellationToken ct) { IReadOnlyList agents = await _agents.ListAsync(ct); return [.. agents.Select(a => View(a, agents))]; } public async ValueTask 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 all) { _runtime.TryGet(agent.Id, out AgentRuntimeState? state); var outgoing = new List(); 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 async ValueTask> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct) { if (await _agents.GetAsync(dialer, ct) is null || await _agents.GetAsync(listener, ct) is null) { return Result.Fail(MoShiroErrors.AgentNotFound, "node not found"); } return await _gateway.DisconnectDialerAsync(listener, dialer, ct); } public ValueTask 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> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) { var errors = new Dictionary(); 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(errors); } Result result = await _agents.PatchAsync(id, patch, expectedRowVersion, ct); if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) { return Result.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.Ok(View(agent, await _agents.ListAsync(ct))); } public ValueTask> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct); public ValueTask> EnableAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct); public async ValueTask> DisableAsync(NodeId id, CancellationToken ct) { Result result = await ChangeStatusAsync(id, NodeStatus.Disabled, ct); if (result.IsSuccess) { await _gateway.DisconnectAsync(id, "disabled", ct); } return result; } public async ValueTask> TakeoverAsync(NodeId pendingId, CancellationToken ct) { Result result = await _agents.TakeoverAsync(pendingId, ct); if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) { return Result.Fail(error); } // 公钥换了,旧连接使用的是旧密钥 await _gateway.DisconnectAsync(agent.Id, "key replaced", ct); await AfterAgentSetChangedAsync(ct); return Result.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); } /// 这个节点要拨出的监听节点列表(激活、可拨号,并且线路是开着的)。版本号在列表变化时递增。 public async ValueTask DialTargetsAsync(AgentRecord dialer, CancellationToken ct) { LinkSnapshot links = await _links.SnapshotAsync(ct); IReadOnlyList 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 chain = await _trust.ChainAsync(ct); result.TrustChain = chain.Count == 0 ? null : [.. chain]; return result; } /// 节点集合或线路变了:递增拨号目标版本,重算各节点的期望状态(任务集和拨号目标都是期望状态,一起通知)。 public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct) { await _planner.BumpDialTargetsVersionAsync(ct); _gateway.NotifyAllTaskSetsChanged(); } private async ValueTask> ChangeStatusAsync(NodeId id, NodeStatus status, CancellationToken ct) { Result result = await _agents.SetStatusAsync(id, status, ct); if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) { return Result.Fail(error); } await AfterAgentSetChangedAsync(ct); _gateway.NotifyTaskSetChanged(id); return Result.Ok(View(agent, await _agents.ListAsync(ct))); } [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] private static partial Regex NamePattern(); }