XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

LumaTunnel

【WinUI】LumaTunnel 是一个面向个人多设备的 Windows 代理系统。客户端在本机提供 HTTP/HTTPS CONNECT 与 SOCKS5 TCP 代理,并通过一个受信任 TLS 证书保护的 WSS 会话,将多个 TCP 流复用到自建 Windows Server 节点。

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.Concurrent;
using LumaTunnel.Client.Core.Abstractions;
using LumaTunnel.Shared.Models;

namespace LumaTunnel.Client.Core.Nodes;

public sealed class NodeManagerService(
    NodeApiClient apiClient,
    ITunnelService tunnelService,
    Func<NodeProfile, CancellationToken, Task<string?>> getDeviceToken,
    string clientVersion) : INodeManagerService
{
    private readonly ConcurrentDictionary<Guid, int> _failures = new();
    private readonly SemaphoreSlim _switchGate = new(1, 1);
    private volatile NodeProfile[] _nodes = [];
    private CancellationTokenSource? _monitorCancellation;
    private Task? _monitorTask;

    public NodeProfile? ActiveNode { get; private set; }
    public IReadOnlyList<NodeProfile> Nodes => _nodes;
    public event EventHandler<NodeProfile?>? ActiveNodeChanged;

    public void SetNodes(IEnumerable<NodeProfile> nodes)
    {
        ArgumentNullException.ThrowIfNull(nodes);
        _nodes = nodes.OrderBy(static node => node.Priority).ToArray();
    }

    public async Task<NodeProfile> ConnectBestAsync(CancellationToken cancellationToken = default)
    {
        await _switchGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var candidates = _nodes.Where(static node => node.Enabled && node.DeviceId is not null).ToArray();
            if (candidates.Length == 0)
                throw new InvalidOperationException("No enrolled LumaTunnel node is enabled.");

            var checks = await Task.WhenAll(candidates.Select(node => CheckNodeAsync(node, cancellationToken))).ConfigureAwait(false);
            var best = checks.Where(static result => result.Success).OrderBy(static result => result.Latency).ThenBy(static result => result.Node.Priority).FirstOrDefault()
                       ?? throw new IOException("None of the configured LumaTunnel nodes is healthy.");
            var token = await getDeviceToken(best.Node, cancellationToken).ConfigureAwait(false)
                        ?? throw new InvalidOperationException($"No device token is available for node '{best.Node.Name}'.");

            await tunnelService.ConnectAsync(new TunnelCredentials(best.Node.TunnelUri, best.Node.DeviceId!, token, clientVersion), cancellationToken).ConfigureAwait(false);
            ActiveNode = best.Node with { LastLatencyMilliseconds = best.Latency.TotalMilliseconds, LastHealthCheckUtc = DateTimeOffset.UtcNow };
            _failures[best.Node.Id] = 0;
            ActiveNodeChanged?.Invoke(this, ActiveNode);
            return ActiveNode;
        }
        finally
        {
            _switchGate.Release();
        }
    }

    public Task StartMonitoringAsync(CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (_monitorCancellation is not null)
            return Task.CompletedTask;
        _monitorCancellation = new CancellationTokenSource();
        _monitorTask = MonitorLoopAsync(_monitorCancellation.Token);
        return Task.CompletedTask;
    }

    public async Task StopMonitoringAsync(CancellationToken cancellationToken = default)
    {
        var cancellation = _monitorCancellation;
        var task = _monitorTask;
        _monitorCancellation = null;
        _monitorTask = null;
        if (cancellation is null)
            return;
        cancellation.Cancel();
        if (task is not null)
            await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)).ConfigureAwait(false);
        cancellation.Dispose();
    }

    public async ValueTask DisposeAsync()
    {
        await StopMonitoringAsync().ConfigureAwait(false);
        _switchGate.Dispose();
        apiClient.Dispose();
        GC.SuppressFinalize(this);
    }

    private async Task MonitorLoopAsync(CancellationToken cancellationToken)
    {
        try
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false);
                var active = ActiveNode;
                if (active is null)
                    continue;
                var check = await CheckNodeAsync(active, cancellationToken).ConfigureAwait(false);
                if (check.Success && tunnelService.IsConnected)
                {
                    _failures[active.Id] = 0;
                    continue;
                }

                var failures = _failures.AddOrUpdate(active.Id, 1, static (_, count) => count + 1);
                if (failures >= 3)
                {
                    try
                    {
                        await ConnectBestAsync(cancellationToken).ConfigureAwait(false);
                    }
                    catch (IOException)
                    {
                    }
                }
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
    }

    private async Task<HealthResult> CheckNodeAsync(NodeProfile node, CancellationToken cancellationToken)
    {
        try
        {
            var (_, latency) = await apiClient.GetHealthAsync(node.ApiUri, cancellationToken).ConfigureAwait(false);
            return new HealthResult(node, true, latency);
        }
        catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or InvalidDataException)
        {
            return new HealthResult(node, false, TimeSpan.MaxValue);
        }
    }

    private sealed record HealthResult(NodeProfile Node, bool Success, TimeSpan Latency);
}