Compare commits

...

10 Commits

Author SHA1 Message Date
Nabi KaramAliZadeh
59b695ba83 fix: remove excluded paths from gzip middleware in router initialization (#3860) 2026-03-01 15:18:16 +01:00
Alireza Ahmadi
159b85f979 Merge pull request #3828 from MHSanaei/restartXrayOption
[feat] restart xray-core from cli #3825
2026-02-25 21:09:42 +01:00
Alireza Ahmadi
3ec5b3589f fix windows build 2026-02-20 02:07:46 +01:00
Alireza Ahmadi
2b1d3e7347 [feat] restart xray-core from cli #3825 2026-02-20 00:03:16 +01:00
MHSanaei
37f0880f8f Bump Go to 1.26 2026-02-16 01:10:43 +01:00
MHSanaei
5b796672e9 Improve telego client robustness and retries
Add a createRobustFastHTTPClient helper to configure fasthttp.Client with better timeouts, connection limits, retries and optional SOCKS5 proxy dialing. Validate and sanitize proxy and API server URLs instead of returning early on invalid values, and build telego.Bot options dynamically. Reduce long-polling timeout to detect connection issues faster and adjust update retrieval comments. Implement exponential-backoff retry logic for SendMessage calls to handle transient connection/timeouts and improve delivery reliability; also reduce inter-message delay for better throughput.
2026-02-14 22:49:19 +01:00
MHSanaei
3fa0da38c9 Add timeouts and delays to backup sends
Add rate-limit friendly delays and context timeouts when sending backups via Telegram. Iterate admin IDs with index to sleep 1s between sends; add 30s context.WithTimeout for each SendDocument call and defer file.Close() for opened files; insert a 500ms pause between sending DB and config files. These changes improve resource cleanup and reduce chance of Telegram rate-limit/timeout failures.
2026-02-14 22:31:41 +01:00
MHSanaei
8eb1225734 translate bug fix #3789 2026-02-14 21:41:20 +01:00
MHSanaei
e5c0fe3edf bug fix #3785 2026-02-11 22:21:09 +01:00
MHSanaei
f4057989f5 Require HTTP 200 from curl before using IP
Replace simple curl+trim checks with a response+http_code parse to ensure the remote URL returns HTTP 200 and a non-empty body before assigning server_ip. Changes applied to install.sh, update.sh and x-ui.sh: use curl -w to append the status code, extract http_code and ip_result, and only set server_ip when http_code == 200 and ip_result is non-empty. This makes the IP discovery more robust against error pages or partial responses while keeping the existing timeout behavior.
2026-02-11 21:32:23 +01:00
33 changed files with 540 additions and 121 deletions

View File

@@ -1,7 +1,7 @@
# ========================================================
# Stage: Builder
# ========================================================
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /app
ARG TARGETARCH

6
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/mhsanaei/3x-ui/v2
go 1.25.7
go 1.26.0
require (
github.com/gin-contrib/gzip v1.2.5
@@ -25,7 +25,7 @@ require (
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
google.golang.org/grpc v1.78.0
google.golang.org/grpc v1.79.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -86,7 +86,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect

30
go.sum
View File

@@ -14,6 +14,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -203,16 +205,16 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -225,8 +227,8 @@ golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
@@ -255,8 +257,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -654,8 +654,11 @@ config_after_install() {
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
if [[ -n "${server_ip}" ]]; then
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break
fi
done

View File

@@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/sub"
"github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/util/sys"
"github.com/mhsanaei/3x-ui/v2/web"
"github.com/mhsanaei/3x-ui/v2/web/global"
"github.com/mhsanaei/3x-ui/v2/web/service"
@@ -70,7 +71,7 @@ func runWebServer() {
sigCh := make(chan os.Signal, 1)
// Trap shutdown signals
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
for {
sig := <-sigCh
@@ -108,6 +109,12 @@ func runWebServer() {
return
}
log.Println("Sub server restarted successfully.")
case sys.SIGUSR1:
logger.Info("Received USR1 signal, restarting xray-core...")
err := server.RestartXray()
if err != nil {
logger.Error("Failed to restart xray-core:", err)
}
default:
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---

View File

@@ -143,11 +143,11 @@ func (a *SUBController) subs(c *gin.Context) {
// Add headers
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
profileUrl := a.subProfileUrl
profileUrl := a.subProfileUrl
if profileUrl == "" {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))

View File

@@ -687,8 +687,11 @@ config_after_update() {
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
server_ip=$(${curl_bin} -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
if [[ -n "${server_ip}" ]]; then
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break
fi
done

View File

@@ -7,11 +7,14 @@ import (
"encoding/binary"
"fmt"
"sync"
"syscall"
"github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/unix"
)
var SIGUSR1 = syscall.SIGUSR1
func GetTCPCount() (int, error) {
stats, err := net.Connections("tcp")
if err != nil {

View File

@@ -12,8 +12,11 @@ import (
"strconv"
"strings"
"sync"
"syscall"
)
var SIGUSR1 = syscall.SIGUSR1
func getLinesNum(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {

View File

@@ -12,6 +12,8 @@ import (
"github.com/shirou/gopsutil/v4/net"
)
var SIGUSR1 = syscall.Signal(0)
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
func GetConnectionCount(proto string) (int, error) {
if proto != "tcp" && proto != "udp" {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service"
@@ -193,6 +194,37 @@ func (a *InboundController) getClientIps(c *gin.Context) {
return
}
// Prefer returning a normalized string list for consistent UI rendering
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
formatted := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
formatted = append(formatted, item.IP)
}
jsonObj(c, formatted, nil)
return
}
var oldIps []string
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
jsonObj(c, oldIps, nil)
return
}
// If parsing fails, return as string
jsonObj(c, ips, nil)
}

View File

@@ -260,15 +260,31 @@
v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
<td>
<a-tag>[[ infoModal.clientIps ]]</a-tag>
<a-icon type="sync" :spin="refreshing" @click="refreshIPs"
:style="{ margin: '0 5px' }"></a-icon>
<a-tooltip :title="[[ dbInbound.address ]]">
<template slot="title">
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
</template>
<a-icon type="delete" @click="clearClientIps"></a-icon>
</a-tooltip>
<div
style="max-height: 150px; overflow-y: auto; text-align: left;">
<div
v-if="infoModal.clientIpsArray && infoModal.clientIpsArray.length > 0">
<a-tag
v-for="(ipInfo, idx) in infoModal.clientIpsArray"
:key="idx"
color="blue"
style="margin: 2px 0; display: block; font-family: monospace; font-size: 11px;">
[[ formatIpInfo(ipInfo) ]]
</a-tag>
</div>
<a-tag v-else>[[ infoModal.clientIps || 'No IP Record'
]]</a-tag>
</div>
<div style="margin-top: 5px;">
<a-icon type="sync" :spin="refreshing" @click="refreshIPs"
:style="{ margin: '0 5px' }"></a-icon>
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
</template>
<a-icon type="delete" @click="clearClientIps"></a-icon>
</a-tooltip>
</div>
</td>
</tr>
</table>
@@ -542,12 +558,73 @@
<script>
function refreshIPs(email) {
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
if (msg.success) {
try {
return JSON.parse(msg.obj).join(', ');
} catch (e) {
return msg.obj;
if (!msg.success) {
return { text: 'No IP Record', array: [] };
}
const formatIpRecord = (record) => {
if (record == null) {
return '';
}
if (typeof record === 'string' || typeof record === 'number') {
return String(record);
}
const ip = record.ip || record.IP || '';
const timestamp = record.timestamp || record.Timestamp || 0;
if (!ip) {
return String(record);
}
if (!timestamp) {
return String(ip);
}
const date = new Date(Number(timestamp) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
return `${ip} (${timeStr})`;
};
try {
let ips = msg.obj;
// If msg.obj is a string, try to parse it
if (typeof ips === 'string') {
try {
ips = JSON.parse(ips);
} catch (e) {
return { text: String(ips), array: [String(ips)] };
}
}
// Normalize single object response to array
if (ips && !Array.isArray(ips) && typeof ips === 'object') {
ips = [ips];
}
// New format or object array
if (Array.isArray(ips) && ips.length > 0 && typeof ips[0] === 'object') {
const result = ips.map((item) => formatIpRecord(item)).filter(Boolean);
return { text: result.join(' | '), array: result };
}
// Old format - simple array of IPs
if (Array.isArray(ips) && ips.length > 0) {
const result = ips.map((ip) => String(ip));
return { text: result.join(', '), array: result };
}
// Fallback for any other format
return { text: String(ips), array: [String(ips)] };
} catch (e) {
return { text: 'Error loading IPs', array: [] };
}
});
}
@@ -566,6 +643,7 @@
subLink: '',
subJsonLink: '',
clientIps: '',
clientIpsArray: [],
show(dbInbound, index) {
this.index = index;
this.inbound = dbInbound.toInbound();
@@ -583,8 +661,9 @@
].includes(this.inbound.protocol)
) {
if (app.ipLimitEnable && this.clientSettings.limitIp) {
refreshIPs(this.clientStats.email).then((ips) => {
this.clientIps = ips;
refreshIPs(this.clientStats.email).then((result) => {
this.clientIps = result.text;
this.clientIpsArray = result.array;
})
}
}
@@ -655,6 +734,35 @@
},
},
methods: {
formatIpInfo(ipInfo) {
if (ipInfo == null) {
return '';
}
if (typeof ipInfo === 'string' || typeof ipInfo === 'number') {
return String(ipInfo);
}
const ip = ipInfo.ip || ipInfo.IP || '';
const timestamp = ipInfo.timestamp || ipInfo.Timestamp || 0;
if (!ip) {
return String(ipInfo);
}
if (!timestamp) {
return String(ip);
}
const date = new Date(Number(timestamp) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
return `${ip} (${timeStr})`;
},
copy(content) {
ClipboardManager
.copyText(content)
@@ -672,8 +780,9 @@
refreshIPs() {
this.refreshing = true;
refreshIPs(this.infoModal.clientStats.email)
.then((ips) => {
this.infoModal.clientIps = ips;
.then((result) => {
this.infoModal.clientIps = result.text;
this.infoModal.clientIpsArray = result.array;
})
.finally(() => {
this.refreshing = false;
@@ -686,6 +795,7 @@
return;
}
this.infoModal.clientIps = 'No IP Record';
this.infoModal.clientIpsArray = [];
})
.catch(() => {});
},

View File

@@ -2141,6 +2141,43 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
if err != nil {
return "", err
}
if InboundClientIps.Ips == "" {
return "", nil
}
// Try to parse as new format (with timestamps)
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []IPWithTimestamp
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
// If successfully parsed as new format, return with timestamps
if err == nil && len(ipsWithTime) > 0 {
return InboundClientIps.Ips, nil
}
// Otherwise, assume it's old format (simple string array)
// Try to parse as simple array and convert to new format
var oldIps []string
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
if err == nil && len(oldIps) > 0 {
// Convert old format to new format with current timestamp
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
for i, ip := range oldIps {
newIpsWithTime[i] = IPWithTimestamp{
IP: ip,
Timestamp: time.Now().Unix(),
}
}
result, _ := json.Marshal(newIpsWithTime)
return string(result), nil
}
// Return as-is if parsing fails
return InboundClientIps.Ips, nil
}

View File

@@ -5,7 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net"
"reflect"
"strconv"
"strings"
@@ -719,25 +719,25 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
}
func extractHostname(host string) string {
h, _, err := net.SplitHostPort(host)
// Err is not nil means host does not contain port
if err != nil {
h = host
}
h, _, err := net.SplitHostPort(host)
// Err is not nil means host does not contain port
if err != nil {
h = host
}
ip := net.ParseIP(h)
// If it's not an IP, return as is
if ip == nil {
return h
}
ip := net.ParseIP(h)
// If it's not an IP, return as is
if ip == nil {
return h
}
// If it's an IPv4, return as is
if ip.To4() != nil {
return h
}
// If it's an IPv4, return as is
if ip.To4() != nil {
return h
}
// IPv6 needs bracketing
return "[" + h + "]"
// IPv6 needs bracketing
return "[" + h + "]"
}
func (s *SettingService) GetDefaultSettings(host string) (any, error) {

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -271,41 +272,78 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
return nil
}
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
client := &fasthttp.Client{
// Connection timeouts
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxIdleConnDuration: 60 * time.Second,
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
MaxIdemponentCallAttempts: 3,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
MaxConnsPerHost: 100,
MaxConnWaitTimeout: 10 * time.Second,
DisableHeaderNamesNormalizing: false,
DisablePathNormalizing: false,
// Retry on connection errors
RetryIf: func(request *fasthttp.Request) bool {
// Retry on connection errors for GET requests
return string(request.Header.Method()) == "GET" || string(request.Header.Method()) == "POST"
},
}
// Set proxy if provided
if proxyUrl != "" {
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
}
return client
}
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
if proxyUrl == "" && apiServerUrl == "" {
return telego.NewBot(token)
}
// Validate proxy URL if provided
if proxyUrl != "" {
if !strings.HasPrefix(proxyUrl, "socks5://") {
logger.Warning("Invalid socks5 URL, using default")
return telego.NewBot(token)
logger.Warning("Invalid socks5 URL, ignoring proxy")
proxyUrl = "" // Clear invalid proxy
} else {
_, err := url.Parse(proxyUrl)
if err != nil {
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
proxyUrl = ""
}
}
}
_, err := url.Parse(proxyUrl)
if err != nil {
logger.Warningf("Can't parse proxy URL, using default instance for tgbot: %v", err)
return telego.NewBot(token)
// Validate API server URL if provided
if apiServerUrl != "" {
if !strings.HasPrefix(apiServerUrl, "http") {
logger.Warning("Invalid http(s) URL for API server, using default")
apiServerUrl = ""
} else {
_, err := url.Parse(apiServerUrl)
if err != nil {
logger.Warningf("Can't parse API server URL, using default: %v", err)
apiServerUrl = ""
}
}
return telego.NewBot(token, telego.WithFastHTTPClient(&fasthttp.Client{
Dial: fasthttpproxy.FasthttpSocksDialer(proxyUrl),
}))
}
if !strings.HasPrefix(apiServerUrl, "http") {
logger.Warning("Invalid http(s) URL, using default")
return telego.NewBot(token)
// Create robust fasthttp client
client := t.createRobustFastHTTPClient(proxyUrl)
// Build bot options
var options []telego.BotOption
options = append(options, telego.WithFastHTTPClient(client))
if apiServerUrl != "" {
options = append(options, telego.WithAPIServer(apiServerUrl))
}
_, err := url.Parse(apiServerUrl)
if err != nil {
logger.Warningf("Can't parse API server URL, using default instance for tgbot: %v", err)
return telego.NewBot(token)
}
return telego.NewBot(token, telego.WithAPIServer(apiServerUrl))
return telego.NewBot(token, options...)
}
// IsRunning checks if the Telegram bot is currently running.
@@ -389,7 +427,7 @@ func (t *Tgbot) decodeQuery(query string) (string, error) {
// OnReceive starts the message receiving loop for the Telegram bot.
func (t *Tgbot) OnReceive() {
params := telego.GetUpdatesParams{
Timeout: 30, // Increased timeout to reduce API calls
Timeout: 20, // Reduced timeout to detect connection issues faster
}
// Strict singleton: never start a second long-polling loop.
tgBotMutex.Lock()
@@ -407,7 +445,7 @@ func (t *Tgbot) OnReceive() {
botWG.Add(1)
tgBotMutex.Unlock()
// Get updates channel using the context.
// Get updates channel using the context with shorter timeout for better error recovery
updates, _ := bot.UpdatesViaLongPolling(ctx, &params)
go func() {
defer botWG.Done()
@@ -2246,10 +2284,36 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
params.ReplyMarkup = replyMarkup[0]
}
_, err := bot.SendMessage(context.Background(), &params)
if err != nil {
logger.Warning("Error sending telegram message :", err)
// Retry logic with exponential backoff for connection errors
maxRetries := 3
for attempt := range maxRetries {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_, err := bot.SendMessage(ctx, &params)
cancel()
if err == nil {
break // Success
}
// Check if error is a connection error
errStr := err.Error()
isConnectionError := strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "closed")
if isConnectionError && attempt < maxRetries-1 {
// Exponential backoff: 1s, 2s, 4s
backoff := time.Duration(1<<uint(attempt)) * time.Second
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
attempt+1, maxRetries, backoff, err)
time.Sleep(backoff)
} else {
logger.Warning("Error sending telegram message:", err)
break
}
}
// Reduced delay to improve performance (only needed for rate limiting)
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
time.Sleep(100 * time.Millisecond)
@@ -2584,8 +2648,12 @@ func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() {
return
}
for _, adminId := range adminIds {
for i, adminId := range adminIds {
t.sendBackup(int64(adminId))
// Add delay between sends to avoid Telegram rate limits
if i < len(adminIds)-1 {
time.Sleep(1 * time.Second)
}
}
}
@@ -3083,9 +3151,41 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
ips = t.I18nBot("tgbot.noIpRecord")
}
formattedIps := ips
if err == nil && len(ips) > 0 {
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
lines := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
lines = append(lines, item.IP)
}
if len(lines) > 0 {
formattedIps = strings.Join(lines, "\n")
}
} else {
var oldIps []string
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
formattedIps = strings.Join(oldIps, "\n")
}
}
}
output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips)
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard(
@@ -3563,13 +3663,17 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in trigger a checkpoint operation: ", err)
}
// Send database backup
file, err := os.Open(config.GetDBPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), document)
_, err = bot.SendDocument(ctx, document)
if err != nil {
logger.Error("Error in uploading backup: ", err)
}
@@ -3577,13 +3681,20 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in opening db file for backup: ", err)
}
// Small delay between file sends
time.Sleep(500 * time.Millisecond)
// Send config.json backup
file, err = os.Open(xray.GetConfigPath())
if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document(
tu.ID(chatId),
tu.File(file),
)
_, err = bot.SendDocument(context.Background(), document)
_, err = bot.SendDocument(ctx, document)
if err != nil {
logger.Error("Error in uploading config.json: ", err)
}

View File

@@ -525,6 +525,12 @@
"accountInfo" = "معلومات الحساب"
"outboundStatus" = "حالة المخرج"
"sendThrough" = "أرسل من خلال"
"test" = "اختبار"
"testResult" = "نتيجة الاختبار"
"testing" = "جاري اختبار الاتصال..."
"testSuccess" = "الاختبار ناجح"
"testFailed" = "فشل الاختبار"
"testError" = "فشل اختبار المخرج"
[pages.xray.balancer]
"addBalancer" = "أضف موازن تحميل"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Información de la Cuenta"
"outboundStatus" = "Estado de Salida"
"sendThrough" = "Enviar a través de"
"test" = "Probar"
"testResult" = "Resultado de la prueba"
"testing" = "Probando conexión..."
"testSuccess" = "Prueba exitosa"
"testFailed" = "Prueba fallida"
"testError" = "Error al probar la salida"
[pages.xray.balancer]
"addBalancer" = "Agregar equilibrador"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "اطلاعات حساب"
"outboundStatus" = "وضعیت خروجی"
"sendThrough" = "ارسال با"
"test" = "تست"
"testResult" = "نتیجه تست"
"testing" = "در حال تست اتصال..."
"testSuccess" = "تست موفقیت‌آمیز"
"testFailed" = "تست ناموفق"
"testError" = "خطا در تست خروجی"
[pages.xray.balancer]
"addBalancer" = "افزودن بالانسر"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Informasi Akun"
"outboundStatus" = "Status Keluar"
"sendThrough" = "Kirim Melalui"
"test" = "Tes"
"testResult" = "Hasil Tes"
"testing" = "Menguji koneksi..."
"testSuccess" = "Tes berhasil"
"testFailed" = "Tes gagal"
"testError" = "Gagal menguji outbound"
[pages.xray.balancer]
"addBalancer" = "Tambahkan Penyeimbang"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "アカウント情報"
"outboundStatus" = "アウトバウンドステータス"
"sendThrough" = "送信経路"
"test" = "テスト"
"testResult" = "テスト結果"
"testing" = "接続をテスト中..."
"testSuccess" = "テスト成功"
"testFailed" = "テスト失敗"
"testError" = "アウトバウンドのテストに失敗しました"
[pages.xray.balancer]
"addBalancer" = "負荷分散追加"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Informações da Conta"
"outboundStatus" = "Status de Saída"
"sendThrough" = "Enviar Através de"
"test" = "Testar"
"testResult" = "Resultado do teste"
"testing" = "Testando conexão..."
"testSuccess" = "Teste bem-sucedido"
"testFailed" = "Teste falhou"
"testError" = "Falha ao testar saída"
[pages.xray.balancer]
"addBalancer" = "Adicionar Balanceador"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Информация об учетной записи"
"outboundStatus" = "Статус исходящего подключения"
"sendThrough" = "Отправить через"
"test" = "Тест"
"testResult" = "Результат теста"
"testing" = "Тестирование соединения..."
"testSuccess" = "Тест успешен"
"testFailed" = "Тест не пройден"
"testError" = "Не удалось протестировать исходящее подключение"
[pages.xray.balancer]
"addBalancer" = "Создать балансировщик"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Hesap Bilgileri"
"outboundStatus" = "Giden Durumu"
"sendThrough" = "Üzerinden Gönder"
"test" = "Test"
"testResult" = "Test Sonucu"
"testing" = "Bağlantı test ediliyor..."
"testSuccess" = "Test başarılı"
"testFailed" = "Test başarısız"
"testError" = "Giden test edilemedi"
[pages.xray.balancer]
"addBalancer" = "Dengeleyici Ekle"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Інформація про обліковий запис"
"outboundStatus" = "Статус виходу"
"sendThrough" = "Надіслати через"
"test" = "Тест"
"testResult" = "Результат тесту"
"testing" = "Тестування з'єднання..."
"testSuccess" = "Тест успішний"
"testFailed" = "Тест не пройдено"
"testError" = "Не вдалося протестувати вихідне з'єднання"
[pages.xray.balancer]
"addBalancer" = "Додати балансир"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "Thông tin tài khoản"
"outboundStatus" = "Trạng thái đầu ra"
"sendThrough" = "Gửi qua"
"test" = "Kiểm tra"
"testResult" = "Kết quả kiểm tra"
"testing" = "Đang kiểm tra kết nối..."
"testSuccess" = "Kiểm tra thành công"
"testFailed" = "Kiểm tra thất bại"
"testError" = "Không thể kiểm tra đầu ra"
[pages.xray.balancer]
"addBalancer" = "Thêm cân bằng"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "帐户信息"
"outboundStatus" = "出站状态"
"sendThrough" = "发送通过"
"test" = "测试"
"testResult" = "测试结果"
"testing" = "正在测试连接..."
"testSuccess" = "测试成功"
"testFailed" = "测试失败"
"testError" = "测试出站失败"
[pages.xray.balancer]
"addBalancer" = "添加负载均衡"

View File

@@ -525,6 +525,12 @@
"accountInfo" = "帳戶資訊"
"outboundStatus" = "出站狀態"
"sendThrough" = "傳送通過"
"test" = "測試"
"testResult" = "測試結果"
"testing" = "正在測試連接..."
"testSuccess" = "測試成功"
"testFailed" = "測試失敗"
"testError" = "測試出站失敗"
[pages.xray.balancer]
"addBalancer" = "新增負載均衡"

View File

@@ -200,7 +200,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
if err != nil {
return nil, err
}
engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"})))
engine.Use(gzip.Gzip(gzip.DefaultCompression))
assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret)
@@ -490,3 +490,7 @@ func (s *Server) GetCron() *cron.Cron {
func (s *Server) GetWSHub() any {
return s.wsHub
}
func (s *Server) RestartXray() error {
return s.xrayService.RestartXray(true)
}

View File

@@ -10,4 +10,9 @@ depend() {
}
start_pre(){
cd /usr/local/x-ui
}
reload() {
ebegin "Reloading ${RC_SVCNAME}"
kill -USR1 $pidfile
eend $?
}

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple
WorkingDirectory=/usr/lib/x-ui/
ExecStart=/usr/lib/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5s

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple
WorkingDirectory=/usr/local/x-ui/
ExecStart=/usr/local/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5s

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple
WorkingDirectory=/usr/local/x-ui/
ExecStart=/usr/local/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5s

76
x-ui.sh
View File

@@ -408,6 +408,16 @@ restart() {
fi
}
restart_xray() {
systemctl reload x-ui
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
sleep 2
show_xray_status
if [[ $# == 0 ]]; then
before_show_menu
fi
}
status() {
if [[ $release == "alpine" ]]; then
rc-service x-ui status
@@ -2062,11 +2072,15 @@ SSH_port_forwarding() {
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
if [[ -n "${server_ip}" ]]; then
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break
fi
done
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}')
@@ -2150,6 +2164,7 @@ show_usage() {
${blue}x-ui start${plain} - Start │
${blue}x-ui stop${plain} - Stop │
${blue}x-ui restart${plain} - Restart │
| ${blue}x-ui restart-xray${plain} - Restart Xray │
${blue}x-ui status${plain} - Current Status │
${blue}x-ui settings${plain} - Current Settings │
${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
@@ -2185,25 +2200,26 @@ show_menu() {
${green}11.${plain} Start │
${green}12.${plain} Stop │
${green}13.${plain} Restart │
${green}14.${plain} Check Status
${green}15.${plain} Logs Management
| ${green}14.${plain} Restart Xray
${green}15.${plain} Check Status
${green}16.${plain} Logs Management │
│────────────────────────────────────────────────│
${green}16.${plain} Enable Autostart │
${green}17.${plain} Disable Autostart │
${green}17.${plain} Enable Autostart │
${green}18.${plain} Disable Autostart │
│────────────────────────────────────────────────│
${green}18.${plain} SSL Certificate Management │
${green}19.${plain} Cloudflare SSL Certificate │
${green}20.${plain} IP Limit Management │
${green}21.${plain} Firewall Management │
${green}22.${plain} SSH Port Forwarding Management │
${green}19.${plain} SSL Certificate Management │
${green}20.${plain} Cloudflare SSL Certificate │
${green}21.${plain} IP Limit Management │
${green}22.${plain} Firewall Management │
${green}23.${plain} SSH Port Forwarding Management │
│────────────────────────────────────────────────│
${green}23.${plain} Enable BBR │
${green}24.${plain} Update Geo Files │
${green}25.${plain} Speedtest by Ookla │
${green}24.${plain} Enable BBR │
${green}25.${plain} Update Geo Files │
${green}26.${plain} Speedtest by Ookla │
╚────────────────────────────────────────────────╝
"
show_status
echo && read -rp "Please enter your selection [0-25]: " num
echo && read -rp "Please enter your selection [0-26]: " num
case "${num}" in
0)
@@ -2249,43 +2265,46 @@ show_menu() {
check_install && restart
;;
14)
check_install && status
check_install && restart_xray
;;
15)
check_install && show_log
check_install && status
;;
16)
check_install && enable
check_install && show_log
;;
17)
check_install && disable
check_install && enable
;;
18)
ssl_cert_issue_main
check_install && disable
;;
19)
ssl_cert_issue_CF
ssl_cert_issue_main
;;
20)
iplimit_main
ssl_cert_issue_CF
;;
21)
firewall_menu
iplimit_main
;;
22)
SSH_port_forwarding
firewall_menu
;;
23)
bbr_menu
SSH_port_forwarding
;;
24)
update_geo
bbr_menu
;;
25)
update_geo
;;
26)
run_speedtest
;;
*)
LOGE "Please enter the correct number [0-25]"
LOGE "Please enter the correct number [0-26]"
;;
esac
}
@@ -2301,6 +2320,9 @@ if [[ $# > 0 ]]; then
"restart")
check_install 0 && restart 0
;;
"restart-xray")
check_install 0 && restart_xray 0
;;
"status")
check_install 0 && status 0
;;