- 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.
260 lines
11 KiB
C#
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();
|
|
}
|
|
}
|