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`) просто пропускает его в этом цикле
синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо