From 9a2c1c6b43471316f5cdd15b995f1e47b2f07d52 Mon Sep 17 00:00:00 2001 From: Ilya Kryuchkov <42733472+kr-ilya@users.noreply.github.com> Date: Sat, 3 Jan 2026 05:05:10 +0300 Subject: [PATCH 1/8] Fix: panel redirecting to old port after restart (#3594) * Fix panel redirect logic * Fix panel redirect logic * remove duplicate code * Cr fixes --- web/html/settings.html | 76 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/web/html/settings.html b/web/html/settings.html index e664e6ec..21294da7 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -120,6 +120,10 @@ oldAllSetting: new AllSetting(), allSetting: new AllSetting(), saveBtnDisable: true, + entryHost: null, + entryPort: null, + entryProtocol: null, + entryIsIP: false, user: {}, lang: LanguageManager.getLanguage(), inboundOptions: [], @@ -233,6 +237,31 @@ loading(spinning = true) { this.loadingStates.spinning = spinning; }, + _isIp(h) { + if (typeof h !== "string") return false; + + // IPv4: four dot-separated octets 0-255 + const v4 = h.split("."); + if ( + v4.length === 4 && + v4.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255) + ) return true; + + // IPv6: hex groups, optional single :: compression + if (!h.includes(":") || h.includes(":::")) return false; + const parts = h.split("::"); + if (parts.length > 2) return false; + + const splitGroups = s => (s ? s.split(":").filter(Boolean) : []); + const head = splitGroups(parts[0]); + const tail = splitGroups(parts[1]); + const validGroup = seg => /^[0-9a-fA-F]{1,4}$/.test(seg); + + if (![...head, ...tail].every(validGroup)) return false; + const groups = head.length + tail.length; + + return parts.length === 2 ? groups < 8 : groups === 8; + }, async getAllSetting() { const msg = await HttpUtil.post("/panel/setting/all"); @@ -307,16 +336,41 @@ this.loading(true); const msg = await HttpUtil.post("/panel/setting/restartPanel"); this.loading(false); - if (msg.success) { - this.loading(true); - await PromiseUtil.sleep(5000); - var { webCertFile, webKeyFile, webDomain: host, webPort: port, webBasePath: base } = this.allSetting; - if (host == this.oldAllSetting.webDomain) host = null; - if (port == this.oldAllSetting.webPort) port = null; - const isTLS = webCertFile !== "" || webKeyFile !== ""; - const url = URLBuilder.buildURL({ host, port, isTLS, base, path: "panel/settings" }); - window.location.replace(url); + if (!msg.success) return; + + this.loading(true); + await PromiseUtil.sleep(5000); + + const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = this.allSetting; + const newProtocol = (webCertFile || webKeyFile) ? "https:" : "http:"; + + let base = webBasePath ? webBasePath.replace(/^\//, "") : ""; + if (base && !base.endsWith("/")) base += "/"; + + if (!this.entryIsIP) { + const url = new URL(window.location.href); + url.pathname = `/${base}panel/settings`; + url.protocol = newProtocol; + window.location.replace(url.toString()); + return; } + + let finalHost = this.entryHost; + let finalPort = this.entryPort || ""; + + if (webDomain && this._isIp(webDomain)) { + finalHost = webDomain; + } + + if (webPort && Number(webPort) !== Number(this.entryPort)) { + finalPort = String(webPort); + } + + const url = new URL(`${newProtocol}//${finalHost}`); + if (finalPort) url.port = finalPort; + url.pathname = `/${base}panel/settings`; + + window.location.replace(url.toString()); }, toggleTwoFactor(newValue) { if (newValue) { @@ -568,6 +622,10 @@ } }, async mounted() { + this.entryHost = window.location.hostname; + this.entryPort = window.location.port; + this.entryProtocol = window.location.protocol; + this.entryIsIP = this._isIp(this.entryHost); await this.getAllSetting(); await this.loadInboundTags(); while (true) { From 1393f981bc313118b3ffde0b469cc07abdeb24c8 Mon Sep 17 00:00:00 2001 From: weekend sorrow Date: Sat, 3 Jan 2026 09:13:00 +0700 Subject: [PATCH 2/8] feat: Add etckeeper compatibility (#3602) --- install.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/install.sh b/install.sh index 480522ed..1009f047 100644 --- a/install.sh +++ b/install.sh @@ -643,6 +643,20 @@ install_x-ui() { chmod +x /usr/bin/x-ui mkdir -p /var/log/x-ui config_after_install + + # Etckeeper compatibility + if [ -d "/etc/.git" ]; then + if [ -f "/etc/.gitignore" ]; then + if ! grep -q "x-ui/x-ui.db" "/etc/.gitignore"; then + echo "" >> "/etc/.gitignore" + echo "x-ui/x-ui.db" >> "/etc/.gitignore" + echo -e "${green}Added x-ui.db to /etc/.gitignore for etckeeper${plain}" + fi + else + echo "x-ui/x-ui.db" > "/etc/.gitignore" + echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}" + fi + fi if [[ $release == "alpine" ]]; then wget --inet4-only -O /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc From 3287fa4d80672f9f5fcf90e6059a190939445297 Mon Sep 17 00:00:00 2001 From: Mikhail Grigorev Date: Sat, 3 Jan 2026 07:37:48 +0500 Subject: [PATCH 3/8] Added EnvironmentFile to systemd unit (#3606) * Added EnvironmentFile to systemd unit * Added support for older releases * Remove ARGS * Fixed copy unit * Fixed unit filename * Update update.sh --- .github/workflows/release.yml | 6 ++++-- install.sh | 13 ++++++++++++- update.sh | 19 +++++++++++++++++-- x-ui.service => x-ui.service.debian | 1 + x-ui.service.rhel | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) rename x-ui.service => x-ui.service.debian (88%) create mode 100644 x-ui.service.rhel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c2a6989..563e1113 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,8 @@ on: - '**.go' - 'go.mod' - 'go.sum' - - 'x-ui.service' + - 'x-ui.service.debian' + - 'x-ui.service.rhel' jobs: build: @@ -78,7 +79,8 @@ jobs: mkdir x-ui cp xui-release x-ui/ - cp x-ui.service x-ui/ + cp x-ui.service.debian x-ui/ + cp x-ui.service.rhel x-ui/ cp x-ui.sh x-ui/ mv x-ui/xui-release x-ui/x-ui mkdir x-ui/bin diff --git a/install.sh b/install.sh index 1009f047..d3f15409 100644 --- a/install.sh +++ b/install.sh @@ -668,7 +668,18 @@ install_x-ui() { rc-update add x-ui rc-service x-ui start else - cp -f x-ui.service /etc/systemd/system/ + if [ -f "x-ui.service" ]; then + cp -f x-ui.service /etc/systemd/system/ + else + case "${release}" in + ubuntu | debian | armbian) + cp -f x-ui.service.debian /etc/systemd/system/x-ui.service + ;; + *) + cp -f x-ui.service.rhel /etc/systemd/system/x-ui.service + ;; + esac + fi systemctl daemon-reload systemctl enable x-ui systemctl start x-ui diff --git a/update.sh b/update.sh index 6da238b1..021167c1 100755 --- a/update.sh +++ b/update.sh @@ -615,6 +615,8 @@ update_x-ui() { echo -e "${green}Removing old x-ui version...${plain}" rm /usr/bin/x-ui -f >/dev/null 2>&1 rm /usr/local/x-ui/x-ui.service -f >/dev/null 2>&1 + rm /usr/local/x-ui/x-ui.service.debian -f >/dev/null 2>&1 + rm /usr/local/x-ui/x-ui.service.rhel -f >/dev/null 2>&1 rm /usr/local/x-ui/x-ui -f >/dev/null 2>&1 rm /usr/local/x-ui/x-ui.sh -f >/dev/null 2>&1 echo -e "${green}Removing old xray version...${plain}" @@ -677,8 +679,21 @@ update_x-ui() { rc-update add x-ui >/dev/null 2>&1 rc-service x-ui start >/dev/null 2>&1 else - echo -e "${green}Installing systemd unit...${plain}" - cp -f x-ui.service /etc/systemd/system/ >/dev/null 2>&1 + if [ -f "x-ui.service" ]; then + echo -e "${green}Installing systemd unit...${plain}" + cp -f x-ui.service /etc/systemd/system/ >/dev/null 2>&1 + else + case "${release}" in + ubuntu | debian | armbian) + echo -e "${green}Installing debian-like systemd unit...${plain}" + cp -f x-ui.service.debian /etc/systemd/system/x-ui.service >/dev/null 2>&1 + ;; + *) + echo -e "${green}Installing rhel-like systemd unit...${plain}" + cp -f x-ui.service.rhel /etc/systemd/system/x-ui.service >/dev/null 2>&1 + ;; + esac + fi chown root:root /etc/systemd/system/x-ui.service >/dev/null 2>&1 systemctl daemon-reload >/dev/null 2>&1 systemctl enable x-ui >/dev/null 2>&1 diff --git a/x-ui.service b/x-ui.service.debian similarity index 88% rename from x-ui.service rename to x-ui.service.debian index e911ef56..037f88bb 100644 --- a/x-ui.service +++ b/x-ui.service.debian @@ -4,6 +4,7 @@ After=network.target Wants=network.target [Service] +EnvironmentFile=-/etc/default/x-ui Environment="XRAY_VMESS_AEAD_FORCED=false" Type=simple WorkingDirectory=/usr/local/x-ui/ diff --git a/x-ui.service.rhel b/x-ui.service.rhel new file mode 100644 index 00000000..30652d52 --- /dev/null +++ b/x-ui.service.rhel @@ -0,0 +1,16 @@ +[Unit] +Description=x-ui Service +After=network.target +Wants=network.target + +[Service] +EnvironmentFile=-/etc/sysconfig/x-ui +Environment="XRAY_VMESS_AEAD_FORCED=false" +Type=simple +WorkingDirectory=/usr/local/x-ui/ +ExecStart=/usr/local/x-ui/x-ui +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target From 692a73788aed32a6247a9bebbf3600cf665e4b0f Mon Sep 17 00:00:00 2001 From: Nebulosa <85841412+nebulosa2007@users.noreply.github.com> Date: Sat, 3 Jan 2026 05:57:19 +0300 Subject: [PATCH 4/8] Set variables for packaging purposes (#3600) * Set Variables for settings --- install.sh | 47 ++++++++++++++++++---------------- update.sh | 75 ++++++++++++++++++++++++++++-------------------------- x-ui.sh | 62 ++++++++++++++++++++++---------------------- 3 files changed, 96 insertions(+), 88 deletions(-) diff --git a/install.sh b/install.sh index d3f15409..0c131377 100644 --- a/install.sh +++ b/install.sh @@ -8,6 +8,9 @@ plain='\033[0m' cur_dir=$(pwd) +xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}" +xui_service="${XUI_SERVICE:=/etc/systemd/system}" + # check root [[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1 @@ -158,7 +161,7 @@ setup_ssl_certificate() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1 + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1 echo -e "${green}SSL certificate installed and configured successfully!${plain}" return 0 else @@ -215,15 +218,15 @@ EOF fi chmod 755 ${certDir}/* 2>/dev/null - /usr/local/x-ui/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 + ${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 echo -e "${yellow}Self-signed certificate configured. Browsers will show a warning.${plain}" return 0 } # Comprehensive manual SSL certificate issuance via acme.sh ssl_cert_issue() { - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]') # check for acme.sh first if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then @@ -366,7 +369,7 @@ ssl_cert_issue() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" echo -e "${green}Certificate paths set for the panel${plain}" echo -e "${green}Certificate File: $webCertFile${plain}" echo -e "${green}Private Key File: $webKeyFile${plain}" @@ -451,11 +454,11 @@ prompt_and_setup_ssl() { } config_after_install() { - local existing_hasDefaultCredential=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') # Properly detect empty cert by checking if cert: line exists and has content after it - local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') local URL_lists=( "https://api4.ipify.org" "https://ipv4.icanhazip.com" @@ -487,7 +490,7 @@ config_after_install() { echo -e "${yellow}Generated random port: ${config_port}${plain}" fi - /usr/local/x-ui/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" + ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" echo "" echo -e "${green}═══════════════════════════════════════════${plain}" @@ -515,7 +518,7 @@ config_after_install() { else local config_webBasePath=$(gen_random_string 18) echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" - /usr/local/x-ui/x-ui setting -webBasePath "${config_webBasePath}" + ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}" # If the panel is already installed but no certificate is configured, prompt for SSL now @@ -539,7 +542,7 @@ config_after_install() { local config_password=$(gen_random_string 10) echo -e "${yellow}Default credentials detected. Security update required...${plain}" - /usr/local/x-ui/x-ui setting -username "${config_username}" -password "${config_password}" + ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" echo -e "Generated new random login credentials:" echo -e "###############################################" echo -e "${green}Username: ${config_username}${plain}" @@ -551,7 +554,7 @@ config_after_install() { # Existing install: if no cert configured, prompt user to set domain or self-signed # Properly detect empty cert by checking if cert: line exists and has content after it - existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') if [[ -z "$existing_cert" ]]; then echo "" echo -e "${green}═══════════════════════════════════════════${plain}" @@ -566,11 +569,11 @@ config_after_install() { fi fi - /usr/local/x-ui/x-ui migrate + ${xui_folder}/x-ui migrate } install_x-ui() { - cd /usr/local/ + cd ${xui_folder%/x-ui}/ # Download resources if [ $# == 0 ]; then @@ -584,7 +587,7 @@ install_x-ui() { fi fi echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." - wget --inet4-only -N -O /usr/local/x-ui-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz + wget --inet4-only -N -O ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz if [[ $? -ne 0 ]]; then echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}" exit 1 @@ -601,7 +604,7 @@ install_x-ui() { url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz" echo -e "Beginning to install x-ui $1" - wget --inet4-only -N -O /usr/local/x-ui-linux-$(arch).tar.gz ${url} + wget --inet4-only -N -O ${xui_folder}-linux-$(arch).tar.gz ${url} if [[ $? -ne 0 ]]; then echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}" exit 1 @@ -614,13 +617,13 @@ install_x-ui() { fi # Stop x-ui service and remove old resources - if [[ -e /usr/local/x-ui/ ]]; then + if [[ -e ${xui_folder}/ ]]; then if [[ $release == "alpine" ]]; then rc-service x-ui stop else systemctl stop x-ui fi - rm /usr/local/x-ui/ -rf + rm ${xui_folder}/ -rf fi # Extract resources and set permissions @@ -669,14 +672,14 @@ install_x-ui() { rc-service x-ui start else if [ -f "x-ui.service" ]; then - cp -f x-ui.service /etc/systemd/system/ + cp -f x-ui.service ${xui_service}/ else case "${release}" in ubuntu | debian | armbian) - cp -f x-ui.service.debian /etc/systemd/system/x-ui.service + cp -f x-ui.service.debian ${xui_service}/x-ui.service ;; *) - cp -f x-ui.service.rhel /etc/systemd/system/x-ui.service + cp -f x-ui.service.rhel ${xui_service}/x-ui.service ;; esac fi diff --git a/update.sh b/update.sh index 021167c1..cc3348ad 100755 --- a/update.sh +++ b/update.sh @@ -6,6 +6,9 @@ blue='\033[0;34m' yellow='\033[0;33m' plain='\033[0m' +xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}" +xui_service="${XUI_SERVICE:=/etc/systemd/system}" + # Don't edit this config b_source="${BASH_SOURCE[0]}" while [ -h "$b_source" ]; do @@ -190,7 +193,7 @@ setup_ssl_certificate() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1 + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1 echo -e "${green}SSL certificate installed and configured successfully!${plain}" return 0 else @@ -246,14 +249,14 @@ EOF fi chmod 755 ${certDir}/* 2>/dev/null - /usr/local/x-ui/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 + ${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 echo -e "${yellow}Self-signed certificate configured. Browsers will show a warning.${plain}" return 0 } # Comprehensive manual SSL certificate issuance via acme.sh ssl_cert_issue() { - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]') # check for acme.sh first if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then @@ -396,7 +399,7 @@ ssl_cert_issue() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" echo -e "${green}Certificate paths set for the panel${plain}" echo -e "${green}Certificate File: $webCertFile${plain}" echo -e "${green}Private Key File: $webKeyFile${plain}" @@ -485,13 +488,13 @@ prompt_and_setup_ssl() { config_after_update() { echo -e "${yellow}x-ui settings:${plain}" - /usr/local/x-ui/x-ui setting -show true - /usr/local/x-ui/x-ui migrate + ${xui_folder}/x-ui setting -show true + ${xui_folder}/x-ui migrate # Properly detect empty cert by checking if cert: line exists and has content after it - local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true 2>/dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') + local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2>/dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') # Get server IP local URL_lists=( @@ -514,7 +517,7 @@ config_after_update() { if [[ ${#existing_webBasePath} -lt 4 ]]; then echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" local config_webBasePath=$(gen_random_string 18) - /usr/local/x-ui/x-ui setting -webBasePath "${config_webBasePath}" + ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" existing_webBasePath="${config_webBasePath}" echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}" fi @@ -559,10 +562,10 @@ config_after_update() { } update_x-ui() { - cd /usr/local/ + cd ${xui_folder%/x-ui}/ - if [ -f "/usr/local/x-ui/x-ui" ]; then - current_xui_version=$(/usr/local/x-ui/x-ui -v) + if [ -f "${xui_folder}/x-ui" ]; then + current_xui_version=$(${xui_folder}/x-ui -v) echo -e "${green}Current x-ui version: ${current_xui_version}${plain}" else _fail "ERROR: Current x-ui version: unknown" @@ -579,16 +582,16 @@ update_x-ui() { fi fi echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." - ${wget_bin} -N -O /usr/local/x-ui-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null + ${wget_bin} -N -O ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null if [[ $? -ne 0 ]]; then echo -e "${yellow}Trying to fetch version with IPv4...${plain}" - ${wget_bin} --inet4-only -N -O /usr/local/x-ui-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null + ${wget_bin} --inet4-only -N -O ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null if [[ $? -ne 0 ]]; then _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub" fi fi - if [[ -e /usr/local/x-ui/ ]]; then + if [[ -e ${xui_folder}/ ]]; then echo -e "${green}Stopping x-ui...${plain}" if [[ $release == "alpine" ]]; then if [ -f "/etc/init.d/x-ui" ]; then @@ -601,11 +604,11 @@ update_x-ui() { _fail "ERROR: x-ui service unit not installed." fi else - if [ -f "/etc/systemd/system/x-ui.service" ]; then + if [ -f "${xui_service}/x-ui.service" ]; then systemctl stop x-ui >/dev/null 2>&1 systemctl disable x-ui >/dev/null 2>&1 echo -e "${green}Removing old systemd unit version...${plain}" - rm /etc/systemd/system/x-ui.service -f >/dev/null 2>&1 + rm ${xui_service}/x-ui.service -f >/dev/null 2>&1 systemctl daemon-reload >/dev/null 2>&1 else rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1 @@ -613,17 +616,17 @@ update_x-ui() { fi fi echo -e "${green}Removing old x-ui version...${plain}" - rm /usr/bin/x-ui -f >/dev/null 2>&1 - rm /usr/local/x-ui/x-ui.service -f >/dev/null 2>&1 - rm /usr/local/x-ui/x-ui.service.debian -f >/dev/null 2>&1 - rm /usr/local/x-ui/x-ui.service.rhel -f >/dev/null 2>&1 - rm /usr/local/x-ui/x-ui -f >/dev/null 2>&1 - rm /usr/local/x-ui/x-ui.sh -f >/dev/null 2>&1 + rm ${xui_folder} -f >/dev/null 2>&1 + rm ${xui_folder}/x-ui.service -f >/dev/null 2>&1 + rm ${xui_folder}/x-ui.service.debian -f >/dev/null 2>&1 + rm ${xui_folder}/x-ui.service.rhel -f >/dev/null 2>&1 + rm ${xui_folder}/x-ui -f >/dev/null 2>&1 + rm ${xui_folder}/x-ui.sh -f >/dev/null 2>&1 echo -e "${green}Removing old xray version...${plain}" - rm /usr/local/x-ui/bin/xray-linux-amd64 -f >/dev/null 2>&1 + rm ${xui_folder}/bin/xray-linux-amd64 -f >/dev/null 2>&1 echo -e "${green}Removing old README and LICENSE file...${plain}" - rm /usr/local/x-ui/bin/README.md -f >/dev/null 2>&1 - rm /usr/local/x-ui/bin/LICENSE -f >/dev/null 2>&1 + rm ${xui_folder}/bin/README.md -f >/dev/null 2>&1 + rm ${xui_folder}/bin/LICENSE -f >/dev/null 2>&1 else rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1 _fail "ERROR: x-ui not installed." @@ -653,16 +656,16 @@ update_x-ui() { fi fi - chmod +x /usr/local/x-ui/x-ui.sh >/dev/null 2>&1 + chmod +x ${xui_folder}/x-ui.sh >/dev/null 2>&1 chmod +x /usr/bin/x-ui >/dev/null 2>&1 mkdir -p /var/log/x-ui >/dev/null 2>&1 echo -e "${green}Changing owner...${plain}" - chown -R root:root /usr/local/x-ui >/dev/null 2>&1 + chown -R root:root ${xui_folder} >/dev/null 2>&1 - if [ -f "/usr/local/x-ui/bin/config.json" ]; then + if [ -f "${xui_folder}/bin/config.json" ]; then echo -e "${green}Changing on config file permissions...${plain}" - chmod 640 /usr/local/x-ui/bin/config.json >/dev/null 2>&1 + chmod 640 ${xui_folder}/bin/config.json >/dev/null 2>&1 fi if [[ $release == "alpine" ]]; then @@ -681,20 +684,20 @@ update_x-ui() { else if [ -f "x-ui.service" ]; then echo -e "${green}Installing systemd unit...${plain}" - cp -f x-ui.service /etc/systemd/system/ >/dev/null 2>&1 + cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1 else case "${release}" in ubuntu | debian | armbian) echo -e "${green}Installing debian-like systemd unit...${plain}" - cp -f x-ui.service.debian /etc/systemd/system/x-ui.service >/dev/null 2>&1 + cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1 ;; *) echo -e "${green}Installing rhel-like systemd unit...${plain}" - cp -f x-ui.service.rhel /etc/systemd/system/x-ui.service >/dev/null 2>&1 + cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1 ;; esac fi - chown root:root /etc/systemd/system/x-ui.service >/dev/null 2>&1 + chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1 systemctl daemon-reload >/dev/null 2>&1 systemctl enable x-ui >/dev/null 2>&1 systemctl start x-ui >/dev/null 2>&1 diff --git a/x-ui.sh b/x-ui.sh index 8df28103..022a1a50 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -53,6 +53,8 @@ os_version="" os_version=$(grep "^VERSION_ID" /etc/os-release | cut -d '=' -f2 | tr -d '"' | tr -d '.') # Declare Variables +xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}" +xui_service="${XUI_SERVICE:=/etc/systemd/system}" log_folder="${XUI_LOG_FOLDER:=/var/log/x-ui}" mkdir -p "${log_folder}" iplimit_log_path="${log_folder}/3xipl.log" @@ -127,7 +129,7 @@ update_menu() { fi wget -O /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh - chmod +x /usr/local/x-ui/x-ui.sh + chmod +x ${xui_folder}/x-ui.sh chmod +x /usr/bin/x-ui if [[ $? == 0 ]]; then @@ -176,13 +178,13 @@ uninstall() { else systemctl stop x-ui systemctl disable x-ui - rm /etc/systemd/system/x-ui.service -f + rm ${xui_service}/x-ui.service -f systemctl daemon-reload systemctl reset-failed fi rm /etc/x-ui/ -rf - rm /usr/local/x-ui/ -rf + rm ${xui_folder}/ -rf echo "" echo -e "Uninstalled Successfully.\n" @@ -210,9 +212,9 @@ reset_user() { read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then - /usr/local/x-ui/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor false >/dev/null 2>&1 + ${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor false >/dev/null 2>&1 else - /usr/local/x-ui/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor true >/dev/null 2>&1 + ${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor true >/dev/null 2>&1 echo -e "Two factor authentication has been disabled." fi @@ -274,7 +276,7 @@ EOF fi chmod 755 ${certDir}/* >/dev/null 2>&1 - /usr/local/x-ui/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 + ${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" >/dev/null 2>&1 LOGI "Self-signed certificate configured. Browsers will show a warning." return 0 } @@ -291,7 +293,7 @@ reset_webbasepath() { config_webBasePath=$(gen_random_string 18) # Apply the new web base path setting - /usr/local/x-ui/x-ui setting -webBasePath "${config_webBasePath}" >/dev/null 2>&1 + ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" >/dev/null 2>&1 echo -e "Web base path has been reset to: ${green}${config_webBasePath}${plain}" echo -e "${green}Please use the new web base path to access the panel.${plain}" @@ -306,13 +308,13 @@ reset_config() { fi return 0 fi - /usr/local/x-ui/x-ui setting -reset + ${xui_folder}/x-ui setting -reset echo -e "All panel settings have been reset to default." restart } check_config() { - local info=$(/usr/local/x-ui/x-ui setting -show true) + local info=$(${xui_folder}/x-ui setting -show true) if [[ $? != 0 ]]; then LOGE "get current settings error, please check logs" show_menu @@ -322,7 +324,7 @@ check_config() { local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}') local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}') - local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') local server_ip=$(curl -s --max-time 3 https://api.ipify.org) if [ -z "$server_ip" ]; then server_ip=$(curl -s --max-time 3 https://4.ident.me) @@ -363,7 +365,7 @@ set_port() { LOGD "Cancelled" before_show_menu else - /usr/local/x-ui/x-ui setting -port ${port} + ${xui_folder}/x-ui setting -port ${port} echo -e "The port is set, Please restart the panel now, and use the new port ${green}${port}${plain} to access web panel" confirm_restart fi @@ -655,7 +657,7 @@ check_status() { return 1 fi else - if [[ ! -f /etc/systemd/system/x-ui.service ]]; then + if [[ ! -f ${xui_service}/x-ui.service ]]; then return 2 fi temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1) @@ -977,7 +979,7 @@ update_geo() { echo -e "${green}\t0.${plain} Back to Main Menu" read -rp "Choose an option: " choice - cd /usr/local/x-ui/bin + cd ${xui_folder}/bin case "$choice" in 0) @@ -1119,7 +1121,7 @@ ssl_cert_issue_main() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" echo "Panel paths set for domain: $domain" echo " - Certificate File: $webCertFile" echo " - Private Key File: $webKeyFile" @@ -1154,8 +1156,8 @@ ssl_cert_issue_main() { ssl_cert_issue_for_ip() { LOGI "Starting automatic SSL certificate generation for server IP..." - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') # Get server IP local server_ip=$(curl -s --max-time 3 https://api.ipify.org) @@ -1265,7 +1267,7 @@ ssl_cert_issue_for_ip() { local webKeyFile="/root/cert/${server_ip}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" LOGI "Certificate configured for panel" LOGI " - Certificate File: $webCertFile" LOGI " - Private Key File: $webKeyFile" @@ -1280,8 +1282,8 @@ ssl_cert_issue_for_ip() { } ssl_cert_issue() { - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') # check for acme.sh first if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then echo "acme.sh could not be found. we will install it" @@ -1446,7 +1448,7 @@ ssl_cert_issue() { local webKeyFile="/root/cert/${domain}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" LOGI "Panel paths set for domain: $domain" LOGI " - Certificate File: $webCertFile" LOGI " - Private Key File: $webKeyFile" @@ -1461,8 +1463,8 @@ ssl_cert_issue() { } ssl_cert_issue_CF() { - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') LOGI "****** Instructions for Use ******" LOGI "Follow the steps below to complete the process:" LOGI "1. Cloudflare Registered E-mail." @@ -1586,7 +1588,7 @@ ssl_cert_issue_CF() { local webKeyFile="${certPath}/privkey.pem" if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then - /usr/local/x-ui/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" LOGI "Panel paths set for domain: $CF_Domain" LOGI " - Certificate File: $webCertFile" LOGI " - Private Key File: $webKeyFile" @@ -2050,11 +2052,11 @@ SSH_port_forwarding() { break fi done - local existing_webBasePath=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') - local existing_port=$(/usr/local/x-ui/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') - local existing_listenIP=$(/usr/local/x-ui/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}') - local existing_cert=$(/usr/local/x-ui/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}') - local existing_key=$(/usr/local/x-ui/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}') + local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}') + local existing_key=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}') local config_listenIP="" local listen_choice="" @@ -2095,7 +2097,7 @@ SSH_port_forwarding() { config_listenIP="127.0.0.1" [[ "$listen_choice" == "2" ]] && read -rp "Enter custom IP to listen on: " config_listenIP - /usr/local/x-ui/x-ui setting -listenIP "${config_listenIP}" >/dev/null 2>&1 + ${xui_folder}/x-ui setting -listenIP "${config_listenIP}" >/dev/null 2>&1 echo -e "${green}listen IP has been set to ${config_listenIP}.${plain}" echo -e "\n${green}SSH Port Forwarding Configuration:${plain}" echo -e "Standard SSH command:" @@ -2111,7 +2113,7 @@ SSH_port_forwarding() { fi ;; 2) - /usr/local/x-ui/x-ui setting -listenIP 0.0.0.0 >/dev/null 2>&1 + ${xui_folder}/x-ui setting -listenIP 0.0.0.0 >/dev/null 2>&1 echo -e "${green}Listen IP has been cleared.${plain}" restart ;; From b7477302112b43a2ae037b63994c59e85f9c0687 Mon Sep 17 00:00:00 2001 From: Igor Kamyshnikov Date: Sat, 3 Jan 2026 03:39:30 +0000 Subject: [PATCH 5/8] vless: use Inbound Listen address in Subscription service (#3610) * vless: use Inbound Listen address in Subscription service vless manual connection link and subscription produced connection link are aligned. subscription service now returns an IP address configured on Inbound, instead of subscription service IP, which is consistent when the address, returned by QR code for manual vless link distribution. --- sub/subService.go | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index 55bddf7f..ade871df 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -179,9 +179,15 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VMESS { return "" } + var address string + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + address = s.address + } else { + address = inbound.Listen + } obj := map[string]any{ "v": "2", - "add": s.address, + "add": address, "port": inbound.Port, "type": "none", } @@ -317,7 +323,13 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { } func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { - address := s.address + var address string + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + address = s.address + } else { + address = inbound.Listen + } + if inbound.Protocol != model.VLESS { return "" } @@ -523,7 +535,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { } func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { - address := s.address + var address string + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + address = s.address + } else { + address = inbound.Listen + } if inbound.Protocol != model.Trojan { return "" } @@ -719,7 +736,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string } func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { - address := s.address + var address string + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + address = s.address + } else { + address = inbound.Listen + } if inbound.Protocol != model.Shadowsocks { return "" } From 313a2acbf66125feb4b145a5636351ed03e666da Mon Sep 17 00:00:00 2001 From: lolka1333 Date: Sat, 3 Jan 2026 05:26:00 +0100 Subject: [PATCH 6/8] feat: Add WebSocket support for real-time updates and enhance VLESS settings (#3605) * feat: add support for trusted X-Forwarded-For and testseed parameters in VLESS settings * chore: update Xray Core version to 25.12.8 in release workflow * chore: update Xray Core version to 25.12.8 in Docker initialization script * chore: bump version to 2.8.6 and add watcher for security changes in inbound modal * refactor: remove default and random seed buttons from outbound form * refactor: update VLESS form to rename 'Test Seed' to 'Vision Seed' and change button functionality for seed generation * refactor: enhance TLS settings form layout with improved button styling and spacing * feat: integrate WebSocket support for real-time updates on inbounds and Xray service status * chore: downgrade version to 2.8.5 * refactor: translate comments to English * fix: ensure testseed is initialized correctly for VLESS protocol and improve client handling in inbound modal * refactor: simplify VLESS divider condition by removing unnecessary flow checks * fix: add fallback date formatting for cases when IntlUtil is not available * refactor: simplify WebSocket message handling by removing batching and ensuring individual message delivery * refactor: disable WebSocket notifications in inbound and index HTML files * refactor: enhance VLESS testseed initialization and button functionality in inbound modal * fix: * refactor: ensure proper WebSocket URL construction by normalizing basePath * fix: * fix: * fix: * refactor: update testseed methods for improved reactivity and binding in VLESS form * logger info to debug --------- Co-authored-by: lolka1333 --- .github/workflows/release.yml | 4 +- DockerInit.sh | 2 +- go.mod | 4 +- go.sum | 4 +- web/assets/js/model/inbound.js | 24 +- web/assets/js/model/outbound.js | 28 +- web/assets/js/websocket.js | 145 +++++++++ web/controller/inbound.go | 12 + web/controller/server.go | 17 + web/controller/websocket.go | 189 +++++++++++ web/global/global.go | 1 + web/html/common/page.html | 1 + web/html/form/outbound.html | 31 ++ web/html/form/protocol/vless.html | 30 ++ web/html/form/stream/stream_sockopt.html | 9 + web/html/form/tls_settings.html | 18 +- web/html/inbounds.html | 121 +++++++- web/html/index.html | 72 ++++- web/html/modals/inbound_modal.html | 94 +++++- web/html/settings/xray/dns.html | 7 + web/html/xray.html | 19 +- web/job/ldap_sync_job.go | 60 ---- web/job/xray_traffic_job.go | 18 ++ web/translation/translate.ar_EG.toml | 2 + web/translation/translate.en_US.toml | 2 + web/translation/translate.es_ES.toml | 2 + web/translation/translate.fa_IR.toml | 2 + web/translation/translate.id_ID.toml | 2 + web/translation/translate.ja_JP.toml | 2 + web/translation/translate.pt_BR.toml | 2 + web/translation/translate.ru_RU.toml | 2 + web/translation/translate.tr_TR.toml | 2 + web/translation/translate.uk_UA.toml | 2 + web/translation/translate.vi_VN.toml | 2 + web/translation/translate.zh_CN.toml | 2 + web/translation/translate.zh_TW.toml | 2 + web/web.go | 22 ++ web/websocket/hub.go | 379 +++++++++++++++++++++++ web/websocket/notifier.go | 74 +++++ xray/api.go | 27 +- 40 files changed, 1329 insertions(+), 109 deletions(-) create mode 100644 web/assets/js/websocket.js create mode 100644 web/controller/websocket.go create mode 100644 web/websocket/hub.go create mode 100644 web/websocket/notifier.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 563e1113..d68ea808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.2/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.12.8/" if [ "${{ matrix.platform }}" == "amd64" ]; then wget -q ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -185,7 +185,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/" Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index 080af293..9c6dbdbc 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -27,7 +27,7 @@ case $1 in esac mkdir -p build/bin cd build/bin -wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.2/Xray-linux-${ARCH}.zip" +wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/go.mod b/go.mod index 458eefcb..ab54cbaf 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.12 github.com/goccy/go-json v0.10.5 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/mymmrac/telego v1.3.1 github.com/nicksnyder/go-i18n/v2 v2.6.0 @@ -19,7 +20,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.68.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.251202.0 + github.com/xtls/xray-core v1.251208.0 go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.45.0 golang.org/x/sys v0.38.0 @@ -51,7 +52,6 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 287a33bb..03a17d8e 100644 --- a/go.sum +++ b/go.sum @@ -203,8 +203,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM= github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ= -github.com/xtls/xray-core v1.251202.0 h1:VwoBnq9IRTbYWEBhR0CqEw2cNjTlXYH6WxzKbSjx+XE= -github.com/xtls/xray-core v1.251202.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4= +github.com/xtls/xray-core v1.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0eyAes= +github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 15410750..e2c9e092 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -857,6 +857,7 @@ class SockoptStreamSettings extends XrayCommonClass { V6Only = false, tcpWindowClamp = 600, interfaceName = "", + trustedXForwardedFor = [], ) { super(); this.acceptProxyProtocol = acceptProxyProtocol; @@ -875,6 +876,7 @@ class SockoptStreamSettings extends XrayCommonClass { this.V6Only = V6Only; this.tcpWindowClamp = tcpWindowClamp; this.interfaceName = interfaceName; + this.trustedXForwardedFor = trustedXForwardedFor; } static fromJson(json = {}) { @@ -896,11 +898,12 @@ class SockoptStreamSettings extends XrayCommonClass { json.V6Only, json.tcpWindowClamp, json.interface, + json.trustedXForwardedFor || [], ); } toJson() { - return { + const result = { acceptProxyProtocol: this.acceptProxyProtocol, tcpFastOpen: this.tcpFastOpen, mark: this.mark, @@ -918,6 +921,10 @@ class SockoptStreamSettings extends XrayCommonClass { tcpWindowClamp: this.tcpWindowClamp, interface: this.interfaceName, }; + if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) { + result.trustedXForwardedFor = this.trustedXForwardedFor; + } + return result; } } @@ -1870,6 +1877,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings { encryption = "none", fallbacks = [], selectedAuth = undefined, + testseed = [900, 500, 900, 256], ) { super(protocol); this.vlesses = vlesses; @@ -1877,6 +1885,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings { this.encryption = encryption; this.fallbacks = fallbacks; this.selectedAuth = selectedAuth; + this.testseed = testseed; } addFallback() { @@ -1888,13 +1897,20 @@ Inbound.VLESSSettings = class extends Inbound.Settings { } static fromJson(json = {}) { + // Ensure testseed is always initialized as an array + let testseed = [900, 500, 900, 256]; + if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) { + testseed = json.testseed; + } + const obj = new Inbound.VLESSSettings( Protocols.VLESS, (json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)), json.decryption, json.encryption, Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []), - json.selectedAuth + json.selectedAuth, + testseed ); return obj; } @@ -1920,6 +1936,10 @@ Inbound.VLESSSettings = class extends Inbound.Settings { json.selectedAuth = this.selectedAuth; } + if (this.testseed && this.testseed.length >= 4) { + json.testseed = this.testseed; + } + return json; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index c727abae..c631040e 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -432,6 +432,7 @@ class SockoptStreamSettings extends CommonClass { tcpMptcp = false, penetrate = false, addressPortStrategy = Address_Port_Strategy.NONE, + trustedXForwardedFor = [], ) { super(); this.dialerProxy = dialerProxy; @@ -440,6 +441,7 @@ class SockoptStreamSettings extends CommonClass { this.tcpMptcp = tcpMptcp; this.penetrate = penetrate; this.addressPortStrategy = addressPortStrategy; + this.trustedXForwardedFor = trustedXForwardedFor; } static fromJson(json = {}) { @@ -450,12 +452,13 @@ class SockoptStreamSettings extends CommonClass { json.tcpKeepAliveInterval, json.tcpMptcp, json.penetrate, - json.addressPortStrategy + json.addressPortStrategy, + json.trustedXForwardedFor || [] ); } toJson() { - return { + const result = { dialerProxy: this.dialerProxy, tcpFastOpen: this.tcpFastOpen, tcpKeepAliveInterval: this.tcpKeepAliveInterval, @@ -463,6 +466,10 @@ class SockoptStreamSettings extends CommonClass { penetrate: this.penetrate, addressPortStrategy: this.addressPortStrategy }; + if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) { + result.trustedXForwardedFor = this.trustedXForwardedFor; + } + return result; } } @@ -1050,13 +1057,15 @@ Outbound.VmessSettings = class extends CommonClass { } }; Outbound.VLESSSettings = class extends CommonClass { - constructor(address, port, id, flow, encryption) { + constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) { super(); this.address = address; this.port = port; this.id = id; this.flow = flow; this.encryption = encryption; + this.testpre = testpre; + this.testseed = testseed; } static fromJson(json = {}) { @@ -1066,18 +1075,27 @@ Outbound.VLESSSettings = class extends CommonClass { json.port, json.id, json.flow, - json.encryption + json.encryption, + json.testpre || 0, + json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256] ); } toJson() { - return { + const result = { address: this.address, port: this.port, id: this.id, flow: this.flow, encryption: this.encryption, }; + if (this.testpre > 0) { + result.testpre = this.testpre; + } + if (this.testseed && this.testseed.length >= 4) { + result.testseed = this.testseed; + } + return result; } }; Outbound.TrojanSettings = class extends CommonClass { diff --git a/web/assets/js/websocket.js b/web/assets/js/websocket.js new file mode 100644 index 00000000..5b8a3948 --- /dev/null +++ b/web/assets/js/websocket.js @@ -0,0 +1,145 @@ +/** + * WebSocket client for real-time updates + */ +class WebSocketClient { + constructor(basePath = '') { + this.basePath = basePath; + this.ws = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.reconnectDelay = 1000; + this.listeners = new Map(); + this.isConnected = false; + this.shouldReconnect = true; + } + + connect() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + // Ensure basePath ends with '/' for proper URL construction + let basePath = this.basePath || ''; + if (basePath && !basePath.endsWith('/')) { + basePath += '/'; + } + const wsUrl = `${protocol}//${window.location.host}${basePath}ws`; + + console.log('WebSocket connecting to:', wsUrl, 'basePath:', this.basePath); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.isConnected = true; + this.reconnectAttempts = 0; + this.emit('connected'); + }; + + this.ws.onmessage = (event) => { + try { + // Validate message size (prevent memory issues) + const maxMessageSize = 10 * 1024 * 1024; // 10MB + if (event.data && event.data.length > maxMessageSize) { + console.error('WebSocket message too large:', event.data.length, 'bytes'); + this.ws.close(); + return; + } + + const message = JSON.parse(event.data); + if (!message || typeof message !== 'object') { + console.error('Invalid WebSocket message format'); + return; + } + + this.handleMessage(message); + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.emit('error', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.isConnected = false; + this.emit('disconnected'); + + if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + setTimeout(() => this.connect(), delay); + } + }; + } catch (e) { + console.error('Failed to create WebSocket connection:', e); + this.emit('error', e); + } + } + + handleMessage(message) { + const { type, payload, time } = message; + + // Emit to specific type listeners + this.emit(type, payload, time); + + // Emit to all listeners + this.emit('message', { type, payload, time }); + } + + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } + + off(event, callback) { + if (!this.listeners.has(event)) { + return; + } + const callbacks = this.listeners.get(event); + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + + emit(event, ...args) { + if (this.listeners.has(event)) { + this.listeners.get(event).forEach(callback => { + try { + callback(...args); + } catch (e) { + console.error('Error in WebSocket event handler:', e); + } + }); + } + } + + disconnect() { + this.shouldReconnect = false; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + send(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } else { + console.warn('WebSocket is not connected'); + } + } +} + +// Create global WebSocket client instance +// Safely get basePath from global scope (defined in page.html) +window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : ''); diff --git a/web/controller/inbound.go b/web/controller/inbound.go index eeb160d6..8317de31 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/session" + "github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/gin-gonic/gin" ) @@ -125,6 +126,9 @@ func (a *InboundController) addInbound(c *gin.Context) { if needRestart { a.xrayService.SetToNeedRestart() } + // Broadcast inbounds update via WebSocket + inbounds, _ := a.inboundService.GetInbounds(user.Id) + websocket.BroadcastInbounds(inbounds) } // delInbound deletes an inbound configuration by its ID. @@ -143,6 +147,10 @@ func (a *InboundController) delInbound(c *gin.Context) { if needRestart { a.xrayService.SetToNeedRestart() } + // Broadcast inbounds update via WebSocket + user := session.GetLoginUser(c) + inbounds, _ := a.inboundService.GetInbounds(user.Id) + websocket.BroadcastInbounds(inbounds) } // updateInbound updates an existing inbound configuration. @@ -169,6 +177,10 @@ func (a *InboundController) updateInbound(c *gin.Context) { if needRestart { a.xrayService.SetToNeedRestart() } + // Broadcast inbounds update via WebSocket + user := session.GetLoginUser(c) + inbounds, _ := a.inboundService.GetInbounds(user.Id) + websocket.BroadcastInbounds(inbounds) } // getClientIps retrieves the IP addresses associated with a client by email. diff --git a/web/controller/server.go b/web/controller/server.go index 292ef338..5b39700e 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -9,6 +9,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/web/global" "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/gin-gonic/gin" ) @@ -67,6 +68,8 @@ func (a *ServerController) refreshStatus() { // collect cpu history when status is fresh if a.lastStatus != nil { a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu) + // Broadcast status update via WebSocket + websocket.BroadcastStatus(a.lastStatus) } } @@ -155,9 +158,16 @@ func (a *ServerController) stopXrayService(c *gin.Context) { err := a.serverService.StopXrayService() if err != nil { jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) + websocket.BroadcastXrayState("error", err.Error()) return } jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err) + websocket.BroadcastXrayState("stop", "") + websocket.BroadcastNotification( + I18nWeb(c, "pages.xray.stopSuccess"), + "Xray service has been stopped", + "warning", + ) } // restartXrayService restarts the Xray service. @@ -165,9 +175,16 @@ func (a *ServerController) restartXrayService(c *gin.Context) { err := a.serverService.RestartXrayService() if err != nil { jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err) + websocket.BroadcastXrayState("error", err.Error()) return } jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err) + websocket.BroadcastXrayState("running", "") + websocket.BroadcastNotification( + I18nWeb(c, "pages.xray.restartSuccess"), + "Xray service has been restarted successfully", + "success", + ) } // getLogs retrieves the application logs based on count, level, and syslog filters. diff --git a/web/controller/websocket.go b/web/controller/websocket.go new file mode 100644 index 00000000..0ad5c845 --- /dev/null +++ b/web/controller/websocket.go @@ -0,0 +1,189 @@ +package controller + +import ( + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/common" + "github.com/mhsanaei/3x-ui/v2/web/session" + "github.com/mhsanaei/3x-ui/v2/web/websocket" + + "github.com/gin-gonic/gin" + ws "github.com/gorilla/websocket" +) + +const ( + // Time allowed to write a message to the peer + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer + pongWait = 60 * time.Second + + // Send pings to peer with this period (must be less than pongWait) + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer + maxMessageSize = 512 +) + +var upgrader = ws.Upgrader{ + ReadBufferSize: 4096, // Increased from 1024 for better performance + WriteBufferSize: 4096, // Increased from 1024 for better performance + CheckOrigin: func(r *http.Request) bool { + // Check origin for security + origin := r.Header.Get("Origin") + if origin == "" { + // Allow connections without Origin header (same-origin requests) + return true + } + // Get the host from the request + host := r.Host + // Extract scheme and host from origin + originURL := origin + // Simple check: origin should match the request host + // This prevents cross-origin WebSocket hijacking + if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") { + // Extract host from origin + originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://") + if idx := strings.Index(originHost, "/"); idx != -1 { + originHost = originHost[:idx] + } + if idx := strings.Index(originHost, ":"); idx != -1 { + originHost = originHost[:idx] + } + // Compare hosts (without port) + requestHost := host + if idx := strings.Index(requestHost, ":"); idx != -1 { + requestHost = requestHost[:idx] + } + return originHost == requestHost || originHost == "" || requestHost == "" + } + return false + }, +} + +// WebSocketController handles WebSocket connections for real-time updates +type WebSocketController struct { + BaseController + hub *websocket.Hub +} + +// NewWebSocketController creates a new WebSocket controller +func NewWebSocketController(hub *websocket.Hub) *WebSocketController { + return &WebSocketController{ + hub: hub, + } +} + +// HandleWebSocket handles WebSocket connections +func (w *WebSocketController) HandleWebSocket(c *gin.Context) { + // Check authentication + if !session.IsLogin(c) { + logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c)) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // Upgrade connection to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Error("Failed to upgrade WebSocket connection:", err) + return + } + + // Create client + clientID := uuid.New().String() + client := &websocket.Client{ + ID: clientID, + Hub: w.hub, + Send: make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow + Topics: make(map[websocket.MessageType]bool), + } + + // Register client + w.hub.Register(client) + logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c)) + + // Start goroutines for reading and writing + go w.writePump(client, conn) + go w.readPump(client, conn) +} + +// readPump pumps messages from the WebSocket connection to the hub +func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) { + defer func() { + if r := common.Recover("WebSocket readPump panic"); r != nil { + logger.Error("WebSocket readPump panic recovered:", r) + } + w.hub.Unregister(client) + conn.Close() + }() + + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + conn.SetReadLimit(maxMessageSize) + + for { + _, message, err := conn.ReadMessage() + if err != nil { + if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) { + logger.Debugf("WebSocket read error for client %s: %v", client.ID, err) + } + break + } + + // Validate message size + if len(message) > maxMessageSize { + logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message)) + continue + } + + // Handle incoming messages (e.g., subscription requests) + // For now, we'll just log them + logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message)) + } +} + +// writePump pumps messages from the hub to the WebSocket connection +func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) { + ticker := time.NewTicker(pingPeriod) + defer func() { + if r := common.Recover("WebSocket writePump panic"); r != nil { + logger.Error("WebSocket writePump panic recovered:", r) + } + ticker.Stop() + conn.Close() + }() + + for { + select { + case message, ok := <-client.Send: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // Hub closed the channel + conn.WriteMessage(ws.CloseMessage, []byte{}) + return + } + + // Send each message individually (no batching) + // This ensures each JSON message is sent separately and can be parsed correctly + if err := conn.WriteMessage(ws.TextMessage, message); err != nil { + logger.Debugf("WebSocket write error for client %s: %v", client.ID, err) + return + } + + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := conn.WriteMessage(ws.PingMessage, nil); err != nil { + logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err) + return + } + } + } +} diff --git a/web/global/global.go b/web/global/global.go index 025fa081..f72c7bfe 100644 --- a/web/global/global.go +++ b/web/global/global.go @@ -17,6 +17,7 @@ var ( type WebServer interface { GetCron() *cron.Cron // Get the cron scheduler GetCtx() context.Context // Get the server context + GetWSHub() interface{} // Get the WebSocket hub (using interface{} to avoid circular dependency) } // SubServer interface defines methods for accessing the subscription server instance. diff --git a/web/html/common/page.html b/web/html/common/page.html index c0a7ca63..0af63afb 100644 --- a/web/html/common/page.html +++ b/web/html/common/page.html @@ -49,6 +49,7 @@ const basePath = '{{ .base_path }}'; axios.defaults.baseURL = basePath; + {{ end }} {{ define "page/body_end" }} diff --git a/web/html/form/outbound.html b/web/html/form/outbound.html index aa6aa323..1926c30e 100644 --- a/web/html/form/outbound.html +++ b/web/html/form/outbound.html @@ -239,6 +239,28 @@ + + @@ -501,6 +523,15 @@ + + + CF-Connecting-IP + X-Real-IP + True-Client-IP + X-Client-IP + + diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html index 140b9c1a..ad5b4265 100644 --- a/web/html/form/protocol/vless.html +++ b/web/html/form/protocol/vless.html @@ -39,6 +39,7 @@ + + {{end}} diff --git a/web/html/form/stream/stream_sockopt.html b/web/html/form/stream/stream_sockopt.html index 4480594a..062b83df 100644 --- a/web/html/form/stream/stream_sockopt.html +++ b/web/html/form/stream/stream_sockopt.html @@ -61,6 +61,15 @@ + + + CF-Connecting-IP + X-Real-IP + True-Client-IP + X-Client-IP + + {{end}} diff --git a/web/html/form/tls_settings.html b/web/html/form/tls_settings.html index c3844a7f..3723130e 100644 --- a/web/html/form/tls_settings.html +++ b/web/html/form/tls_settings.html @@ -60,16 +60,20 @@ +