diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index 575b573..a128d09 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; @@ -221,7 +223,9 @@ internal sealed class XuiPanelGateway( remoteInbound ); - return Result.Success(builder.Build(request)); + return Result.Success( + ForceFingerprint(builder.Build(request), inbound.Protocol, ForcedFingerprint) + ); } catch (Exception ex) { @@ -234,6 +238,70 @@ internal sealed class XuiPanelGateway( } } + /// + /// Клиентские приложения выдают TLS-fingerprint панели, если fp не задан явно в + /// share-ссылке — подставляем "firefox" поверх того, что вернул ThreeXui.Net + /// (сам параметр берётся из streamSettings ноды, per-nod настройки не даём). + /// Применимо только к tls/reality-ссылкам (vless/trojan/vmess); shadowsocks TLS + /// не использует. + /// + private const string ForcedFingerprint = "firefox"; + + private static string ForceFingerprint(string link, VpnProtocol protocol, string fingerprint) => + protocol switch + { + VpnProtocol.Vless or VpnProtocol.Trojan => ForceQueryFingerprint(link, fingerprint), + VpnProtocol.Vmess => ForceVmessFingerprint(link, fingerprint), + _ => link, + }; + + private static string ForceQueryFingerprint(string link, string fingerprint) + { + var hashIndex = link.IndexOf('#'); + var head = hashIndex >= 0 ? link[..hashIndex] : link; + var fragment = hashIndex >= 0 ? link[hashIndex..] : string.Empty; + + var queryIndex = head.IndexOf('?'); + if (queryIndex < 0) + return link; + + var baseUri = head[..queryIndex]; + var query = head[(queryIndex + 1)..]; + if (!query.Contains("security=tls") && !query.Contains("security=reality")) + return link; + + var parts = query + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Where(p => !p.StartsWith("fp=", StringComparison.Ordinal)) + .Append($"fp={fingerprint}"); + + return $"{baseUri}?{string.Join('&', parts)}{fragment}"; + } + + private static string ForceVmessFingerprint(string link, string fingerprint) + { + const string prefix = "vmess://"; + if (!link.StartsWith(prefix, StringComparison.Ordinal)) + return link; + + try + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(link[prefix.Length..])); + var node = JsonNode.Parse(json)?.AsObject(); + if (node is null || string.IsNullOrEmpty(node["tls"]?.GetValue())) + return link; + + node["fp"] = fingerprint; + var updatedJson = node.ToJsonString(); + return prefix + Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)); + } + catch (Exception) + { + // Неожиданный формат vmess-пейлоада — возвращаем ссылку как есть. + return link; + } + } + public async Task>> GetClientTrafficAsync( Node node, string inboundRemoteId, diff --git a/docs/architecture.md b/docs/architecture.md index be95d67..8ff220a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,6 +170,10 @@ POST /api/configs к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`. - Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу. - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды). +- `BuildConnectionStringAsync` поверх ссылки из `ThreeXui.Net` принудительно подставляет + `fp=firefox` (TLS-fingerprint клиента) для tls/reality-ссылок vless/trojan/vmess, независимо + от того, что задано в `streamSettings` ноды; shadowsocks (без TLS) и ссылки без security не + трогает. Правится в одном месте — применяется и к сайту, и к боту, и к подписке. - **Дрейф с 3x-ui активно не реконсилируется**: `TrafficSyncService` при недоступной ноде или при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо