Files
PnvPanel/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs
T
Leonid Pershin 7b6fe9ad78 Add Telegram bot integration and enhance user management features
- Introduced Telegram.Bot package for bot functionality.
- Updated user management to include Telegram linking and blocking features.
- Enhanced activation request handling with notifications via Telegram.
- Added new database entities for Telegram link tokens and login requests.
- Implemented traffic synchronization for client stats in the XuiPanelGateway.
- Updated application structure to support new test projects and improved dependency injection for Telegram services.
2026-07-02 01:01:03 +03:00

260 lines
11 KiB
C#

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;
/// <summary>
/// ThreeXui.Net настроен на один BaseAddress, а нод у нас много — держим клиента per-node
/// (кэш по NodeId), создавая его из расшифрованных NodeCredentials. Singleton-время жизни
/// (см. регистрацию в DI): кэш должен переживать отдельные HTTP-запросы, чтобы переиспользовать
/// cookie-сессию клиента.
/// </summary>
internal sealed class XuiPanelGateway(
IXuiHttpClientFactory httpClientFactory,
IXuiConnectionStringBuilderResolver connectionStringResolver,
ISecretProtector secretProtector,
ILoggerFactory loggerFactory)
: IXuiPanelGateway, IDisposable
{
private readonly ConcurrentDictionary<Guid, Lazy<IXuiClient>> _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<NodeProbeResult> 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<Result<IReadOnlyList<RemoteInboundInfo>>> 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<IReadOnlyList<RemoteInboundInfo>>(mapped);
}
catch (Exception ex)
{
return Result.Failure<IReadOnlyList<RemoteInboundInfo>>(
Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}"));
}
}
public async Task<Result<string>> 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<string>(Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}"));
}
}
public async Task<Result> 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<Result> 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<Result<string>> 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<string>(
Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели."));
}
var builder = connectionStringResolver.Resolve(ToRemoteProtocol(inbound.Protocol));
if (builder is null)
{
return Result.Failure<string>(
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<string>(Error.Failure("Xui.ConnectionStringFailed", $"Не удалось построить ссылку: {ex.Message}"));
}
}
public async Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> 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<IReadOnlyDictionary<string, ClientTrafficInfo>>(
Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели."));
}
return Result.Success(ParseClientStats(remoteInbound.RawInboundJson));
}
catch (Exception ex)
{
return Result.Failure<IReadOnlyDictionary<string, ClientTrafficInfo>>(
Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}"));
}
}
/// <summary>
/// ThreeXui.Net типизированно не отдаёт трафик по клиентам — достаём его из сырого JSON инбаунда:
/// стандартное поле 3x-ui API "clientStats": [{ "email": "...", "up": N, "down": N }, ...].
/// Формат форка может отличаться — при ошибке парсинга просто возвращаем пусто, не валим синхронизацию.
/// </summary>
private static IReadOnlyDictionary<string, ClientTrafficInfo> ParseClientStats(string? rawInboundJson)
{
var result = new Dictionary<string, ClientTrafficInfo>();
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<IXuiClient>(() => 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<XuiClient>();
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();
}
}