mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-12-23 06:42:41 +00:00
Merge 6247e79ac3 into f000322a06
This commit is contained in:
commit
c1870db8e7
40 changed files with 1271 additions and 98 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -85,7 +85,7 @@ jobs:
|
||||||
cd x-ui/bin
|
cd x-ui/bin
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.2/"
|
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
|
||||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||||
unzip Xray-linux-64.zip
|
unzip Xray-linux-64.zip
|
||||||
|
|
@ -183,7 +183,7 @@ jobs:
|
||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
# Download Xray for Windows
|
# Download Xray for Windows
|
||||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/"
|
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/"
|
||||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||||
Remove-Item "Xray-windows-64.zip"
|
Remove-Item "Xray-windows-64.zip"
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ case $1 in
|
||||||
esac
|
esac
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/Xray-linux-${ARCH}.zip"
|
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${ARCH}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "xray-linux-${FNAME}"
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -9,6 +9,7 @@ require (
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mymmrac/telego v1.3.1
|
github.com/mymmrac/telego v1.3.1
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||||
|
|
@ -19,7 +20,7 @@ require (
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.68.0
|
github.com/valyala/fasthttp v1.68.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.251202.0
|
github.com/xtls/xray-core v1.251208.0
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/sys v0.38.0
|
golang.org/x/sys v0.38.0
|
||||||
|
|
@ -51,7 +52,6 @@ require (
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -203,8 +203,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
||||||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
||||||
github.com/xtls/xray-core v1.251202.0 h1:VwoBnq9IRTbYWEBhR0CqEw2cNjTlXYH6WxzKbSjx+XE=
|
github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0eyAes=
|
||||||
github.com/xtls/xray-core v1.251202.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
|
github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
|
|
||||||
|
|
@ -857,6 +857,7 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||||
V6Only = false,
|
V6Only = false,
|
||||||
tcpWindowClamp = 600,
|
tcpWindowClamp = 600,
|
||||||
interfaceName = "",
|
interfaceName = "",
|
||||||
|
trustedXForwardedFor = [],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.acceptProxyProtocol = acceptProxyProtocol;
|
this.acceptProxyProtocol = acceptProxyProtocol;
|
||||||
|
|
@ -875,6 +876,7 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||||
this.V6Only = V6Only;
|
this.V6Only = V6Only;
|
||||||
this.tcpWindowClamp = tcpWindowClamp;
|
this.tcpWindowClamp = tcpWindowClamp;
|
||||||
this.interfaceName = interfaceName;
|
this.interfaceName = interfaceName;
|
||||||
|
this.trustedXForwardedFor = trustedXForwardedFor;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
|
|
@ -896,11 +898,12 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||||
json.V6Only,
|
json.V6Only,
|
||||||
json.tcpWindowClamp,
|
json.tcpWindowClamp,
|
||||||
json.interface,
|
json.interface,
|
||||||
|
json.trustedXForwardedFor || [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
const result = {
|
||||||
acceptProxyProtocol: this.acceptProxyProtocol,
|
acceptProxyProtocol: this.acceptProxyProtocol,
|
||||||
tcpFastOpen: this.tcpFastOpen,
|
tcpFastOpen: this.tcpFastOpen,
|
||||||
mark: this.mark,
|
mark: this.mark,
|
||||||
|
|
@ -918,6 +921,10 @@ class SockoptStreamSettings extends XrayCommonClass {
|
||||||
tcpWindowClamp: this.tcpWindowClamp,
|
tcpWindowClamp: this.tcpWindowClamp,
|
||||||
interface: this.interfaceName,
|
interface: this.interfaceName,
|
||||||
};
|
};
|
||||||
|
if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
|
||||||
|
result.trustedXForwardedFor = this.trustedXForwardedFor;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1870,6 +1877,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
encryption = "none",
|
encryption = "none",
|
||||||
fallbacks = [],
|
fallbacks = [],
|
||||||
selectedAuth = undefined,
|
selectedAuth = undefined,
|
||||||
|
testseed = [900, 500, 900, 256],
|
||||||
) {
|
) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.vlesses = vlesses;
|
this.vlesses = vlesses;
|
||||||
|
|
@ -1877,6 +1885,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
this.fallbacks = fallbacks;
|
this.fallbacks = fallbacks;
|
||||||
this.selectedAuth = selectedAuth;
|
this.selectedAuth = selectedAuth;
|
||||||
|
this.testseed = testseed;
|
||||||
}
|
}
|
||||||
|
|
||||||
addFallback() {
|
addFallback() {
|
||||||
|
|
@ -1894,7 +1903,8 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
json.decryption,
|
json.decryption,
|
||||||
json.encryption,
|
json.encryption,
|
||||||
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
||||||
json.selectedAuth
|
json.selectedAuth,
|
||||||
|
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||||
);
|
);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
@ -1920,6 +1930,10 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
json.selectedAuth = this.selectedAuth;
|
json.selectedAuth = this.selectedAuth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.testseed && this.testseed.length >= 4) {
|
||||||
|
json.testseed = this.testseed;
|
||||||
|
}
|
||||||
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,7 @@ class SockoptStreamSettings extends CommonClass {
|
||||||
tcpMptcp = false,
|
tcpMptcp = false,
|
||||||
penetrate = false,
|
penetrate = false,
|
||||||
addressPortStrategy = Address_Port_Strategy.NONE,
|
addressPortStrategy = Address_Port_Strategy.NONE,
|
||||||
|
trustedXForwardedFor = [],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.dialerProxy = dialerProxy;
|
this.dialerProxy = dialerProxy;
|
||||||
|
|
@ -440,6 +441,7 @@ class SockoptStreamSettings extends CommonClass {
|
||||||
this.tcpMptcp = tcpMptcp;
|
this.tcpMptcp = tcpMptcp;
|
||||||
this.penetrate = penetrate;
|
this.penetrate = penetrate;
|
||||||
this.addressPortStrategy = addressPortStrategy;
|
this.addressPortStrategy = addressPortStrategy;
|
||||||
|
this.trustedXForwardedFor = trustedXForwardedFor;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
|
|
@ -450,12 +452,13 @@ class SockoptStreamSettings extends CommonClass {
|
||||||
json.tcpKeepAliveInterval,
|
json.tcpKeepAliveInterval,
|
||||||
json.tcpMptcp,
|
json.tcpMptcp,
|
||||||
json.penetrate,
|
json.penetrate,
|
||||||
json.addressPortStrategy
|
json.addressPortStrategy,
|
||||||
|
json.trustedXForwardedFor || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
const result = {
|
||||||
dialerProxy: this.dialerProxy,
|
dialerProxy: this.dialerProxy,
|
||||||
tcpFastOpen: this.tcpFastOpen,
|
tcpFastOpen: this.tcpFastOpen,
|
||||||
tcpKeepAliveInterval: this.tcpKeepAliveInterval,
|
tcpKeepAliveInterval: this.tcpKeepAliveInterval,
|
||||||
|
|
@ -463,6 +466,10 @@ class SockoptStreamSettings extends CommonClass {
|
||||||
penetrate: this.penetrate,
|
penetrate: this.penetrate,
|
||||||
addressPortStrategy: this.addressPortStrategy
|
addressPortStrategy: this.addressPortStrategy
|
||||||
};
|
};
|
||||||
|
if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
|
||||||
|
result.trustedXForwardedFor = this.trustedXForwardedFor;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1050,13 +1057,15 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Outbound.VLESSSettings = class extends CommonClass {
|
Outbound.VLESSSettings = class extends CommonClass {
|
||||||
constructor(address, port, id, flow, encryption) {
|
constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||||
super();
|
super();
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.flow = flow;
|
this.flow = flow;
|
||||||
this.encryption = encryption;
|
this.encryption = encryption;
|
||||||
|
this.testpre = testpre;
|
||||||
|
this.testseed = testseed;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
|
|
@ -1066,18 +1075,27 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||||
json.port,
|
json.port,
|
||||||
json.id,
|
json.id,
|
||||||
json.flow,
|
json.flow,
|
||||||
json.encryption
|
json.encryption,
|
||||||
|
json.testpre || 0,
|
||||||
|
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
const result = {
|
||||||
address: this.address,
|
address: this.address,
|
||||||
port: this.port,
|
port: this.port,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
flow: this.flow,
|
flow: this.flow,
|
||||||
encryption: this.encryption,
|
encryption: this.encryption,
|
||||||
};
|
};
|
||||||
|
if (this.testpre > 0) {
|
||||||
|
result.testpre = this.testpre;
|
||||||
|
}
|
||||||
|
if (this.testseed && this.testseed.length >= 4) {
|
||||||
|
result.testseed = this.testseed;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Outbound.TrojanSettings = class extends CommonClass {
|
Outbound.TrojanSettings = class extends CommonClass {
|
||||||
|
|
|
||||||
138
web/assets/js/websocket.js
Normal file
138
web/assets/js/websocket.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* WebSocket client for real-time updates
|
||||||
|
*/
|
||||||
|
class WebSocketClient {
|
||||||
|
constructor(basePath = '') {
|
||||||
|
this.basePath = basePath;
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 10;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.isConnected = false;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}${this.basePath}ws`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.emit('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// Validate message size (prevent memory issues)
|
||||||
|
const maxMessageSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (event.data && event.data.length > maxMessageSize) {
|
||||||
|
console.error('WebSocket message too large:', event.data.length, 'bytes');
|
||||||
|
this.ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
console.error('Invalid WebSocket message format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleMessage(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse WebSocket message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
this.isConnected = false;
|
||||||
|
this.emit('disconnected');
|
||||||
|
|
||||||
|
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||||
|
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||||
|
setTimeout(() => this.connect(), delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create WebSocket connection:', e);
|
||||||
|
this.emit('error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(message) {
|
||||||
|
const { type, payload, time } = message;
|
||||||
|
|
||||||
|
// Emit to specific type listeners
|
||||||
|
this.emit(type, payload, time);
|
||||||
|
|
||||||
|
// Emit to all listeners
|
||||||
|
this.emit('message', { type, payload, time });
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const callbacks = this.listeners.get(event);
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, ...args) {
|
||||||
|
if (this.listeners.has(event)) {
|
||||||
|
this.listeners.get(event).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in WebSocket event handler:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket is not connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global WebSocket client instance
|
||||||
|
// Safely get basePath from global scope (defined in page.html)
|
||||||
|
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -125,6 +126,9 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
// Broadcast inbounds update via WebSocket
|
||||||
|
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||||
|
websocket.BroadcastInbounds(inbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delInbound deletes an inbound configuration by its ID.
|
// delInbound deletes an inbound configuration by its ID.
|
||||||
|
|
@ -143,6 +147,10 @@ func (a *InboundController) delInbound(c *gin.Context) {
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
// Broadcast inbounds update via WebSocket
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||||
|
websocket.BroadcastInbounds(inbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateInbound updates an existing inbound configuration.
|
// updateInbound updates an existing inbound configuration.
|
||||||
|
|
@ -169,6 +177,10 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
// Broadcast inbounds update via WebSocket
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
inbounds, _ := a.inboundService.GetInbounds(user.Id)
|
||||||
|
websocket.BroadcastInbounds(inbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -67,6 +68,8 @@ func (a *ServerController) refreshStatus() {
|
||||||
// collect cpu history when status is fresh
|
// collect cpu history when status is fresh
|
||||||
if a.lastStatus != nil {
|
if a.lastStatus != nil {
|
||||||
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
|
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
|
||||||
|
// Broadcast status update via WebSocket
|
||||||
|
websocket.BroadcastStatus(a.lastStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,9 +158,16 @@ func (a *ServerController) stopXrayService(c *gin.Context) {
|
||||||
err := a.serverService.StopXrayService()
|
err := a.serverService.StopXrayService()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
||||||
|
websocket.BroadcastXrayState("error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
||||||
|
websocket.BroadcastXrayState("stop", "")
|
||||||
|
websocket.BroadcastNotification(
|
||||||
|
I18nWeb(c, "pages.xray.stopSuccess"),
|
||||||
|
"Xray service has been stopped",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartXrayService restarts the Xray service.
|
// restartXrayService restarts the Xray service.
|
||||||
|
|
@ -165,9 +175,16 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||||
err := a.serverService.RestartXrayService()
|
err := a.serverService.RestartXrayService()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
||||||
|
websocket.BroadcastXrayState("error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
||||||
|
websocket.BroadcastXrayState("running", "")
|
||||||
|
websocket.BroadcastNotification(
|
||||||
|
I18nWeb(c, "pages.xray.restartSuccess"),
|
||||||
|
"Xray service has been restarted successfully",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||||
|
|
|
||||||
232
web/controller/websocket.go
Normal file
232
web/controller/websocket.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
ws "github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period (must be less than pongWait)
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
|
||||||
|
// Maximum message size allowed from peer
|
||||||
|
maxMessageSize = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = ws.Upgrader{
|
||||||
|
ReadBufferSize: 4096, // Increased from 1024 for better performance
|
||||||
|
WriteBufferSize: 4096, // Increased from 1024 for better performance
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
// Check origin for security
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
// Allow connections without Origin header (same-origin requests)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Get the host from the request
|
||||||
|
host := r.Host
|
||||||
|
// Extract scheme and host from origin
|
||||||
|
originURL := origin
|
||||||
|
// Simple check: origin should match the request host
|
||||||
|
// This prevents cross-origin WebSocket hijacking
|
||||||
|
if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") {
|
||||||
|
// Extract host from origin
|
||||||
|
originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://")
|
||||||
|
if idx := strings.Index(originHost, "/"); idx != -1 {
|
||||||
|
originHost = originHost[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(originHost, ":"); idx != -1 {
|
||||||
|
originHost = originHost[:idx]
|
||||||
|
}
|
||||||
|
// Compare hosts (without port)
|
||||||
|
requestHost := host
|
||||||
|
if idx := strings.Index(requestHost, ":"); idx != -1 {
|
||||||
|
requestHost = requestHost[:idx]
|
||||||
|
}
|
||||||
|
return originHost == requestHost || originHost == "" || requestHost == ""
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketController handles WebSocket connections for real-time updates
|
||||||
|
type WebSocketController struct {
|
||||||
|
BaseController
|
||||||
|
hub *websocket.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebSocketController creates a new WebSocket controller
|
||||||
|
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
||||||
|
return &WebSocketController{
|
||||||
|
hub: hub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebSocket handles WebSocket connections
|
||||||
|
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||||
|
// Check authentication
|
||||||
|
if !session.IsLogin(c) {
|
||||||
|
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade connection to WebSocket
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to upgrade WebSocket connection:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client
|
||||||
|
clientID := uuid.New().String()
|
||||||
|
client := &websocket.Client{
|
||||||
|
ID: clientID,
|
||||||
|
Hub: w.hub,
|
||||||
|
Send: make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow
|
||||||
|
Topics: make(map[websocket.MessageType]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register client
|
||||||
|
w.hub.Register(client)
|
||||||
|
logger.Infof("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
|
||||||
|
|
||||||
|
// Start goroutines for reading and writing
|
||||||
|
go w.writePump(client, conn)
|
||||||
|
go w.readPump(client, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump pumps messages from the WebSocket connection to the hub
|
||||||
|
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
||||||
|
logger.Error("WebSocket readPump panic recovered:", r)
|
||||||
|
}
|
||||||
|
w.hub.Unregister(client)
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conn.SetReadLimit(maxMessageSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||||
|
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
if len(message) > maxMessageSize {
|
||||||
|
logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages (e.g., subscription requests)
|
||||||
|
// For now, we'll just log them
|
||||||
|
logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump pumps messages from the hub to the WebSocket connection
|
||||||
|
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
if r := common.Recover("WebSocket writePump panic"); r != nil {
|
||||||
|
logger.Error("WebSocket writePump panic recovered:", r)
|
||||||
|
}
|
||||||
|
ticker.Stop()
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-client.Send:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
// Hub closed the channel
|
||||||
|
conn.WriteMessage(ws.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writer, err := conn.NextWriter(ws.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Write(message)
|
||||||
|
|
||||||
|
// Optimization: message batching with smart limit
|
||||||
|
// Process accumulated messages but limit to prevent delays
|
||||||
|
n := len(client.Send)
|
||||||
|
maxQueued := 20 // Increased from 10 to 20 for better throughput
|
||||||
|
if n > maxQueued {
|
||||||
|
// Skip old messages, keep only the latest for relevance
|
||||||
|
skipped := n - maxQueued
|
||||||
|
for i := 0; i < skipped; i++ {
|
||||||
|
select {
|
||||||
|
case <-client.Send:
|
||||||
|
// Skip old message
|
||||||
|
default:
|
||||||
|
// Channel closed or empty, stop skipping
|
||||||
|
goto skipDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipDone:
|
||||||
|
n = len(client.Send) // Update count after skipping
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batching: send multiple messages in one frame
|
||||||
|
// Safe reading with channel close check
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-client.Send:
|
||||||
|
if !ok {
|
||||||
|
// Channel closed, exit
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Write([]byte{'\n'})
|
||||||
|
writer.Write(msg)
|
||||||
|
default:
|
||||||
|
// No more messages in queue, stop batching
|
||||||
|
goto batchDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batchDone:
|
||||||
|
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
logger.Debugf("WebSocket writer close error for client %s: %v", client.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
|
||||||
|
logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ var (
|
||||||
type WebServer interface {
|
type WebServer interface {
|
||||||
GetCron() *cron.Cron // Get the cron scheduler
|
GetCron() *cron.Cron // Get the cron scheduler
|
||||||
GetCtx() context.Context // Get the server context
|
GetCtx() context.Context // Get the server context
|
||||||
|
GetWSHub() interface{} // Get the WebSocket hub (using interface{} to avoid circular dependency)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubServer interface defines methods for accessing the subscription server instance.
|
// SubServer interface defines methods for accessing the subscription server instance.
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
const basePath = '{{ .base_path }}';
|
const basePath = '{{ .base_path }}';
|
||||||
axios.defaults.baseURL = basePath;
|
axios.defaults.baseURL = basePath;
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "page/body_end" }}
|
{{ define "page/body_end" }}
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,28 @@
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- XTLS Vision Advanced Settings -->
|
||||||
|
<template v-if="outbound.protocol === Protocols.VLESS && (outbound.settings.flow === 'xtls-rprx-vision' || outbound.settings.flow === 'xtls-rprx-vision-udp443')">
|
||||||
|
<a-form-item label="Vision Pre-Connect">
|
||||||
|
<a-input-number v-model.number="outbound.settings.testpre" :min="0" :max="10" :style="{ width: '100%' }" placeholder="0"></a-input-number>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Vision Seed">
|
||||||
|
<a-row :gutter="8">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="outbound.settings.testseed[0]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="outbound.settings.testseed[1]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="outbound.settings.testseed[2]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="outbound.settings.testseed[3]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Servers (trojan/shadowsocks/socks/http) settings -->
|
<!-- Servers (trojan/shadowsocks/socks/http) settings -->
|
||||||
|
|
@ -501,6 +523,15 @@
|
||||||
<a-form-item label="Penetrate">
|
<a-form-item label="Penetrate">
|
||||||
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
|
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="Trusted X-Forwarded-For">
|
||||||
|
<a-select mode="tags" v-model="outbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
||||||
|
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
||||||
|
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
||||||
|
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- mux settings -->
|
<!-- mux settings -->
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
<a-divider v-if="inbound.settings.selectedAuth && inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443')" :style="{ margin: '5px 0' }"></a-divider>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
|
@ -69,4 +70,33 @@
|
||||||
</a-form>
|
</a-form>
|
||||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443')">
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
<a-form-item label="Vision Seed">
|
||||||
|
<a-row :gutter="8">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="inbound.settings.testseed[0]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="inbound.settings.testseed[1]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="inbound.settings.testseed[2]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-number v-model.number="inbound.settings.testseed[3]" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
<a-space :size="8" :style="{ marginTop: '8px' }">
|
||||||
|
<a-button type="primary" @click="inbound.settings.testseed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)]">
|
||||||
|
Rand
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="inbound.settings.testseed = [900, 500, 900, 256]">
|
||||||
|
Reset
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||||
|
</template>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,15 @@
|
||||||
<a-form-item label="Interface Name">
|
<a-form-item label="Interface Name">
|
||||||
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="Trusted X-Forwarded-For">
|
||||||
|
<a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
||||||
|
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
||||||
|
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
||||||
|
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,20 @@
|
||||||
<a-form-item label="VerifyPeerCertInNames">
|
<a-form-item label="VerifyPeerCertInNames">
|
||||||
<a-input v-model.trim="inbound.stream.tls.verifyPeerCertInNames"></a-input>
|
<a-input v-model.trim="inbound.stream.tls.verifyPeerCertInNames"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||||
<template v-for="cert,index in inbound.stream.tls.certs">
|
<template v-for="cert,index in inbound.stream.tls.certs">
|
||||||
<a-form-item label='{{ i18n "certificate" }}'>
|
<a-form-item label='{{ i18n "certificate" }}'>
|
||||||
<a-radio-group v-model="cert.useFile" button-style="solid">
|
<a-radio-group v-model="cert.useFile" button-style="solid" :style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }">
|
||||||
<a-radio-button :value="true">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
|
<a-radio-button :value="true" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button>
|
||||||
<a-radio-button :value="false">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
|
<a-radio-button :value="false" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"
|
</a-form-item>
|
||||||
:style="{ marginLeft: '10px' }"></a-button>
|
<a-form-item label=" ">
|
||||||
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
|
<a-space>
|
||||||
@click="inbound.stream.tls.removeCert(index)" :style="{ marginLeft: '10px' }"></a-button>
|
<a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"></a-button>
|
||||||
|
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
|
||||||
|
@click="inbound.stream.tls.removeCert(index)"></a-button>
|
||||||
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-if="cert.useFile">
|
<template v-if="cert.useFile">
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.publicKey" }}'>
|
<a-form-item label='{{ i18n "pages.inbounds.publicKey" }}'>
|
||||||
|
|
|
||||||
|
|
@ -1567,13 +1567,97 @@
|
||||||
}
|
}
|
||||||
this.loading();
|
this.loading();
|
||||||
this.getDefaultSettings();
|
this.getDefaultSettings();
|
||||||
if (this.isRefreshEnabled) {
|
|
||||||
this.startDataRefreshLoop();
|
// Initial data fetch
|
||||||
|
this.getDBInbounds().then(() => {
|
||||||
|
this.loading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup WebSocket for real-time updates
|
||||||
|
if (window.wsClient) {
|
||||||
|
window.wsClient.connect();
|
||||||
|
|
||||||
|
// Listen for inbounds updates
|
||||||
|
window.wsClient.on('inbounds', (payload) => {
|
||||||
|
if (payload && Array.isArray(payload)) {
|
||||||
|
// Use setInbounds to properly convert to DBInbound objects with methods
|
||||||
|
this.setInbounds(payload);
|
||||||
|
this.searchInbounds(this.searchKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for traffic updates
|
||||||
|
window.wsClient.on('traffic', (payload) => {
|
||||||
|
if (payload && payload.clientTraffics) {
|
||||||
|
// Update client traffic statistics
|
||||||
|
payload.clientTraffics.forEach(clientTraffic => {
|
||||||
|
const dbInbound = this.dbInbounds.find(ib => {
|
||||||
|
const clients = this.getInboundClients(ib);
|
||||||
|
return clients && clients.some(c => c.email === clientTraffic.email);
|
||||||
|
});
|
||||||
|
if (dbInbound && dbInbound.clientStats) {
|
||||||
|
const stats = dbInbound.clientStats.find(s => s.email === clientTraffic.email);
|
||||||
|
if (stats) {
|
||||||
|
stats.up = clientTraffic.up || stats.up;
|
||||||
|
stats.down = clientTraffic.down || stats.down;
|
||||||
|
stats.total = clientTraffic.total || stats.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update online clients list in real-time
|
||||||
|
if (payload && Array.isArray(payload.onlineClients)) {
|
||||||
|
this.onlineClients = payload.onlineClients;
|
||||||
|
// Recalculate client counts to update online status
|
||||||
|
this.dbInbounds.forEach(dbInbound => {
|
||||||
|
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
|
||||||
|
if (inbound && this.clientCount[dbInbound.id]) {
|
||||||
|
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last online map in real-time
|
||||||
|
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||||
|
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for notifications
|
||||||
|
window.wsClient.on('notification', (payload) => {
|
||||||
|
if (payload && payload.title) {
|
||||||
|
const type = payload.level || 'info';
|
||||||
|
this.$notification[type]({
|
||||||
|
message: payload.title,
|
||||||
|
description: payload.message || '',
|
||||||
|
duration: 4.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to polling if WebSocket fails
|
||||||
|
window.wsClient.on('error', () => {
|
||||||
|
console.warn('WebSocket connection failed, falling back to polling');
|
||||||
|
if (this.isRefreshEnabled) {
|
||||||
|
this.startDataRefreshLoop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wsClient.on('disconnected', () => {
|
||||||
|
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
||||||
|
console.warn('WebSocket reconnection failed, falling back to polling');
|
||||||
|
if (this.isRefreshEnabled) {
|
||||||
|
this.startDataRefreshLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to polling if WebSocket is not available
|
||||||
|
if (this.isRefreshEnabled) {
|
||||||
|
this.startDataRefreshLoop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
this.getDBInbounds();
|
|
||||||
}
|
|
||||||
this.loading(false);
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
total() {
|
total() {
|
||||||
|
|
|
||||||
|
|
@ -1102,6 +1102,20 @@
|
||||||
});
|
});
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
},
|
},
|
||||||
|
startPolling() {
|
||||||
|
// Fallback polling mechanism
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
if (window.wsClient && window.wsClient.isConnected) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (window.location.protocol !== "https:") {
|
if (window.location.protocol !== "https:") {
|
||||||
|
|
@ -1113,13 +1127,67 @@
|
||||||
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
this.ipLimitEnable = msg.obj.ipLimitEnable;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
// Initial status fetch
|
||||||
try {
|
await this.getStatus();
|
||||||
await this.getStatus();
|
|
||||||
} catch (e) {
|
// Setup WebSocket for real-time updates
|
||||||
console.error(e);
|
if (window.wsClient) {
|
||||||
}
|
window.wsClient.connect();
|
||||||
await PromiseUtil.sleep(2000);
|
|
||||||
|
// Listen for status updates
|
||||||
|
window.wsClient.on('status', (payload) => {
|
||||||
|
this.setStatus(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for Xray state changes
|
||||||
|
window.wsClient.on('xray_state', (payload) => {
|
||||||
|
if (this.status && this.status.xray) {
|
||||||
|
this.status.xray.state = payload.state;
|
||||||
|
this.status.xray.errorMsg = payload.errorMsg || '';
|
||||||
|
switch (payload.state) {
|
||||||
|
case 'running':
|
||||||
|
this.status.xray.color = "green";
|
||||||
|
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
|
||||||
|
break;
|
||||||
|
case 'stop':
|
||||||
|
this.status.xray.color = "orange";
|
||||||
|
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
this.status.xray.color = "red";
|
||||||
|
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for notifications
|
||||||
|
window.wsClient.on('notification', (payload) => {
|
||||||
|
if (payload && payload.title) {
|
||||||
|
const type = payload.level || 'info';
|
||||||
|
this.$notification[type]({
|
||||||
|
message: payload.title,
|
||||||
|
description: payload.message || '',
|
||||||
|
duration: 4.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to polling if WebSocket fails
|
||||||
|
window.wsClient.on('error', () => {
|
||||||
|
console.warn('WebSocket connection failed, falling back to polling');
|
||||||
|
this.startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wsClient.on('disconnected', () => {
|
||||||
|
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
|
||||||
|
console.warn('WebSocket reconnection failed, falling back to polling');
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to polling if WebSocket is not available
|
||||||
|
this.startPolling();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
'inModal.inbound.stream.security'(newVal, oldVal) {
|
||||||
|
// Clear flow when security changes from reality/tls to none
|
||||||
|
if (inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
||||||
|
inModal.inbound.settings.vlesses.forEach(client => {
|
||||||
|
client.flow = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
streamNetworkChange() {
|
streamNetworkChange() {
|
||||||
if (!inModal.inbound.canEnableTls()) {
|
if (!inModal.inbound.canEnableTls()) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@
|
||||||
<a-switch v-model="dnsDisableFallbackIfMatch"></a-switch>
|
<a-switch v-model="dnsDisableFallbackIfMatch"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.xray.dns.enableParallelQuery" }}</template>
|
||||||
|
<template #description>{{ i18n "pages.xray.dns.enableParallelQueryDesc" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model="dnsEnableParallelQuery"></a-switch>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.dns.useSystemHosts" }}</template>
|
<template #title>{{ i18n "pages.xray.dns.useSystemHosts" }}</template>
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@
|
||||||
tag: "direct",
|
tag: "direct",
|
||||||
protocol: "freedom"
|
protocol: "freedom"
|
||||||
},
|
},
|
||||||
routingDomainStrategies: ["AsIs", "IPIfNonMatch", "IPOnDemand"],
|
routingDomainStrategies: ["AsIs", "IpIfNonMatch", "IpOnDemand"],
|
||||||
log: {
|
log: {
|
||||||
loglevel: ["none", "debug", "info", "warning", "error"],
|
loglevel: ["none", "debug", "info", "warning", "error"],
|
||||||
access: ["none", "./access.log"],
|
access: ["none", "./access.log"],
|
||||||
|
|
@ -1315,7 +1315,8 @@
|
||||||
newTemplateSettings.dns = {
|
newTemplateSettings.dns = {
|
||||||
servers: [],
|
servers: [],
|
||||||
queryStrategy: "UseIP",
|
queryStrategy: "UseIP",
|
||||||
tag: "dns_inbound"
|
tag: "dns_inbound",
|
||||||
|
enableParallelQuery: false
|
||||||
};
|
};
|
||||||
newTemplateSettings.fakedns = null;
|
newTemplateSettings.fakedns = null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1391,6 +1392,20 @@
|
||||||
this.templateSettings = newTemplateSettings;
|
this.templateSettings = newTemplateSettings;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
dnsEnableParallelQuery: {
|
||||||
|
get: function () {
|
||||||
|
return this.enableDNS ? (this.templateSettings.dns.enableParallelQuery || false) : false;
|
||||||
|
},
|
||||||
|
set: function (newValue) {
|
||||||
|
newTemplateSettings = this.templateSettings;
|
||||||
|
if (newValue) {
|
||||||
|
newTemplateSettings.dns.enableParallelQuery = newValue;
|
||||||
|
} else {
|
||||||
|
delete newTemplateSettings.dns.enableParallelQuery
|
||||||
|
}
|
||||||
|
this.templateSettings = newTemplateSettings;
|
||||||
|
}
|
||||||
|
},
|
||||||
dnsUseSystemHosts: {
|
dnsUseSystemHosts: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false;
|
return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false;
|
||||||
|
|
|
||||||
|
|
@ -322,66 +322,6 @@ func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureClientExists adds client with defaults to inbound tag if not present
|
|
||||||
func (j *LdapSyncJob) ensureClientExists(inboundTag string, email string, defGB int, defExpiryDays int, defLimitIP int) {
|
|
||||||
inbounds, err := j.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("ensureClientExists: get inbounds failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var target *model.Inbound
|
|
||||||
for _, ib := range inbounds {
|
|
||||||
if ib.Tag == inboundTag {
|
|
||||||
target = ib
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target == nil {
|
|
||||||
logger.Debugf("ensureClientExists: inbound tag %s not found", inboundTag)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// check if email already exists in this inbound
|
|
||||||
clients, err := j.inboundService.GetClients(target)
|
|
||||||
if err == nil {
|
|
||||||
for _, c := range clients {
|
|
||||||
if c.Email == email {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// build new client according to protocol
|
|
||||||
newClient := model.Client{
|
|
||||||
Email: email,
|
|
||||||
Enable: true,
|
|
||||||
LimitIP: defLimitIP,
|
|
||||||
TotalGB: int64(defGB),
|
|
||||||
}
|
|
||||||
if defExpiryDays > 0 {
|
|
||||||
newClient.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch target.Protocol {
|
|
||||||
case model.Trojan:
|
|
||||||
newClient.Password = uuid.NewString()
|
|
||||||
case model.Shadowsocks:
|
|
||||||
newClient.Password = uuid.NewString()
|
|
||||||
default: // VMESS/VLESS and others using ID
|
|
||||||
newClient.ID = uuid.NewString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepare inbound payload with only the new client
|
|
||||||
payload := &model.Inbound{Id: target.Id}
|
|
||||||
payload.Settings = `{"clients":[` + j.clientToJSON(newClient) + `]}`
|
|
||||||
|
|
||||||
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
|
|
||||||
logger.Warning("ensureClientExists: add client failed:", err)
|
|
||||||
} else {
|
|
||||||
j.xrayService.SetToNeedRestart()
|
|
||||||
logger.Infof("LDAP auto-create: %s in %s", email, inboundTag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientToJSON serializes minimal client fields to JSON object string without extra deps
|
// clientToJSON serializes minimal client fields to JSON object string without extra deps
|
||||||
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
|
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
|
||||||
// construct minimal JSON manually to avoid importing json for simple case
|
// construct minimal JSON manually to avoid importing json for simple case
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
|
|
@ -48,6 +49,23 @@ func (j *XrayTrafficJob) Run() {
|
||||||
if needRestart0 || needRestart1 {
|
if needRestart0 || needRestart1 {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get online clients and last online map for real-time status updates
|
||||||
|
onlineClients := j.inboundService.GetOnlineClients()
|
||||||
|
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("get clients last online failed:", err)
|
||||||
|
lastOnlineMap = make(map[string]int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast traffic update via WebSocket
|
||||||
|
trafficUpdate := map[string]interface{}{
|
||||||
|
"traffics": traffics,
|
||||||
|
"clientTraffics": clientTraffics,
|
||||||
|
"onlineClients": onlineClients,
|
||||||
|
"lastOnlineMap": lastOnlineMap,
|
||||||
|
}
|
||||||
|
websocket.BroadcastTraffic(trafficUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "بيعطل استعلامات DNS الاحتياطية"
|
"disableFallbackDesc" = "بيعطل استعلامات DNS الاحتياطية"
|
||||||
"disableFallbackIfMatch" = "تعطيل النسخ الاحتياطي عند التطابق"
|
"disableFallbackIfMatch" = "تعطيل النسخ الاحتياطي عند التطابق"
|
||||||
"disableFallbackIfMatchDesc" = "بيعطل استعلامات DNS الاحتياطية لما يتحقق تطابق مع قائمة الدومينات"
|
"disableFallbackIfMatchDesc" = "بيعطل استعلامات DNS الاحتياطية لما يتحقق تطابق مع قائمة الدومينات"
|
||||||
|
"enableParallelQuery" = "تفعيل الاستعلام المتوازي"
|
||||||
|
"enableParallelQueryDesc" = "تفعيل استعلامات DNS المتوازية لعدة خوادم لحل أسرع"
|
||||||
"strategy" = "استراتيجية الاستعلام"
|
"strategy" = "استراتيجية الاستعلام"
|
||||||
"strategyDesc" = "الاستراتيجية العامة لحل أسماء الدومين"
|
"strategyDesc" = "الاستراتيجية العامة لحل أسماء الدومين"
|
||||||
"add" = "أضف سيرفر"
|
"add" = "أضف سيرفر"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Disables fallback DNS queries"
|
"disableFallbackDesc" = "Disables fallback DNS queries"
|
||||||
"disableFallbackIfMatch" = "Disable Fallback If Match"
|
"disableFallbackIfMatch" = "Disable Fallback If Match"
|
||||||
"disableFallbackIfMatchDesc" = "Disables fallback DNS queries when the matching domain list of the DNS server is hit"
|
"disableFallbackIfMatchDesc" = "Disables fallback DNS queries when the matching domain list of the DNS server is hit"
|
||||||
|
"enableParallelQuery" = "Enable Parallel Query"
|
||||||
|
"enableParallelQueryDesc" = "Enable parallel DNS queries to multiple servers for faster resolution"
|
||||||
"strategy" = "Query Strategy"
|
"strategy" = "Query Strategy"
|
||||||
"strategyDesc" = "Overall strategy to resolve domain names"
|
"strategyDesc" = "Overall strategy to resolve domain names"
|
||||||
"add" = "Add Server"
|
"add" = "Add Server"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Desactiva las consultas DNS de respaldo"
|
"disableFallbackDesc" = "Desactiva las consultas DNS de respaldo"
|
||||||
"disableFallbackIfMatch" = "Desactivar respaldo si coincide"
|
"disableFallbackIfMatch" = "Desactivar respaldo si coincide"
|
||||||
"disableFallbackIfMatchDesc" = "Desactiva las consultas DNS de respaldo cuando se acierta en la lista de dominios coincidentes del servidor DNS"
|
"disableFallbackIfMatchDesc" = "Desactiva las consultas DNS de respaldo cuando se acierta en la lista de dominios coincidentes del servidor DNS"
|
||||||
|
"enableParallelQuery" = "Habilitar consulta paralela"
|
||||||
|
"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas a múltiples servidores para una resolución más rápida"
|
||||||
"strategy" = "Estrategia de Consulta"
|
"strategy" = "Estrategia de Consulta"
|
||||||
"strategyDesc" = "Estrategia general para resolver nombres de dominio"
|
"strategyDesc" = "Estrategia general para resolver nombres de dominio"
|
||||||
"add" = "Agregar Servidor"
|
"add" = "Agregar Servidor"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "درخواستهای DNS Fallback را غیرفعال میکند"
|
"disableFallbackDesc" = "درخواستهای DNS Fallback را غیرفعال میکند"
|
||||||
"disableFallbackIfMatch" = "غیرفعالسازی Fallback در صورت تطابق"
|
"disableFallbackIfMatch" = "غیرفعالسازی Fallback در صورت تطابق"
|
||||||
"disableFallbackIfMatchDesc" = "درخواستهای DNS Fallback را زمانی که لیست دامنههای مطابقتیافته سرور DNS فعال است، غیرفعال میکند"
|
"disableFallbackIfMatchDesc" = "درخواستهای DNS Fallback را زمانی که لیست دامنههای مطابقتیافته سرور DNS فعال است، غیرفعال میکند"
|
||||||
|
"enableParallelQuery" = "فعالسازی پرسوجوی موازی"
|
||||||
|
"enableParallelQueryDesc" = "فعالسازی پرسوجوهای DNS موازی به چندین سرور برای وضوح سریعتر"
|
||||||
"strategy" = "استراتژی پرسوجو"
|
"strategy" = "استراتژی پرسوجو"
|
||||||
"strategyDesc" = "استراتژی کلی برای حل نام دامنه"
|
"strategyDesc" = "استراتژی کلی برای حل نام دامنه"
|
||||||
"add" = "افزودن سرور"
|
"add" = "افزودن سرور"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Menonaktifkan kueri DNS fallback"
|
"disableFallbackDesc" = "Menonaktifkan kueri DNS fallback"
|
||||||
"disableFallbackIfMatch" = "Nonaktifkan Fallback Jika Cocok"
|
"disableFallbackIfMatch" = "Nonaktifkan Fallback Jika Cocok"
|
||||||
"disableFallbackIfMatchDesc" = "Menonaktifkan kueri DNS fallback ketika daftar domain yang cocok dari server DNS terpenuhi"
|
"disableFallbackIfMatchDesc" = "Menonaktifkan kueri DNS fallback ketika daftar domain yang cocok dari server DNS terpenuhi"
|
||||||
|
"enableParallelQuery" = "Aktifkan Kueri Paralel"
|
||||||
|
"enableParallelQueryDesc" = "Aktifkan kueri DNS paralel ke beberapa server untuk resolusi yang lebih cepat"
|
||||||
"strategy" = "Strategi Kueri"
|
"strategy" = "Strategi Kueri"
|
||||||
"strategyDesc" = "Strategi keseluruhan untuk menyelesaikan nama domain"
|
"strategyDesc" = "Strategi keseluruhan untuk menyelesaikan nama domain"
|
||||||
"add" = "Tambahkan Server"
|
"add" = "Tambahkan Server"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "フォールバックDNSクエリを無効にします"
|
"disableFallbackDesc" = "フォールバックDNSクエリを無効にします"
|
||||||
"disableFallbackIfMatch" = "一致した場合にフォールバックを無効にする"
|
"disableFallbackIfMatch" = "一致した場合にフォールバックを無効にする"
|
||||||
"disableFallbackIfMatchDesc" = "DNSサーバーの一致するドメインリストにヒットした場合、フォールバックDNSクエリを無効にします"
|
"disableFallbackIfMatchDesc" = "DNSサーバーの一致するドメインリストにヒットした場合、フォールバックDNSクエリを無効にします"
|
||||||
|
"enableParallelQuery" = "並列クエリを有効にする"
|
||||||
|
"enableParallelQueryDesc" = "複数のサーバーへの並列DNSクエリを有効にして、より高速な解決を実現"
|
||||||
"strategy" = "クエリ戦略"
|
"strategy" = "クエリ戦略"
|
||||||
"strategyDesc" = "ドメイン名解決の全体的な戦略"
|
"strategyDesc" = "ドメイン名解決の全体的な戦略"
|
||||||
"add" = "サーバー追加"
|
"add" = "サーバー追加"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Desativa consultas DNS de fallback"
|
"disableFallbackDesc" = "Desativa consultas DNS de fallback"
|
||||||
"disableFallbackIfMatch" = "Desativar Fallback Se Corresponder"
|
"disableFallbackIfMatch" = "Desativar Fallback Se Corresponder"
|
||||||
"disableFallbackIfMatchDesc" = "Desativa consultas DNS de fallback quando a lista de domínios correspondentes do servidor DNS é atingida"
|
"disableFallbackIfMatchDesc" = "Desativa consultas DNS de fallback quando a lista de domínios correspondentes do servidor DNS é atingida"
|
||||||
|
"enableParallelQuery" = "Habilitar Consulta Paralela"
|
||||||
|
"enableParallelQueryDesc" = "Habilitar consultas DNS paralelas para múltiplos servidores para resolução mais rápida"
|
||||||
"strategy" = "Estratégia de Consulta"
|
"strategy" = "Estratégia de Consulta"
|
||||||
"strategyDesc" = "Estratégia geral para resolver nomes de domínio"
|
"strategyDesc" = "Estratégia geral para resolver nomes de domínio"
|
||||||
"add" = "Adicionar Servidor"
|
"add" = "Adicionar Servidor"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Отключает резервные DNS-запросы"
|
"disableFallbackDesc" = "Отключает резервные DNS-запросы"
|
||||||
"disableFallbackIfMatch" = "Отключить резервный DNS при совпадении"
|
"disableFallbackIfMatch" = "Отключить резервный DNS при совпадении"
|
||||||
"disableFallbackIfMatchDesc" = "Отключает резервные DNS-запросы при совпадении списка доменов DNS-сервера"
|
"disableFallbackIfMatchDesc" = "Отключает резервные DNS-запросы при совпадении списка доменов DNS-сервера"
|
||||||
|
"enableParallelQuery" = "Включить параллельные запросы"
|
||||||
|
"enableParallelQueryDesc" = "Включить параллельные DNS-запросы к нескольким серверам для более быстрого разрешения"
|
||||||
"strategy" = "Стратегия запроса"
|
"strategy" = "Стратегия запроса"
|
||||||
"strategyDesc" = "Общая стратегия разрешения доменных имен"
|
"strategyDesc" = "Общая стратегия разрешения доменных имен"
|
||||||
"add" = "Создать DNS"
|
"add" = "Создать DNS"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Yedek DNS sorgularını devre dışı bırakır"
|
"disableFallbackDesc" = "Yedek DNS sorgularını devre dışı bırakır"
|
||||||
"disableFallbackIfMatch" = "Eşleşirse Yedeklemeyi Devre Dışı Bırak"
|
"disableFallbackIfMatch" = "Eşleşirse Yedeklemeyi Devre Dışı Bırak"
|
||||||
"disableFallbackIfMatchDesc" = "DNS sunucusunun eşleşen alan adı listesi vurulduğunda yedek DNS sorgularını devre dışı bırakır"
|
"disableFallbackIfMatchDesc" = "DNS sunucusunun eşleşen alan adı listesi vurulduğunda yedek DNS sorgularını devre dışı bırakır"
|
||||||
|
"enableParallelQuery" = "Paralel Sorguyu Etkinleştir"
|
||||||
|
"enableParallelQueryDesc" = "Daha hızlı çözümleme için birden fazla sunucuya paralel DNS sorgularını etkinleştir"
|
||||||
"strategy" = "Sorgu Stratejisi"
|
"strategy" = "Sorgu Stratejisi"
|
||||||
"strategyDesc" = "Alan adlarını çözmek için genel strateji"
|
"strategyDesc" = "Alan adlarını çözmek için genel strateji"
|
||||||
"add" = "Sunucu Ekle"
|
"add" = "Sunucu Ekle"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Вимкнути резервні DNS-запити"
|
"disableFallbackDesc" = "Вимкнути резервні DNS-запити"
|
||||||
"disableFallbackIfMatch" = "Вимкнути резервний DNS при збігу"
|
"disableFallbackIfMatch" = "Вимкнути резервний DNS при збігу"
|
||||||
"disableFallbackIfMatchDesc" = "Вимкнути резервні DNS-запити при збігу списку доменів DNS-сервера"
|
"disableFallbackIfMatchDesc" = "Вимкнути резервні DNS-запити при збігу списку доменів DNS-сервера"
|
||||||
|
"enableParallelQuery" = "Увімкнути паралельні запити"
|
||||||
|
"enableParallelQueryDesc" = "Увімкнути паралельні DNS-запити до кількох серверів для швидшого вирішення"
|
||||||
"strategy" = "Стратегія запиту"
|
"strategy" = "Стратегія запиту"
|
||||||
"strategyDesc" = "Загальна стратегія вирішення доменних імен"
|
"strategyDesc" = "Загальна стратегія вирішення доменних імен"
|
||||||
"add" = "Додати сервер"
|
"add" = "Додати сервер"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "Tắt các truy vấn DNS Fallback"
|
"disableFallbackDesc" = "Tắt các truy vấn DNS Fallback"
|
||||||
"disableFallbackIfMatch" = "Tắt Fallback Nếu Khớp"
|
"disableFallbackIfMatch" = "Tắt Fallback Nếu Khớp"
|
||||||
"disableFallbackIfMatchDesc" = "Tắt các truy vấn DNS Fallback khi danh sách tên miền khớp của máy chủ DNS được kích hoạt"
|
"disableFallbackIfMatchDesc" = "Tắt các truy vấn DNS Fallback khi danh sách tên miền khớp của máy chủ DNS được kích hoạt"
|
||||||
|
"enableParallelQuery" = "Bật Truy vấn Song song"
|
||||||
|
"enableParallelQueryDesc" = "Bật truy vấn DNS song song đến nhiều máy chủ để phân giải nhanh hơn"
|
||||||
"strategy" = "Chiến lược truy vấn"
|
"strategy" = "Chiến lược truy vấn"
|
||||||
"strategyDesc" = "Chiến lược tổng thể để phân giải tên miền"
|
"strategyDesc" = "Chiến lược tổng thể để phân giải tên miền"
|
||||||
"add" = "Thêm máy chủ"
|
"add" = "Thêm máy chủ"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "禁用回退DNS查询"
|
"disableFallbackDesc" = "禁用回退DNS查询"
|
||||||
"disableFallbackIfMatch" = "匹配时禁用回退"
|
"disableFallbackIfMatch" = "匹配时禁用回退"
|
||||||
"disableFallbackIfMatchDesc" = "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询"
|
"disableFallbackIfMatchDesc" = "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询"
|
||||||
|
"enableParallelQuery" = "启用并行查询"
|
||||||
|
"enableParallelQueryDesc" = "启用并行DNS查询到多个服务器以实现更快的解析"
|
||||||
"strategy" = "查询策略"
|
"strategy" = "查询策略"
|
||||||
"strategyDesc" = "解析域名的总体策略"
|
"strategyDesc" = "解析域名的总体策略"
|
||||||
"add" = "添加服务器"
|
"add" = "添加服务器"
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
"disableFallbackDesc" = "禁用回退DNS查詢"
|
"disableFallbackDesc" = "禁用回退DNS查詢"
|
||||||
"disableFallbackIfMatch" = "匹配時禁用回退"
|
"disableFallbackIfMatch" = "匹配時禁用回退"
|
||||||
"disableFallbackIfMatchDesc" = "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢"
|
"disableFallbackIfMatchDesc" = "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢"
|
||||||
|
"enableParallelQuery" = "啟用並行查詢"
|
||||||
|
"enableParallelQueryDesc" = "啟用並行DNS查詢到多個伺服器以實現更快的解析"
|
||||||
"strategy" = "查詢策略"
|
"strategy" = "查詢策略"
|
||||||
"strategyDesc" = "解析域名的總體策略"
|
"strategyDesc" = "解析域名的總體策略"
|
||||||
"add" = "新增伺服器"
|
"add" = "新增伺服器"
|
||||||
|
|
|
||||||
22
web/web.go
22
web/web.go
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/network"
|
"github.com/mhsanaei/3x-ui/v2/web/network"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
|
|
@ -98,11 +99,14 @@ type Server struct {
|
||||||
index *controller.IndexController
|
index *controller.IndexController
|
||||||
panel *controller.XUIController
|
panel *controller.XUIController
|
||||||
api *controller.APIController
|
api *controller.APIController
|
||||||
|
ws *controller.WebSocketController
|
||||||
|
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
tgbotService service.Tgbot
|
tgbotService service.Tgbot
|
||||||
|
|
||||||
|
wsHub *websocket.Hub
|
||||||
|
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
@ -266,6 +270,15 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
s.panel = controller.NewXUIController(g)
|
s.panel = controller.NewXUIController(g)
|
||||||
s.api = controller.NewAPIController(g)
|
s.api = controller.NewAPIController(g)
|
||||||
|
|
||||||
|
// Initialize WebSocket hub
|
||||||
|
s.wsHub = websocket.NewHub()
|
||||||
|
go s.wsHub.Run()
|
||||||
|
|
||||||
|
// Initialize WebSocket controller
|
||||||
|
s.ws = controller.NewWebSocketController(s.wsHub)
|
||||||
|
// Register WebSocket route with basePath (g already has basePath prefix)
|
||||||
|
g.GET("/ws", s.ws.HandleWebSocket)
|
||||||
|
|
||||||
// Chrome DevTools endpoint for debugging web apps
|
// Chrome DevTools endpoint for debugging web apps
|
||||||
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
|
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{})
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
|
@ -448,6 +461,10 @@ func (s *Server) Stop() error {
|
||||||
if s.tgbotService.IsRunning() {
|
if s.tgbotService.IsRunning() {
|
||||||
s.tgbotService.Stop()
|
s.tgbotService.Stop()
|
||||||
}
|
}
|
||||||
|
// Gracefully stop WebSocket hub
|
||||||
|
if s.wsHub != nil {
|
||||||
|
s.wsHub.Stop()
|
||||||
|
}
|
||||||
var err1 error
|
var err1 error
|
||||||
var err2 error
|
var err2 error
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
|
|
@ -468,3 +485,8 @@ func (s *Server) GetCtx() context.Context {
|
||||||
func (s *Server) GetCron() *cron.Cron {
|
func (s *Server) GetCron() *cron.Cron {
|
||||||
return s.cron
|
return s.cron
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWSHub returns the WebSocket hub instance.
|
||||||
|
func (s *Server) GetWSHub() interface{} {
|
||||||
|
return s.wsHub
|
||||||
|
}
|
||||||
|
|
|
||||||
379
web/websocket/hub.go
Normal file
379
web/websocket/hub.go
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
// Package websocket provides WebSocket hub for real-time updates and notifications.
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageType represents the type of WebSocket message
|
||||||
|
type MessageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageTypeStatus MessageType = "status" // Server status update
|
||||||
|
MessageTypeTraffic MessageType = "traffic" // Traffic statistics update
|
||||||
|
MessageTypeInbounds MessageType = "inbounds" // Inbounds list update
|
||||||
|
MessageTypeNotification MessageType = "notification" // System notification
|
||||||
|
MessageTypeXrayState MessageType = "xray_state" // Xray state change
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a WebSocket message
|
||||||
|
type Message struct {
|
||||||
|
Type MessageType `json:"type"`
|
||||||
|
Payload interface{} `json:"payload"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a WebSocket client connection
|
||||||
|
type Client struct {
|
||||||
|
ID string
|
||||||
|
Send chan []byte
|
||||||
|
Hub *Hub
|
||||||
|
Topics map[MessageType]bool // Subscribed topics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages to them
|
||||||
|
type Hub struct {
|
||||||
|
// Registered clients
|
||||||
|
clients map[*Client]bool
|
||||||
|
|
||||||
|
// Inbound messages from clients
|
||||||
|
broadcast chan []byte
|
||||||
|
|
||||||
|
// Register requests from clients
|
||||||
|
register chan *Client
|
||||||
|
|
||||||
|
// Unregister requests from clients
|
||||||
|
unregister chan *Client
|
||||||
|
|
||||||
|
// Mutex for thread-safe operations
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// Context for graceful shutdown
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
// Worker pool for parallel broadcasting
|
||||||
|
workerPoolSize int
|
||||||
|
broadcastWg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new WebSocket hub
|
||||||
|
func NewHub() *Hub {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Calculate optimal worker pool size (CPU cores * 2, but max 100)
|
||||||
|
workerPoolSize := runtime.NumCPU() * 2
|
||||||
|
if workerPoolSize > 100 {
|
||||||
|
workerPoolSize = 100
|
||||||
|
}
|
||||||
|
if workerPoolSize < 10 {
|
||||||
|
workerPoolSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte, 2048), // Increased from 256 to 2048 for high load
|
||||||
|
register: make(chan *Client, 100), // Buffered channel for fast registration
|
||||||
|
unregister: make(chan *Client, 100), // Buffered channel for fast unregistration
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
workerPoolSize: workerPoolSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub's main loop
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("WebSocket hub panic recovered:", r)
|
||||||
|
// Restart the hub loop
|
||||||
|
go h.Run()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-h.ctx.Done():
|
||||||
|
// Graceful shutdown: close all clients
|
||||||
|
h.mu.Lock()
|
||||||
|
for client := range h.clients {
|
||||||
|
// Safely close channel (avoid double close panic)
|
||||||
|
select {
|
||||||
|
case _, stillOpen := <-client.Send:
|
||||||
|
if stillOpen {
|
||||||
|
close(client.Send)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
close(client.Send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.clients = make(map[*Client]bool)
|
||||||
|
h.mu.Unlock()
|
||||||
|
// Wait for all broadcast workers to finish
|
||||||
|
h.broadcastWg.Wait()
|
||||||
|
logger.Info("WebSocket hub stopped gracefully")
|
||||||
|
return
|
||||||
|
|
||||||
|
case client := <-h.register:
|
||||||
|
if client == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client] = true
|
||||||
|
count := len(h.clients)
|
||||||
|
h.mu.Unlock()
|
||||||
|
logger.Infof("WebSocket client connected: %s (total: %d)", client.ID, count)
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
if client == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
// Safely close channel (avoid double close panic)
|
||||||
|
// Check if channel is already closed by trying to read from it
|
||||||
|
select {
|
||||||
|
case _, stillOpen := <-client.Send:
|
||||||
|
if stillOpen {
|
||||||
|
// Channel was open and had data, now it's empty, safe to close
|
||||||
|
close(client.Send)
|
||||||
|
}
|
||||||
|
// If stillOpen is false, channel was already closed, do nothing
|
||||||
|
default:
|
||||||
|
// Channel is empty and open, safe to close
|
||||||
|
close(client.Send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count := len(h.clients)
|
||||||
|
h.mu.Unlock()
|
||||||
|
logger.Infof("WebSocket client disconnected: %s (total: %d)", client.ID, count)
|
||||||
|
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
if message == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Optimization: quickly copy client list and release lock
|
||||||
|
h.mu.RLock()
|
||||||
|
clientCount := len(h.clients)
|
||||||
|
if clientCount == 0 {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate memory for client list
|
||||||
|
clients := make([]*Client, 0, clientCount)
|
||||||
|
for client := range h.clients {
|
||||||
|
clients = append(clients, client)
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
// Parallel broadcast using worker pool
|
||||||
|
h.broadcastParallel(clients, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastParallel sends message to all clients in parallel for maximum performance
|
||||||
|
func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For small number of clients, use simple parallel sending
|
||||||
|
if len(clients) < h.workerPoolSize {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, client := range clients {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c *Client) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Channel may be closed, safely ignore
|
||||||
|
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", c.ID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case c.Send <- message:
|
||||||
|
default:
|
||||||
|
// Client's send buffer is full, disconnect
|
||||||
|
logger.Debugf("WebSocket client %s send buffer full, disconnecting", c.ID)
|
||||||
|
h.Unregister(c)
|
||||||
|
}
|
||||||
|
}(client)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For large number of clients, use worker pool for optimal performance
|
||||||
|
clientChan := make(chan *Client, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
clientChan <- client
|
||||||
|
}
|
||||||
|
close(clientChan)
|
||||||
|
|
||||||
|
// Start workers for parallel processing
|
||||||
|
h.broadcastWg.Add(h.workerPoolSize)
|
||||||
|
for i := 0; i < h.workerPoolSize; i++ {
|
||||||
|
go func() {
|
||||||
|
defer h.broadcastWg.Done()
|
||||||
|
for client := range clientChan {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Channel may be closed, safely ignore
|
||||||
|
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", client.ID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case client.Send <- message:
|
||||||
|
default:
|
||||||
|
// Client's send buffer is full, disconnect
|
||||||
|
logger.Debugf("WebSocket client %s send buffer full, disconnecting", client.ID)
|
||||||
|
h.Unregister(client)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all workers to finish
|
||||||
|
h.broadcastWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends a message to all connected clients
|
||||||
|
func (h *Hub) Broadcast(messageType MessageType, payload interface{}) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload == nil {
|
||||||
|
logger.Warning("Attempted to broadcast nil payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
Type: messageType,
|
||||||
|
Payload: payload,
|
||||||
|
Time: getCurrentTimestamp(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to marshal WebSocket message:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit message size to prevent memory issues
|
||||||
|
const maxMessageSize = 1024 * 1024 // 1MB
|
||||||
|
if len(data) > maxMessageSize {
|
||||||
|
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking send with timeout to prevent delays
|
||||||
|
select {
|
||||||
|
case h.broadcast <- data:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
logger.Warning("WebSocket broadcast channel is full, dropping message")
|
||||||
|
case <-h.ctx.Done():
|
||||||
|
// Hub is shutting down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastToTopic sends a message only to clients subscribed to the specific topic
|
||||||
|
func (h *Hub) BroadcastToTopic(messageType MessageType, payload interface{}) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload == nil {
|
||||||
|
logger.Warning("Attempted to broadcast nil payload to topic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
Type: messageType,
|
||||||
|
Payload: payload,
|
||||||
|
Time: getCurrentTimestamp(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to marshal WebSocket message:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit message size to prevent memory issues
|
||||||
|
const maxMessageSize = 1024 * 1024 // 1MB
|
||||||
|
if len(data) > maxMessageSize {
|
||||||
|
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
// Filter clients by topics and quickly release lock
|
||||||
|
subscribedClients := make([]*Client, 0)
|
||||||
|
for client := range h.clients {
|
||||||
|
if len(client.Topics) == 0 || client.Topics[messageType] {
|
||||||
|
subscribedClients = append(subscribedClients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
// Parallel send to subscribed clients
|
||||||
|
if len(subscribedClients) > 0 {
|
||||||
|
h.broadcastParallel(subscribedClients, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientCount returns the number of connected clients
|
||||||
|
func (h *Hub) GetClientCount() int {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a new client with the hub
|
||||||
|
func (h *Hub) Register(client *Client) {
|
||||||
|
if h == nil || client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case h.register <- client:
|
||||||
|
case <-h.ctx.Done():
|
||||||
|
// Hub is shutting down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister unregisters a client from the hub
|
||||||
|
func (h *Hub) Unregister(client *Client) {
|
||||||
|
if h == nil || client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case h.unregister <- client:
|
||||||
|
case <-h.ctx.Done():
|
||||||
|
// Hub is shutting down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the hub and closes all connections
|
||||||
|
func (h *Hub) Stop() {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cancel != nil {
|
||||||
|
h.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentTimestamp returns current Unix timestamp in milliseconds
|
||||||
|
func getCurrentTimestamp() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
74
web/websocket/notifier.go
Normal file
74
web/websocket/notifier.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Package websocket provides WebSocket hub for real-time updates and notifications.
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHub returns the global WebSocket hub instance
|
||||||
|
func GetHub() *Hub {
|
||||||
|
webServer := global.GetWebServer()
|
||||||
|
if webServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hub := webServer.GetWSHub()
|
||||||
|
if hub == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
wsHub, ok := hub.(*Hub)
|
||||||
|
if !ok {
|
||||||
|
logger.Warning("WebSocket hub type assertion failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return wsHub
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastStatus broadcasts server status update to all connected clients
|
||||||
|
func BroadcastStatus(status interface{}) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
hub.Broadcast(MessageTypeStatus, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastTraffic broadcasts traffic statistics update to all connected clients
|
||||||
|
func BroadcastTraffic(traffic interface{}) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
hub.Broadcast(MessageTypeTraffic, traffic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastInbounds broadcasts inbounds list update to all connected clients
|
||||||
|
func BroadcastInbounds(inbounds interface{}) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
hub.Broadcast(MessageTypeInbounds, inbounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastNotification broadcasts a system notification to all connected clients
|
||||||
|
func BroadcastNotification(title, message, level string) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
notification := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"message": message,
|
||||||
|
"level": level, // info, warning, error, success
|
||||||
|
}
|
||||||
|
hub.Broadcast(MessageTypeNotification, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastXrayState broadcasts Xray state change to all connected clients
|
||||||
|
func BroadcastXrayState(state string, errorMsg string) {
|
||||||
|
hub := GetHub()
|
||||||
|
if hub != nil {
|
||||||
|
stateUpdate := map[string]string{
|
||||||
|
"state": state,
|
||||||
|
"errorMsg": errorMsg,
|
||||||
|
}
|
||||||
|
hub.Broadcast(MessageTypeXrayState, stateUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
xray/api.go
27
xray/api.go
|
|
@ -110,10 +110,33 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
||||||
Id: user["id"].(string),
|
Id: user["id"].(string),
|
||||||
})
|
})
|
||||||
case "vless":
|
case "vless":
|
||||||
account = serial.ToTypedMessage(&vless.Account{
|
vlessAccount := &vless.Account{
|
||||||
Id: user["id"].(string),
|
Id: user["id"].(string),
|
||||||
Flow: user["flow"].(string),
|
Flow: user["flow"].(string),
|
||||||
})
|
}
|
||||||
|
// Add testseed if provided
|
||||||
|
if testseedVal, ok := user["testseed"]; ok {
|
||||||
|
if testseedArr, ok := testseedVal.([]interface{}); ok && len(testseedArr) >= 4 {
|
||||||
|
testseed := make([]uint32, len(testseedArr))
|
||||||
|
for i, v := range testseedArr {
|
||||||
|
if num, ok := v.(float64); ok {
|
||||||
|
testseed[i] = uint32(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vlessAccount.Testseed = testseed
|
||||||
|
} else if testseedArr, ok := testseedVal.([]uint32); ok && len(testseedArr) >= 4 {
|
||||||
|
vlessAccount.Testseed = testseedArr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add testpre if provided (for outbound, but can be in user for compatibility)
|
||||||
|
if testpreVal, ok := user["testpre"]; ok {
|
||||||
|
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
|
||||||
|
vlessAccount.Testpre = uint32(testpre)
|
||||||
|
} else if testpre, ok := testpreVal.(uint32); ok && testpre > 0 {
|
||||||
|
vlessAccount.Testpre = testpre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
account = serial.ToTypedMessage(vlessAccount)
|
||||||
case "trojan":
|
case "trojan":
|
||||||
account = serial.ToTypedMessage(&trojan.Account{
|
account = serial.ToTypedMessage(&trojan.Account{
|
||||||
Password: user["password"].(string),
|
Password: user["password"].(string),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue