mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into feat/daily-traffic-speed
This commit is contained in:
commit
72672203e8
37 changed files with 776 additions and 158 deletions
6
go.mod
6
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module github.com/mhsanaei/3x-ui/v2
|
module github.com/mhsanaei/3x-ui/v2
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/gzip v1.2.6
|
github.com/gin-contrib/gzip v1.2.6
|
||||||
|
|
@ -19,7 +19,7 @@ require (
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4
|
github.com/shirou/gopsutil/v4 v4.26.4
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.70.0
|
github.com/valyala/fasthttp v1.71.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260327.0
|
github.com/xtls/xray-core v1.260327.0
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
|
|
@ -95,7 +95,7 @@ require (
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.44.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -185,8 +185,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
|
|
@ -256,8 +256,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|
|
||||||
16
install.sh
16
install.sh
|
|
@ -678,13 +678,25 @@ config_after_install() {
|
||||||
for ip_address in "${URL_lists[@]}"; do
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
||||||
local http_code=$(echo "$response" | tail -n1)
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
||||||
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
server_ip="${ip_result}"
|
server_ip="${ip_result}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ -z "$server_ip" ]]; then
|
||||||
|
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||||||
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||||
|
server_ip="${server_ip// /}"
|
||||||
|
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||||||
|
server_ip=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
||||||
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
|
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
|
||||||
local config_webBasePath=$(gen_random_string 18)
|
local config_webBasePath=$(gen_random_string 18)
|
||||||
|
|
|
||||||
22
update.sh
22
update.sh
|
|
@ -711,13 +711,25 @@ config_after_update() {
|
||||||
for ip_address in "${URL_lists[@]}"; do
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
||||||
local http_code=$(echo "$response" | tail -n1)
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
||||||
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
server_ip="${ip_result}"
|
server_ip="${ip_result}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ -z "$server_ip" ]]; then
|
||||||
|
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||||||
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||||
|
server_ip="${server_ip// /}"
|
||||||
|
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||||||
|
server_ip=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# Handle missing/short webBasePath
|
# Handle missing/short webBasePath
|
||||||
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
if [[ ${#existing_webBasePath} -lt 4 ]]; then
|
||||||
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
||||||
|
|
@ -737,12 +749,6 @@ config_after_update() {
|
||||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ -z "${server_ip}" ]]; then
|
|
||||||
echo -e "${red}Failed to detect server IP${plain}"
|
|
||||||
echo -e "${yellow}Please configure SSL manually using: x-ui${plain}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prompt and setup SSL (domain or IP)
|
# Prompt and setup SSL (domain or IP)
|
||||||
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
axios.interceptors.request.use(
|
axios.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||||
|
const method = (config.method || 'get').toUpperCase();
|
||||||
|
if (csrfToken && !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) {
|
||||||
|
config.headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
}
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers['Content-Type'] = 'multipart/form-data';
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ const REALITY_TARGETS = [
|
||||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||||
{ target: 'www.tesla.com:443', sni: 'www.tesla.com' },
|
|
||||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,15 +104,25 @@ class WebSocketClient {
|
||||||
}
|
}
|
||||||
this.ws = socket;
|
this.ws = socket;
|
||||||
|
|
||||||
|
// Every handler must check `this.ws !== socket` first. A previous socket
|
||||||
|
// can still fire events (especially `close`) after we've moved on to a
|
||||||
|
// new one — e.g. connect() called while the old socket is in CLOSING
|
||||||
|
// state. Without the guard, a stale close would null out the freshly
|
||||||
|
// opened socket and silently break send().
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.#emit('connected');
|
this.#emit('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => this.#onMessage(event));
|
socket.addEventListener('message', (event) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.#onMessage(event);
|
||||||
|
});
|
||||||
|
|
||||||
socket.addEventListener('error', (event) => {
|
socket.addEventListener('error', (event) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||||
// — bad networks would flood the console otherwise.
|
// — bad networks would flood the console otherwise.
|
||||||
|
|
@ -120,6 +130,7 @@ class WebSocketClient {
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.#emit('disconnected');
|
this.#emit('disconnected');
|
||||||
|
|
@ -196,6 +207,10 @@ class WebSocketClient {
|
||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
// clearTimeout doesn't cancel a callback that has already fired but
|
||||||
|
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
||||||
|
// disconnect() called in that window can't be overridden.
|
||||||
|
if (!this.shouldReconnect) return;
|
||||||
this.#openSocket();
|
this.#openSocket();
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package controller
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||||
// Main API group
|
// Main API group
|
||||||
api := g.Group("/panel/api")
|
api := g.Group("/panel/api")
|
||||||
api.Use(a.checkAPIAuth)
|
api.Use(a.checkAPIAuth)
|
||||||
|
api.Use(middleware.CSRFMiddleware())
|
||||||
|
|
||||||
// Inbounds API
|
// Inbounds API
|
||||||
inbounds := api.Group("/inbounds")
|
inbounds := api.Group("/inbounds")
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
|
@ -41,8 +41,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/logout", a.logout)
|
g.GET("/logout", a.logout)
|
||||||
|
|
||||||
g.POST("/login", a.login)
|
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||||
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||||
|
|
@ -71,28 +71,51 @@ func (a *IndexController) login(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
remoteIP := getRemoteIp(c)
|
||||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
safeUser := template.HTMLEscapeString(form.Username)
|
safeUser := template.HTMLEscapeString(form.Username)
|
||||||
safePass := template.HTMLEscapeString(form.Password)
|
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
||||||
if user == nil {
|
reason := "too many failed attempts"
|
||||||
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||||
|
a.tgbot.UserLoginNotify(service.LoginAttempt{
|
||||||
notifyPass := safePass
|
Username: safeUser,
|
||||||
|
IP: remoteIP,
|
||||||
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
|
Time: timeStr,
|
||||||
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
|
Status: service.LoginFail,
|
||||||
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
|
Reason: reason,
|
||||||
}
|
})
|
||||||
|
|
||||||
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
|
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
||||||
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
|
|
||||||
|
if user == nil {
|
||||||
|
reason := loginFailureReason(checkErr)
|
||||||
|
if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
|
||||||
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||||
|
} else {
|
||||||
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
|
||||||
|
}
|
||||||
|
a.tgbot.UserLoginNotify(service.LoginAttempt{
|
||||||
|
Username: safeUser,
|
||||||
|
IP: remoteIP,
|
||||||
|
Time: timeStr,
|
||||||
|
Status: service.LoginFail,
|
||||||
|
Reason: reason,
|
||||||
|
})
|
||||||
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
||||||
|
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
|
||||||
|
a.tgbot.UserLoginNotify(service.LoginAttempt{
|
||||||
|
Username: safeUser,
|
||||||
|
IP: remoteIP,
|
||||||
|
Time: timeStr,
|
||||||
|
Status: service.LoginSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
if err := session.SetLoginUser(c, user); err != nil {
|
if err := session.SetLoginUser(c, user); err != nil {
|
||||||
logger.Warning("Unable to save session:", err)
|
logger.Warning("Unable to save session:", err)
|
||||||
|
|
@ -103,6 +126,13 @@ func (a *IndexController) login(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loginFailureReason(err error) string {
|
||||||
|
if err != nil && err.Error() == "invalid 2fa code" {
|
||||||
|
return "invalid 2FA code"
|
||||||
|
}
|
||||||
|
return "invalid credentials"
|
||||||
|
}
|
||||||
|
|
||||||
// logout handles user logout by clearing the session and redirecting to the login page.
|
// logout handles user logout by clearing the session and redirecting to the login page.
|
||||||
func (a *IndexController) logout(c *gin.Context) {
|
func (a *IndexController) logout(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
|
|
|
||||||
99
web/controller/login_limiter.go
Normal file
99
web/controller/login_limiter.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginLimitMaxFailures = 5
|
||||||
|
loginLimitWindow = 5 * time.Minute
|
||||||
|
loginLimitCooldown = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
|
||||||
|
|
||||||
|
type loginLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
maxFailures int
|
||||||
|
window time.Duration
|
||||||
|
cooldown time.Duration
|
||||||
|
attempts map[string]*loginLimitRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginLimitRecord struct {
|
||||||
|
failures []time.Time
|
||||||
|
blockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
|
||||||
|
return &loginLimiter{
|
||||||
|
now: time.Now,
|
||||||
|
maxFailures: maxFailures,
|
||||||
|
window: window,
|
||||||
|
cooldown: cooldown,
|
||||||
|
attempts: make(map[string]*loginLimitRecord),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
key := loginLimitKey(ip, username)
|
||||||
|
record := l.attempts[key]
|
||||||
|
if record == nil {
|
||||||
|
return time.Time{}, true
|
||||||
|
}
|
||||||
|
now := l.now()
|
||||||
|
if now.Before(record.blockedUntil) {
|
||||||
|
return record.blockedUntil, false
|
||||||
|
}
|
||||||
|
record.blockedUntil = time.Time{}
|
||||||
|
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
|
||||||
|
if len(record.failures) == 0 {
|
||||||
|
delete(l.attempts, key)
|
||||||
|
}
|
||||||
|
return time.Time{}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
key := loginLimitKey(ip, username)
|
||||||
|
record := l.attempts[key]
|
||||||
|
if record == nil {
|
||||||
|
record = &loginLimitRecord{}
|
||||||
|
l.attempts[key] = record
|
||||||
|
}
|
||||||
|
now := l.now()
|
||||||
|
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
|
||||||
|
record.failures = append(record.failures, now)
|
||||||
|
if len(record.failures) >= l.maxFailures {
|
||||||
|
record.failures = nil
|
||||||
|
record.blockedUntil = now.Add(l.cooldown)
|
||||||
|
return record.blockedUntil, true
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) registerSuccess(ip, username string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.attempts, loginLimitKey(ip, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginLimitKey(ip, username string) string {
|
||||||
|
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
|
||||||
|
keepFrom := 0
|
||||||
|
for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
|
||||||
|
keepFrom++
|
||||||
|
}
|
||||||
|
return failures[keepFrom:]
|
||||||
|
}
|
||||||
74
web/controller/login_limiter_test.go
Normal file
74
web/controller/login_limiter_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||||
|
limiter.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
for i := range 4 {
|
||||||
|
if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
|
||||||
|
t.Fatalf("failure %d should not block yet", i+1)
|
||||||
|
}
|
||||||
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
||||||
|
t.Fatalf("failure %d should still allow login attempts", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
|
||||||
|
if !blocked {
|
||||||
|
t.Fatal("fifth failure should start cooldown")
|
||||||
|
}
|
||||||
|
if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
|
||||||
|
t.Fatalf("blocked until %s, want %s", blockedUntil, want)
|
||||||
|
}
|
||||||
|
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
|
||||||
|
t.Fatal("login should be blocked during cooldown")
|
||||||
|
}
|
||||||
|
|
||||||
|
now = blockedUntil
|
||||||
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
||||||
|
t.Fatal("login should be allowed after cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||||
|
limiter.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
for range 4 {
|
||||||
|
limiter.registerFailure("192.0.2.10", "admin")
|
||||||
|
}
|
||||||
|
now = now.Add(6 * time.Minute)
|
||||||
|
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
||||||
|
t.Fatal("old failures should be pruned outside the rolling window")
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.registerSuccess("192.0.2.10", "admin")
|
||||||
|
for i := range 4 {
|
||||||
|
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
||||||
|
t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||||
|
limiter.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
limiter.registerFailure("192.0.2.10", "admin")
|
||||||
|
}
|
||||||
|
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
|
||||||
|
t.Fatal("different IP should not be blocked")
|
||||||
|
}
|
||||||
|
if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
|
||||||
|
t.Fatal("different username should not be blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
"github.com/mhsanaei/3x-ui/v2/web/entity"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -121,6 +122,12 @@ func html(c *gin.Context, name string, title string, data gin.H) {
|
||||||
data = gin.H{}
|
data = gin.H{}
|
||||||
}
|
}
|
||||||
data["title"] = title
|
data["title"] = title
|
||||||
|
csrfToken, err := session.EnsureCSRFToken(c)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Unable to create CSRF token:", err)
|
||||||
|
} else {
|
||||||
|
data["csrf_token"] = csrfToken
|
||||||
|
}
|
||||||
host := c.GetHeader("X-Forwarded-Host")
|
host := c.GetHeader("X-Forwarded-Host")
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = c.GetHeader("X-Real-IP")
|
host = c.GetHeader("X-Real-IP")
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,15 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
ws "github.com/gorilla/websocket"
|
ws "github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
writeWait = 10 * time.Second
|
|
||||||
pongWait = 60 * time.Second
|
|
||||||
pingPeriod = (pongWait * 9) / 10
|
|
||||||
clientReadLimit = 512
|
|
||||||
)
|
|
||||||
|
|
||||||
var upgrader = ws.Upgrader{
|
var upgrader = ws.Upgrader{
|
||||||
ReadBufferSize: 32768,
|
ReadBufferSize: 32768,
|
||||||
WriteBufferSize: 32768,
|
WriteBufferSize: 32768,
|
||||||
|
|
@ -57,18 +47,21 @@ func checkSameOrigin(r *http.Request) bool {
|
||||||
return strings.EqualFold(u.Hostname(), host)
|
return strings.EqualFold(u.Hostname(), host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocketController handles WebSocket connections for real-time updates.
|
// WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
|
||||||
|
// All per-connection lifecycle (pumps, hub registration) lives in
|
||||||
|
// service.WebSocketService — this controller is HTTP-layer only.
|
||||||
type WebSocketController struct {
|
type WebSocketController struct {
|
||||||
BaseController
|
BaseController
|
||||||
hub *websocket.Hub
|
service *service.WebSocketService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebSocketController creates a new WebSocket controller.
|
// NewWebSocketController creates a controller wired to the given service.
|
||||||
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
|
func NewWebSocketController(svc *service.WebSocketService) *WebSocketController {
|
||||||
return &WebSocketController{hub: hub}
|
return &WebSocketController{service: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
|
// HandleWebSocket authenticates the request, upgrades the HTTP connection, and
|
||||||
|
// hands ownership of the connection off to the service.
|
||||||
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||||
if !session.IsLogin(c) {
|
if !session.IsLogin(c) {
|
||||||
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
|
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
|
||||||
|
|
@ -82,71 +75,5 @@ func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := websocket.NewClient(uuid.New().String())
|
w.service.HandleConnection(conn, getRemoteIp(c))
|
||||||
w.hub.Register(client)
|
|
||||||
logger.Debugf("WebSocket client %s registered from %s", client.ID, getRemoteIp(c))
|
|
||||||
|
|
||||||
go w.writePump(client, conn)
|
|
||||||
go w.readPump(client, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
|
|
||||||
// running. Clients send no commands today; frames are discarded.
|
|
||||||
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
|
|
||||||
defer func() {
|
|
||||||
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
|
||||||
logger.Error("WebSocket readPump panic recovered:", r)
|
|
||||||
}
|
|
||||||
w.hub.Unregister(client)
|
|
||||||
conn.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
conn.SetReadLimit(clientReadLimit)
|
|
||||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
||||||
conn.SetPongHandler(func(string) error {
|
|
||||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
||||||
})
|
|
||||||
|
|
||||||
for {
|
|
||||||
if _, _, err := conn.ReadMessage(); err != nil {
|
|
||||||
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
|
||||||
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePump pushes hub messages to the connection and emits keepalive pings.
|
|
||||||
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
|
|
||||||
ticker := time.NewTicker(pingPeriod)
|
|
||||||
defer func() {
|
|
||||||
if r := common.Recover("WebSocket writePump panic"); r != nil {
|
|
||||||
logger.Error("WebSocket writePump panic recovered:", r)
|
|
||||||
}
|
|
||||||
ticker.Stop()
|
|
||||||
conn.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg, ok := <-client.Send:
|
|
||||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
||||||
if !ok {
|
|
||||||
conn.WriteMessage(ws.CloseMessage, []byte{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := conn.WriteMessage(ws.TextMessage, msg); 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ func NewXUIController(g *gin.RouterGroup) *XUIController {
|
||||||
func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
g = g.Group("/panel")
|
g = g.Group("/panel")
|
||||||
g.Use(a.checkLogin)
|
g.Use(a.checkLogin)
|
||||||
|
g.Use(middleware.CSRFMiddleware())
|
||||||
|
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<meta name="renderer" content="webkit">
|
<meta name="renderer" content="webkit">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="robots" content="noindex,nofollow">
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
{{ if .csrf_token }}<meta name="csrf-token" content="{{ .csrf_token }}">{{ end }}
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css">
|
<link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css">
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}">
|
<link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}">
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -102,4 +103,4 @@
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
47
web/middleware/security.go
Normal file
47
web/middleware/security.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
|
||||||
|
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("X-Frame-Options", "DENY")
|
||||||
|
c.Header("Referrer-Policy", "no-referrer")
|
||||||
|
c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||||
|
if directHTTPS {
|
||||||
|
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
|
||||||
|
func CSRFMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if isSafeMethod(c.Request.Method) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !session.ValidateCSRFToken(c) {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeMethod(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
121
web/middleware/security_test.go
Normal file
121
web/middleware/security_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCSRFMiddlewareAllowsSafeMethods(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(CSRFMiddleware())
|
||||||
|
router.GET("/safe", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFMiddlewareRejectsMissingTokenAndAcceptsValidToken(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
store := cookie.NewStore([]byte("01234567890123456789012345678901"))
|
||||||
|
router.Use(sessions.Sessions("3x-ui", store))
|
||||||
|
router.GET("/token", func(c *gin.Context) {
|
||||||
|
token, err := session.EnsureCSRFToken(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.String(http.StatusOK, token)
|
||||||
|
})
|
||||||
|
router.POST("/submit", CSRFMiddleware(), func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
tokenRec := httptest.NewRecorder()
|
||||||
|
tokenReq := httptest.NewRequest(http.MethodGet, "/token", nil)
|
||||||
|
router.ServeHTTP(tokenRec, tokenReq)
|
||||||
|
if tokenRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("token status = %d, want %d", tokenRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
cookies := tokenRec.Result().Cookies()
|
||||||
|
token := tokenRec.Body.String()
|
||||||
|
|
||||||
|
missingRec := httptest.NewRecorder()
|
||||||
|
missingReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
missingReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
router.ServeHTTP(missingRec, missingReq)
|
||||||
|
if missingRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("missing token status = %d, want %d", missingRec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
validRec := httptest.NewRecorder()
|
||||||
|
validReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
validReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
validReq.Header.Set(session.CSRFHeaderName, token)
|
||||||
|
router.ServeHTTP(validRec, validReq)
|
||||||
|
if validRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("valid token status = %d, want %d", validRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeadersMiddleware(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(SecurityHeadersMiddleware(true))
|
||||||
|
router.GET("/", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
headers := rec.Result().Header
|
||||||
|
if got := headers.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Fatalf("X-Content-Type-Options = %q", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Frame-Options"); got != "DENY" {
|
||||||
|
t.Fatalf("X-Frame-Options = %q", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("Referrer-Policy"); got != "no-referrer" {
|
||||||
|
t.Fatalf("Referrer-Policy = %q", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("Strict-Transport-Security"); got == "" {
|
||||||
|
t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(SecurityHeadersMiddleware(false))
|
||||||
|
router.GET("/", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Result().Header.Get("Strict-Transport-Security"); got != "" {
|
||||||
|
t.Fatalf("Strict-Transport-Security = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,16 @@ const (
|
||||||
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoginAttempt contains safe metadata for panel login notifications.
|
||||||
|
// It intentionally does not include attempted passwords.
|
||||||
|
type LoginAttempt struct {
|
||||||
|
Username string
|
||||||
|
IP string
|
||||||
|
Time string
|
||||||
|
Status LoginStatus
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
// Tgbot provides business logic for Telegram bot integration.
|
// Tgbot provides business logic for Telegram bot integration.
|
||||||
// It handles bot commands, user interactions, and status reporting via Telegram.
|
// It handles bot commands, user interactions, and status reporting via Telegram.
|
||||||
type Tgbot struct {
|
type Tgbot struct {
|
||||||
|
|
@ -2769,12 +2779,12 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserLoginNotify sends a notification about user login attempts to admins.
|
// UserLoginNotify sends a notification about user login attempts to admins.
|
||||||
func (t *Tgbot) UserLoginNotify(username string, password string, ip string, time string, status LoginStatus) {
|
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
||||||
if !t.IsRunning() {
|
if !t.IsRunning() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if username == "" || ip == "" || time == "" {
|
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
|
||||||
logger.Warning("UserLoginNotify failed, invalid info!")
|
logger.Warning("UserLoginNotify failed, invalid info!")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2785,18 +2795,20 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := ""
|
msg := ""
|
||||||
switch status {
|
switch attempt.Status {
|
||||||
case LoginSuccess:
|
case LoginSuccess:
|
||||||
msg += t.I18nBot("tgbot.messages.loginSuccess")
|
msg += t.I18nBot("tgbot.messages.loginSuccess")
|
||||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||||
case LoginFail:
|
case LoginFail:
|
||||||
msg += t.I18nBot("tgbot.messages.loginFailed")
|
msg += t.I18nBot("tgbot.messages.loginFailed")
|
||||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||||
msg += t.I18nBot("tgbot.messages.password", "Password=="+password)
|
if attempt.Reason != "" {
|
||||||
|
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+username)
|
msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
|
||||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+ip)
|
msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
|
||||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+time)
|
msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
|
||||||
t.SendMsgToTgbotAdmins(msg)
|
t.SendMsgToTgbotAdmins(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
web/service/tgbot_test.go
Normal file
13
web/service/tgbot_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
||||||
|
typ := reflect.TypeOf(LoginAttempt{})
|
||||||
|
if _, ok := typ.FieldByName("Password"); ok {
|
||||||
|
t.Fatal("LoginAttempt must not carry attempted passwords")
|
||||||
|
}
|
||||||
|
}
|
||||||
115
web/service/websocket.go
Normal file
115
web/service/websocket.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
// Package service: WebSocketService owns the per-connection pump goroutines
|
||||||
|
// and bridges the HTTP-layer controller to the broadcast hub. The controller
|
||||||
|
// handles the upgrade handshake and authentication, then hands the raw
|
||||||
|
// connection to this service which takes ownership of its lifecycle.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
ws "github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsWriteWait = 10 * time.Second
|
||||||
|
wsPongWait = 60 * time.Second
|
||||||
|
wsPingPeriod = (wsPongWait * 9) / 10
|
||||||
|
wsClientReadLimit = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocketService manages WebSocket client connections. It owns the
|
||||||
|
// read/write pumps for each accepted connection and registers/unregisters
|
||||||
|
// clients with the hub.
|
||||||
|
type WebSocketService struct {
|
||||||
|
hub *websocket.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebSocketService creates a service backed by the given hub.
|
||||||
|
func NewWebSocketService(hub *websocket.Hub) *WebSocketService {
|
||||||
|
return &WebSocketService{hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConnection takes ownership of an upgraded WebSocket connection:
|
||||||
|
// registers a new client, starts the read/write pumps, and returns
|
||||||
|
// immediately. The connection is closed when both pumps exit.
|
||||||
|
func (s *WebSocketService) HandleConnection(conn *ws.Conn, remoteIP string) {
|
||||||
|
if s == nil || s.hub == nil || conn == nil {
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := websocket.NewClient(uuid.New().String())
|
||||||
|
s.hub.Register(client)
|
||||||
|
logger.Debugf("WebSocket client %s registered from %s", client.ID, remoteIP)
|
||||||
|
|
||||||
|
go s.writePump(client, conn)
|
||||||
|
go s.readPump(client, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
|
||||||
|
// running. Clients send no commands today; frames are discarded.
|
||||||
|
func (s *WebSocketService) 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)
|
||||||
|
}
|
||||||
|
s.hub.Unregister(client)
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn.SetReadLimit(wsClientReadLimit)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||||
|
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump pushes hub messages to the connection and emits keepalive pings.
|
||||||
|
func (s *WebSocketService) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||||
|
ticker := time.NewTicker(wsPingPeriod)
|
||||||
|
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 msg, ok := <-client.Send:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
|
if !ok {
|
||||||
|
conn.WriteMessage(ws.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
|
||||||
|
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
|
if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
|
||||||
|
logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
web/session/csrf.go
Normal file
55
web/session/csrf.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const csrfTokenKey = "CSRF_TOKEN"
|
||||||
|
|
||||||
|
// CSRFHeaderName is the request header used by browser clients for unsafe methods.
|
||||||
|
const CSRFHeaderName = "X-CSRF-Token"
|
||||||
|
|
||||||
|
// EnsureCSRFToken returns the current session CSRF token or creates one.
|
||||||
|
func EnsureCSRFToken(c *gin.Context) (string, error) {
|
||||||
|
s := sessions.Default(c)
|
||||||
|
if token, ok := s.Get(csrfTokenKey).(string); ok && token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
token, err := newCSRFToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.Set(csrfTokenKey, token)
|
||||||
|
return token, s.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCSRFToken checks the submitted CSRF token against the session token.
|
||||||
|
func ValidateCSRFToken(c *gin.Context) bool {
|
||||||
|
s := sessions.Default(c)
|
||||||
|
expected, ok := s.Get(csrfTokenKey).(string)
|
||||||
|
if !ok || expected == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actual := c.GetHeader(CSRFHeaderName)
|
||||||
|
if actual == "" {
|
||||||
|
actual = c.PostForm("_csrf")
|
||||||
|
}
|
||||||
|
if len(actual) != len(expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCSRFToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ func ClearSession(c *gin.Context) error {
|
||||||
Path: cookiePath,
|
Path: cookiePath,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: c.Request.TLS != nil,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
return s.Save()
|
return s.Save()
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ الحالة: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ الحالة: {{ .State }}\r\n"
|
||||||
"username" = "👤 اسم المستخدم: {{ .Username }}\r\n"
|
"username" = "👤 اسم المستخدم: {{ .Username }}\r\n"
|
||||||
"password" = "👤 الباسورد: {{ .Password }}\r\n"
|
"reason" = "❗️ السبب: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ الوقت: {{ .Time }}\r\n"
|
"time" = "⏰ الوقت: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 الإدخال: {{ .Remark }}\r\n"
|
"inbound" = "📍 الإدخال: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 البورت: {{ .Port }}\r\n"
|
"port" = "🔌 البورت: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -767,7 +767,7 @@
|
||||||
"traffic" = "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
||||||
"username" = "👤 Username: {{ .Username }}\r\n"
|
"username" = "👤 Username: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Password: {{ .Password }}\r\n"
|
"reason" = "❗️ Reason: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Time: {{ .Time }}\r\n"
|
"time" = "⏰ Time: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Port: {{ .Port }}\r\n"
|
"port" = "🔌 Port: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Estado de Xray: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Estado de Xray: {{ .State }}\r\n"
|
||||||
"username" = "👤 Nombre de usuario: {{ .Username }}\r\n"
|
"username" = "👤 Nombre de usuario: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Contraseña: {{ .Password }}\r\n"
|
"reason" = "❗️ Motivo: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Hora: {{ .Time }}\r\n"
|
"time" = "⏰ Hora: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Puerto: {{ .Port }}\r\n"
|
"port" = "🔌 Puerto: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ وضعیتایکسری: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ وضعیتایکسری: {{ .State }}\r\n"
|
||||||
"username" = "👤 نامکاربری: {{ .Username }}\r\n"
|
"username" = "👤 نامکاربری: {{ .Username }}\r\n"
|
||||||
"password" = "👤 رمز عبور: {{ .Password }}\r\n"
|
"reason" = "❗️ دلیل: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ زمان: {{ .Time }}\r\n"
|
"time" = "⏰ زمان: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 نامورودی: {{ .Remark }}\r\n"
|
"inbound" = "📍 نامورودی: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 پورت: {{ .Port }}\r\n"
|
"port" = "🔌 پورت: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
||||||
"username" = "👤 Nama Pengguna: {{ .Username }}\r\n"
|
"username" = "👤 Nama Pengguna: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Kata Sandi: {{ .Password }}\r\n"
|
"reason" = "❗️ Alasan: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Waktu: {{ .Time }}\r\n"
|
"time" = "⏰ Waktu: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Port: {{ .Port }}\r\n"
|
"port" = "🔌 Port: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Xrayステータス:{{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Xrayステータス:{{ .State }}\r\n"
|
||||||
"username" = "👤 ユーザー名:{{ .Username }}\r\n"
|
"username" = "👤 ユーザー名:{{ .Username }}\r\n"
|
||||||
"password" = "👤 パスワード: {{ .Password }}\r\n"
|
"reason" = "❗️ 理由:{{ .Reason }}\r\n"
|
||||||
"time" = "⏰ 時間:{{ .Time }}\r\n"
|
"time" = "⏰ 時間:{{ .Time }}\r\n"
|
||||||
"inbound" = "📍 インバウンド:{{ .Remark }}\r\n"
|
"inbound" = "📍 インバウンド:{{ .Remark }}\r\n"
|
||||||
"port" = "🔌 ポート:{{ .Port }}\r\n"
|
"port" = "🔌 ポート:{{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
|
||||||
"username" = "👤 Nome de usuário: {{ .Username }}\r\n"
|
"username" = "👤 Nome de usuário: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Senha: {{ .Password }}\r\n"
|
"reason" = "❗️ Motivo: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Hora: {{ .Time }}\r\n"
|
"time" = "⏰ Hora: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Porta: {{ .Port }}\r\n"
|
"port" = "🔌 Porta: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -767,7 +767,7 @@
|
||||||
"traffic" = "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Состояние Xray: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Состояние Xray: {{ .State }}\r\n"
|
||||||
"username" = "👤 Имя пользователя: {{ .Username }}\r\n"
|
"username" = "👤 Имя пользователя: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Пароль: {{ .Password }}\r\n"
|
"reason" = "❗️ Причина: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Время: {{ .Time }}\r\n"
|
"time" = "⏰ Время: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
|
"inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Durum: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Durum: {{ .State }}\r\n"
|
||||||
"username" = "👤 Kullanıcı Adı: {{ .Username }}\r\n"
|
"username" = "👤 Kullanıcı Adı: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Şifre: {{ .Password }}\r\n"
|
"reason" = "❗️ Sebep: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Zaman: {{ .Time }}\r\n"
|
"time" = "⏰ Zaman: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Gelen: {{ .Remark }}\r\n"
|
"inbound" = "📍 Gelen: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Port: {{ .Port }}\r\n"
|
"port" = "🔌 Port: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Статус: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Статус: {{ .State }}\r\n"
|
||||||
"username" = "👤 Ім'я користувача: {{ .Username }}\r\n"
|
"username" = "👤 Ім'я користувача: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Пароль: {{ .Password }}\r\n"
|
"reason" = "❗️ Причина: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Час: {{ .Time }}\r\n"
|
"time" = "⏰ Час: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Trạng thái Xray: {{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Trạng thái Xray: {{ .State }}\r\n"
|
||||||
"username" = "👤 Tên người dùng: {{ .Username }}\r\n"
|
"username" = "👤 Tên người dùng: {{ .Username }}\r\n"
|
||||||
"password" = "👤 Mật khẩu: {{ .Password }}\r\n"
|
"reason" = "❗️ Lý do: {{ .Reason }}\r\n"
|
||||||
"time" = "⏰ Thời gian: {{ .Time }}\r\n"
|
"time" = "⏰ Thời gian: {{ .Time }}\r\n"
|
||||||
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
"inbound" = "📍 Inbound: {{ .Remark }}\r\n"
|
||||||
"port" = "🔌 Cổng: {{ .Port }}\r\n"
|
"port" = "🔌 Cổng: {{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Xray 状态:{{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Xray 状态:{{ .State }}\r\n"
|
||||||
"username" = "👤 用户名:{{ .Username }}\r\n"
|
"username" = "👤 用户名:{{ .Username }}\r\n"
|
||||||
"password" = "👤 密码: {{ .Password }}\r\n"
|
"reason" = "❗️ 原因:{{ .Reason }}\r\n"
|
||||||
"time" = "⏰ 时间:{{ .Time }}\r\n"
|
"time" = "⏰ 时间:{{ .Time }}\r\n"
|
||||||
"inbound" = "📍 入站:{{ .Remark }}\r\n"
|
"inbound" = "📍 入站:{{ .Remark }}\r\n"
|
||||||
"port" = "🔌 端口:{{ .Port }}\r\n"
|
"port" = "🔌 端口:{{ .Port }}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
"traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
|
||||||
"xrayStatus" = "ℹ️ Xray 狀態:{{ .State }}\r\n"
|
"xrayStatus" = "ℹ️ Xray 狀態:{{ .State }}\r\n"
|
||||||
"username" = "👤 使用者名稱:{{ .Username }}\r\n"
|
"username" = "👤 使用者名稱:{{ .Username }}\r\n"
|
||||||
"password" = "👤 密碼: {{ .Password }}\r\n"
|
"reason" = "❗️ 原因:{{ .Reason }}\r\n"
|
||||||
"time" = "⏰ 時間:{{ .Time }}\r\n"
|
"time" = "⏰ 時間:{{ .Time }}\r\n"
|
||||||
"inbound" = "📍 入站:{{ .Remark }}\r\n"
|
"inbound" = "📍 入站:{{ .Remark }}\r\n"
|
||||||
"port" = "🔌 埠:{{ .Port }}\r\n"
|
"port" = "🔌 埠:{{ .Port }}\r\n"
|
||||||
|
|
|
||||||
18
web/web.go
18
web/web.go
|
|
@ -170,6 +170,16 @@ func (s *Server) getHtmlTemplate(funcMap template.FuncMap) (*template.Template,
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) isDirectHTTPSConfigured() bool {
|
||||||
|
certFile, certErr := s.settingService.GetCertFile()
|
||||||
|
keyFile, keyErr := s.settingService.GetKeyFile()
|
||||||
|
if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// initRouter initializes Gin, registers middleware, templates, static
|
// initRouter initializes Gin, registers middleware, templates, static
|
||||||
// assets, controllers and returns the configured engine.
|
// assets, controllers and returns the configured engine.
|
||||||
func (s *Server) initRouter() (*gin.Engine, error) {
|
func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
|
|
@ -182,6 +192,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := gin.Default()
|
engine := gin.Default()
|
||||||
|
directHTTPS := s.isDirectHTTPSConfigured()
|
||||||
|
engine.Use(middleware.SecurityHeadersMiddleware(directHTTPS))
|
||||||
|
|
||||||
webDomain, err := s.settingService.GetWebDomain()
|
webDomain, err := s.settingService.GetWebDomain()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -209,6 +221,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
sessionOptions := sessions.Options{
|
sessionOptions := sessions.Options{
|
||||||
Path: basePath,
|
Path: basePath,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: directHTTPS,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
|
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
|
||||||
|
|
@ -276,8 +289,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
s.wsHub = websocket.NewHub()
|
s.wsHub = websocket.NewHub()
|
||||||
go s.wsHub.Run()
|
go s.wsHub.Run()
|
||||||
|
|
||||||
// Initialize WebSocket controller
|
// Initialize WebSocket controller — service owns per-connection pumps,
|
||||||
s.ws = controller.NewWebSocketController(s.wsHub)
|
// controller is HTTP-layer only (auth + upgrade).
|
||||||
|
s.ws = controller.NewWebSocketController(service.NewWebSocketService(s.wsHub))
|
||||||
// Register WebSocket route with basePath (g already has basePath prefix)
|
// Register WebSocket route with basePath (g already has basePath prefix)
|
||||||
g.GET("/ws", s.ws.HandleWebSocket)
|
g.GET("/ws", s.ws.HandleWebSocket)
|
||||||
|
|
||||||
|
|
|
||||||
83
x-ui.sh
83
x-ui.sh
|
|
@ -292,9 +292,35 @@ check_config() {
|
||||||
local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||||
local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
|
local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
|
||||||
local existing_cert=$(${xui_folder}/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)
|
local URL_lists=(
|
||||||
if [ -z "$server_ip" ]; then
|
"https://api4.ipify.org"
|
||||||
server_ip=$(curl -s --max-time 3 https://4.ident.me)
|
"https://ipv4.icanhazip.com"
|
||||||
|
"https://v4.api.ipinfo.io/ip"
|
||||||
|
"https://ipv4.myexternalip.com/raw"
|
||||||
|
"https://4.ident.me"
|
||||||
|
"https://check-host.net/ip"
|
||||||
|
)
|
||||||
|
local server_ip=""
|
||||||
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
|
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" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
server_ip="${ip_result}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$server_ip" ]]; then
|
||||||
|
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||||||
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||||
|
server_ip="${server_ip// /}"
|
||||||
|
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||||||
|
server_ip=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$existing_cert" ]]; then
|
if [[ -n "$existing_cert" ]]; then
|
||||||
|
|
@ -1139,14 +1165,35 @@ ssl_cert_issue_for_ip() {
|
||||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||||
|
|
||||||
# Get server IP
|
# Get server IP
|
||||||
local server_ip=$(curl -s --max-time 3 https://api.ipify.org)
|
local URL_lists=(
|
||||||
if [ -z "$server_ip" ]; then
|
"https://api4.ipify.org"
|
||||||
server_ip=$(curl -s --max-time 3 https://4.ident.me)
|
"https://ipv4.icanhazip.com"
|
||||||
fi
|
"https://v4.api.ipinfo.io/ip"
|
||||||
|
"https://ipv4.myexternalip.com/raw"
|
||||||
|
"https://4.ident.me"
|
||||||
|
"https://check-host.net/ip"
|
||||||
|
)
|
||||||
|
local server_ip=""
|
||||||
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
|
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" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
server_ip="${ip_result}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [ -z "$server_ip" ]; then
|
if [[ -z "$server_ip" ]]; then
|
||||||
LOGE "Failed to get server IP address"
|
LOGI "Could not auto-detect server IP from any provider."
|
||||||
return 1
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||||
|
server_ip="${server_ip// /}"
|
||||||
|
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
LOGE "Invalid IPv4 address. Please try again."
|
||||||
|
server_ip=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
LOGI "Server IP detected: ${server_ip}"
|
LOGI "Server IP detected: ${server_ip}"
|
||||||
|
|
@ -2104,13 +2151,25 @@ SSH_port_forwarding() {
|
||||||
for ip_address in "${URL_lists[@]}"; do
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
||||||
local http_code=$(echo "$response" | tail -n1)
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
||||||
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
server_ip="${ip_result}"
|
server_ip="${ip_result}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ -z "$server_ip" ]]; then
|
||||||
|
echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
|
||||||
|
while [[ -z "$server_ip" ]]; do
|
||||||
|
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||||
|
server_ip="${server_ip// /}"
|
||||||
|
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||||||
|
server_ip=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | 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_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_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue