Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user