mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
ws/inbounds: realtime fixes + perf for 10k+ client inbounds
- hub: dedup, throttle, panic-restart, deadlock fix, race tests - client: backoff cap + slow-retry instead of giving up - broadcast: delta-only payload, count-based invalidate fallback - filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound) - perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint - traffic: monotonic all_time + UI clamp + propagate in delta handler - session: persist on update/logout (fixes logout-after-password-change) - ui: protocol tags flex, traffic bar normalize
This commit is contained in:
parent
0b5c239f98
commit
d14c54d21d
12 changed files with 1260 additions and 718 deletions
|
|
@ -1,150 +1,208 @@
|
|||
/**
|
||||
* WebSocket client for real-time updates
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.listeners = new Map();
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
|
||||
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, []);
|
||||
}
|
||||
const callbacks = this.listeners.get(event);
|
||||
if (!callbacks.includes(callback)) {
|
||||
callbacks.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.#onMessage(event));
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let basePath = this.basePath || '';
|
||||
if (basePath && !basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. event.data is a string for
|
||||
// text frames; .length is the character count which is always ≤ byte
|
||||
// count, so checking it is a conservative gate.
|
||||
if (typeof data === 'string' && data.length > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${data.length} chars), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
console.warn('WebSocket is not connected');
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global WebSocket client instance
|
||||
// Safely get basePath from global scope (defined in page.html)
|
||||
// Global instance — basePath is set by page.html before this script loads.
|
||||
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');
|
||||
|
|
|
|||
|
|
@ -27,6 +27,34 @@ func NewInboundController(g *gin.RouterGroup) *InboundController {
|
|||
return a
|
||||
}
|
||||
|
||||
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
|
||||
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
|
||||
// Mirrors the same heuristic used by the periodic traffic job.
|
||||
const broadcastInboundsUpdateClientLimit = 5000
|
||||
|
||||
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
|
||||
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
|
||||
// so we send an invalidate signal instead — frontend re-fetches via REST.
|
||||
// Skipped entirely when no WebSocket clients are connected.
|
||||
func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
inbounds, err := a.inboundService.GetInbounds(userId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
totalClients := 0
|
||||
for _, ib := range inbounds {
|
||||
totalClients += len(ib.ClientStats)
|
||||
}
|
||||
if totalClients > broadcastInboundsUpdateClientLimit {
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
return
|
||||
}
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
}
|
||||
|
||||
// initRouter initializes the routes for inbound-related operations.
|
||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
|
|
@ -38,6 +66,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
g.POST("/update/:id", a.updateInbound)
|
||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||
g.POST("/clientIps/:email", a.getClientIps)
|
||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||
g.POST("/addClient", a.addInboundClient)
|
||||
|
|
@ -134,9 +163,7 @@ 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)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// delInbound deletes an inbound configuration by its ID.
|
||||
|
|
@ -155,10 +182,8 @@ 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)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// updateInbound updates an existing inbound configuration.
|
||||
|
|
@ -185,10 +210,43 @@ 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)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
}
|
||||
|
||||
// setInboundEnable flips only the enable flag of an inbound. This is a
|
||||
// dedicated endpoint because the regular update path serialises the entire
|
||||
// settings JSON (every client) — far too heavy for an interactive switch
|
||||
// on inbounds with thousands of clients. Frontend optimistically updates
|
||||
// the UI; we just persist + sync xray + nudge other open admin sessions.
|
||||
func (a *InboundController) setInboundEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
type form struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
var f form
|
||||
if err := c.ShouldBind(&f); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
||||
// instead of fetching + serialising the whole inbound list. Other open
|
||||
// sessions re-fetch via REST. The toggling admin's own UI already
|
||||
// updated optimistically.
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
}
|
||||
|
||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
|
@ -95,9 +94,8 @@ func (a *IndexController) login(c *gin.Context) {
|
|||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
||||
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
|
||||
|
||||
session.SetLoginUser(c, user)
|
||||
if err := sessions.Default(c).Save(); err != nil {
|
||||
logger.Warning("Unable to save session: ", err)
|
||||
if err := session.SetLoginUser(c, user); err != nil {
|
||||
logger.Warning("Unable to save session:", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -111,9 +109,8 @@ func (a *IndexController) logout(c *gin.Context) {
|
|||
if user != nil {
|
||||
logger.Infof("%s logged out successfully", user.Username)
|
||||
}
|
||||
session.ClearSession(c)
|
||||
if err := sessions.Default(c).Save(); err != nil {
|
||||
logger.Warning("Unable to save session after clearing:", err)
|
||||
if err := session.ClearSession(c); err != nil {
|
||||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
|||
if err == nil {
|
||||
user.Username = form.NewUsername
|
||||
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
|
||||
session.SetLoginUser(c, user)
|
||||
if saveErr := session.SetLoginUser(c, user); saveErr != nil {
|
||||
err = saveErr
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -16,105 +18,74 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
clientReadLimit = 512
|
||||
)
|
||||
|
||||
var upgrader = ws.Upgrader{
|
||||
ReadBufferSize: 32768,
|
||||
WriteBufferSize: 32768,
|
||||
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
|
||||
|
||||
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
|
||||
},
|
||||
EnableCompression: true,
|
||||
CheckOrigin: checkSameOrigin,
|
||||
}
|
||||
|
||||
// WebSocketController handles WebSocket connections for real-time updates
|
||||
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
|
||||
// clients) and otherwise requires the Origin hostname to match the request hostname.
|
||||
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
|
||||
// (the panel often sits behind a reverse proxy on a different port).
|
||||
func checkSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), host)
|
||||
}
|
||||
|
||||
// WebSocketController handles WebSocket connections for real-time updates.
|
||||
type WebSocketController struct {
|
||||
BaseController
|
||||
hub *websocket.Hub
|
||||
}
|
||||
|
||||
// NewWebSocketController creates a new WebSocket controller
|
||||
// NewWebSocketController creates a new WebSocket controller.
|
||||
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
||||
return &WebSocketController{
|
||||
hub: hub,
|
||||
}
|
||||
return &WebSocketController{hub: hub}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles WebSocket connections
|
||||
// HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
|
||||
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
|
||||
client := websocket.NewClient(uuid.New().String())
|
||||
w.hub.Register(client)
|
||||
logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
|
||||
logger.Debugf("WebSocket client %s registered from %s", client.ID, 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
|
||||
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
|
||||
// running. Clients send no commands today; frames are discarded.
|
||||
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
||||
|
|
@ -124,35 +95,23 @@ func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn)
|
|||
conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(clientReadLimit)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||
}
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
// writePump pushes hub messages to the connection and emits keepalive pings.
|
||||
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
|
|
@ -165,17 +124,13 @@ func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn)
|
|||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-client.Send:
|
||||
case msg, 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 {
|
||||
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
|
||||
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,19 +318,15 @@
|
|||
</a-dropdown>
|
||||
</template>
|
||||
<template slot="protocol" slot-scope="text, dbInbound">
|
||||
<a-tag :style="{ margin: '0' }" color="purple">[[
|
||||
dbInbound.protocol ]]</a-tag>
|
||||
<template
|
||||
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
|
||||
<a-tag :style="{ margin: '0' }" color="green">[[
|
||||
dbInbound.toInbound().stream.network ]]</a-tag>
|
||||
<a-tag :style="{ margin: '0' }"
|
||||
v-if="dbInbound.toInbound().stream.isTls"
|
||||
color="blue">TLS</a-tag>
|
||||
<a-tag :style="{ margin: '0' }"
|
||||
v-if="dbInbound.toInbound().stream.isReality"
|
||||
color="blue">Reality</a-tag>
|
||||
</template>
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">[[ dbInbound.protocol ]]</a-tag>
|
||||
<template
|
||||
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
|
||||
<a-tag color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
|
||||
<a-tag v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="clients" slot-scope="text, dbInbound">
|
||||
<template v-if="clientCount[dbInbound.id]">
|
||||
|
|
@ -1107,7 +1103,7 @@
|
|||
trafficDiff: 0,
|
||||
defaultCert: '',
|
||||
defaultKey: '',
|
||||
clientCount: [],
|
||||
clientCount: {},
|
||||
onlineClients: [],
|
||||
lastOnlineMap: {},
|
||||
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
|
||||
|
|
@ -1131,6 +1127,71 @@
|
|||
loading(spinning = true) {
|
||||
this.loadingStates.spinning = spinning;
|
||||
},
|
||||
// applyClientStatsDelta updates client traffic counters and inbound totals
|
||||
// in-place from a WebSocket delta payload. Avoids full-list re-fetch and
|
||||
// re-render — critical at 10k+ client scale.
|
||||
applyClientStatsDelta(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
|
||||
const inboundsById = new Map();
|
||||
this.dbInbounds.forEach(ib => inboundsById.set(ib.id, ib));
|
||||
const touched = new Set();
|
||||
|
||||
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
|
||||
for (const stat of payload.clients) {
|
||||
const dbInbound = inboundsById.get(stat.inboundId);
|
||||
if (!dbInbound || !Array.isArray(dbInbound.clientStats)) continue;
|
||||
const cs = dbInbound.clientStats.find(c => c.email === stat.email);
|
||||
if (!cs) continue;
|
||||
cs.up = stat.up;
|
||||
cs.down = stat.down;
|
||||
// allTime is the cumulative-historical counter shown in the
|
||||
// "Общий трафик" column. The previous handler updated up/down/
|
||||
// total but skipped allTime, so that column stayed frozen at
|
||||
// its initial-page-load value until a manual refresh.
|
||||
if (stat.allTime !== undefined) cs.allTime = stat.allTime;
|
||||
if (stat.total !== undefined) cs.total = stat.total;
|
||||
if (stat.expiryTime !== undefined) cs.expiryTime = stat.expiryTime;
|
||||
if (stat.lastOnline !== undefined) cs.lastOnline = stat.lastOnline;
|
||||
if (stat.enable !== undefined) cs.enable = stat.enable;
|
||||
touched.add(stat.inboundId);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
|
||||
for (const summary of payload.inbounds) {
|
||||
const dbInbound = inboundsById.get(summary.id);
|
||||
if (!dbInbound) continue;
|
||||
dbInbound.up = summary.up;
|
||||
dbInbound.down = summary.down;
|
||||
if (summary.total !== undefined) dbInbound.total = summary.total;
|
||||
if (summary.allTime !== undefined) dbInbound.allTime = summary.allTime;
|
||||
if (summary.enable !== undefined) dbInbound.enable = summary.enable;
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute clientCount for inbounds whose stats changed. The cached
|
||||
// parsed Inbound is fetched via dbInbound.toInbound() — earlier
|
||||
// versions used `this.inbounds.find(ib => ib.id === id)` which
|
||||
// ALWAYS returned undefined (the Inbound class has no id field), so
|
||||
// this branch silently never ran and depleted/expiring/online filters
|
||||
// never refreshed from delta updates.
|
||||
if (touched.size > 0) {
|
||||
for (const id of touched) {
|
||||
const dbInbound = inboundsById.get(id);
|
||||
if (dbInbound) {
|
||||
this.$set(this.clientCount, id, this.getClientCounts(dbInbound, dbInbound.toInbound()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run filter/search so the displayed slice picks up updated values.
|
||||
if (this.enableFilter) {
|
||||
this.filterInbounds();
|
||||
} else {
|
||||
this.searchInbounds(this.searchKey);
|
||||
}
|
||||
},
|
||||
async getDBInbounds() {
|
||||
this.refreshing = true;
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
|
|
@ -1185,7 +1246,11 @@
|
|||
setInbounds(dbInbounds) {
|
||||
this.inbounds.splice(0);
|
||||
this.dbInbounds.splice(0);
|
||||
this.clientCount.splice(0);
|
||||
// Drop every existing key — Vue.delete keeps it reactive so any
|
||||
// template expression watching clientCount[id] re-renders cleanly.
|
||||
for (const key of Object.keys(this.clientCount)) {
|
||||
this.$delete(this.clientCount, key);
|
||||
}
|
||||
for (const inbound of dbInbounds) {
|
||||
const dbInbound = new DBInbound(inbound);
|
||||
to_inbound = dbInbound.toInbound()
|
||||
|
|
@ -1195,7 +1260,9 @@
|
|||
if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
|
||||
continue;
|
||||
}
|
||||
this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound);
|
||||
// Reactive add — direct assignment on the map would not trigger
|
||||
// template updates in Vue 2.
|
||||
this.$set(this.clientCount, inbound.id, this.getClientCounts(inbound, to_inbound));
|
||||
}
|
||||
}
|
||||
if (!this.loadingStates.fetched) {
|
||||
|
|
@ -1681,37 +1748,29 @@
|
|||
newDbInbound = this.checkFallback(dbInbound);
|
||||
infoModal.show(newDbInbound, index);
|
||||
},
|
||||
switchEnable(dbInboundId, state) {
|
||||
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
// switchEnable toggles inbound.enable through a dedicated lightweight
|
||||
// endpoint. The previous implementation re-submitted the entire
|
||||
// inbound settings JSON (every client) just to flip a boolean — on a
|
||||
// 7000+ client inbound that meant a multi-MB request, an O(N) traffic
|
||||
// diff and a full xray-config rebuild for every click of the switch.
|
||||
async switchEnable(dbInboundId, state) {
|
||||
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
|
||||
if (!dbInbound) return;
|
||||
dbInbound.enable = state;
|
||||
let inbound = dbInbound.toInbound();
|
||||
const data = {
|
||||
up: dbInbound.up,
|
||||
down: dbInbound.down,
|
||||
total: dbInbound.total,
|
||||
remark: dbInbound.remark,
|
||||
enable: dbInbound.enable,
|
||||
expiryTime: dbInbound.expiryTime,
|
||||
trafficReset: dbInbound.trafficReset,
|
||||
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
|
||||
|
||||
listen: inbound.listen,
|
||||
port: inbound.port,
|
||||
protocol: inbound.protocol,
|
||||
settings: inbound.settings.toString(),
|
||||
};
|
||||
if (inbound.canEnableStream()) {
|
||||
data.streamSettings = inbound.stream.toString();
|
||||
} else if (inbound.stream?.sockopt) {
|
||||
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
|
||||
}
|
||||
data.sniffing = inbound.sniffing.toString();
|
||||
const previous = dbInbound.enable;
|
||||
dbInbound.enable = state; // optimistic: UI reflects the click immediately
|
||||
|
||||
const formData = new FormData();
|
||||
Object.keys(data).forEach(key => formData.append(key, data[key]));
|
||||
formData.append('enable', String(state));
|
||||
|
||||
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInboundId}`, formData);
|
||||
if (!msg || !msg.success) {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
} catch (e) {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
},
|
||||
async switchEnableClient(dbInboundId, client, state) {
|
||||
this.loading();
|
||||
|
|
@ -1820,9 +1879,15 @@
|
|||
},
|
||||
getAllTimeClient(dbInbound, email) {
|
||||
if (!email || email.length == 0) return 0;
|
||||
let clientStats = this.getClientStats(dbInbound, email);
|
||||
const clientStats = this.getClientStats(dbInbound, email);
|
||||
if (!clientStats) return 0;
|
||||
return clientStats.allTime || (clientStats.up + clientStats.down);
|
||||
// allTime represents cumulative historical usage and must never
|
||||
// appear smaller than the currently-tracked counters. If a stale
|
||||
// row drifts below up+down (manual edits, partial migrations) we
|
||||
// surface the live total instead of the misleading historical one.
|
||||
const current = (clientStats.up || 0) + (clientStats.down || 0);
|
||||
const allTime = clientStats.allTime || 0;
|
||||
return allTime > current ? allTime : current;
|
||||
},
|
||||
getRemStats(dbInbound, email) {
|
||||
if (!email || email.length == 0) return 0;
|
||||
|
|
@ -2029,16 +2094,21 @@
|
|||
}
|
||||
this.loading();
|
||||
this.getDefaultSettings();
|
||||
|
||||
// Initial data fetch
|
||||
|
||||
// Bootstrap from REST first, then attach WebSocket subscriptions.
|
||||
// Doing this in order eliminates a race where an early `inbounds` push
|
||||
// fires getClientCounts() before this.onlineClients is populated,
|
||||
// leaving online[] empty for every inbound and breaking the filter.
|
||||
this.getDBInbounds().then(() => {
|
||||
this.loading(false);
|
||||
});
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (window.wsClient) {
|
||||
if (!window.wsClient) {
|
||||
// Fallback to polling if WebSocket is not available
|
||||
if (this.isRefreshEnabled) this.startDataRefreshLoop();
|
||||
return;
|
||||
}
|
||||
window.wsClient.connect();
|
||||
|
||||
|
||||
// Listen for inbounds updates
|
||||
window.wsClient.on('inbounds', (payload) => {
|
||||
if (payload && Array.isArray(payload)) {
|
||||
|
|
@ -2047,12 +2117,13 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Listen for invalidate signals (sent when payload is too large for WebSocket)
|
||||
// The server sends a lightweight notification and we re-fetch via REST API
|
||||
// Listen for invalidate signals — last-resort safety only.
|
||||
// Under normal operation the server pushes 'client_stats' deltas
|
||||
// instead, so this fires only when an admin mutation produces an
|
||||
// oversized full-list payload.
|
||||
let invalidateTimer = null;
|
||||
window.wsClient.on('invalidate', (payload) => {
|
||||
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
|
||||
// Debounce to avoid flooding the REST API with multiple invalidate signals
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
|
|
@ -2061,15 +2132,36 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Listen for traffic updates
|
||||
// Real-time delta updates: per-client absolute counters + inbound
|
||||
// totals applied in-place. Replaces the periodic full-list refresh
|
||||
// and scales to 10k+ clients without REST fallback.
|
||||
window.wsClient.on('client_stats', (payload) => {
|
||||
if (!payload) return;
|
||||
this.applyClientStatsDelta(payload);
|
||||
});
|
||||
|
||||
// Listen for traffic updates.
|
||||
// Note: clientTraffics contains DELTA values (incremental since last
|
||||
// tick), not absolute totals. Absolute counters are updated through
|
||||
// the 'client_stats' event in applyClientStatsDelta.
|
||||
window.wsClient.on('traffic', (payload) => {
|
||||
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
|
||||
// because clientTraffics contains delta/incremental values, not total accumulated values.
|
||||
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
|
||||
|
||||
// Update online clients list in real-time
|
||||
if (payload && Array.isArray(payload.onlineClients)) {
|
||||
const nextOnlineClients = payload.onlineClients;
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
|
||||
// Normalize onlineClients: server marshals a nil []string slice as
|
||||
// JSON null when nobody is online. Treat null/undefined/missing as
|
||||
// an empty array so the "everyone went offline" transition still
|
||||
// updates the UI — without this fix, the last set of online users
|
||||
// stayed visible (and the online filter kept showing them) until
|
||||
// someone came back online.
|
||||
const hasOnlinePayload =
|
||||
'onlineClients' in payload &&
|
||||
(Array.isArray(payload.onlineClients) || payload.onlineClients == null);
|
||||
if (hasOnlinePayload) {
|
||||
const nextOnlineClients = Array.isArray(payload.onlineClients)
|
||||
? payload.onlineClients
|
||||
: [];
|
||||
|
||||
// Detect change in either direction: length differs OR sets differ.
|
||||
let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
|
||||
if (!onlineChanged) {
|
||||
const prevSet = new Set(this.onlineClients);
|
||||
|
|
@ -2080,18 +2172,24 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onlineClients = nextOnlineClients;
|
||||
if (onlineChanged) {
|
||||
// Recalculate client counts to update online status
|
||||
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive
|
||||
// Recompute clientCount for every inbound whose stats can host
|
||||
// online clients. `dbInbound.toInbound()` returns the cached
|
||||
// parsed Inbound (with the .clients array) — using it directly
|
||||
// avoids a brittle `this.inbounds.find(ib => ib.id === ...)`
|
||||
// lookup that ALWAYS failed because the Inbound class has no
|
||||
// `id` field. That silent failure was the real cause of the
|
||||
// online filter showing an empty list while a client was
|
||||
// clearly online elsewhere on the page.
|
||||
this.dbInbounds.forEach(dbInbound => {
|
||||
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
|
||||
if (inbound && this.clientCount[dbInbound.id]) {
|
||||
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
|
||||
}
|
||||
const inbound = dbInbound.toInbound();
|
||||
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
|
||||
});
|
||||
|
||||
// Always trigger UI refresh — not just when filter is enabled
|
||||
// Re-run filter/search so the UI reflects the new state — both
|
||||
// when clients come online and when they go offline.
|
||||
if (this.enableFilter) {
|
||||
this.filterInbounds();
|
||||
} else {
|
||||
|
|
@ -2099,10 +2197,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last online map in real-time
|
||||
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
|
||||
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
|
||||
// Update last-online map. Server sends the full map (not delta) so
|
||||
// we can replace entirely without growing unbounded from deleted clients.
|
||||
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
this.lastOnlineMap = payload.lastOnlineMap;
|
||||
}
|
||||
});
|
||||
|
|
@ -2123,12 +2221,7 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to polling if WebSocket is not available
|
||||
if (this.isRefreshEnabled) {
|
||||
this.startDataRefreshLoop();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
total() {
|
||||
|
|
@ -2171,5 +2264,65 @@
|
|||
left: 50vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Protocol cell — wrap tags into a flex grid with consistent gap so
|
||||
vless/xhttp/Reality line up cleanly instead of stacking awkwardly. */
|
||||
.inbounds-page .protocol-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.inbounds-page .protocol-tags .ant-tag {
|
||||
margin: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Traffic cell — text on the sides sizes to its content, the progress bar
|
||||
takes whatever's left. Without this, fixed-width text cells leave gaps
|
||||
around short values like "∞" and clip long ones like "999.99 GB". */
|
||||
.inbounds-page .tr-table-box {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.inbounds-page .tr-table-rt,
|
||||
.inbounds-page .tr-table-lt {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.inbounds-page .tr-table-rt { text-align: end; }
|
||||
.inbounds-page .tr-table-lt { text-align: start; }
|
||||
.inbounds-page .tr-table-bar {
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Make the progress widget fill its flex cell, and align the inner fill
|
||||
pill with the outer track pill (the "two pills" drift was caused by
|
||||
box-sizing: content-box plus a 1px border on .ant-progress-bg). */
|
||||
.inbounds-page .tr-table-bar .ant-progress,
|
||||
.inbounds-page .tr-table-bar .ant-progress-outer,
|
||||
.inbounds-page .tr-table-bar .ant-progress-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.inbounds-page .infinite-bar .ant-progress-inner,
|
||||
.inbounds-page .tr-table-bar .ant-progress-inner {
|
||||
box-sizing: border-box;
|
||||
border-radius: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg,
|
||||
.inbounds-page .tr-table-bar .ant-progress-inner .ant-progress-bg {
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
}
|
||||
</style>
|
||||
{{ template "page/body_end" .}}
|
||||
|
|
@ -24,7 +24,9 @@ func NewXrayTrafficJob() *XrayTrafficJob {
|
|||
return new(XrayTrafficJob)
|
||||
}
|
||||
|
||||
// Run collects traffic statistics from Xray and updates the database, triggering restart if needed.
|
||||
// Run collects traffic statistics from Xray, updates the database, and pushes
|
||||
// real-time updates over WebSocket using compact delta payloads — no REST
|
||||
// fallback, scales to 10k–20k+ clients per inbound.
|
||||
func (j *XrayTrafficJob) Run() {
|
||||
if !j.xrayService.IsXrayRunning() {
|
||||
return
|
||||
|
|
@ -50,50 +52,85 @@ func (j *XrayTrafficJob) Run() {
|
|||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
|
||||
// If no frontend client is connected, skip all WebSocket broadcasting routines,
|
||||
// including expensive DB queries for online clients and JSON marshaling.
|
||||
// If no frontend client is connected, skip all WebSocket broadcasting
|
||||
// routines — including the active-client DB query and JSON marshaling.
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
|
||||
// Update online clients list and map
|
||||
// Online presence + traffic deltas — small payload, always fits in WS.
|
||||
// Force non-nil slice/map so JSON marshalling produces [] / {} instead of
|
||||
// `null` when everyone is offline. The frontend's traffic handler treats
|
||||
// a missing/null onlineClients field as "no update", so without this the
|
||||
// "everyone went offline" transition was silently dropped — stale online
|
||||
// users lingered in the list and the online filter kept showing them.
|
||||
onlineClients := j.inboundService.GetOnlineClients()
|
||||
if onlineClients == nil {
|
||||
onlineClients = []string{}
|
||||
}
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
}
|
||||
if lastOnlineMap == nil {
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Broadcast traffic update (deltas and online stats) via WebSocket
|
||||
trafficUpdate := map[string]any{
|
||||
websocket.BroadcastTraffic(map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
}
|
||||
websocket.BroadcastTraffic(trafficUpdate)
|
||||
})
|
||||
|
||||
// Fetch updated inbounds from database with accumulated traffic values
|
||||
// This ensures frontend receives the actual total traffic for real-time UI refresh.
|
||||
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("get all inbounds for websocket failed:", err)
|
||||
// Compact delta payload: per-client absolute counters for clients active
|
||||
// this cycle, plus inbound-level absolute totals. Frontend applies both
|
||||
// in-place — typical payload ~10–50KB even for 10k+ client deployments.
|
||||
// Replaces the old full-inbound-list broadcast that hit WS size limits
|
||||
// (5–10MB) and forced the frontend into a REST refetch.
|
||||
clientStatsPayload := map[string]any{}
|
||||
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
|
||||
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
|
||||
logger.Warning("get active client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStatsPayload["clients"] = stats
|
||||
}
|
||||
}
|
||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
||||
} else if len(inboundSummary) > 0 {
|
||||
clientStatsPayload["inbounds"] = inboundSummary
|
||||
}
|
||||
if len(clientStatsPayload) > 0 {
|
||||
websocket.BroadcastClientStats(clientStatsPayload)
|
||||
}
|
||||
|
||||
updatedOutbounds, err := j.outboundService.GetOutboundsTraffic()
|
||||
if err != nil {
|
||||
// Outbounds list is small (one row per outbound, no per-client expansion)
|
||||
// so the full snapshot still fits comfortably in WS.
|
||||
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
} else if err != nil {
|
||||
logger.Warning("get all outbounds for websocket failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The WebSocket hub will automatically check the payload size.
|
||||
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
|
||||
if updatedInbounds != nil {
|
||||
websocket.BroadcastInbounds(updatedInbounds)
|
||||
// activeEmails returns the set of client emails that had non-zero traffic in
|
||||
// the current collection window. Idle clients are skipped — no need to push
|
||||
// their (unchanged) counters to the frontend.
|
||||
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
|
||||
if len(clientTraffics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
emails := make([]string, 0, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct == nil || ct.Email == "" {
|
||||
continue
|
||||
}
|
||||
if ct.Up == 0 && ct.Down == 0 {
|
||||
continue
|
||||
}
|
||||
emails = append(emails, ct.Email)
|
||||
}
|
||||
return emails
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
|
|
|
|||
|
|
@ -366,10 +366,21 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, client := range clients {
|
||||
err := s.DelClientIPs(db, client.Email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
// Bulk-delete client IPs for every email in this inbound. The previous
|
||||
// per-client loop fired one DELETE per row — at 7k+ clients that meant
|
||||
// thousands of synchronous SQL roundtrips and a multi-second freeze.
|
||||
if len(clients) > 0 {
|
||||
emails := make([]string, 0, len(clients))
|
||||
for i := range clients {
|
||||
if clients[i].Email != "" {
|
||||
emails = append(emails, clients[i].Email)
|
||||
}
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
if err := db.Where("client_email IN ?", emails).
|
||||
Delete(model.InboundClientIps{}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -386,6 +397,58 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
|
|||
return inbound, nil
|
||||
}
|
||||
|
||||
// SetInboundEnable toggles only the enable flag of an inbound, without
|
||||
// rewriting the (potentially multi-MB) settings JSON. Used by the UI's
|
||||
// per-row enable switch — for inbounds with thousands of clients the full
|
||||
// UpdateInbound path is an order of magnitude too slow for an interactive
|
||||
// toggle (parses + reserialises every client, runs O(N) traffic diff).
|
||||
//
|
||||
// Returns (needRestart, error). needRestart is true when the xray runtime
|
||||
// could not be re-synced from the cached config and a full restart is
|
||||
// required to pick up the change.
|
||||
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if inbound.Enable == enable {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(model.Inbound{}).Where("id = ?", id).
|
||||
Update("enable", enable).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
inbound.Enable = enable
|
||||
|
||||
// Sync xray runtime: drop the live inbound, add it back if we're enabling.
|
||||
needRestart := false
|
||||
s.xrayApi.Init(p.GetAPIPort())
|
||||
defer s.xrayApi.Close()
|
||||
|
||||
_ = s.xrayApi.DelInbound(inbound.Tag)
|
||||
if !enable {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
}
|
||||
inboundJson, err := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ")
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: marshal runtime config failed:", err)
|
||||
return true, nil
|
||||
}
|
||||
if err := s.xrayApi.AddInbound(inboundJson); err != nil {
|
||||
logger.Debug("SetInboundEnable: AddInbound via api failed:", err)
|
||||
needRestart = true
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
// UpdateInbound modifies an existing inbound configuration.
|
||||
// It validates changes, updates the database, and syncs with the running Xray instance.
|
||||
// Returns the updated inbound, whether Xray needs restart, and any error.
|
||||
|
|
@ -589,6 +652,11 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I
|
|||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
// updateClientTraffics syncs the ClientTraffic rows with the inbound's clients
|
||||
// list: removes rows for emails that disappeared, inserts rows for newly-added
|
||||
// emails. Uses sets for O(N) lookup — the previous nested-loop implementation
|
||||
// was O(N²) and degraded into multi-second pauses on inbounds with thousands
|
||||
// of clients (toggling, saving, or deleting any such inbound felt frozen).
|
||||
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
|
||||
oldClients, err := s.GetClients(oldInbound)
|
||||
if err != nil {
|
||||
|
|
@ -599,36 +667,31 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
|
|||
return err
|
||||
}
|
||||
|
||||
var emailExists bool
|
||||
oldEmails := make(map[string]struct{}, len(oldClients))
|
||||
for i := range oldClients {
|
||||
oldEmails[oldClients[i].Email] = struct{}{}
|
||||
}
|
||||
newEmails := make(map[string]struct{}, len(newClients))
|
||||
for i := range newClients {
|
||||
newEmails[newClients[i].Email] = struct{}{}
|
||||
}
|
||||
|
||||
for _, oldClient := range oldClients {
|
||||
emailExists = false
|
||||
for _, newClient := range newClients {
|
||||
if oldClient.Email == newClient.Email {
|
||||
emailExists = true
|
||||
break
|
||||
}
|
||||
// Removed clients — drop their stats rows.
|
||||
for i := range oldClients {
|
||||
if _, kept := newEmails[oldClients[i].Email]; kept {
|
||||
continue
|
||||
}
|
||||
if !emailExists {
|
||||
err = s.DelClientStat(tx, oldClient.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DelClientStat(tx, oldClients[i].Email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, newClient := range newClients {
|
||||
emailExists = false
|
||||
for _, oldClient := range oldClients {
|
||||
if newClient.Email == oldClient.Email {
|
||||
emailExists = true
|
||||
break
|
||||
}
|
||||
// Added clients — create their stats rows.
|
||||
for i := range newClients {
|
||||
if _, existed := oldEmails[newClients[i].Email]; existed {
|
||||
continue
|
||||
}
|
||||
if !emailExists {
|
||||
err = s.AddClientStat(tx, oldInbound.Id, &newClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.AddClientStat(tx, oldInbound.Id, &newClients[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
@ -1320,20 +1383,27 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
|||
return err
|
||||
}
|
||||
|
||||
// Index by email for O(N) merge — the previous nested loop was O(N²)
|
||||
// and dominated each cron tick on inbounds with thousands of active
|
||||
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
||||
for i := range traffics {
|
||||
if traffics[i] != nil {
|
||||
trafficByEmail[traffics[i].Email] = traffics[i]
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
for dbTraffic_index := range dbClientTraffics {
|
||||
for traffic_index := range traffics {
|
||||
if dbClientTraffics[dbTraffic_index].Email == traffics[traffic_index].Email {
|
||||
dbClientTraffics[dbTraffic_index].Up += traffics[traffic_index].Up
|
||||
dbClientTraffics[dbTraffic_index].Down += traffics[traffic_index].Down
|
||||
dbClientTraffics[dbTraffic_index].AllTime += (traffics[traffic_index].Up + traffics[traffic_index].Down)
|
||||
|
||||
// Add user in onlineUsers array on traffic
|
||||
if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
|
||||
onlineClients = append(onlineClients, traffics[traffic_index].Email)
|
||||
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
|
||||
}
|
||||
break
|
||||
}
|
||||
t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dbClientTraffics[dbTraffic_index].Up += t.Up
|
||||
dbClientTraffics[dbTraffic_index].Down += t.Down
|
||||
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
|
||||
if t.Up+t.Down > 0 {
|
||||
onlineClients = append(onlineClients, t.Email)
|
||||
dbClientTraffics[dbTraffic_index].LastOnline = now
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2318,6 +2388,50 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
|||
return traffics, nil
|
||||
}
|
||||
|
||||
// GetActiveClientTraffics returns the absolute ClientTraffic rows for the given
|
||||
// emails in a single batched query. Used by the WebSocket delta path to push
|
||||
// per-client absolute counters without re-serializing the full inbound list.
|
||||
// Empty input or a "record not found" result returns an empty slice.
|
||||
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
|
||||
if len(emails) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
err := db.Model(xray.ClientTraffic{}).Where("email IN (?)", emails).Find(&traffics).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// InboundTrafficSummary is the minimal projection of an inbound's traffic
|
||||
// counters used by the WebSocket delta path. Excludes Settings/StreamSettings
|
||||
// blobs so the broadcast stays compact even with many inbounds.
|
||||
type InboundTrafficSummary struct {
|
||||
Id int `json:"id"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
Total int64 `json:"total"`
|
||||
AllTime int64 `json:"allTime"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
|
||||
// GetInboundsTrafficSummary returns inbound-level absolute traffic counters
|
||||
// (no per-client expansion). Companion to GetActiveClientTraffics — together
|
||||
// they replace the heavy "full inbound list" broadcast on each cron tick.
|
||||
func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {
|
||||
db := database.GetDB()
|
||||
var summaries []InboundTrafficSummary
|
||||
err := db.Model(&model.Inbound{}).
|
||||
Select("id, up, down, total, all_time, enable").
|
||||
Find(&summaries).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
||||
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
|
||||
t, client, err := s.GetClientByEmail(email)
|
||||
|
|
@ -2336,9 +2450,17 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
|||
func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, download int64) error {
|
||||
db := database.GetDB()
|
||||
|
||||
// Keep all_time monotonic: it represents historical cumulative usage and
|
||||
// must never be less than the currently-tracked up+down. Without this,
|
||||
// the UI showed "Общий трафик" (allTime) below the live consumed value
|
||||
// after admins manually edited a client's counters.
|
||||
result := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", email).
|
||||
Updates(map[string]any{"up": upload, "down": download})
|
||||
Updates(map[string]any{
|
||||
"up": upload,
|
||||
"down": download,
|
||||
"all_time": gorm.Expr("CASE WHEN COALESCE(all_time, 0) < ? THEN ? ELSE all_time END", upload+download, upload+download),
|
||||
})
|
||||
|
||||
err := result.Error
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -20,14 +21,16 @@ func init() {
|
|||
gob.Register(model.User{})
|
||||
}
|
||||
|
||||
// SetLoginUser stores the authenticated user in the session.
|
||||
// The user object is serialized and stored for subsequent requests.
|
||||
func SetLoginUser(c *gin.Context, user *model.User) {
|
||||
// SetLoginUser stores the authenticated user in the session and persists it.
|
||||
// gin-contrib/sessions does not auto-save; callers that forget Save() leave
|
||||
// the cookie out of sync with server state — this helper avoids that pitfall.
|
||||
func SetLoginUser(c *gin.Context, user *model.User) error {
|
||||
if user == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
s := sessions.Default(c)
|
||||
s.Set(loginUserKey, *user)
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
// GetLoginUser retrieves the authenticated user from the session.
|
||||
|
|
@ -40,22 +43,26 @@ func GetLoginUser(c *gin.Context) *model.User {
|
|||
}
|
||||
user, ok := obj.(model.User)
|
||||
if !ok {
|
||||
|
||||
// Stale or incompatible session payload — wipe and persist immediately
|
||||
// so subsequent requests don't keep hitting the same broken cookie.
|
||||
s.Delete(loginUserKey)
|
||||
if err := s.Save(); err != nil {
|
||||
logger.Warning("session: failed to drop stale user payload:", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &user
|
||||
}
|
||||
|
||||
// IsLogin checks if a user is currently authenticated in the session.
|
||||
// Returns true if a valid user session exists, false otherwise.
|
||||
func IsLogin(c *gin.Context) bool {
|
||||
return GetLoginUser(c) != nil
|
||||
}
|
||||
|
||||
// ClearSession removes all session data and invalidates the session.
|
||||
// This effectively logs out the user and clears any stored session information.
|
||||
func ClearSession(c *gin.Context) {
|
||||
// ClearSession invalidates the session and tells the browser to drop the cookie.
|
||||
// The cookie attributes (Path/HttpOnly/SameSite) must mirror those used when
|
||||
// the cookie was created or browsers will keep it.
|
||||
func ClearSession(c *gin.Context) error {
|
||||
s := sessions.Default(c)
|
||||
s.Clear()
|
||||
cookiePath := c.GetString("base_path")
|
||||
|
|
@ -68,4 +75,5 @@ func ClearSession(c *gin.Context) {
|
|||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return s.Save()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,402 +1,360 @@
|
|||
// Package websocket provides WebSocket hub for real-time updates and notifications.
|
||||
// Package websocket provides a 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
|
||||
// MessageType identifies the kind 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
|
||||
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
|
||||
MessageTypeInvalidate MessageType = "invalidate" // Lightweight signal telling frontend to re-fetch data via REST
|
||||
MessageTypeStatus MessageType = "status"
|
||||
MessageTypeTraffic MessageType = "traffic"
|
||||
MessageTypeInbounds MessageType = "inbounds"
|
||||
MessageTypeOutbounds MessageType = "outbounds"
|
||||
MessageTypeNotification MessageType = "notification"
|
||||
MessageTypeXrayState MessageType = "xray_state"
|
||||
// MessageTypeClientStats carries absolute traffic counters for the clients
|
||||
// that had activity in the latest collection window. Frontend applies these
|
||||
// in-place — far smaller than re-broadcasting the full inbound list and
|
||||
// scales to 10k+ clients without falling back to REST.
|
||||
MessageTypeClientStats MessageType = "client_stats"
|
||||
MessageTypeInvalidate MessageType = "invalidate" // Tells frontend to re-fetch via REST (last-resort).
|
||||
|
||||
// maxMessageSize caps the WebSocket payload. Beyond this the hub sends a
|
||||
// lightweight invalidate signal and the frontend re-fetches via REST.
|
||||
// 10MB lets typical 2k–8k-client deployments push directly via WS (low
|
||||
// latency); larger installs fall back to invalidate.
|
||||
maxMessageSize = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
enqueueTimeout = 100 * time.Millisecond
|
||||
clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser.
|
||||
hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts.
|
||||
|
||||
// minBroadcastInterval throttles per-type broadcasts so cron storms or
|
||||
// rapid mutations cannot drown the hub. Bursts collapse to one delivery.
|
||||
// Status/traffic/notifications/xray_state/invalidate bypass this gate so
|
||||
// real-time signals are never delayed.
|
||||
minBroadcastInterval = 250 * time.Millisecond
|
||||
|
||||
// hubRestartAttempts caps panic-recovery restarts. After this many
|
||||
// consecutive failures we stop trying and log; the panel keeps running
|
||||
// (frontend falls back to REST polling) and the operator can investigate.
|
||||
hubRestartAttempts = 3
|
||||
)
|
||||
|
||||
// Message represents a WebSocket message
|
||||
// NewClient builds a Client ready for hub registration.
|
||||
func NewClient(id string) *Client {
|
||||
return &Client{
|
||||
ID: id,
|
||||
Send: make(chan []byte, clientSendQueue),
|
||||
}
|
||||
}
|
||||
|
||||
// Message is the wire format sent to clients.
|
||||
type Message struct {
|
||||
Type MessageType `json:"type"`
|
||||
Payload any `json:"payload"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
// Client represents a WebSocket client connection
|
||||
// Client represents a single WebSocket connection.
|
||||
type Client struct {
|
||||
ID string
|
||||
Send chan []byte
|
||||
Hub *Hub
|
||||
Topics map[MessageType]bool // Subscribed topics
|
||||
closeOnce sync.Once // Ensures Send channel is closed exactly once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages to them
|
||||
// Hub fan-outs messages to all connected clients.
|
||||
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
|
||||
clients map[*Client]struct{}
|
||||
broadcast chan []byte
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// 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
|
||||
throttleMu sync.Mutex
|
||||
lastBroadcast map[MessageType]time.Time
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub
|
||||
// NewHub creates a hub. Call Run in a goroutine to start its event loop.
|
||||
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,
|
||||
clients: make(map[*Client]struct{}),
|
||||
broadcast: make(chan []byte, hubBroadcastQueue),
|
||||
register: make(chan *Client, 64),
|
||||
unregister: make(chan *Client, 64),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
lastBroadcast: make(map[MessageType]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub's main loop
|
||||
// shouldThrottle returns true if a broadcast of msgType happened within
|
||||
// minBroadcastInterval. Status/traffic/invalidate skip the gate so heartbeats
|
||||
// and re-fetch signals are never dropped.
|
||||
func (h *Hub) shouldThrottle(msgType MessageType) bool {
|
||||
switch msgType {
|
||||
case MessageTypeStatus, MessageTypeTraffic, MessageTypeClientStats,
|
||||
MessageTypeInvalidate, MessageTypeNotification, MessageTypeXrayState:
|
||||
return false
|
||||
}
|
||||
h.throttleMu.Lock()
|
||||
defer h.throttleMu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := h.lastBroadcast[msgType]; ok && now.Sub(last) < minBroadcastInterval {
|
||||
return true
|
||||
}
|
||||
h.lastBroadcast[msgType] = now
|
||||
return false
|
||||
}
|
||||
|
||||
// Run drives the hub. The inner loop is wrapped in a panic-recovery harness
|
||||
// that retries up to hubRestartAttempts times with backoff so a transient
|
||||
// panic doesn't permanently kill real-time updates for commercial deployments.
|
||||
// After the cap, the hub stays down and the frontend falls back to REST polling.
|
||||
func (h *Hub) Run() {
|
||||
for attempt := 0; attempt < hubRestartAttempts; attempt++ {
|
||||
stopped := h.runOnce()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if attempt < hubRestartAttempts-1 {
|
||||
wait := time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
|
||||
logger.Errorf("WebSocket hub crashed, restarting in %s (%d/%d)", wait, attempt+1, hubRestartAttempts-1)
|
||||
select {
|
||||
case <-time.After(wait):
|
||||
case <-h.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Error("WebSocket hub stopped after exhausting restart attempts")
|
||||
}
|
||||
|
||||
// runOnce drives the event loop once and returns true if the hub stopped
|
||||
// cleanly (context cancelled). On panic, recover logs and returns false so
|
||||
// Run can decide whether to retry.
|
||||
func (h *Hub) runOnce() (stopped bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("WebSocket hub panic recovered:", r)
|
||||
// Restart the hub loop
|
||||
go h.Run()
|
||||
logger.Errorf("WebSocket hub panic recovered: %v", r)
|
||||
stopped = false
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
// Graceful shutdown: close all clients
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
client.closeOnce.Do(func() {
|
||||
close(client.Send)
|
||||
})
|
||||
}
|
||||
h.clients = make(map[*Client]bool)
|
||||
h.mu.Unlock()
|
||||
logger.Info("WebSocket hub stopped gracefully")
|
||||
return
|
||||
h.shutdown()
|
||||
return true
|
||||
|
||||
case client := <-h.register:
|
||||
if client == nil {
|
||||
case c := <-h.register:
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
count := len(h.clients)
|
||||
h.clients[c] = struct{}{}
|
||||
n := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
logger.Debugf("WebSocket client connected: %s (total: %d)", client.ID, count)
|
||||
logger.Debugf("WebSocket client connected: %s (total: %d)", c.ID, n)
|
||||
|
||||
case client := <-h.unregister:
|
||||
if client == nil {
|
||||
case c := <-h.unregister:
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
client.closeOnce.Do(func() {
|
||||
close(client.Send)
|
||||
})
|
||||
}
|
||||
count := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
logger.Debugf("WebSocket client disconnected: %s (total: %d)", client.ID, count)
|
||||
h.removeClient(c)
|
||||
|
||||
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)
|
||||
case msg := <-h.broadcast:
|
||||
h.fanout(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastParallel sends message to all clients in parallel for maximum performance
|
||||
func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
|
||||
if len(clients) == 0 {
|
||||
return
|
||||
// shutdown closes all client send channels and clears the registry.
|
||||
func (h *Hub) shutdown() {
|
||||
h.mu.Lock()
|
||||
for c := range h.clients {
|
||||
c.closeOnce.Do(func() { close(c.Send) })
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Use a local WaitGroup to avoid blocking hub shutdown
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(h.workerPoolSize)
|
||||
for i := 0; i < h.workerPoolSize; i++ {
|
||||
go func() {
|
||||
defer wg.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
|
||||
wg.Wait()
|
||||
h.clients = make(map[*Client]struct{})
|
||||
h.mu.Unlock()
|
||||
logger.Info("WebSocket hub stopped")
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients
|
||||
func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if payload == nil {
|
||||
logger.Warning("Attempted to broadcast nil payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip all work if no clients are connected
|
||||
if h.GetClientCount() == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// If message exceeds size limit, send a lightweight invalidate notification
|
||||
// instead of dropping it entirely — the frontend will re-fetch via REST API
|
||||
const maxMessageSize = 10 * 1024 * 1024 // 10MB
|
||||
if len(data) > maxMessageSize {
|
||||
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
|
||||
h.broadcastInvalidate(messageType)
|
||||
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
|
||||
// removeClient deletes a client and closes its send channel exactly once.
|
||||
func (h *Hub) removeClient(c *Client) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
c.closeOnce.Do(func() { close(c.Send) })
|
||||
}
|
||||
n := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
logger.Debugf("WebSocket client disconnected: %s (total: %d)", c.ID, n)
|
||||
}
|
||||
|
||||
// BroadcastToTopic sends a message only to clients subscribed to the specific topic
|
||||
func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
|
||||
if h == nil {
|
||||
// fanout delivers msg to every client. Each send is non-blocking — a client
|
||||
// whose buffer is full is collected for direct removal at the end. We do NOT
|
||||
// route slow-client unregistrations through the unregister channel: under
|
||||
// burst load (panel restart, network blip) that channel can fill up while the
|
||||
// hub itself is the consumer, causing a self-deadlock.
|
||||
func (h *Hub) fanout(msg []byte) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
if payload == nil {
|
||||
logger.Warning("Attempted to broadcast nil payload to topic")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip all work if no clients are connected
|
||||
if h.GetClientCount() == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// If message exceeds size limit, send a lightweight invalidate notification
|
||||
const maxMessageSize = 10 * 1024 * 1024 // 10MB
|
||||
if len(data) > maxMessageSize {
|
||||
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
|
||||
h.broadcastInvalidate(messageType)
|
||||
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)
|
||||
}
|
||||
if len(h.clients) == 0 {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
targets := make([]*Client, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Parallel send to subscribed clients
|
||||
if len(subscribedClients) > 0 {
|
||||
h.broadcastParallel(subscribedClients, data)
|
||||
var dead []*Client
|
||||
for _, c := range targets {
|
||||
if !trySend(c, msg) {
|
||||
dead = append(dead, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dead) == 0 {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, c := range dead {
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
c.closeOnce.Do(func() { close(c.Send) })
|
||||
logger.Debugf("WebSocket client %s send buffer full, disconnected", c.ID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// trySend performs a non-blocking write to the client's Send channel.
|
||||
// Returns false if the client should be evicted (full buffer or closed channel).
|
||||
// A defer-recover guards against the rare race where the channel was closed
|
||||
// concurrently — sending on a closed channel always panics, even with select+default.
|
||||
func trySend(c *Client, msg []byte) (ok bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case c.Send <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientCount returns the number of connected clients
|
||||
// Broadcast serializes payload and queues it for delivery to all clients.
|
||||
// If the serialized message exceeds maxMessageSize, an invalidate signal is
|
||||
// queued instead so the frontend re-fetches via REST. Bursts of the same
|
||||
// message type within minBroadcastInterval collapse to a single delivery.
|
||||
func (h *Hub) Broadcast(messageType MessageType, payload any) {
|
||||
if h == nil || payload == nil || h.GetClientCount() == 0 {
|
||||
return
|
||||
}
|
||||
if h.shouldThrottle(messageType) {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(Message{
|
||||
Type: messageType,
|
||||
Payload: payload,
|
||||
Time: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("WebSocket marshal failed:", err)
|
||||
return
|
||||
}
|
||||
if len(data) > maxMessageSize {
|
||||
logger.Debugf("WebSocket payload %d bytes exceeds limit, sending invalidate for %s", len(data), messageType)
|
||||
h.broadcastInvalidate(messageType)
|
||||
return
|
||||
}
|
||||
h.enqueue(data)
|
||||
}
|
||||
|
||||
// broadcastInvalidate queues a lightweight signal telling clients to re-fetch
|
||||
// the named data type via REST.
|
||||
func (h *Hub) broadcastInvalidate(originalType MessageType) {
|
||||
data, err := json.Marshal(Message{
|
||||
Type: MessageTypeInvalidate,
|
||||
Payload: map[string]string{"type": string(originalType)},
|
||||
Time: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("WebSocket invalidate marshal failed:", err)
|
||||
return
|
||||
}
|
||||
h.enqueue(data)
|
||||
}
|
||||
|
||||
// enqueue submits raw bytes to the broadcast channel. Dropped on backpressure
|
||||
// (channel full for >100ms) or shutdown.
|
||||
func (h *Hub) enqueue(data []byte) {
|
||||
select {
|
||||
case h.broadcast <- data:
|
||||
case <-time.After(enqueueTimeout):
|
||||
logger.Warning("WebSocket broadcast channel full, dropping message")
|
||||
case <-h.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientCount returns the number of connected clients.
|
||||
func (h *Hub) GetClientCount() int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
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 {
|
||||
// Register adds a client to the hub.
|
||||
func (h *Hub) Register(c *Client) {
|
||||
if h == nil || c == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.register <- client:
|
||||
case h.register <- c:
|
||||
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 {
|
||||
// Unregister removes a client from the hub. Non-blocking: if the unregister
|
||||
// channel is full (transient burst), the request is dropped — the client will
|
||||
// be unregistered on its next failed send or when the hub shuts down.
|
||||
// A blocking send here is unsafe because callers may include the hub goroutine
|
||||
// itself, which would self-deadlock.
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
if h == nil || c == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.unregister <- client:
|
||||
case <-h.ctx.Done():
|
||||
// Hub is shutting down
|
||||
case h.unregister <- c:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully stops the hub and closes all connections
|
||||
// Stop signals the hub to shut down and close all client connections.
|
||||
func (h *Hub) Stop() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if h.cancel != nil {
|
||||
if h != nil && h.cancel != nil {
|
||||
h.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastInvalidate sends a lightweight invalidate message to all clients,
|
||||
// telling them to re-fetch the specified data type via REST API.
|
||||
// This is used when the full payload exceeds the WebSocket message size limit.
|
||||
func (h *Hub) broadcastInvalidate(originalType MessageType) {
|
||||
msg := Message{
|
||||
Type: MessageTypeInvalidate,
|
||||
Payload: map[string]string{"type": string(originalType)},
|
||||
Time: getCurrentTimestamp(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to marshal invalidate message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Non-blocking send with timeout
|
||||
select {
|
||||
case h.broadcast <- data:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
logger.Warning("WebSocket broadcast channel is full, dropping invalidate message")
|
||||
case <-h.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// getCurrentTimestamp returns current Unix timestamp in milliseconds
|
||||
func getCurrentTimestamp() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
|
|
|||
192
web/websocket/hub_test.go
Normal file
192
web/websocket/hub_test.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package websocket
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Initialize logger so hub.go calls don't panic on nil global.
|
||||
logger.InitLogger(logging.CRITICAL)
|
||||
code := m.Run()
|
||||
logger.CloseLogger()
|
||||
// Clean up the log directory created by InitLogger so the test leaves
|
||||
// no artefacts in the working tree.
|
||||
os.RemoveAll("log")
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestFanoutNoDeadlockOnSlowClients verifies that the hub does NOT self-deadlock
|
||||
// when many clients have full Send buffers simultaneously. Regression guard for
|
||||
// the bug where fanout called Unregister() on each slow client, the unregister
|
||||
// channel filled (cap 64), and the hub blocked on its own consumer.
|
||||
func TestFanoutNoDeadlockOnSlowClients(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
// Spawn 200 clients but never read from their Send channels — all are "slow".
|
||||
// 200 > unregister channel capacity (64), which would have triggered the
|
||||
// deadlock in the old code.
|
||||
const n = 200
|
||||
clients := make([]*Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
clients[i] = NewClient(string(rune('a' + i%26)))
|
||||
hub.Register(clients[i])
|
||||
}
|
||||
// Wait for registrations to be processed.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && hub.GetClientCount() < n {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := hub.GetClientCount(); got < n {
|
||||
t.Fatalf("only %d/%d clients registered after 2s", got, n)
|
||||
}
|
||||
|
||||
// Fill every client's send buffer so the next broadcast triggers eviction.
|
||||
for _, c := range clients {
|
||||
for i := 0; i < clientSendQueue; i++ {
|
||||
select {
|
||||
case c.Send <- []byte("filler"):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This broadcast should evict ALL clients without deadlocking the hub.
|
||||
hub.Broadcast(MessageTypeStatus, map[string]string{"x": "y"})
|
||||
|
||||
// Wait for eviction with a hard cap — if the hub deadlocked, this hangs
|
||||
// past the timeout and t.Fatalf fires.
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) && hub.GetClientCount() > 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := hub.GetClientCount(); got > 0 {
|
||||
t.Fatalf("deadlock: %d clients still registered after broadcast (expected 0)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentBroadcastAndDisconnect stresses the hub with parallel
|
||||
// Broadcast calls while clients connect and disconnect. Regression guard for
|
||||
// races between fanout, removeClient, and shutdown.
|
||||
func TestConcurrentBroadcastAndDisconnect(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// Continuous broadcasters.
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
hub.Broadcast(MessageTypeStatus, map[string]int{"v": 1})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Continuous register/unregister churn.
|
||||
var connected int64
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
c := NewClient("churn")
|
||||
hub.Register(c)
|
||||
atomic.AddInt64(&connected, 1)
|
||||
// Drain a few messages so we don't block.
|
||||
go func() {
|
||||
for range c.Send {
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond)
|
||||
hub.Unregister(c)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if atomic.LoadInt64(&connected) == 0 {
|
||||
t.Fatal("no clients churned through hub")
|
||||
}
|
||||
}
|
||||
|
||||
// TestThrottlingBlocksBurstButLetsRealtimeThrough verifies that real-time
|
||||
// message types (status, traffic) are NEVER throttled, while inbounds bursts
|
||||
// are throttled.
|
||||
func TestThrottlingBlocksBurstButLetsRealtimeThrough(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
if hub.shouldThrottle(MessageTypeStatus) {
|
||||
t.Error("status must never be throttled")
|
||||
}
|
||||
if hub.shouldThrottle(MessageTypeTraffic) {
|
||||
t.Error("traffic must never be throttled")
|
||||
}
|
||||
if hub.shouldThrottle(MessageTypeNotification) {
|
||||
t.Error("notification must never be throttled")
|
||||
}
|
||||
if hub.shouldThrottle(MessageTypeInvalidate) {
|
||||
t.Error("invalidate must never be throttled")
|
||||
}
|
||||
|
||||
// First inbounds broadcast goes through, immediate retry is throttled.
|
||||
if hub.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Error("first inbounds broadcast must pass")
|
||||
}
|
||||
if !hub.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Error("second inbounds broadcast within window must throttle")
|
||||
}
|
||||
|
||||
// After the window passes, throttle releases.
|
||||
time.Sleep(minBroadcastInterval + 10*time.Millisecond)
|
||||
if hub.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Error("inbounds broadcast after window must pass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubStopUnblocksWaiters ensures that pending Broadcast/Register/Unregister
|
||||
// calls don't leak goroutines after Stop().
|
||||
func TestHubStopUnblocksWaiters(t *testing.T) {
|
||||
hub := NewHub()
|
||||
// Don't start Run — leave channels unfeed so any blocking call would hang.
|
||||
|
||||
hub.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// All these should return promptly since ctx is cancelled.
|
||||
hub.Register(NewClient("x"))
|
||||
hub.Unregister(NewClient("x"))
|
||||
hub.Broadcast(MessageTypeStatus, "data")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("calls did not return after Stop()")
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||
)
|
||||
|
||||
// GetHub returns the global WebSocket hub instance
|
||||
// GetHub returns the global WebSocket hub instance.
|
||||
func GetHub() *Hub {
|
||||
webServer := global.GetWebServer()
|
||||
if webServer == nil {
|
||||
|
|
@ -24,80 +24,82 @@ func GetHub() *Hub {
|
|||
return wsHub
|
||||
}
|
||||
|
||||
// HasClients returns true if there are any WebSocket clients connected.
|
||||
// HasClients returns true if any WebSocket client is connected.
|
||||
// Use this to skip expensive work (DB queries, serialization) when no browser is open.
|
||||
func HasClients() bool {
|
||||
hub := GetHub()
|
||||
if hub == nil {
|
||||
return false
|
||||
}
|
||||
return hub.GetClientCount() > 0
|
||||
return hub != nil && hub.GetClientCount() > 0
|
||||
}
|
||||
|
||||
// BroadcastStatus broadcasts server status update to all connected clients
|
||||
// BroadcastStatus broadcasts server status update to all connected clients.
|
||||
func BroadcastStatus(status any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTraffic broadcasts traffic statistics update to all connected clients
|
||||
// BroadcastTraffic broadcasts traffic statistics update to all connected clients.
|
||||
func BroadcastTraffic(traffic any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeTraffic, traffic)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastInbounds broadcasts inbounds list update to all connected clients
|
||||
// BroadcastClientStats broadcasts absolute per-client traffic counters for the
|
||||
// clients that had activity in the latest collection window. Use this instead
|
||||
// of re-broadcasting the full inbound list — it scales to 10k+ clients because
|
||||
// the payload only includes active rows (typically a fraction of total).
|
||||
func BroadcastClientStats(stats any) {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeClientStats, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastInbounds broadcasts inbounds list update to all connected clients.
|
||||
func BroadcastInbounds(inbounds any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeInbounds, inbounds)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastOutbounds broadcasts outbounds list update to all connected clients
|
||||
// BroadcastOutbounds broadcasts outbounds list update to all connected clients.
|
||||
func BroadcastOutbounds(outbounds any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeOutbounds, outbounds)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastNotification broadcasts a system notification to all connected clients
|
||||
// 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)
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
hub.Broadcast(MessageTypeNotification, map[string]string{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"level": level,
|
||||
})
|
||||
}
|
||||
|
||||
// BroadcastXrayState broadcasts Xray state change to all connected clients
|
||||
// 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)
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
hub.Broadcast(MessageTypeXrayState, map[string]string{
|
||||
"state": state,
|
||||
"errorMsg": errorMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// BroadcastInvalidate sends a lightweight invalidate signal for the given data type,
|
||||
// telling connected frontends to re-fetch data via REST API.
|
||||
// Use this instead of BroadcastInbounds/BroadcastOutbounds when you know the payload
|
||||
// will be too large, to avoid wasting resources on serialization.
|
||||
// BroadcastInvalidate sends a lightweight signal telling clients to re-fetch
|
||||
// the named data type via REST. Use this when the caller already knows the
|
||||
// payload is too large to push directly (e.g., 10k+ clients) to skip the
|
||||
// JSON-marshal cost on the hot path.
|
||||
func BroadcastInvalidate(dataType MessageType) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.broadcastInvalidate(dataType)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue