diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 563e1113..d68ea808 100644
--- a/.github/workflows/release.yml
+++ b/.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"
diff --git a/DockerInit.sh b/DockerInit.sh
index 080af293..9c6dbdbc 100755
--- a/DockerInit.sh
+++ b/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}"
diff --git a/go.mod b/go.mod
index 458eefcb..ab54cbaf 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 287a33bb..03a17d8e 100644
--- a/go.sum
+++ b/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=
diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js
index 15410750..e2c9e092 100644
--- a/web/assets/js/model/inbound.js
+++ b/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;
}
diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js
index c727abae..c631040e 100644
--- a/web/assets/js/model/outbound.js
+++ b/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 {
diff --git a/web/assets/js/websocket.js b/web/assets/js/websocket.js
new file mode 100644
index 00000000..5b8a3948
--- /dev/null
+++ b/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 : '');
diff --git a/web/controller/inbound.go b/web/controller/inbound.go
index eeb160d6..8317de31 100644
--- a/web/controller/inbound.go
+++ b/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.
diff --git a/web/controller/server.go b/web/controller/server.go
index 292ef338..5b39700e 100644
--- a/web/controller/server.go
+++ b/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.
diff --git a/web/controller/websocket.go b/web/controller/websocket.go
new file mode 100644
index 00000000..0ad5c845
--- /dev/null
+++ b/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
+ }
+ }
+ }
+}
diff --git a/web/global/global.go b/web/global/global.go
index 025fa081..f72c7bfe 100644
--- a/web/global/global.go
+++ b/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.
diff --git a/web/html/common/page.html b/web/html/common/page.html
index c0a7ca63..0af63afb 100644
--- a/web/html/common/page.html
+++ b/web/html/common/page.html
@@ -49,6 +49,7 @@
const basePath = '{{ .base_path }}';
axios.defaults.baseURL = basePath;
+
{{ end }}
{{ define "page/body_end" }}
diff --git a/web/html/form/outbound.html b/web/html/form/outbound.html
index aa6aa323..1926c30e 100644
--- a/web/html/form/outbound.html
+++ b/web/html/form/outbound.html
@@ -239,6 +239,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -501,6 +523,15 @@
+
+
+ CF-Connecting-IP
+ X-Real-IP
+ True-Client-IP
+ X-Client-IP
+
+
diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html
index 140b9c1a..ad5b4265 100644
--- a/web/html/form/protocol/vless.html
+++ b/web/html/form/protocol/vless.html
@@ -39,6 +39,7 @@
+
@@ -69,4 +70,33 @@
+
+
+
+
+
+ updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[0]">
+
+
+ updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" addon-before="[1]">
+
+
+ updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[2]">
+
+
+ updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" addon-before="[3]">
+
+
+
+
+ Rand
+
+
+ Reset
+
+
+
+
+
+
{{end}}
diff --git a/web/html/form/stream/stream_sockopt.html b/web/html/form/stream/stream_sockopt.html
index 4480594a..062b83df 100644
--- a/web/html/form/stream/stream_sockopt.html
+++ b/web/html/form/stream/stream_sockopt.html
@@ -61,6 +61,15 @@
+
+
+ CF-Connecting-IP
+ X-Real-IP
+ True-Client-IP
+ X-Client-IP
+
+
{{end}}
diff --git a/web/html/form/tls_settings.html b/web/html/form/tls_settings.html
index c3844a7f..3723130e 100644
--- a/web/html/form/tls_settings.html
+++ b/web/html/form/tls_settings.html
@@ -60,16 +60,20 @@
+
-
- {{ i18n "pages.inbounds.certificatePath" }}
- {{ i18n "pages.inbounds.certificateContent" }}
+
+ {{ i18n "pages.inbounds.certificatePath" }}
+ {{ i18n "pages.inbounds.certificateContent" }}
-
-
+
+
+
+
+
+
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index 86bde2c8..4e1149ae 100644
--- a/web/html/inbounds.html
+++ b/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();
+
+ // 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();
+ }
}
- else {
- this.getDBInbounds();
- }
- this.loading(false);
},
computed: {
total() {
diff --git a/web/html/index.html b/web/html/index.html
index 9cbb019d..bbbbb708 100644
--- a/web/html/index.html
+++ b/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();
}
},
});
diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html
index 3c844381..c3883285 100644
--- a/web/html/modals/inbound_modal.html
+++ b/web/html/modals/inbound_modal.html
@@ -6,7 +6,8 @@