using System.Collections.Concurrent; using System.Text.Json; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Nodes; using ThreeXui; using ThreeXui.ConnectionStrings; using ThreeXui.Http; namespace PnvPanel.Infrastructure.Xui; /// /// ThreeXui.Net настроен на один BaseAddress, а нод у нас много — держим клиента per-node /// (кэш по NodeId), создавая его из расшифрованных NodeCredentials. Singleton-время жизни /// (см. регистрацию в DI): кэш должен переживать отдельные HTTP-запросы, чтобы переиспользовать /// cookie-сессию клиента. /// internal sealed class XuiPanelGateway( IXuiHttpClientFactory httpClientFactory, IXuiConnectionStringBuilderResolver connectionStringResolver, ISecretProtector secretProtector, ILoggerFactory loggerFactory) : IXuiPanelGateway, IDisposable { private readonly ConcurrentDictionary> _clients = new(); public Result ValidateBaseAddress(Uri baseAddress) { return XuiBaseUrlValidator.IsAllowed(baseAddress.ToString(), out var reason) ? Result.Success() : Result.Failure(Error.Validation("Nodes.BaseAddressNotAllowed", reason ?? "Адрес панели не разрешён.")); } public async Task ProbeAsync(Node node, CancellationToken cancellationToken) { try { var client = GetClient(node); var health = await client.CheckHealthAsync(cancellationToken); return new NodeProbeResult(health.Ok, health.Ok ? null : health.ErrorMessage); } catch (Exception ex) { return new NodeProbeResult(false, ex.Message); } } public async Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken) { try { var client = GetClient(node); var remoteInbounds = await client.ListInboundsAsync(cancellationToken); var mapped = remoteInbounds .Select(i => (Summary: i, Protocol: TryParseProtocol(i.Protocol))) .Where(x => x.Protocol is not null) .Select(x => new RemoteInboundInfo(x.Summary.ExternalId, x.Protocol!.Value, x.Summary.Remark, x.Summary.Port)) .ToList(); return Result.Success>(mapped); } catch (Exception ex) { return Result.Failure>( Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}")); } } public async Task> AddClientAsync( Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int deviceLimit, CancellationToken cancellationToken) { try { var client = GetClient(node); var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), deviceLimit, null); var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken); return Result.Success(result.ExternalClientId); } catch (Exception ex) { return Result.Failure(Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}")); } } public async Task RemoveClientAsync( Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken) { try { var client = GetClient(node); await client.RemoveClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), cancellationToken); return Result.Success(); } catch (Exception ex) { return Result.Failure(Error.Failure("Xui.RemoveClientFailed", $"Не удалось удалить клиента: {ex.Message}")); } } public async Task UpdateClientAsync( Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, string name, int deviceLimit, bool enable, CancellationToken cancellationToken) { try { var client = GetClient(node); var request = new UpdateClientRequest(deviceLimit, null, enable, name); await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken); return Result.Success(); } catch (Exception ex) { return Result.Failure(Error.Failure("Xui.UpdateClientFailed", $"Не удалось изменить клиента: {ex.Message}")); } } public async Task> BuildConnectionStringAsync( Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, CancellationToken cancellationToken) { try { var client = GetClient(node); var remoteInbound = await client.GetInboundAsync(inbound.RemoteInboundId, cancellationToken); if (remoteInbound is null) { return Result.Failure( Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели.")); } var builder = connectionStringResolver.Resolve(ToRemoteProtocol(inbound.Protocol)); if (builder is null) { return Result.Failure( Error.Failure("Xui.ConnectionStringFailed", $"Протокол {inbound.Protocol} не поддерживается.")); } var request = new XuiConnectionStringRequest( clientExternalId, clientName, inbound.Port, publicHost, node.BaseAddress.ToString(), remoteInbound); return Result.Success(builder.Build(request)); } catch (Exception ex) { return Result.Failure(Error.Failure("Xui.ConnectionStringFailed", $"Не удалось построить ссылку: {ex.Message}")); } } public async Task>> GetClientTrafficAsync( Node node, string inboundRemoteId, CancellationToken cancellationToken) { try { var client = GetClient(node); var remoteInbound = await client.GetInboundAsync(inboundRemoteId, cancellationToken); if (remoteInbound is null) { return Result.Failure>( Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели.")); } return Result.Success(ParseClientStats(remoteInbound.RawInboundJson)); } catch (Exception ex) { return Result.Failure>( Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}")); } } /// /// ThreeXui.Net типизированно не отдаёт трафик по клиентам — достаём его из сырого JSON инбаунда: /// стандартное поле 3x-ui API "clientStats": [{ "email": "...", "up": N, "down": N }, ...]. /// Формат форка может отличаться — при ошибке парсинга просто возвращаем пусто, не валим синхронизацию. /// private static IReadOnlyDictionary ParseClientStats(string? rawInboundJson) { var result = new Dictionary(); if (string.IsNullOrWhiteSpace(rawInboundJson)) return result; try { using var doc = JsonDocument.Parse(rawInboundJson); if (!doc.RootElement.TryGetProperty("clientStats", out var clientStats) || clientStats.ValueKind != JsonValueKind.Array) return result; foreach (var stat in clientStats.EnumerateArray()) { if (!stat.TryGetProperty("email", out var emailProp) || emailProp.ValueKind != JsonValueKind.String) continue; var email = emailProp.GetString(); if (string.IsNullOrEmpty(email)) continue; var up = stat.TryGetProperty("up", out var upProp) ? upProp.GetInt64() : 0; var down = stat.TryGetProperty("down", out var downProp) ? downProp.GetInt64() : 0; result[email] = new ClientTrafficInfo(up, down); } } catch (JsonException) { // Не удалось распарсить — возвращаем пусто, вызывающий код просто пропустит синк для этого инбаунда. } return result; } public void InvalidateClient(Guid nodeId) { if (_clients.TryRemove(nodeId, out var lazy) && lazy.IsValueCreated) (lazy.Value as IDisposable)?.Dispose(); } private IXuiClient GetClient(Node node) => _clients.GetOrAdd(node.Id, _ => new Lazy(() => CreateClient(node))).Value; private IXuiClient CreateClient(Node node) { var httpClient = httpClientFactory.Create(node.BaseAddress, allowInsecureTls: false, timeout: TimeSpan.FromSeconds(15)); var password = secretProtector.Unprotect(node.Credentials.ProtectedPassword); var logger = loggerFactory.CreateLogger(); return new XuiClient(httpClient, node.Credentials.Username, password, logger); } private static VpnProtocol? TryParseProtocol(string raw) => raw.ToLowerInvariant() switch { "vless" => VpnProtocol.Vless, "vmess" => VpnProtocol.Vmess, "trojan" => VpnProtocol.Trojan, "shadowsocks" => VpnProtocol.Shadowsocks, _ => null, }; private static string ToRemoteProtocol(VpnProtocol protocol) => protocol switch { VpnProtocol.Vless => "vless", VpnProtocol.Vmess => "vmess", VpnProtocol.Trojan => "trojan", VpnProtocol.Shadowsocks => "shadowsocks", _ => throw new ArgumentOutOfRangeException(nameof(protocol)), }; public void Dispose() { foreach (var lazy in _clients.Values) { if (lazy.IsValueCreated) (lazy.Value as IDisposable)?.Dispose(); } _clients.Clear(); } }