Explorar el Código

feat: Add WebSocket support for real-time updates and enhance VLESS settings (#3605)

* feat: add support for trusted X-Forwarded-For and testseed parameters in VLESS settings

* chore: update Xray Core version to 25.12.8 in release workflow

* chore: update Xray Core version to 25.12.8 in Docker initialization script

* chore: bump version to 2.8.6 and add watcher for security changes in inbound modal

* refactor: remove default and random seed buttons from outbound form

* refactor: update VLESS form to rename 'Test Seed' to 'Vision Seed' and change button functionality for seed generation

* refactor: enhance TLS settings form layout with improved button styling and spacing

* feat: integrate WebSocket support for real-time updates on inbounds and Xray service status

* chore: downgrade version to 2.8.5

* refactor: translate comments to English

* fix: ensure testseed is initialized correctly for VLESS protocol and improve client handling in inbound modal

* refactor: simplify VLESS divider condition by removing unnecessary flow checks

* fix: add fallback date formatting for cases when IntlUtil is not available

* refactor: simplify WebSocket message handling by removing batching and ensuring individual message delivery

* refactor: disable WebSocket notifications in inbound and index HTML files

* refactor: enhance VLESS testseed initialization and button functionality in inbound modal

* fix:

* refactor: ensure proper WebSocket URL construction by normalizing basePath

* fix:

* fix:

* fix:

* refactor: update testseed methods for improved reactivity and binding in VLESS form

* logger info to debug

---------

Co-authored-by: lolka1333 <[email protected]>
lolka1333 hace 4 días
padre
commit
313a2acbf6
Se han modificado 40 ficheros con 1329 adiciones y 109 borrados
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 2 2
      go.mod
  4. 2 2
      go.sum
  5. 22 2
      web/assets/js/model/inbound.js
  6. 23 5
      web/assets/js/model/outbound.js
  7. 145 0
      web/assets/js/websocket.js
  8. 12 0
      web/controller/inbound.go
  9. 17 0
      web/controller/server.go
  10. 189 0
      web/controller/websocket.go
  11. 1 0
      web/global/global.go
  12. 1 0
      web/html/common/page.html
  13. 31 0
      web/html/form/outbound.html
  14. 30 0
      web/html/form/protocol/vless.html
  15. 9 0
      web/html/form/stream/stream_sockopt.html
  16. 11 7
      web/html/form/tls_settings.html
  17. 108 13
      web/html/inbounds.html
  18. 65 7
      web/html/index.html
  19. 90 4
      web/html/modals/inbound_modal.html
  20. 7 0
      web/html/settings/xray/dns.html
  21. 17 2
      web/html/xray.html
  22. 0 60
      web/job/ldap_sync_job.go
  23. 18 0
      web/job/xray_traffic_job.go
  24. 2 0
      web/translation/translate.ar_EG.toml
  25. 2 0
      web/translation/translate.en_US.toml
  26. 2 0
      web/translation/translate.es_ES.toml
  27. 2 0
      web/translation/translate.fa_IR.toml
  28. 2 0
      web/translation/translate.id_ID.toml
  29. 2 0
      web/translation/translate.ja_JP.toml
  30. 2 0
      web/translation/translate.pt_BR.toml
  31. 2 0
      web/translation/translate.ru_RU.toml
  32. 2 0
      web/translation/translate.tr_TR.toml
  33. 2 0
      web/translation/translate.uk_UA.toml
  34. 2 0
      web/translation/translate.vi_VN.toml
  35. 2 0
      web/translation/translate.zh_CN.toml
  36. 2 0
      web/translation/translate.zh_TW.toml
  37. 22 0
      web/web.go
  38. 379 0
      web/websocket/hub.go
  39. 74 0
      web/websocket/notifier.go
  40. 25 2
      xray/api.go

+ 2 - 2
.github/workflows/release.yml

@@ -87,7 +87,7 @@ jobs:
           cd x-ui/bin
           
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.2/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -185,7 +185,7 @@ jobs:
           cd x-ui\bin
           
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
           Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -27,7 +27,7 @@ case $1 in
 esac
 mkdir -p build/bin
 cd build/bin
-wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/Xray-linux-${ARCH}.zip"
+wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 2 - 2
go.mod

@@ -9,6 +9,7 @@ require (
 	github.com/go-ldap/ldap/v3 v3.4.12
 	github.com/goccy/go-json v0.10.5
 	github.com/google/uuid v1.6.0
+	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/mymmrac/telego v1.3.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.0
@@ -19,7 +20,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.68.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.251202.0
+	github.com/xtls/xray-core v1.251208.0
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.45.0
 	golang.org/x/sys v0.38.0
@@ -51,7 +52,6 @@ require (
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
-	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect

+ 2 - 2
go.sum

@@ -203,8 +203,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
 github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
-github.com/xtls/xray-core v1.251202.0 h1:VwoBnq9IRTbYWEBhR0CqEw2cNjTlXYH6WxzKbSjx+XE=
-github.com/xtls/xray-core v1.251202.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
+github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0eyAes=
+github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 22 - 2
web/assets/js/model/inbound.js

@@ -857,6 +857,7 @@ class SockoptStreamSettings extends XrayCommonClass {
         V6Only = false,
         tcpWindowClamp = 600,
         interfaceName = "",
+        trustedXForwardedFor = [],
     ) {
         super();
         this.acceptProxyProtocol = acceptProxyProtocol;
@@ -875,6 +876,7 @@ class SockoptStreamSettings extends XrayCommonClass {
         this.V6Only = V6Only;
         this.tcpWindowClamp = tcpWindowClamp;
         this.interfaceName = interfaceName;
+        this.trustedXForwardedFor = trustedXForwardedFor;
     }
 
     static fromJson(json = {}) {
@@ -896,11 +898,12 @@ class SockoptStreamSettings extends XrayCommonClass {
             json.V6Only,
             json.tcpWindowClamp,
             json.interface,
+            json.trustedXForwardedFor || [],
         );
     }
 
     toJson() {
-        return {
+        const result = {
             acceptProxyProtocol: this.acceptProxyProtocol,
             tcpFastOpen: this.tcpFastOpen,
             mark: this.mark,
@@ -918,6 +921,10 @@ class SockoptStreamSettings extends XrayCommonClass {
             tcpWindowClamp: this.tcpWindowClamp,
             interface: this.interfaceName,
         };
+        if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
+            result.trustedXForwardedFor = this.trustedXForwardedFor;
+        }
+        return result;
     }
 }
 
@@ -1870,6 +1877,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         encryption = "none",
         fallbacks = [],
         selectedAuth = undefined,
+        testseed = [900, 500, 900, 256],
     ) {
         super(protocol);
         this.vlesses = vlesses;
@@ -1877,6 +1885,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         this.encryption = encryption;
         this.fallbacks = fallbacks;
         this.selectedAuth = selectedAuth;
+        this.testseed = testseed;
     }
 
     addFallback() {
@@ -1888,13 +1897,20 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
     }
 
     static fromJson(json = {}) {
+        // Ensure testseed is always initialized as an array
+        let testseed = [900, 500, 900, 256];
+        if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) {
+            testseed = json.testseed;
+        }
+        
         const obj = new Inbound.VLESSSettings(
             Protocols.VLESS,
             (json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)),
             json.decryption,
             json.encryption,
             Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
-            json.selectedAuth
+            json.selectedAuth,
+            testseed
         );
         return obj;
     }
@@ -1920,6 +1936,10 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
             json.selectedAuth = this.selectedAuth;
         }
 
+        if (this.testseed && this.testseed.length >= 4) {
+            json.testseed = this.testseed;
+        }
+
         return json;
     }
 

+ 23 - 5
web/assets/js/model/outbound.js

@@ -432,6 +432,7 @@ class SockoptStreamSettings extends CommonClass {
         tcpMptcp = false,
         penetrate = false,
         addressPortStrategy = Address_Port_Strategy.NONE,
+        trustedXForwardedFor = [],
     ) {
         super();
         this.dialerProxy = dialerProxy;
@@ -440,6 +441,7 @@ class SockoptStreamSettings extends CommonClass {
         this.tcpMptcp = tcpMptcp;
         this.penetrate = penetrate;
         this.addressPortStrategy = addressPortStrategy;
+        this.trustedXForwardedFor = trustedXForwardedFor;
     }
 
     static fromJson(json = {}) {
@@ -450,12 +452,13 @@ class SockoptStreamSettings extends CommonClass {
             json.tcpKeepAliveInterval,
             json.tcpMptcp,
             json.penetrate,
-            json.addressPortStrategy
+            json.addressPortStrategy,
+            json.trustedXForwardedFor || []
         );
     }
 
     toJson() {
-        return {
+        const result = {
             dialerProxy: this.dialerProxy,
             tcpFastOpen: this.tcpFastOpen,
             tcpKeepAliveInterval: this.tcpKeepAliveInterval,
@@ -463,6 +466,10 @@ class SockoptStreamSettings extends CommonClass {
             penetrate: this.penetrate,
             addressPortStrategy: this.addressPortStrategy
         };
+        if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
+            result.trustedXForwardedFor = this.trustedXForwardedFor;
+        }
+        return result;
     }
 }
 
@@ -1050,13 +1057,15 @@ Outbound.VmessSettings = class extends CommonClass {
     }
 };
 Outbound.VLESSSettings = class extends CommonClass {
-    constructor(address, port, id, flow, encryption) {
+    constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
         super();
         this.address = address;
         this.port = port;
         this.id = id;
         this.flow = flow;
         this.encryption = encryption;
+        this.testpre = testpre;
+        this.testseed = testseed;
     }
 
     static fromJson(json = {}) {
@@ -1066,18 +1075,27 @@ Outbound.VLESSSettings = class extends CommonClass {
             json.port,
             json.id,
             json.flow,
-            json.encryption
+            json.encryption,
+            json.testpre || 0,
+            json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
         );
     }
 
     toJson() {
-        return {
+        const result = {
             address: this.address,
             port: this.port,
             id: this.id,
             flow: this.flow,
             encryption: this.encryption,
         };
+        if (this.testpre > 0) {
+            result.testpre = this.testpre;
+        }
+        if (this.testseed && this.testseed.length >= 4) {
+            result.testseed = this.testseed;
+        }
+        return result;
     }
 };
 Outbound.TrojanSettings = class extends CommonClass {

+ 145 - 0
web/assets/js/websocket.js

@@ -0,0 +1,145 @@
+/**
+ * WebSocket client for real-time updates
+ */
+class WebSocketClient {
+  constructor(basePath = '') {
+    this.basePath = basePath;
+    this.ws = null;
+    this.reconnectAttempts = 0;
+    this.maxReconnectAttempts = 10;
+    this.reconnectDelay = 1000;
+    this.listeners = new Map();
+    this.isConnected = false;
+    this.shouldReconnect = true;
+  }
+
+  connect() {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      return;
+    }
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    // Ensure basePath ends with '/' for proper URL construction
+    let basePath = this.basePath || '';
+    if (basePath && !basePath.endsWith('/')) {
+      basePath += '/';
+    }
+    const wsUrl = `${protocol}//${window.location.host}${basePath}ws`;
+    
+    console.log('WebSocket connecting to:', wsUrl, 'basePath:', this.basePath);
+    
+    try {
+      this.ws = new WebSocket(wsUrl);
+      
+      this.ws.onopen = () => {
+        console.log('WebSocket connected');
+        this.isConnected = true;
+        this.reconnectAttempts = 0;
+        this.emit('connected');
+      };
+
+      this.ws.onmessage = (event) => {
+        try {
+          // Validate message size (prevent memory issues)
+          const maxMessageSize = 10 * 1024 * 1024; // 10MB
+          if (event.data && event.data.length > maxMessageSize) {
+            console.error('WebSocket message too large:', event.data.length, 'bytes');
+            this.ws.close();
+            return;
+          }
+          
+          const message = JSON.parse(event.data);
+          if (!message || typeof message !== 'object') {
+            console.error('Invalid WebSocket message format');
+            return;
+          }
+          
+          this.handleMessage(message);
+        } catch (e) {
+          console.error('Failed to parse WebSocket message:', e);
+        }
+      };
+
+      this.ws.onerror = (error) => {
+        console.error('WebSocket error:', error);
+        this.emit('error', error);
+      };
+
+      this.ws.onclose = () => {
+        console.log('WebSocket disconnected');
+        this.isConnected = false;
+        this.emit('disconnected');
+        
+        if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
+          this.reconnectAttempts++;
+          const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
+          console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
+          setTimeout(() => this.connect(), delay);
+        }
+      };
+    } catch (e) {
+      console.error('Failed to create WebSocket connection:', e);
+      this.emit('error', e);
+    }
+  }
+
+  handleMessage(message) {
+    const { type, payload, time } = message;
+    
+    // Emit to specific type listeners
+    this.emit(type, payload, time);
+    
+    // Emit to all listeners
+    this.emit('message', { type, payload, time });
+  }
+
+  on(event, callback) {
+    if (!this.listeners.has(event)) {
+      this.listeners.set(event, []);
+    }
+    this.listeners.get(event).push(callback);
+  }
+
+  off(event, callback) {
+    if (!this.listeners.has(event)) {
+      return;
+    }
+    const callbacks = this.listeners.get(event);
+    const index = callbacks.indexOf(callback);
+    if (index > -1) {
+      callbacks.splice(index, 1);
+    }
+  }
+
+  emit(event, ...args) {
+    if (this.listeners.has(event)) {
+      this.listeners.get(event).forEach(callback => {
+        try {
+          callback(...args);
+        } catch (e) {
+          console.error('Error in WebSocket event handler:', e);
+        }
+      });
+    }
+  }
+
+  disconnect() {
+    this.shouldReconnect = false;
+    if (this.ws) {
+      this.ws.close();
+      this.ws = null;
+    }
+  }
+
+  send(data) {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      this.ws.send(JSON.stringify(data));
+    } else {
+      console.warn('WebSocket is not connected');
+    }
+  }
+}
+
+// Create global WebSocket client instance
+// Safely get basePath from global scope (defined in page.html)
+window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');

+ 12 - 0
web/controller/inbound.go

@@ -8,6 +8,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v2/database/model"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
 	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )
@@ -125,6 +126,9 @@ func (a *InboundController) addInbound(c *gin.Context) {
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
+	// Broadcast inbounds update via WebSocket
+	inbounds, _ := a.inboundService.GetInbounds(user.Id)
+	websocket.BroadcastInbounds(inbounds)
 }
 
 // delInbound deletes an inbound configuration by its ID.
@@ -143,6 +147,10 @@ func (a *InboundController) delInbound(c *gin.Context) {
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
+	// Broadcast inbounds update via WebSocket
+	user := session.GetLoginUser(c)
+	inbounds, _ := a.inboundService.GetInbounds(user.Id)
+	websocket.BroadcastInbounds(inbounds)
 }
 
 // updateInbound updates an existing inbound configuration.
@@ -169,6 +177,10 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
+	// Broadcast inbounds update via WebSocket
+	user := session.GetLoginUser(c)
+	inbounds, _ := a.inboundService.GetInbounds(user.Id)
+	websocket.BroadcastInbounds(inbounds)
 }
 
 // getClientIps retrieves the IP addresses associated with a client by email.

+ 17 - 0
web/controller/server.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v2/web/global"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )
@@ -67,6 +68,8 @@ func (a *ServerController) refreshStatus() {
 	// collect cpu history when status is fresh
 	if a.lastStatus != nil {
 		a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
+		// Broadcast status update via WebSocket
+		websocket.BroadcastStatus(a.lastStatus)
 	}
 }
 
@@ -155,9 +158,16 @@ func (a *ServerController) stopXrayService(c *gin.Context) {
 	err := a.serverService.StopXrayService()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
+		websocket.BroadcastXrayState("error", err.Error())
 		return
 	}
 	jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
+	websocket.BroadcastXrayState("stop", "")
+	websocket.BroadcastNotification(
+		I18nWeb(c, "pages.xray.stopSuccess"),
+		"Xray service has been stopped",
+		"warning",
+	)
 }
 
 // restartXrayService restarts the Xray service.
@@ -165,9 +175,16 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
 	err := a.serverService.RestartXrayService()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
+		websocket.BroadcastXrayState("error", err.Error())
 		return
 	}
 	jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
+	websocket.BroadcastXrayState("running", "")
+	websocket.BroadcastNotification(
+		I18nWeb(c, "pages.xray.restartSuccess"),
+		"Xray service has been restarted successfully",
+		"success",
+	)
 }
 
 // getLogs retrieves the application logs based on count, level, and syslog filters.

+ 189 - 0
web/controller/websocket.go

@@ -0,0 +1,189 @@
+package controller
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+
+	"github.com/gin-gonic/gin"
+	ws "github.com/gorilla/websocket"
+)
+
+const (
+	// Time allowed to write a message to the peer
+	writeWait = 10 * time.Second
+
+	// Time allowed to read the next pong message from the peer
+	pongWait = 60 * time.Second
+
+	// Send pings to peer with this period (must be less than pongWait)
+	pingPeriod = (pongWait * 9) / 10
+
+	// Maximum message size allowed from peer
+	maxMessageSize = 512
+)
+
+var upgrader = ws.Upgrader{
+	ReadBufferSize:  4096, // Increased from 1024 for better performance
+	WriteBufferSize: 4096, // Increased from 1024 for better performance
+	CheckOrigin: func(r *http.Request) bool {
+		// Check origin for security
+		origin := r.Header.Get("Origin")
+		if origin == "" {
+			// Allow connections without Origin header (same-origin requests)
+			return true
+		}
+		// Get the host from the request
+		host := r.Host
+		// Extract scheme and host from origin
+		originURL := origin
+		// Simple check: origin should match the request host
+		// This prevents cross-origin WebSocket hijacking
+		if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") {
+			// Extract host from origin
+			originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://")
+			if idx := strings.Index(originHost, "/"); idx != -1 {
+				originHost = originHost[:idx]
+			}
+			if idx := strings.Index(originHost, ":"); idx != -1 {
+				originHost = originHost[:idx]
+			}
+			// Compare hosts (without port)
+			requestHost := host
+			if idx := strings.Index(requestHost, ":"); idx != -1 {
+				requestHost = requestHost[:idx]
+			}
+			return originHost == requestHost || originHost == "" || requestHost == ""
+		}
+		return false
+	},
+}
+
+// WebSocketController handles WebSocket connections for real-time updates
+type WebSocketController struct {
+	BaseController
+	hub *websocket.Hub
+}
+
+// NewWebSocketController creates a new WebSocket controller
+func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
+	return &WebSocketController{
+		hub: hub,
+	}
+}
+
+// HandleWebSocket handles WebSocket connections
+func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
+	// Check authentication
+	if !session.IsLogin(c) {
+		logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
+		c.AbortWithStatus(http.StatusUnauthorized)
+		return
+	}
+
+	// Upgrade connection to WebSocket
+	conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		logger.Error("Failed to upgrade WebSocket connection:", err)
+		return
+	}
+
+	// Create client
+	clientID := uuid.New().String()
+	client := &websocket.Client{
+		ID:     clientID,
+		Hub:    w.hub,
+		Send:   make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow
+		Topics: make(map[websocket.MessageType]bool),
+	}
+
+	// Register client
+	w.hub.Register(client)
+	logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
+
+	// Start goroutines for reading and writing
+	go w.writePump(client, conn)
+	go w.readPump(client, conn)
+}
+
+// readPump pumps messages from the WebSocket connection to the hub
+func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
+	defer func() {
+		if r := common.Recover("WebSocket readPump panic"); r != nil {
+			logger.Error("WebSocket readPump panic recovered:", r)
+		}
+		w.hub.Unregister(client)
+		conn.Close()
+	}()
+
+	conn.SetReadDeadline(time.Now().Add(pongWait))
+	conn.SetPongHandler(func(string) error {
+		conn.SetReadDeadline(time.Now().Add(pongWait))
+		return nil
+	})
+	conn.SetReadLimit(maxMessageSize)
+
+	for {
+		_, message, err := conn.ReadMessage()
+		if err != nil {
+			if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
+				logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
+			}
+			break
+		}
+
+		// Validate message size
+		if len(message) > maxMessageSize {
+			logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message))
+			continue
+		}
+
+		// Handle incoming messages (e.g., subscription requests)
+		// For now, we'll just log them
+		logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message))
+	}
+}
+
+// writePump pumps messages from the hub to the WebSocket connection
+func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
+	ticker := time.NewTicker(pingPeriod)
+	defer func() {
+		if r := common.Recover("WebSocket writePump panic"); r != nil {
+			logger.Error("WebSocket writePump panic recovered:", r)
+		}
+		ticker.Stop()
+		conn.Close()
+	}()
+
+	for {
+		select {
+		case message, ok := <-client.Send:
+			conn.SetWriteDeadline(time.Now().Add(writeWait))
+			if !ok {
+				// Hub closed the channel
+				conn.WriteMessage(ws.CloseMessage, []byte{})
+				return
+			}
+
+			// Send each message individually (no batching)
+			// This ensures each JSON message is sent separately and can be parsed correctly
+			if err := conn.WriteMessage(ws.TextMessage, message); err != nil {
+				logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
+				return
+			}
+
+		case <-ticker.C:
+			conn.SetWriteDeadline(time.Now().Add(writeWait))
+			if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
+				logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
+				return
+			}
+		}
+	}
+}

+ 1 - 0
web/global/global.go

@@ -17,6 +17,7 @@ var (
 type WebServer interface {
 	GetCron() *cron.Cron     // Get the cron scheduler
 	GetCtx() context.Context // Get the server context
+	GetWSHub() interface{}   // Get the WebSocket hub (using interface{} to avoid circular dependency)
 }
 
 // SubServer interface defines methods for accessing the subscription server instance.

+ 1 - 0
web/html/common/page.html

@@ -49,6 +49,7 @@
   const basePath = '{{ .base_path }}';
   axios.defaults.baseURL = basePath;
 </script>
+<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
 {{ end }}
   
 {{ define "page/body_end" }}

+ 31 - 0
web/html/form/outbound.html

@@ -239,6 +239,28 @@
             </a-select>
           </a-form-item>
         </template>
+        <!-- XTLS Vision Advanced Settings -->
+        <template v-if="outbound.protocol === Protocols.VLESS && (outbound.settings.flow === 'xtls-rprx-vision' || outbound.settings.flow === 'xtls-rprx-vision-udp443')">
+          <a-form-item label="Vision Pre-Connect">
+            <a-input-number v-model.number="outbound.settings.testpre" :min="0" :max="10" :style="{ width: '100%' }" placeholder="0"></a-input-number>
+          </a-form-item>
+          <a-form-item label="Vision Seed">
+            <a-row :gutter="8">
+              <a-col :span="6">
+                <a-input-number v-model.number="outbound.settings.testseed[0]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number>
+              </a-col>
+              <a-col :span="6">
+                <a-input-number v-model.number="outbound.settings.testseed[1]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number>
+              </a-col>
+              <a-col :span="6">
+                <a-input-number v-model.number="outbound.settings.testseed[2]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number>
+              </a-col>
+              <a-col :span="6">
+                <a-input-number v-model.number="outbound.settings.testseed[3]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
+              </a-col>
+            </a-row>
+          </a-form-item>
+        </template>
       </template>
 
       <!-- Servers (trojan/shadowsocks/socks/http) settings -->
@@ -501,6 +523,15 @@
         <a-form-item label="Penetrate">
           <a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
         </a-form-item>
+        <a-form-item label="Trusted X-Forwarded-For">
+          <a-select mode="tags" v-model="outbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
+              <a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
+              <a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
+              <a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
+          </a-select>
+        </a-form-item>
       </template>
 
       <!-- mux settings -->

+ 30 - 0
web/html/form/protocol/vless.html

@@ -39,6 +39,7 @@
       </a-space>
     </a-form-item>
   </a-form>
+  <a-divider v-if="inbound.settings.selectedAuth" :style="{ margin: '5px 0' }"></a-divider>
 </template>
 <template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
   <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
@@ -69,4 +70,33 @@
   </a-form>
   <a-divider :style="{ margin: '5px 0' }"></a-divider>
 </template>
+<template v-if="inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443')">
+  <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
+    <a-form-item label="Vision Seed">
+      <a-row :gutter="8">
+        <a-col :span="6">
+          <a-input-number :value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900" @change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number>
+        </a-col>
+        <a-col :span="6">
+          <a-input-number :value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500" @change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number>
+        </a-col>
+        <a-col :span="6">
+          <a-input-number :value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900" @change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number>
+        </a-col>
+        <a-col :span="6">
+          <a-input-number :value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256" @change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
+        </a-col>
+      </a-row>
+      <a-space :size="8" :style="{ marginTop: '8px' }">
+        <a-button type="primary" @click="setRandomTestseed">
+          Rand
+        </a-button>
+        <a-button @click="resetTestseed">
+          Reset
+        </a-button>
+      </a-space>
+    </a-form-item>
+  </a-form>
+  <a-divider :style="{ margin: '5px 0' }"></a-divider>
+</template>
 {{end}}

+ 9 - 0
web/html/form/stream/stream_sockopt.html

@@ -61,6 +61,15 @@
         <a-form-item label="Interface Name">
             <a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
         </a-form-item>
+        <a-form-item label="Trusted X-Forwarded-For">
+            <a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
+                :dropdown-class-name="themeSwitcher.currentTheme">
+                <a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
+                <a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
+                <a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
+                <a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
+            </a-select>
+        </a-form-item>
     </template>
 </a-form>
 {{end}}

+ 11 - 7
web/html/form/tls_settings.html

@@ -60,16 +60,20 @@
     <a-form-item label="VerifyPeerCertInNames">
       <a-input v-model.trim="inbound.stream.tls.verifyPeerCertInNames"></a-input>
     </a-form-item>
+    <a-divider :style="{ margin: '3px 0' }"></a-divider>
     <template v-for="cert,index in inbound.stream.tls.certs">
       <a-form-item label='{{ i18n "certificate" }}'>
-        <a-radio-group v-model="cert.useFile" button-style="solid">
-          <a-radio-button :value="true">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
-          <a-radio-button :value="false">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
+        <a-radio-group v-model="cert.useFile" button-style="solid" :style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }">
+          <a-radio-button :value="true" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
+          <a-radio-button :value="false" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
         </a-radio-group>
-        <a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"
-          :style="{ marginLeft: '10px' }"></a-button>
-        <a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
-          @click="inbound.stream.tls.removeCert(index)" :style="{ marginLeft: '10px' }"></a-button>
+      </a-form-item>
+      <a-form-item label=" ">
+        <a-space>
+          <a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"></a-button>
+          <a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
+            @click="inbound.stream.tls.removeCert(index)"></a-button>
+        </a-space>
       </a-form-item>
       <template v-if="cert.useFile">
         <a-form-item label='{{ i18n "pages.inbounds.publicKey" }}'>

+ 108 - 13
web/html/inbounds.html

@@ -1128,8 +1128,11 @@
       },
       openEditClient(dbInboundId, client) {
         dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        if (!dbInbound) return;
         clients = this.getInboundClients(dbInbound);
+        if (!clients || !Array.isArray(clients)) return;
         index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+        if (index < 0) return;
         clientModal.show({
           title: '{{ i18n "pages.client.edit"}}',
           okText: '{{ i18n "pages.client.submitEdit"}}',
@@ -1144,11 +1147,14 @@
         });
       },
       findIndexOfClient(protocol, clients, client) {
+        if (!clients || !Array.isArray(clients) || !client) {
+          return -1;
+        }
         switch (protocol) {
           case Protocols.TROJAN:
           case Protocols.SHADOWSOCKS:
-            return clients.findIndex(item => item.password === client.password && item.email === client.email);
-          default: return clients.findIndex(item => item.id === client.id && item.email === client.email);
+            return clients.findIndex(item => item && item.password === client.password && item.email === client.email);
+          default: return clients.findIndex(item => item && item.id === client.id && item.email === client.email);
         }
       },
       async addClient(clients, dbInboundId, modal) {
@@ -1271,11 +1277,15 @@
       },
       showInfo(dbInboundId, client) {
         dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        if (!dbInbound) return;
         index = 0;
         if (dbInbound.isMultiUser()) {
           inbound = dbInbound.toInbound();
-          clients = inbound.clients;
-          index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+          clients = inbound && inbound.clients ? inbound.clients : null;
+          if (clients && Array.isArray(clients)) {
+            index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+            if (index < 0) index = 0;
+          }
         }
         newDbInbound = this.checkFallback(dbInbound);
         infoModal.show(newDbInbound, index);
@@ -1288,9 +1298,12 @@
       async switchEnableClient(dbInboundId, client) {
         this.loading()
         dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        if (!dbInbound) return;
         inbound = dbInbound.toInbound();
-        clients = inbound.clients;
+        clients = inbound && inbound.clients ? inbound.clients : null;
+        if (!clients || !Array.isArray(clients)) return;
         index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+        if (index < 0 || !clients[index]) return;
         clients[index].enable = !clients[index].enable;
         clientId = this.getClientId(dbInbound.protocol, clients[index]);
         await this.updateClient(clients[index], dbInboundId, clientId);
@@ -1303,7 +1316,9 @@
         }
       },
       getInboundClients(dbInbound) {
-        return dbInbound.toInbound().clients;
+        if (!dbInbound) return null;
+        const inbound = dbInbound.toInbound();
+        return inbound && inbound.clients ? inbound.clients : null;
       },
       resetClientTraffic(client, dbInboundId, confirmation = true) {
         if (confirmation) {
@@ -1443,7 +1458,12 @@
       formatLastOnline(email) {
         const ts = this.getLastOnline(email)
         if (!ts) return '-'
-        return IntlUtil.formatDate(ts)
+        // Check if IntlUtil is available (may not be loaded yet)
+        if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
+          return IntlUtil.formatDate(ts)
+        }
+        // Fallback to simple date formatting if IntlUtil is not available
+        return new Date(ts).toLocaleString()
       },
       isRemovable(dbInboundId) {
         return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
@@ -1567,13 +1587,88 @@
       }
       this.loading();
       this.getDefaultSettings();
-      if (this.isRefreshEnabled) {
-        this.startDataRefreshLoop();
-      }
-      else {
-        this.getDBInbounds();
+      
+      // Initial data fetch
+      this.getDBInbounds().then(() => {
+        this.loading(false);
+      });
+
+      // Setup WebSocket for real-time updates
+      if (window.wsClient) {
+        window.wsClient.connect();
+        
+        // Listen for inbounds updates
+        window.wsClient.on('inbounds', (payload) => {
+          if (payload && Array.isArray(payload)) {
+            // Use setInbounds to properly convert to DBInbound objects with methods
+            this.setInbounds(payload);
+            this.searchInbounds(this.searchKey);
+          }
+        });
+
+        // Listen for traffic updates
+        window.wsClient.on('traffic', (payload) => {
+          if (payload && payload.clientTraffics && Array.isArray(payload.clientTraffics)) {
+            // Update client traffic statistics
+            payload.clientTraffics.forEach(clientTraffic => {
+              const dbInbound = this.dbInbounds.find(ib => {
+                if (!ib) return false;
+                const clients = this.getInboundClients(ib);
+                return clients && Array.isArray(clients) && clients.some(c => c && c.email === clientTraffic.email);
+              });
+              if (dbInbound && dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
+                const stats = dbInbound.clientStats.find(s => s && s.email === clientTraffic.email);
+                if (stats) {
+                  stats.up = clientTraffic.up || stats.up;
+                  stats.down = clientTraffic.down || stats.down;
+                  stats.total = clientTraffic.total || stats.total;
+                }
+              }
+            });
+          }
+          
+          // Update online clients list in real-time
+          if (payload && Array.isArray(payload.onlineClients)) {
+            this.onlineClients = payload.onlineClients;
+            // Recalculate client counts to update online status
+            this.dbInbounds.forEach(dbInbound => {
+              const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
+              if (inbound && this.clientCount[dbInbound.id]) {
+                this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
+              }
+            });
+          }
+          
+          // Update last online map in real-time
+          if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
+            this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
+          }
+        });
+
+        // Notifications disabled - white notifications are not needed
+
+        // Fallback to polling if WebSocket fails
+        window.wsClient.on('error', () => {
+          console.warn('WebSocket connection failed, falling back to polling');
+          if (this.isRefreshEnabled) {
+            this.startDataRefreshLoop();
+          }
+        });
+
+        window.wsClient.on('disconnected', () => {
+          if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
+            console.warn('WebSocket reconnection failed, falling back to polling');
+            if (this.isRefreshEnabled) {
+              this.startDataRefreshLoop();
+            }
+          }
+        });
+      } else {
+        // Fallback to polling if WebSocket is not available
+        if (this.isRefreshEnabled) {
+          this.startDataRefreshLoop();
+        }
       }
-      this.loading(false);
     },
     computed: {
       total() {

+ 65 - 7
web/html/index.html

@@ -1102,6 +1102,20 @@
         });
         fileInput.click();
       },
+      startPolling() {
+        // Fallback polling mechanism
+        const pollInterval = setInterval(async () => {
+          if (window.wsClient && window.wsClient.isConnected) {
+            clearInterval(pollInterval);
+            return;
+          }
+          try {
+            await this.getStatus();
+          } catch (e) {
+            console.error(e);
+          }
+        }, 2000);
+      },
     },
     async mounted() {
       if (window.location.protocol !== "https:") {
@@ -1113,13 +1127,57 @@
         this.ipLimitEnable = msg.obj.ipLimitEnable;
       }
 
-      while (true) {
-        try {
-          await this.getStatus();
-        } catch (e) {
-          console.error(e);
-        }
-        await PromiseUtil.sleep(2000);
+      // Initial status fetch
+      await this.getStatus();
+
+      // Setup WebSocket for real-time updates
+      if (window.wsClient) {
+        window.wsClient.connect();
+        
+        // Listen for status updates
+        window.wsClient.on('status', (payload) => {
+          this.setStatus(payload);
+        });
+
+        // Listen for Xray state changes
+        window.wsClient.on('xray_state', (payload) => {
+          if (this.status && this.status.xray) {
+            this.status.xray.state = payload.state;
+            this.status.xray.errorMsg = payload.errorMsg || '';
+            switch (payload.state) {
+              case 'running':
+                this.status.xray.color = "green";
+                this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
+                break;
+              case 'stop':
+                this.status.xray.color = "orange";
+                this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
+                break;
+              case 'error':
+                this.status.xray.color = "red";
+                this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
+                break;
+            }
+          }
+        });
+
+        // Notifications disabled - white notifications are not needed
+
+        // Fallback to polling if WebSocket fails
+        window.wsClient.on('error', () => {
+          console.warn('WebSocket connection failed, falling back to polling');
+          this.startPolling();
+        });
+
+        window.wsClient.on('disconnected', () => {
+          if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
+            console.warn('WebSocket reconnection failed, falling back to polling');
+            this.startPolling();
+          }
+        });
+      } else {
+        // Fallback to polling if WebSocket is not available
+        this.startPolling();
       }
     },
   });

+ 90 - 4
web/html/modals/inbound_modal.html

@@ -6,7 +6,8 @@
 </a-modal>
 <script>
 
-    const inModal = {
+    // Make inModal globally available to ensure it works with any base path
+    const inModal = window.inModal = {
         title: '',
         visible: false,
         confirmLoading: false,
@@ -26,6 +27,14 @@
             } else {
                 this.inbound = new Inbound();
             }
+            // Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
+            // This ensures Vue reactivity works properly
+            if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
+                if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed) || this.inbound.settings.testseed.length < 4) {
+                    // Create a new array to ensure Vue reactivity
+                    this.inbound.settings.testseed = [900, 500, 900, 256].slice();
+                }
+            }
             if (dbInbound) {
                 this.dbInbound = new DBInbound(dbInbound);
             } else {
@@ -42,9 +51,43 @@
         loading(loading = true) {
             inModal.confirmLoading = loading;
         },
+        // Vision Seed methods - always available regardless of Vue context
+        updateTestseed(index, value) {
+            // Use inModal.inbound explicitly to ensure correct context
+            if (!inModal.inbound || !inModal.inbound.settings) return;
+            // Ensure testseed is initialized
+            if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed)) {
+                inModal.inbound.settings.testseed = [900, 500, 900, 256];
+            }
+            // Ensure array has enough elements
+            while (inModal.inbound.settings.testseed.length <= index) {
+                inModal.inbound.settings.testseed.push(0);
+            }
+            // Update value
+            inModal.inbound.settings.testseed[index] = value;
+        },
+        setRandomTestseed() {
+            // Use inModal.inbound explicitly to ensure correct context
+            if (!inModal.inbound || !inModal.inbound.settings) return;
+            // Ensure testseed is initialized
+            if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4) {
+                inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
+            }
+            // Create new array with random values
+            inModal.inbound.settings.testseed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
+        },
+        resetTestseed() {
+            // Use inModal.inbound explicitly to ensure correct context
+            if (!inModal.inbound || !inModal.inbound.settings) return;
+            // Reset testseed to default values
+            inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
+        }
     };
 
-    new Vue({
+    // Store Vue instance globally to ensure methods are always accessible
+    let inboundModalVueInstance = null;
+    
+    inboundModalVueInstance = new Vue({
         delimiters: ['[[', ']]'],
         el: '#inbound-modal',
         data: {
@@ -60,7 +103,7 @@
                 return inModal.isEdit;
             },
             get client() {
-                return inModal.inbound.clients[0];
+                return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
             },
             get datepicker() {
                 return app.datepicker;
@@ -87,6 +130,28 @@
                 }
             }
         },
+        watch: {
+            'inModal.inbound.stream.security'(newVal, oldVal) {
+                // Clear flow when security changes from reality/tls to none
+                if (inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
+                    inModal.inbound.settings.vlesses.forEach(client => {
+                        client.flow = "";
+                    });
+                }
+            },
+            // Ensure testseed is always initialized when vision flow is enabled
+            'inModal.inbound.settings.vlesses': {
+                handler() {
+                    if (inModal.inbound.protocol === Protocols.VLESS && inModal.inbound.settings && inModal.inbound.settings.vlesses) {
+                        const hasVisionFlow = inModal.inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443');
+                        if (hasVisionFlow && (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4)) {
+                            inModal.inbound.settings.testseed = [900, 500, 900, 256];
+                        }
+                    }
+                },
+                deep: true
+            }
+        },
         methods: {
             streamNetworkChange() {
                 if (!inModal.inbound.canEnableTls()) {
@@ -204,8 +269,29 @@
                 this.inbound.settings.decryption = 'none';
                 this.inbound.settings.encryption = 'none';
                 this.inbound.settings.selectedAuth = undefined;
+            },
+            // Vision Seed methods - must be in Vue methods for proper binding
+            updateTestseed(index, value) {
+                // Ensure testseed is initialized
+                if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed)) {
+                    this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
+                }
+                // Ensure array has enough elements
+                while (this.inbound.settings.testseed.length <= index) {
+                    this.inbound.settings.testseed.push(0);
+                }
+                // Update value using Vue.set for reactivity
+                this.$set(this.inbound.settings.testseed, index, value);
+            },
+            setRandomTestseed() {
+                // Create new array with random values and use Vue.set for reactivity
+                const newSeed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
+                this.$set(this.inbound.settings, 'testseed', newSeed);
+            },
+            resetTestseed() {
+                // Reset testseed to default values using Vue.set for reactivity
+                this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
             }
-
         },
     });
 

+ 7 - 0
web/html/settings/xray/dns.html

@@ -56,6 +56,13 @@
                     <a-switch v-model="dnsDisableFallbackIfMatch"></a-switch>
                 </template>
             </a-setting-list-item>
+            <a-setting-list-item paddings="small">
+                <template #title>{{ i18n "pages.xray.dns.enableParallelQuery" }}</template>
+                <template #description>{{ i18n "pages.xray.dns.enableParallelQueryDesc" }}</template>
+                <template #control>
+                    <a-switch v-model="dnsEnableParallelQuery"></a-switch>
+                </template>
+            </a-setting-list-item>
 
             <a-setting-list-item paddings="small">
                 <template #title>{{ i18n "pages.xray.dns.useSystemHosts" }}</template>

+ 17 - 2
web/html/xray.html

@@ -269,7 +269,7 @@
         tag: "direct",
         protocol: "freedom"
       },
-      routingDomainStrategies: ["AsIs", "IPIfNonMatch", "IPOnDemand"],
+      routingDomainStrategies: ["AsIs", "IpIfNonMatch", "IpOnDemand"],
       log: {
         loglevel: ["none", "debug", "info", "warning", "error"],
         access: ["none", "./access.log"],
@@ -1315,7 +1315,8 @@
             newTemplateSettings.dns = {
               servers: [],
               queryStrategy: "UseIP",
-              tag: "dns_inbound"
+              tag: "dns_inbound",
+              enableParallelQuery: false
             };
             newTemplateSettings.fakedns = null;
           } else {
@@ -1391,6 +1392,20 @@
           this.templateSettings = newTemplateSettings;
         }
       },
+      dnsEnableParallelQuery: {
+        get: function () {
+          return this.enableDNS ? (this.templateSettings.dns.enableParallelQuery || false) : false;
+        },
+        set: function (newValue) {
+          newTemplateSettings = this.templateSettings;
+          if (newValue) {
+            newTemplateSettings.dns.enableParallelQuery = newValue;
+          } else {
+            delete newTemplateSettings.dns.enableParallelQuery
+          }
+          this.templateSettings = newTemplateSettings;
+        }
+      },
       dnsUseSystemHosts: {
         get: function () {
           return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false;

+ 0 - 60
web/job/ldap_sync_job.go

@@ -322,66 +322,6 @@ func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
 	return b.String()
 }
 
-// ensureClientExists adds client with defaults to inbound tag if not present
-func (j *LdapSyncJob) ensureClientExists(inboundTag string, email string, defGB int, defExpiryDays int, defLimitIP int) {
-	inbounds, err := j.inboundService.GetAllInbounds()
-	if err != nil {
-		logger.Warning("ensureClientExists: get inbounds failed:", err)
-		return
-	}
-	var target *model.Inbound
-	for _, ib := range inbounds {
-		if ib.Tag == inboundTag {
-			target = ib
-			break
-		}
-	}
-	if target == nil {
-		logger.Debugf("ensureClientExists: inbound tag %s not found", inboundTag)
-		return
-	}
-	// check if email already exists in this inbound
-	clients, err := j.inboundService.GetClients(target)
-	if err == nil {
-		for _, c := range clients {
-			if c.Email == email {
-				return
-			}
-		}
-	}
-
-	// build new client according to protocol
-	newClient := model.Client{
-		Email:   email,
-		Enable:  true,
-		LimitIP: defLimitIP,
-		TotalGB: int64(defGB),
-	}
-	if defExpiryDays > 0 {
-		newClient.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
-	}
-
-	switch target.Protocol {
-	case model.Trojan:
-		newClient.Password = uuid.NewString()
-	case model.Shadowsocks:
-		newClient.Password = uuid.NewString()
-	default: // VMESS/VLESS and others using ID
-		newClient.ID = uuid.NewString()
-	}
-
-	// prepare inbound payload with only the new client
-	payload := &model.Inbound{Id: target.Id}
-	payload.Settings = `{"clients":[` + j.clientToJSON(newClient) + `]}`
-
-	if _, err := j.inboundService.AddInboundClient(payload); err != nil {
-		logger.Warning("ensureClientExists: add client failed:", err)
-	} else {
-		j.xrayService.SetToNeedRestart()
-		logger.Infof("LDAP auto-create: %s in %s", email, inboundTag)
-	}
-}
-
 // clientToJSON serializes minimal client fields to JSON object string without extra deps
 func (j *LdapSyncJob) clientToJSON(c model.Client) string {
 	// construct minimal JSON manually to avoid importing json for simple case

+ 18 - 0
web/job/xray_traffic_job.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v2/logger"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
 	"github.com/mhsanaei/3x-ui/v2/xray"
 
 	"github.com/valyala/fasthttp"
@@ -48,6 +49,23 @@ func (j *XrayTrafficJob) Run() {
 	if needRestart0 || needRestart1 {
 		j.xrayService.SetToNeedRestart()
 	}
+
+	// Get online clients and last online map for real-time status updates
+	onlineClients := j.inboundService.GetOnlineClients()
+	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
+	if err != nil {
+		logger.Warning("get clients last online failed:", err)
+		lastOnlineMap = make(map[string]int64)
+	}
+
+	// Broadcast traffic update via WebSocket
+	trafficUpdate := map[string]interface{}{
+		"traffics":       traffics,
+		"clientTraffics": clientTraffics,
+		"onlineClients":  onlineClients,
+		"lastOnlineMap":  lastOnlineMap,
+	}
+	websocket.BroadcastTraffic(trafficUpdate)
 }
 
 func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {

+ 2 - 0
web/translation/translate.ar_EG.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "بيعطل استعلامات DNS الاحتياطية"
 "disableFallbackIfMatch" = "تعطيل النسخ الاحتياطي عند التطابق"
 "disableFallbackIfMatchDesc" = "بيعطل استعلامات DNS الاحتياطية لما يتحقق تطابق مع قائمة الدومينات"
+"enableParallelQuery" = "تفعيل الاستعلام المتوازي"
+"enableParallelQueryDesc" = "تفعيل استعلامات DNS المتوازية لعدة خوادم لحل أسرع"
 "strategy" = "استراتيجية الاستعلام"
 "strategyDesc" = "الاستراتيجية العامة لحل أسماء الدومين"
 "add" = "أضف سيرفر"

+ 2 - 0
web/translation/translate.en_US.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Disables fallback DNS queries"
 "disableFallbackIfMatch" = "Disable Fallback If Match"
 "disableFallbackIfMatchDesc" = "Disables fallback DNS queries when the matching domain list of the DNS server is hit"
+"enableParallelQuery" = "Enable Parallel Query"
+"enableParallelQueryDesc" = "Enable parallel DNS queries to multiple servers for faster resolution"
 "strategy" = "Query Strategy"
 "strategyDesc" = "Overall strategy to resolve domain names"
 "add" = "Add Server"

+ 2 - 0
web/translation/translate.es_ES.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Desactiva las consultas DNS de respaldo"
 "disableFallbackIfMatch" = "Desactivar respaldo si coincide"
 "disableFallbackIfMatchDesc" = "Desactiva las consultas DNS de respaldo cuando se acierta en la lista de dominios coincidentes del servidor DNS"
+"enableParallelQuery" = "Habilitar consulta paralela"
+"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas a múltiples servidores para una resolución más rápida"
 "strategy" = "Estrategia de Consulta"
 "strategyDesc" = "Estrategia general para resolver nombres de dominio"
 "add" = "Agregar Servidor"

+ 2 - 0
web/translation/translate.fa_IR.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "درخواست‌های DNS Fallback را غیرفعال می‌کند"
 "disableFallbackIfMatch" = "غیرفعال‌سازی Fallback در صورت تطابق"
 "disableFallbackIfMatchDesc" = "درخواست‌های DNS Fallback را زمانی که لیست دامنه‌های مطابقت‌یافته سرور DNS فعال است، غیرفعال می‌کند"
+"enableParallelQuery" = "فعال‌سازی پرس‌وجوی موازی"
+"enableParallelQueryDesc" = "فعال‌سازی پرس‌وجوهای DNS موازی به چندین سرور برای وضوح سریع‌تر"
 "strategy" = "استراتژی پرس‌وجو"
 "strategyDesc" = "استراتژی کلی برای حل نام دامنه"
 "add" = "افزودن سرور"

+ 2 - 0
web/translation/translate.id_ID.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Menonaktifkan kueri DNS fallback"
 "disableFallbackIfMatch" = "Nonaktifkan Fallback Jika Cocok"
 "disableFallbackIfMatchDesc" = "Menonaktifkan kueri DNS fallback ketika daftar domain yang cocok dari server DNS terpenuhi"
+"enableParallelQuery" = "Aktifkan Kueri Paralel"
+"enableParallelQueryDesc" = "Aktifkan kueri DNS paralel ke beberapa server untuk resolusi yang lebih cepat"
 "strategy" = "Strategi Kueri"
 "strategyDesc" = "Strategi keseluruhan untuk menyelesaikan nama domain"
 "add" = "Tambahkan Server"

+ 2 - 0
web/translation/translate.ja_JP.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "フォールバックDNSクエリを無効にします"
 "disableFallbackIfMatch" = "一致した場合にフォールバックを無効にする"
 "disableFallbackIfMatchDesc" = "DNSサーバーの一致するドメインリストにヒットした場合、フォールバックDNSクエリを無効にします"
+"enableParallelQuery" = "並列クエリを有効にする"
+"enableParallelQueryDesc" = "複数のサーバーへの並列DNSクエリを有効にして、より高速な解決を実現"
 "strategy" = "クエリ戦略"
 "strategyDesc" = "ドメイン名解決の全体的な戦略"
 "add" = "サーバー追加"

+ 2 - 0
web/translation/translate.pt_BR.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Desativa consultas DNS de fallback"
 "disableFallbackIfMatch" = "Desativar Fallback Se Corresponder"
 "disableFallbackIfMatchDesc" = "Desativa consultas DNS de fallback quando a lista de domínios correspondentes do servidor DNS é atingida"
+"enableParallelQuery" = "Habilitar Consulta Paralela"
+"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas para múltiplos servidores para resolução mais rápida"
 "strategy" = "Estratégia de Consulta"
 "strategyDesc" = "Estratégia geral para resolver nomes de domínio"
 "add" = "Adicionar Servidor"

+ 2 - 0
web/translation/translate.ru_RU.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Отключает резервные DNS-запросы"
 "disableFallbackIfMatch" = "Отключить резервный DNS при совпадении"
 "disableFallbackIfMatchDesc" = "Отключает резервные DNS-запросы при совпадении списка доменов DNS-сервера"
+"enableParallelQuery" = "Включить параллельные запросы"
+"enableParallelQueryDesc" = "Включить параллельные DNS-запросы к нескольким серверам для более быстрого разрешения"
 "strategy" = "Стратегия запроса"
 "strategyDesc" = "Общая стратегия разрешения доменных имен"
 "add" = "Создать DNS"

+ 2 - 0
web/translation/translate.tr_TR.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Yedek DNS sorgularını devre dışı bırakır"
 "disableFallbackIfMatch" = "Eşleşirse Yedeklemeyi Devre Dışı Bırak"
 "disableFallbackIfMatchDesc" = "DNS sunucusunun eşleşen alan adı listesi vurulduğunda yedek DNS sorgularını devre dışı bırakır"
+"enableParallelQuery" = "Paralel Sorguyu Etkinleştir"
+"enableParallelQueryDesc" = "Daha hızlı çözümleme için birden fazla sunucuya paralel DNS sorgularını etkinleştir"
 "strategy" = "Sorgu Stratejisi"
 "strategyDesc" = "Alan adlarını çözmek için genel strateji"
 "add" = "Sunucu Ekle"

+ 2 - 0
web/translation/translate.uk_UA.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Вимкнути резервні DNS-запити"
 "disableFallbackIfMatch" = "Вимкнути резервний DNS при збігу"
 "disableFallbackIfMatchDesc" = "Вимкнути резервні DNS-запити при збігу списку доменів DNS-сервера"
+"enableParallelQuery" = "Увімкнути паралельні запити"
+"enableParallelQueryDesc" = "Увімкнути паралельні DNS-запити до кількох серверів для швидшого вирішення"
 "strategy" = "Стратегія запиту"
 "strategyDesc" = "Загальна стратегія вирішення доменних імен"
 "add" = "Додати сервер"

+ 2 - 0
web/translation/translate.vi_VN.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "Tắt các truy vấn DNS Fallback"
 "disableFallbackIfMatch" = "Tắt Fallback Nếu Khớp"
 "disableFallbackIfMatchDesc" = "Tắt các truy vấn DNS Fallback khi danh sách tên miền khớp của máy chủ DNS được kích hoạt"
+"enableParallelQuery" = "Bật Truy vấn Song song"
+"enableParallelQueryDesc" = "Bật truy vấn DNS song song đến nhiều máy chủ để phân giải nhanh hơn"
 "strategy" = "Chiến lược truy vấn"
 "strategyDesc" = "Chiến lược tổng thể để phân giải tên miền"
 "add" = "Thêm máy chủ"

+ 2 - 0
web/translation/translate.zh_CN.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "禁用回退DNS查询"
 "disableFallbackIfMatch" = "匹配时禁用回退"
 "disableFallbackIfMatchDesc" = "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询"
+"enableParallelQuery" = "启用并行查询"
+"enableParallelQueryDesc" = "启用并行DNS查询到多个服务器以实现更快的解析"
 "strategy" = "查询策略"
 "strategyDesc" = "解析域名的总体策略"
 "add" = "添加服务器"

+ 2 - 0
web/translation/translate.zh_TW.toml

@@ -544,6 +544,8 @@
 "disableFallbackDesc" = "禁用回退DNS查詢"
 "disableFallbackIfMatch" = "匹配時禁用回退"
 "disableFallbackIfMatchDesc" = "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢"
+"enableParallelQuery" = "啟用並行查詢"
+"enableParallelQueryDesc" = "啟用並行DNS查詢到多個伺服器以實現更快的解析"
 "strategy" = "查詢策略"
 "strategyDesc" = "解析域名的總體策略"
 "add" = "新增伺服器"

+ 22 - 0
web/web.go

@@ -25,6 +25,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v2/web/middleware"
 	"github.com/mhsanaei/3x-ui/v2/web/network"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
 
 	"github.com/gin-contrib/gzip"
 	"github.com/gin-contrib/sessions"
@@ -98,11 +99,14 @@ type Server struct {
 	index *controller.IndexController
 	panel *controller.XUIController
 	api   *controller.APIController
+	ws    *controller.WebSocketController
 
 	xrayService    service.XrayService
 	settingService service.SettingService
 	tgbotService   service.Tgbot
 
+	wsHub *websocket.Hub
+
 	cron *cron.Cron
 
 	ctx    context.Context
@@ -266,6 +270,15 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	s.panel = controller.NewXUIController(g)
 	s.api = controller.NewAPIController(g)
 
+	// Initialize WebSocket hub
+	s.wsHub = websocket.NewHub()
+	go s.wsHub.Run()
+
+	// Initialize WebSocket controller
+	s.ws = controller.NewWebSocketController(s.wsHub)
+	// Register WebSocket route with basePath (g already has basePath prefix)
+	g.GET("/ws", s.ws.HandleWebSocket)
+
 	// Chrome DevTools endpoint for debugging web apps
 	engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
 		c.JSON(http.StatusOK, gin.H{})
@@ -448,6 +461,10 @@ func (s *Server) Stop() error {
 	if s.tgbotService.IsRunning() {
 		s.tgbotService.Stop()
 	}
+	// Gracefully stop WebSocket hub
+	if s.wsHub != nil {
+		s.wsHub.Stop()
+	}
 	var err1 error
 	var err2 error
 	if s.httpServer != nil {
@@ -468,3 +485,8 @@ func (s *Server) GetCtx() context.Context {
 func (s *Server) GetCron() *cron.Cron {
 	return s.cron
 }
+
+// GetWSHub returns the WebSocket hub instance.
+func (s *Server) GetWSHub() interface{} {
+	return s.wsHub
+}

+ 379 - 0
web/websocket/hub.go

@@ -0,0 +1,379 @@
+// Package websocket provides WebSocket hub for real-time updates and notifications.
+package websocket
+
+import (
+	"context"
+	"encoding/json"
+	"runtime"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/logger"
+)
+
+// MessageType represents the type of WebSocket message
+type MessageType string
+
+const (
+	MessageTypeStatus       MessageType = "status"       // Server status update
+	MessageTypeTraffic      MessageType = "traffic"      // Traffic statistics update
+	MessageTypeInbounds     MessageType = "inbounds"     // Inbounds list update
+	MessageTypeNotification MessageType = "notification" // System notification
+	MessageTypeXrayState    MessageType = "xray_state"   // Xray state change
+)
+
+// Message represents a WebSocket message
+type Message struct {
+	Type    MessageType `json:"type"`
+	Payload interface{} `json:"payload"`
+	Time    int64       `json:"time"`
+}
+
+// Client represents a WebSocket client connection
+type Client struct {
+	ID     string
+	Send   chan []byte
+	Hub    *Hub
+	Topics map[MessageType]bool // Subscribed topics
+}
+
+// Hub maintains the set of active clients and broadcasts messages to them
+type Hub struct {
+	// Registered clients
+	clients map[*Client]bool
+
+	// Inbound messages from clients
+	broadcast chan []byte
+
+	// Register requests from clients
+	register chan *Client
+
+	// Unregister requests from clients
+	unregister chan *Client
+
+	// Mutex for thread-safe operations
+	mu sync.RWMutex
+
+	// Context for graceful shutdown
+	ctx    context.Context
+	cancel context.CancelFunc
+
+	// Worker pool for parallel broadcasting
+	workerPoolSize int
+	broadcastWg    sync.WaitGroup
+}
+
+// NewHub creates a new WebSocket hub
+func NewHub() *Hub {
+	ctx, cancel := context.WithCancel(context.Background())
+
+	// Calculate optimal worker pool size (CPU cores * 2, but max 100)
+	workerPoolSize := runtime.NumCPU() * 2
+	if workerPoolSize > 100 {
+		workerPoolSize = 100
+	}
+	if workerPoolSize < 10 {
+		workerPoolSize = 10
+	}
+
+	return &Hub{
+		clients:        make(map[*Client]bool),
+		broadcast:      make(chan []byte, 2048), // Increased from 256 to 2048 for high load
+		register:       make(chan *Client, 100), // Buffered channel for fast registration
+		unregister:     make(chan *Client, 100), // Buffered channel for fast unregistration
+		ctx:            ctx,
+		cancel:         cancel,
+		workerPoolSize: workerPoolSize,
+	}
+}
+
+// Run starts the hub's main loop
+func (h *Hub) Run() {
+	defer func() {
+		if r := recover(); r != nil {
+			logger.Error("WebSocket hub panic recovered:", r)
+			// Restart the hub loop
+			go h.Run()
+		}
+	}()
+
+	for {
+		select {
+		case <-h.ctx.Done():
+			// Graceful shutdown: close all clients
+			h.mu.Lock()
+			for client := range h.clients {
+				// Safely close channel (avoid double close panic)
+				select {
+				case _, stillOpen := <-client.Send:
+					if stillOpen {
+						close(client.Send)
+					}
+				default:
+					close(client.Send)
+				}
+			}
+			h.clients = make(map[*Client]bool)
+			h.mu.Unlock()
+			// Wait for all broadcast workers to finish
+			h.broadcastWg.Wait()
+			logger.Info("WebSocket hub stopped gracefully")
+			return
+
+		case client := <-h.register:
+			if client == nil {
+				continue
+			}
+			h.mu.Lock()
+			h.clients[client] = true
+			count := len(h.clients)
+			h.mu.Unlock()
+			logger.Debugf("WebSocket client connected: %s (total: %d)", client.ID, count)
+
+		case client := <-h.unregister:
+			if client == nil {
+				continue
+			}
+			h.mu.Lock()
+			if _, ok := h.clients[client]; ok {
+				delete(h.clients, client)
+				// Safely close channel (avoid double close panic)
+				// Check if channel is already closed by trying to read from it
+				select {
+				case _, stillOpen := <-client.Send:
+					if stillOpen {
+						// Channel was open and had data, now it's empty, safe to close
+						close(client.Send)
+					}
+					// If stillOpen is false, channel was already closed, do nothing
+				default:
+					// Channel is empty and open, safe to close
+					close(client.Send)
+				}
+			}
+			count := len(h.clients)
+			h.mu.Unlock()
+			logger.Debugf("WebSocket client disconnected: %s (total: %d)", client.ID, count)
+
+		case message := <-h.broadcast:
+			if message == nil {
+				continue
+			}
+			// Optimization: quickly copy client list and release lock
+			h.mu.RLock()
+			clientCount := len(h.clients)
+			if clientCount == 0 {
+				h.mu.RUnlock()
+				continue
+			}
+
+			// Pre-allocate memory for client list
+			clients := make([]*Client, 0, clientCount)
+			for client := range h.clients {
+				clients = append(clients, client)
+			}
+			h.mu.RUnlock()
+
+			// Parallel broadcast using worker pool
+			h.broadcastParallel(clients, message)
+		}
+	}
+}
+
+// broadcastParallel sends message to all clients in parallel for maximum performance
+func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
+	if len(clients) == 0 {
+		return
+	}
+
+	// For small number of clients, use simple parallel sending
+	if len(clients) < h.workerPoolSize {
+		var wg sync.WaitGroup
+		for _, client := range clients {
+			wg.Add(1)
+			go func(c *Client) {
+				defer wg.Done()
+				defer func() {
+					if r := recover(); r != nil {
+						// Channel may be closed, safely ignore
+						logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", c.ID, r)
+					}
+				}()
+				select {
+				case c.Send <- message:
+				default:
+					// Client's send buffer is full, disconnect
+					logger.Debugf("WebSocket client %s send buffer full, disconnecting", c.ID)
+					h.Unregister(c)
+				}
+			}(client)
+		}
+		wg.Wait()
+		return
+	}
+
+	// For large number of clients, use worker pool for optimal performance
+	clientChan := make(chan *Client, len(clients))
+	for _, client := range clients {
+		clientChan <- client
+	}
+	close(clientChan)
+
+	// Start workers for parallel processing
+	h.broadcastWg.Add(h.workerPoolSize)
+	for i := 0; i < h.workerPoolSize; i++ {
+		go func() {
+			defer h.broadcastWg.Done()
+			for client := range clientChan {
+				func() {
+					defer func() {
+						if r := recover(); r != nil {
+							// Channel may be closed, safely ignore
+							logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", client.ID, r)
+						}
+					}()
+					select {
+					case client.Send <- message:
+					default:
+						// Client's send buffer is full, disconnect
+						logger.Debugf("WebSocket client %s send buffer full, disconnecting", client.ID)
+						h.Unregister(client)
+					}
+				}()
+			}
+		}()
+	}
+
+	// Wait for all workers to finish
+	h.broadcastWg.Wait()
+}
+
+// Broadcast sends a message to all connected clients
+func (h *Hub) Broadcast(messageType MessageType, payload interface{}) {
+	if h == nil {
+		return
+	}
+	if payload == nil {
+		logger.Warning("Attempted to broadcast nil payload")
+		return
+	}
+
+	msg := Message{
+		Type:    messageType,
+		Payload: payload,
+		Time:    getCurrentTimestamp(),
+	}
+
+	data, err := json.Marshal(msg)
+	if err != nil {
+		logger.Error("Failed to marshal WebSocket message:", err)
+		return
+	}
+
+	// Limit message size to prevent memory issues
+	const maxMessageSize = 1024 * 1024 // 1MB
+	if len(data) > maxMessageSize {
+		logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
+		return
+	}
+
+	// Non-blocking send with timeout to prevent delays
+	select {
+	case h.broadcast <- data:
+	case <-time.After(100 * time.Millisecond):
+		logger.Warning("WebSocket broadcast channel is full, dropping message")
+	case <-h.ctx.Done():
+		// Hub is shutting down
+	}
+}
+
+// BroadcastToTopic sends a message only to clients subscribed to the specific topic
+func (h *Hub) BroadcastToTopic(messageType MessageType, payload interface{}) {
+	if h == nil {
+		return
+	}
+	if payload == nil {
+		logger.Warning("Attempted to broadcast nil payload to topic")
+		return
+	}
+
+	msg := Message{
+		Type:    messageType,
+		Payload: payload,
+		Time:    getCurrentTimestamp(),
+	}
+
+	data, err := json.Marshal(msg)
+	if err != nil {
+		logger.Error("Failed to marshal WebSocket message:", err)
+		return
+	}
+
+	// Limit message size to prevent memory issues
+	const maxMessageSize = 1024 * 1024 // 1MB
+	if len(data) > maxMessageSize {
+		logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
+		return
+	}
+
+	h.mu.RLock()
+	// Filter clients by topics and quickly release lock
+	subscribedClients := make([]*Client, 0)
+	for client := range h.clients {
+		if len(client.Topics) == 0 || client.Topics[messageType] {
+			subscribedClients = append(subscribedClients, client)
+		}
+	}
+	h.mu.RUnlock()
+
+	// Parallel send to subscribed clients
+	if len(subscribedClients) > 0 {
+		h.broadcastParallel(subscribedClients, data)
+	}
+}
+
+// GetClientCount returns the number of connected clients
+func (h *Hub) GetClientCount() int {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+	return len(h.clients)
+}
+
+// Register registers a new client with the hub
+func (h *Hub) Register(client *Client) {
+	if h == nil || client == nil {
+		return
+	}
+	select {
+	case h.register <- client:
+	case <-h.ctx.Done():
+		// Hub is shutting down
+	}
+}
+
+// Unregister unregisters a client from the hub
+func (h *Hub) Unregister(client *Client) {
+	if h == nil || client == nil {
+		return
+	}
+	select {
+	case h.unregister <- client:
+	case <-h.ctx.Done():
+		// Hub is shutting down
+	}
+}
+
+// Stop gracefully stops the hub and closes all connections
+func (h *Hub) Stop() {
+	if h == nil {
+		return
+	}
+	if h.cancel != nil {
+		h.cancel()
+	}
+}
+
+// getCurrentTimestamp returns current Unix timestamp in milliseconds
+func getCurrentTimestamp() int64 {
+	return time.Now().UnixMilli()
+}

+ 74 - 0
web/websocket/notifier.go

@@ -0,0 +1,74 @@
+// Package websocket provides WebSocket hub for real-time updates and notifications.
+package websocket
+
+import (
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/global"
+)
+
+// GetHub returns the global WebSocket hub instance
+func GetHub() *Hub {
+	webServer := global.GetWebServer()
+	if webServer == nil {
+		return nil
+	}
+	hub := webServer.GetWSHub()
+	if hub == nil {
+		return nil
+	}
+	wsHub, ok := hub.(*Hub)
+	if !ok {
+		logger.Warning("WebSocket hub type assertion failed")
+		return nil
+	}
+	return wsHub
+}
+
+// BroadcastStatus broadcasts server status update to all connected clients
+func BroadcastStatus(status interface{}) {
+	hub := GetHub()
+	if hub != nil {
+		hub.Broadcast(MessageTypeStatus, status)
+	}
+}
+
+// BroadcastTraffic broadcasts traffic statistics update to all connected clients
+func BroadcastTraffic(traffic interface{}) {
+	hub := GetHub()
+	if hub != nil {
+		hub.Broadcast(MessageTypeTraffic, traffic)
+	}
+}
+
+// BroadcastInbounds broadcasts inbounds list update to all connected clients
+func BroadcastInbounds(inbounds interface{}) {
+	hub := GetHub()
+	if hub != nil {
+		hub.Broadcast(MessageTypeInbounds, inbounds)
+	}
+}
+
+// BroadcastNotification broadcasts a system notification to all connected clients
+func BroadcastNotification(title, message, level string) {
+	hub := GetHub()
+	if hub != nil {
+		notification := map[string]string{
+			"title":   title,
+			"message": message,
+			"level":   level, // info, warning, error, success
+		}
+		hub.Broadcast(MessageTypeNotification, notification)
+	}
+}
+
+// BroadcastXrayState broadcasts Xray state change to all connected clients
+func BroadcastXrayState(state string, errorMsg string) {
+	hub := GetHub()
+	if hub != nil {
+		stateUpdate := map[string]string{
+			"state":    state,
+			"errorMsg": errorMsg,
+		}
+		hub.Broadcast(MessageTypeXrayState, stateUpdate)
+	}
+}

+ 25 - 2
xray/api.go

@@ -110,10 +110,33 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 			Id: user["id"].(string),
 		})
 	case "vless":
-		account = serial.ToTypedMessage(&vless.Account{
+		vlessAccount := &vless.Account{
 			Id:   user["id"].(string),
 			Flow: user["flow"].(string),
-		})
+		}
+		// Add testseed if provided
+		if testseedVal, ok := user["testseed"]; ok {
+			if testseedArr, ok := testseedVal.([]interface{}); ok && len(testseedArr) >= 4 {
+				testseed := make([]uint32, len(testseedArr))
+				for i, v := range testseedArr {
+					if num, ok := v.(float64); ok {
+						testseed[i] = uint32(num)
+					}
+				}
+				vlessAccount.Testseed = testseed
+			} else if testseedArr, ok := testseedVal.([]uint32); ok && len(testseedArr) >= 4 {
+				vlessAccount.Testseed = testseedArr
+			}
+		}
+		// Add testpre if provided (for outbound, but can be in user for compatibility)
+		if testpreVal, ok := user["testpre"]; ok {
+			if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
+				vlessAccount.Testpre = uint32(testpre)
+			} else if testpre, ok := testpreVal.(uint32); ok && testpre > 0 {
+				vlessAccount.Testpre = testpre
+			}
+		}
+		account = serial.ToTypedMessage(vlessAccount)
 	case "trojan":
 		account = serial.ToTypedMessage(&trojan.Account{
 			Password: user["password"].(string),