Merge branch 'main' into main

This commit is contained in:
Sanaei 2026-04-20 00:30:16 +02:00 committed by GitHub
commit beb34213a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3488 additions and 319 deletions

43
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: "CodeQL Advanced"
on:
push:
pull_request:
schedule:
- cron: '18 2 * * 2'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View file

@ -22,6 +22,14 @@
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
## مصادر DAT مخصصة GeoSite / GeoIP
يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_<alias>.dat` و`geoip_<alias>.dat`.
**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`).
**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-``_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز.
## البدء السريع
```

View file

@ -22,6 +22,14 @@
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
## Fuentes DAT personalizadas GeoSite / GeoIP
Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_<alias>.dat` y `geoip_<alias>.dat`.
**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`).
**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-``_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada.
## Inicio Rápido
```

View file

@ -22,6 +22,14 @@
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
## منابع DAT سفارشی GeoSite / GeoIP
سرپرستان می‌توانند از طریق پنل فایل‌های `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی به‌روزرسانی ژئوفایل‌های داخلی). فایل‌ها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیش‌فرض `bin/`) با نام‌های ثابت `geosite_<alias>.dat` و `geoip_<alias>.dat` ذخیره می‌شوند.
**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`).
**نام‌های رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمال‌شده (`strings.ToLower`، `-``_`) مقایسه می‌شود. نام‌های واردشده و رکورد پایگاه داده بازنویسی نمی‌شوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان می‌خورند.
## شروع سریع
```

View file

@ -22,6 +22,14 @@
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
## Custom GeoSite / GeoIP DAT sources
Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_<alias>.dat` and `geoip_<alias>.dat`.
**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`).
**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-``_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry.
## Quick Start
```bash

View file

@ -22,6 +22,14 @@
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
## Пользовательские GeoSite / GeoIP (DAT)
В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_<alias>.dat` и `geoip_<alias>.dat`.
**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`).
**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-``_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись.
## Быстрый старт
```

View file

@ -22,6 +22,14 @@
作为原始 X-UI 项目的增强版本3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
## 自定义 GeoSite / GeoIPDAT
管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_<alias>.dat``geoip_<alias>.dat`
**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag``ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。
**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower``-` → `_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir``geoip_ir` 视为同一保留项。
## 快速开始
```

View file

@ -38,6 +38,7 @@ func initModels() error {
&model.InboundClientIps{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {
@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
return db
}
// IsNotFound checks if the given error is a GORM record not found error.
func IsNotFound(err error) bool {
return err == gorm.ErrRecordNotFound
return errors.Is(err, gorm.ErrRecordNotFound)
}
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.

View file

@ -104,6 +104,18 @@ type Setting struct {
Value string `json:"value" form:"value"`
}
type CustomGeoResource struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
Url string `json:"url" gorm:"not null"`
LocalPath string `json:"localPath" gorm:"column:local_path"`
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
LastModified string `json:"lastModified" gorm:"column:last_modified"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
}
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id"` // Unique client identifier

View file

@ -13,4 +13,4 @@ services:
XUI_ENABLE_FAIL2BAN: "true"
tty: true
network_mode: host
restart: unless-stopped
restart: unless-stopped

4
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/goccy/go-json v0.10.6
github.com/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
@ -26,6 +27,7 @@ require (
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@ -47,7 +49,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
@ -96,7 +97,6 @@ require (
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)

View file

@ -168,26 +168,26 @@ install_base() {
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates openssl
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum install -y curl tar tzdata socat ca-certificates openssl
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
else
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates openssl
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
fi
;;
arch | manjaro | parch)
pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat ca-certificates openssl
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh && zypper -q install -y curl tar timezone socat ca-certificates openssl
zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
;;
alpine)
apk update && apk add curl tar tzdata socat ca-certificates openssl
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
;;
*)
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates openssl
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
esac
}
@ -468,15 +468,15 @@ ssl_cert_issue() {
done
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}"
# check if there already exists a certificate
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
if [ "${currentCert}" == "${domain}" ]; then
local certInfo=$(~/.acme.sh/acme.sh --list)
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
echo -e "${yellow}Current certificate details:${plain}"
echo "$certInfo"
return 1
# detect existing certificate and reuse it if present
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi
@ -503,16 +503,20 @@ ssl_cert_issue() {
echo -e "${yellow}Stopping panel temporarily...${plain}"
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
fi
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
echo -e "${green}Using existing certificate, installing certificates...${plain}"
fi
# Setup reload command
@ -542,17 +546,27 @@ ssl_cert_issue() {
fi
# install the certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
local installOutput=""
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
echo "${installOutput}"
if [ $? -ne 0 ]; then
local installWroteFiles=0
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
installWroteFiles=1
fi
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
rm -rf ~/.acme.sh/${domain}
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
fi
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
fi
# enable auto-renew
@ -623,14 +637,21 @@ prompt_and_setup_ssl() {
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
ssl_cert_issue
# Extract the domain that was used from the certificate
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
fi
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
;;
@ -668,7 +689,7 @@ prompt_and_setup_ssl() {
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}"
custom_domain="${custom_domain// /}" # Remove spaces
# 3.2 Loop for Certificate Path
while true; do

View file

@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err
}
// Determine if JSON subscription endpoint is enabled
ClashPath, err := s.settingService.GetSubClashPath()
if err != nil {
return nil, err
}
subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil {
return nil, err
}
subClashEnable, err := s.settingService.GetSubClashEnable()
if err != nil {
return nil, err
}
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group("/")
s.sub = NewSUBController(
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)

385
sub/subClashService.go Normal file
View file

@ -0,0 +1,385 @@
package sub
import (
"fmt"
"strings"
"github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
)
type SubClashService struct {
inboundService service.InboundService
SubService *SubService
}
type ClashConfig struct {
Proxies []map[string]any `yaml:"proxies"`
ProxyGroups []map[string]any `yaml:"proxy-groups"`
Rules []string `yaml:"rules"`
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 {
return "", "", err
}
var traffic xray.ClientTraffic
var clientTraffics []xray.ClientTraffic
var proxies []map[string]any
for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound)
if err != nil {
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
}
if clients == nil {
continue
}
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
if err == nil {
inbound.Listen = listen
inbound.Port = port
inbound.StreamSettings = streamSettings
}
}
for _, client := range clients {
if client.Enable && client.SubID == subId {
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
proxies = append(proxies, s.getProxies(inbound, client, host)...)
}
}
}
if len(proxies) == 0 {
return "", "", nil
}
for index, clientTraffic := range clientTraffics {
if index == 0 {
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 {
traffic.ExpiryTime = clientTraffic.ExpiryTime
}
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
} else {
traffic.Total += clientTraffic.Total
}
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
traffic.ExpiryTime = 0
}
}
}
proxyNames := make([]string, 0, len(proxies)+1)
for _, proxy := range proxies {
if name, ok := proxy["name"].(string); ok && name != "" {
proxyNames = append(proxyNames, name)
}
}
proxyNames = append(proxyNames, "DIRECT")
config := ClashConfig{
Proxies: proxies,
ProxyGroups: []map[string]any{{
"name": "PROXY",
"type": "select",
"proxies": proxyNames,
}},
Rules: []string{"MATCH,PROXY"},
}
finalYAML, err := yaml.Marshal(config)
if err != nil {
return "", "", err
}
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
return string(finalYAML), header, nil
}
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
stream := s.streamData(inbound.StreamSettings)
externalProxies, ok := stream["externalProxy"].([]any)
if !ok || len(externalProxies) == 0 {
externalProxies = []any{map[string]any{
"forceTls": "same",
"dest": host,
"port": float64(inbound.Port),
"remark": "",
}}
}
delete(stream, "externalProxy")
proxies := make([]map[string]any, 0, len(externalProxies))
for _, ep := range externalProxies {
extPrxy := ep.(map[string]any)
workingInbound := *inbound
workingInbound.Listen = extPrxy["dest"].(string)
workingInbound.Port = int(extPrxy["port"].(float64))
workingStream := cloneMap(stream)
switch extPrxy["forceTls"].(string) {
case "tls":
if workingStream["security"] != "tls" {
workingStream["security"] = "tls"
workingStream["tlsSettings"] = map[string]any{}
}
case "none":
if workingStream["security"] != "none" {
workingStream["security"] = "none"
delete(workingStream, "tlsSettings")
delete(workingStream, "realitySettings")
}
}
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
if len(proxy) > 0 {
proxies = append(proxies, proxy)
}
}
return proxies
}
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
}
network, _ := stream["network"].(string)
if !s.applyTransport(proxy, network, stream) {
return nil
}
switch inbound.Protocol {
case model.VMESS:
proxy["type"] = "vmess"
proxy["uuid"] = client.ID
proxy["alterId"] = 0
cipher := client.Security
if cipher == "" {
cipher = "auto"
}
proxy["cipher"] = cipher
case model.VLESS:
proxy["type"] = "vless"
proxy["uuid"] = client.ID
if client.Flow != "" && network == "tcp" {
proxy["flow"] = client.Flow
}
var inboundSettings map[string]any
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
proxy["packet-encoding"] = encryption
}
case model.Trojan:
proxy["type"] = "trojan"
proxy["password"] = client.Password
case model.Shadowsocks:
proxy["type"] = "ss"
proxy["password"] = client.Password
var inboundSettings map[string]any
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
method, _ := inboundSettings["method"].(string)
if method == "" {
return nil
}
proxy["cipher"] = method
if strings.HasPrefix(method, "2022") {
if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
}
}
default:
return nil
}
security, _ := stream["security"].(string)
if !s.applySecurity(proxy, security, stream) {
return nil
}
return proxy
}
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
switch network {
case "", "tcp":
proxy["network"] = "tcp"
tcp, _ := stream["tcpSettings"].(map[string]any)
if tcp != nil {
header, _ := tcp["header"].(map[string]any)
if header != nil {
typeStr, _ := header["type"].(string)
if typeStr != "" && typeStr != "none" {
return false
}
}
}
return true
case "ws":
proxy["network"] = "ws"
ws, _ := stream["wsSettings"].(map[string]any)
wsOpts := map[string]any{}
if ws != nil {
if path, ok := ws["path"].(string); ok && path != "" {
wsOpts["path"] = path
}
host := ""
if v, ok := ws["host"].(string); ok && v != "" {
host = v
} else if headers, ok := ws["headers"].(map[string]any); ok {
host = searchHost(headers)
}
if host != "" {
wsOpts["headers"] = map[string]any{"Host": host}
}
}
if len(wsOpts) > 0 {
proxy["ws-opts"] = wsOpts
}
return true
case "grpc":
proxy["network"] = "grpc"
grpc, _ := stream["grpcSettings"].(map[string]any)
grpcOpts := map[string]any{}
if grpc != nil {
if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
grpcOpts["grpc-service-name"] = serviceName
}
}
if len(grpcOpts) > 0 {
proxy["grpc-opts"] = grpcOpts
}
return true
default:
return false
}
}
func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
switch security {
case "", "none":
proxy["tls"] = false
return true
case "tls":
proxy["tls"] = true
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
if tlsSettings != nil {
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
proxy["servername"] = serverName
switch proxy["type"] {
case "trojan":
proxy["sni"] = serverName
}
}
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
proxy["client-fingerprint"] = fingerprint
}
}
return true
case "reality":
proxy["tls"] = true
realitySettings, _ := stream["realitySettings"].(map[string]any)
if realitySettings == nil {
return false
}
if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
proxy["servername"] = serverName
}
realityOpts := map[string]any{}
if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
realityOpts["public-key"] = publicKey
}
if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
realityOpts["short-id"] = shortID
}
if len(realityOpts) > 0 {
proxy["reality-opts"] = realityOpts
}
if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
proxy["client-fingerprint"] = fingerprint
}
return true
default:
return false
}
}
func (s *SubClashService) streamData(stream string) map[string]any {
var streamSettings map[string]any
json.Unmarshal([]byte(stream), &streamSettings)
security, _ := streamSettings["security"].(string)
switch security {
case "tls":
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
}
case "reality":
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
streamSettings["realitySettings"] = s.realityData(realitySettings)
}
}
delete(streamSettings, "sockopt")
return streamSettings
}
func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
tlsData := make(map[string]any, 1)
tlsClientSettings, _ := tData["settings"].(map[string]any)
tlsData["serverName"] = tData["serverName"]
tlsData["alpn"] = tData["alpn"]
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint
}
return tlsData
}
func (s *SubClashService) realityData(rData map[string]any) map[string]any {
rDataOut := make(map[string]any, 1)
realityClientSettings, _ := rData["settings"].(map[string]any)
if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
rDataOut["publicKey"] = publicKey
}
if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
rDataOut["fingerprint"] = fingerprint
}
if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
rDataOut["serverName"] = fmt.Sprint(serverNames[0])
}
if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
}
return rDataOut
}
func cloneMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View file

@ -21,12 +21,15 @@ type SUBController struct {
subRoutingRules string
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subService *SubService
subJsonService *SubJsonService
subService *SubService
subJsonService *SubJsonService
subClashService *SubClashService
}
// NewSUBController creates a new subscription controller with the given configuration.
@ -34,7 +37,9 @@ func NewSUBController(
g *gin.RouterGroup,
subPath string,
jsonPath string,
clashPath string,
jsonEnabled bool,
clashEnabled bool,
encrypt bool,
showInfo bool,
rModel string,
@ -60,12 +65,15 @@ func NewSUBController(
subRoutingRules: subRoutingRules,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subClashService: NewSubClashService(sub),
}
a.initRouter(g)
return a
@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
gJson := g.Group(a.subJsonPath)
gJson.GET(":subid", a.subJsons)
}
if a.clashEnabled {
gClash := g.Group(a.subClashPath)
gClash.GET(":subid", a.subClashs)
}
}
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
// Build page data in service
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
}
if !a.clashEnabled {
subClashURL = ""
}
// Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
// Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
}
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),
@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"result": page.Result,
})
return
@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!")
} else {
// Add headers
profileUrl := a.subProfileUrl
if profileUrl == "" {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) {
}
}
func (a *SUBController) subClashs(c *gin.Context) {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
clashSub, header, err := a.subClashService.GetClash(subId, host)
if err != nil || len(clashSub) == 0 {
c.String(400, "Error!")
} else {
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)
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
}
}
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders(
c *gin.Context,

View file

@ -247,7 +247,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
headers, _ := xhttp["headers"].(map[string]any)
obj["host"] = searchHost(headers)
}
obj["mode"] = xhttp["mode"].(string)
obj["mode"], _ = xhttp["mode"].(string)
}
security, _ := stream["security"].(string)
obj["tls"] = security
@ -405,7 +405,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
headers, _ := xhttp["headers"].(map[string]any)
params["host"] = searchHost(headers)
}
params["mode"] = xhttp["mode"].(string)
params["mode"], _ = xhttp["mode"].(string)
}
security, _ := stream["security"].(string)
if security == "tls" {
@ -601,7 +601,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
headers, _ := xhttp["headers"].(map[string]any)
params["host"] = searchHost(headers)
}
params["mode"] = xhttp["mode"].(string)
params["mode"], _ = xhttp["mode"].(string)
}
security, _ := stream["security"].(string)
if security == "tls" {
@ -800,7 +800,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
headers, _ := xhttp["headers"].(map[string]any)
params["host"] = searchHost(headers)
}
params["mode"] = xhttp["mode"].(string)
params["mode"], _ = xhttp["mode"].(string)
}
security, _ := stream["security"].(string)
@ -1031,6 +1031,7 @@ type PageData struct {
TotalByte int64
SubUrl string
SubJsonUrl string
SubClashUrl string
Result []string
}
@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
// Input validation
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
if subId == "" {
return "", ""
return "", "", ""
}
// Get configured URIs first (highest priority)
configuredSubURI, _ := s.settingService.GetSubURI()
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
// Determine base scheme and host (cached to avoid duplicate calls)
var baseScheme, baseHostWithPort string
if configuredSubURI == "" || configuredSubJsonURI == "" {
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
}
// Build subscription URL
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
// Build JSON subscription URL
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
return subURL, subJsonURL
return subURL, subJsonURL, subClashURL
}
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
SubClashUrl: subClashURL,
Result: subs,
}
}

View file

@ -109,29 +109,29 @@ install_base() {
echo -e "${green}Updating and install dependency packages...${plain}"
case "${release}" in
ubuntu | debian | armbian)
apt-get update >/dev/null 2>&1 && apt-get install -y -q curl tar tzdata socat openssl >/dev/null 2>&1
apt-get update >/dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat openssl >/dev/null 2>&1
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update >/dev/null 2>&1 && yum install -y -q curl tar tzdata socat openssl >/dev/null 2>&1
yum -y update >/dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
else
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat openssl >/dev/null 2>&1
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
fi
;;
arch | manjaro | parch)
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm curl tar tzdata socat openssl >/dev/null 2>&1
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl >/dev/null 2>&1
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh >/dev/null 2>&1 && zypper -q install -y curl tar timezone socat openssl >/dev/null 2>&1
zypper refresh >/dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl >/dev/null 2>&1
;;
alpine)
apk update >/dev/null 2>&1 && apk add curl tar tzdata socat openssl>/dev/null 2>&1
apk update >/dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl>/dev/null 2>&1
;;
*)
apt-get update >/dev/null 2>&1 && apt install -y -q curl tar tzdata socat openssl >/dev/null 2>&1
apt-get update >/dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
;;
esac
}
@ -402,15 +402,15 @@ ssl_cert_issue() {
break
done
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}"
# check if there already exists a certificate
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
if [ "${currentCert}" == "${domain}" ]; then
local certInfo=$(~/.acme.sh/acme.sh --list)
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
echo -e "${yellow}Current certificate details:${plain}"
echo "$certInfo"
return 1
# detect existing certificate and reuse it if present
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi
@ -437,16 +437,20 @@ ssl_cert_issue() {
echo -e "${yellow}Stopping panel temporarily...${plain}"
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
fi
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
echo -e "${green}Using existing certificate, installing certificates...${plain}"
fi
# Setup reload command
@ -476,17 +480,27 @@ ssl_cert_issue() {
fi
# install the certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
local installOutput=""
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
echo "${installOutput}"
if [ $? -ne 0 ]; then
local installWroteFiles=0
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
installWroteFiles=1
fi
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
rm -rf ~/.acme.sh/${domain}
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
fi
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
return 1
else
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
fi
# enable auto-renew
@ -556,14 +570,21 @@ prompt_and_setup_ssl() {
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
ssl_cert_issue
# Extract the domain that was used from the certificate
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
fi
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
;;
@ -609,7 +630,7 @@ prompt_and_setup_ssl() {
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Убираем пробелы
custom_domain="${custom_domain// /}" # Remove spaces
# 3.2 Loop for Certificate Path
while true; do

File diff suppressed because one or more lines are too long

View file

@ -90,7 +90,16 @@ class DBInbound {
return this.expiryTime < new Date().getTime();
}
invalidateCache() {
this._cachedInbound = null;
this._clientStatsMap = null;
}
toInbound() {
if (this._cachedInbound) {
return this._cachedInbound;
}
let settings = {};
if (!ObjectUtil.isEmpty(this.settings)) {
settings = JSON.parse(this.settings);
@ -116,7 +125,21 @@ class DBInbound {
sniffing: sniffing,
clientStats: this.clientStats,
};
return Inbound.fromJson(config);
this._cachedInbound = Inbound.fromJson(config);
return this._cachedInbound;
}
getClientStats(email) {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();
if (this.clientStats && Array.isArray(this.clientStats)) {
for (const stats of this.clientStats) {
this._clientStatsMap.set(stats.email, stats);
}
}
}
return this._clientStatsMap.get(email);
}
isMultiUser() {

View file

@ -38,6 +38,8 @@ class AllSetting {
this.subPort = 2096;
this.subPath = "/sub/";
this.subJsonPath = "/json/";
this.subClashEnable = true;
this.subClashPath = "/clash/";
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
@ -48,6 +50,7 @@ class AllSetting {
this.subShowInfo = true;
this.subURI = "";
this.subJsonURI = "";
this.subClashURI = "";
this.subJsonFragment = "";
this.subJsonNoises = "";
this.subJsonMux = "";

View file

@ -9,6 +9,7 @@
sId: el.getAttribute('data-sid') || '',
subUrl: el.getAttribute('data-sub-url') || '',
subJsonUrl: el.getAttribute('data-subjson-url') || '',
subClashUrl: el.getAttribute('data-subclash-url') || '',
download: el.getAttribute('data-download') || '',
upload: el.getAttribute('data-upload') || '',
used: el.getAttribute('data-used') || '',
@ -98,13 +99,19 @@
this.lang = LanguageManager.getLanguage();
const tpl = document.getElementById('subscription-data');
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
if (sj) this.app.subJsonUrl = sj;
if (sc) this.app.subClashUrl = sc;
drawQR(this.app.subUrl);
try {
const elJson = document.getElementById('qrcode-subjson');
if (elJson && this.app.subJsonUrl) {
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
}
const elClash = document.getElementById('qrcode-subclash');
if (elClash && this.app.subClashUrl) {
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
}
} catch (e) { /* ignore */ }
this._onResize = () => { this.viewportWidth = window.innerWidth; };
window.addEventListener('resize', this._onResize);

View file

@ -18,9 +18,9 @@ type APIController struct {
}
// NewAPIController creates a new APIController instance and initializes its routes.
func NewAPIController(g *gin.RouterGroup) *APIController {
func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
a := &APIController{}
a.initRouter(g)
a.initRouter(g, customGeo)
return a
}
@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
// Main API group
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
server := api.Group("/server")
a.serverController = NewServerController(server)
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot)
}

View file

@ -0,0 +1,180 @@
package controller
import (
"errors"
"net/http"
"strconv"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin"
)
type CustomGeoController struct {
BaseController
customGeoService *service.CustomGeoService
}
func NewCustomGeoController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *CustomGeoController {
a := &CustomGeoController{customGeoService: customGeo}
a.initRouter(g)
return a
}
func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/aliases", a.aliases)
g.POST("/add", a.add)
g.POST("/update/:id", a.update)
g.POST("/delete/:id", a.delete)
g.POST("/download/:id", a.download)
g.POST("/update-all", a.updateAll)
}
func mapCustomGeoErr(c *gin.Context, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, service.ErrCustomGeoInvalidType):
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
case errors.Is(err, service.ErrCustomGeoAliasRequired):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
case errors.Is(err, service.ErrCustomGeoAliasPattern):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
case errors.Is(err, service.ErrCustomGeoAliasReserved):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
case errors.Is(err, service.ErrCustomGeoURLRequired):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
case errors.Is(err, service.ErrCustomGeoInvalidURL):
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
case errors.Is(err, service.ErrCustomGeoURLScheme):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
case errors.Is(err, service.ErrCustomGeoURLHost):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
case errors.Is(err, service.ErrCustomGeoDuplicateAlias):
return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
case errors.Is(err, service.ErrCustomGeoNotFound):
return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
case errors.Is(err, service.ErrCustomGeoDownload):
logger.Warning("custom geo download:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
case errors.Is(err, service.ErrCustomGeoSSRFBlocked):
logger.Warning("custom geo SSRF blocked:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
case errors.Is(err, service.ErrCustomGeoPathTraversal):
logger.Warning("custom geo path traversal blocked:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
default:
return err
}
}
func (a *CustomGeoController) list(c *gin.Context) {
list, err := a.customGeoService.GetAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
return
}
jsonObj(c, list, nil)
}
func (a *CustomGeoController) aliases(c *gin.Context) {
out, err := a.customGeoService.GetAliasesForUI()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
return
}
jsonObj(c, out, nil)
}
type customGeoForm struct {
Type string `json:"type" form:"type"`
Alias string `json:"alias" form:"alias"`
Url string `json:"url" form:"url"`
}
func (a *CustomGeoController) add(c *gin.Context) {
var form customGeoForm
if err := c.ShouldBind(&form); err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
return
}
r := &model.CustomGeoResource{
Type: form.Type,
Alias: form.Alias,
Url: form.Url,
}
err := a.customGeoService.Create(r)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
}
func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
id, err := strconv.Atoi(idStr)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
return 0, false
}
if id <= 0 {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
return 0, false
}
return id, true
}
func (a *CustomGeoController) update(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
var form customGeoForm
if bindErr := c.ShouldBind(&form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
return
}
r := &model.CustomGeoResource{
Type: form.Type,
Alias: form.Alias,
Url: form.Url,
}
err := a.customGeoService.Update(id, r)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) delete(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
name, err := a.customGeoService.Delete(id)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) download(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
name, err := a.customGeoService.TriggerUpdate(id)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) updateAll(c *gin.Context) {
res, err := a.customGeoService.TriggerUpdateAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
return
}
if len(res.Failed) > 0 {
c.JSON(http.StatusOK, entity.Msg{
Success: false,
Msg: I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
Obj: res,
})
return
}
jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
}

View file

@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
}
} else {
m.Success = false
m.Msg = msg + " (" + err.Error() + ")"
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
errStr := err.Error()
if errStr != "" {
m.Msg = msg + " (" + errStr + ")"
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
} else if msg != "" {
m.Msg = msg
logger.Warning(msg + " " + I18nWeb(c, "fail"))
} else {
m.Msg = I18nWeb(c, "somethingWentWrong")
logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
}
}
c.JSON(http.StatusOK, m)
}

View file

@ -30,8 +30,10 @@ const (
)
var upgrader = ws.Upgrader{
ReadBufferSize: 4096, // Increased from 1024 for better performance
WriteBufferSize: 4096, // Increased from 1024 for better performance
ReadBufferSize: 32768,
WriteBufferSize: 32768,
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
CheckOrigin: func(r *http.Request) bool {
// Check origin for security
origin := r.Header.Get("Origin")

View file

@ -76,6 +76,9 @@ type AllSetting struct {
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
s.SubJsonPath += "/"
}
if !strings.HasPrefix(s.SubClashPath, "/") {
s.SubClashPath = "/" + s.SubClashPath
}
if !strings.HasSuffix(s.SubClashPath, "/") {
s.SubClashPath += "/"
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)

View file

@ -73,6 +73,8 @@
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="never">{{ i18n
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
<a-select-option value="hourly">{{ i18n
"pages.inbounds.periodicTrafficReset.hourly" }}</a-select-option>
<a-select-option value="daily">{{ i18n
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
<a-select-option value="weekly">{{ i18n

View file

@ -70,6 +70,8 @@
<a-select-option
value="queryInHeader">queryInHeader</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding Method">
@ -127,6 +129,7 @@
<a-select-option value>Default (body)</a-select-option>
<a-select-option value="body">body</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>

View file

@ -6,7 +6,7 @@
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@ -14,10 +14,7 @@
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
@ -1101,7 +1098,10 @@
}
data.sniffing = inbound.sniffing.toString();
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
},
openAddClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
@ -1291,9 +1291,36 @@
infoModal.show(newDbInbound, index);
},
switchEnable(dbInboundId, state) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
dbInbound.enable = state;
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
let inbound = dbInbound.toInbound();
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
},
async switchEnableClient(dbInboundId, client) {
this.loading()
@ -1367,42 +1394,54 @@
isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index);
},
getClientStats(dbInbound, email) {
if (!dbInbound) return null;
if (!dbInbound._clientStatsMap) {
dbInbound._clientStatsMap = new Map();
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
for (const stats of dbInbound.clientStats) {
dbInbound._clientStatsMap.set(stats.email, stats);
}
}
}
return dbInbound._clientStatsMap.get(email);
},
getUpStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up : 0;
},
getDownStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.down : 0;
},
getSumStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up + clientStats.down : 0;
},
getAllTimeClient(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
return clientStats.allTime || (clientStats.up + clientStats.down);
},
getRemStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
remained = clientStats.total - (clientStats.up + clientStats.down);
let remained = clientStats.total - (clientStats.up + clientStats.down);
return remained > 0 ? remained : 0;
},
clientStatsColor(dbInbound, email) {
if (email.length == 0) return ColorUtils.clientUsageColor();
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return ColorUtils.clientUsageColor();
let clientStats = this.getClientStats(dbInbound, email);
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
},
statsProgress(dbInbound, email) {
if (email.length == 0) return 100;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 100;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
if (clientStats.total == 0) return 100;
return 100 * (clientStats.down + clientStats.up) / clientStats.total;
@ -1415,11 +1454,11 @@
return 100 * (1 - (remainedSeconds / resetSeconds));
},
statsExpColor(dbInbound, email) {
if (email.length == 0) return '#7a316f';
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return '#7a316f';
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return '#7a316f';
statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
switch (true) {
case statsColor == "red" || expColor == "red":
return "#cf3c3c"; // Red
@ -1432,12 +1471,12 @@
}
},
isClientEnabled(dbInbound, email) {
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
return clientStats ? clientStats['enable'] : true;
},
isClientDepleted(dbInbound, email) {
if (!email || !dbInbound || !dbInbound.clientStats) return false;
const stats = dbInbound.clientStats.find(s => s.email === email);
if (!email || !dbInbound) return false;
const stats = this.getClientStats(dbInbound, email);
if (!stats) return false;
const total = stats.total ?? 0;
const used = (stats.up ?? 0) + (stats.down ?? 0);
@ -1557,12 +1596,18 @@
pagination(obj) {
if (this.pageSize > 0 && obj.length > this.pageSize) {
// Set page options based on object size
sizeOptions = [];
for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
sizeOptions.push(i.toString());
let sizeOptions = [this.pageSize.toString()];
const increments = [2, 5, 10, 20];
for (const m of increments) {
const val = this.pageSize * m;
if (val < obj.length && val <= 1000) {
sizeOptions.push(val.toString());
}
}
// Add option to see all in one page
sizeOptions.push(i.toString());
if (!sizeOptions.includes(obj.length.toString())) {
sizeOptions.push(obj.length.toString());
}
p = {
showSizeChanger: true,
@ -1605,11 +1650,25 @@
}
});
// Listen for invalidate signals (sent when payload is too large for WebSocket)
// The server sends a lightweight notification and we re-fetch via REST API
let invalidateTimer = null;
window.wsClient.on('invalidate', (payload) => {
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
// Debounce to avoid flooding the REST API with multiple invalidate signals
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => {
invalidateTimer = null;
this.getDBInbounds();
}, 1000);
}
});
// Listen for traffic updates
window.wsClient.on('traffic', (payload) => {
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
// because clientTraffics contains delta/incremental values, not total accumulated values.
// Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
// Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) {
@ -1627,22 +1686,27 @@
this.onlineClients = nextOnlineClients;
if (onlineChanged) {
// Recalculate client counts to update online status
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive
this.dbInbounds.forEach(dbInbound => {
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
if (inbound && this.clientCount[dbInbound.id]) {
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
}
});
// Always trigger UI refresh — not just when filter is enabled
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
}
}
// Update last online map in real-time
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
this.lastOnlineMap = payload.lastOnlineMap;
}
});
@ -1697,4 +1761,18 @@
},
});
</script>
<style>
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
position: fixed !important;
top: 50vh !important;
left: calc(50vw + 100px) !important;
transform: translate(-50%, -50%);
z-index: 99999 !important;
}
@media (max-width: 768px) {
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
left: 50vw !important;
}
}
</style>
{{ template "page/body_end" .}}

View file

@ -2,11 +2,25 @@
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<style>
body.dark .custom-geo-section code.custom-geo-ext-code {
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
background: var(--dark-color-surface-200, #222d42);
border: 1px solid var(--dark-color-stroke, #2c3950);
padding: 2px 6px;
border-radius: 3px;
}
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
background: var(--dark-color-surface-700, #111929);
border-color: var(--dark-color-stroke, #2c3950);
}
</style>
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip" size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@ -15,9 +29,7 @@
<transition name="list" appear>
<template>
<a-row v-if="!loadingStates.fetched">
<a-card class="card-placeholder text-center">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
@ -107,7 +119,7 @@
</a-row>
</span>
<template slot="content">
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
</template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
@ -115,7 +127,7 @@
</template>
</template>
<template #actions>
<a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
<a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
<a-icon type="bars"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
</a-space>
@ -330,8 +342,65 @@
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button></div>
</a-collapse-panel>
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
<div class="custom-geo-section">
<a-alert type="info" show-icon class="mb-10"
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
<div class="mb-10">
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
{{ i18n "pages.index.customGeoAdd" }}
</a-button>
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button>
</div>
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
<template slot="extDat" slot-scope="text, record">
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
</template>
<template slot="lastUpdatedAt" slot-scope="text, record">
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
<span v-else></span>
</template>
<template slot="action" slot-scope="text, record">
<a-space size="small">
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
</a-tooltip>
</a-space>
</template>
</a-table>
</div>
</a-collapse-panel>
</a-collapse>
</a-modal>
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
:class="themeSwitcher.currentTheme">
<a-form layout="vertical">
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="geosite">geosite</a-select-option>
<a-select-option value="geoip">geoip</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
</a-form-item>
</a-form>
</a-modal>
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
:class="themeSwitcher.currentTheme" width="800px" footer="">
<template slot="title">
@ -872,6 +941,12 @@
},
};
const customGeoColumns = [
{ title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
{ title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
{ title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
@ -895,6 +970,25 @@
showAlert: false,
showIp: false,
ipLimitEnable: false,
customGeoColumns,
customGeoList: [],
customGeoLoading: false,
customGeoUpdatingAll: false,
customGeoActionId: null,
customGeoModal: {
visible: false,
editId: null,
saving: false,
form: {
type: 'geosite',
alias: '',
url: '',
},
},
customGeoValidation: {
alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
},
},
methods: {
loading(spinning, tip = '{{ i18n "loading"}}') {
@ -963,6 +1057,128 @@
return;
}
versionModal.show(msg.obj);
this.loadCustomGeo();
},
customGeoFormatTime(ts) {
if (!ts) return '';
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
},
customGeoExtDisplay(record) {
const fn = record.type === 'geoip'
? `geoip_${record.alias}.dat`
: `geosite_${record.alias}.dat`;
return `ext:${fn}:tag`;
},
async loadCustomGeo() {
this.customGeoLoading = true;
try {
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
if (msg.success && Array.isArray(msg.obj)) {
this.customGeoList = msg.obj;
}
} finally {
this.customGeoLoading = false;
}
},
openCustomGeoModal(record) {
if (record) {
this.customGeoModal.editId = record.id;
this.customGeoModal.form = {
type: record.type,
alias: record.alias,
url: record.url,
};
} else {
this.customGeoModal.editId = null;
this.customGeoModal.form = {
type: 'geosite',
alias: '',
url: '',
};
}
this.customGeoModal.visible = true;
},
validateCustomGeoForm() {
const f = this.customGeoModal.form;
const re = /^[a-z0-9_-]+$/;
if (!re.test(f.alias || '')) {
this.$message.error(this.customGeoValidation.alias);
return false;
}
const u = (f.url || '').trim();
if (!/^https?:\/\//i.test(u)) {
this.$message.error(this.customGeoValidation.url);
return false;
}
try {
const parsed = new URL(u);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
this.$message.error(this.customGeoValidation.url);
return false;
}
} catch (e) {
this.$message.error(this.customGeoValidation.url);
return false;
}
return true;
},
async submitCustomGeo() {
if (!this.validateCustomGeoForm()) {
return;
}
const f = this.customGeoModal.form;
this.customGeoModal.saving = true;
try {
let msg;
if (this.customGeoModal.editId) {
msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
} else {
msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
}
if (msg && msg.success) {
this.customGeoModal.visible = false;
await this.loadCustomGeo();
}
} finally {
this.customGeoModal.saving = false;
}
},
confirmDeleteCustomGeo(record) {
this.$confirm({
title: '{{ i18n "pages.index.customGeoDelete" }}',
content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
okText: '{{ i18n "confirm"}}',
cancelText: '{{ i18n "cancel"}}',
class: themeSwitcher.currentTheme,
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
if (msg.success) {
await this.loadCustomGeo();
}
},
});
},
async downloadCustomGeo(id) {
this.customGeoActionId = id;
try {
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
if (msg.success) {
await this.loadCustomGeo();
}
} finally {
this.customGeoActionId = null;
}
},
async updateAllCustomGeo() {
this.customGeoUpdatingAll = true;
try {
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
await this.loadCustomGeo();
}
} finally {
this.customGeoUpdatingAll = false;
}
},
switchV2rayVersion(version) {
this.$confirm({

View file

@ -26,7 +26,7 @@
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
@ -204,7 +204,7 @@
this.security = "auto";
this.flow = "";
this.dbInbound = new DBInbound(dbInbound);
this.inbound = dbInbound.toInbound();
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
this.delayedStart = false;
this.reset = 0;
},
@ -247,4 +247,4 @@
});
</script>
{{end}}
{{end}}

View file

@ -37,7 +37,7 @@
this.okText = okText;
this.isEdit = isEdit;
this.dbInbound = new DBInbound(dbInbound);
this.inbound = dbInbound.toInbound();
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
this.clients = this.inbound.clients;
this.index = index === null ? this.clients.length : index;
this.delayedStart = false;
@ -98,9 +98,9 @@
return app.datepicker;
},
get isTrafficExhausted() {
if (!clientStats) return false
if (clientStats.total <= 0) return false
if (clientStats.up + clientStats.down < clientStats.total) return false
if (!this.clientStats) return false
if (this.clientStats.total <= 0) return false
if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
return true
},
get isExpiry() {

View file

@ -6,7 +6,7 @@
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
@ -21,10 +21,7 @@
<transition name="list" appear>
<template>
<a-row v-if="!loadingStates.fetched">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
@ -82,10 +79,10 @@
</template>
{{ template "settings/panel/subscription/general" . }}
</a-tab-pane>
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="code"></a-icon>
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
</template>
{{ template "settings/panel/subscription/json" . }}
</a-tab-pane>

View file

@ -3,43 +3,58 @@
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subEnableDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>JSON Subscription</template>
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
<template #description>{{ i18n
"pages.settings.subJsonEnable"}}</template>
<template #control>
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>Clash / Mihomo Subscription</template>
<template #description>Enable direct Clash and Mihomo YAML
subscriptions.</template>
<template #control>
<a-switch v-model="allSetting.subClashEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subListen"}}</template>
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subListenDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subListen"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subDomainDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subDomain"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPort"}}</template>
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subPortDesc"}}</template>
<template #control>
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
<a-input-number v-model="allSetting.subPort" :min="1"
:min="65535"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPath"}}</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subPath"
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@ -49,9 +64,11 @@
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subURI"}}</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subURIDesc"}}</template>
<template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
<a-input type="text"
placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subURI"></a-input>
</template>
</a-setting-list-item>
@ -59,14 +76,16 @@
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subEncryptDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEncrypt"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subShowInfoDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subShowInfo"></a-switch>
</template>
@ -74,59 +93,72 @@
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subTitleDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subTitle"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subSupportUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
<a-input type="text" v-model="allSetting.subSupportUrl"
placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subProfileUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
<a-input type="text" v-model="allSetting.subProfileUrl"
placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subAnnounceDesc"}}</template>
<template #control>
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
</template>
</a-setting-list-item>
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
<template #title>{{ i18n
"pages.settings.subEnableRouting"}}</template>
<template #description>{{ i18n
"pages.settings.subEnableRoutingDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
<template #title>{{ i18n
"pages.settings.subRoutingRules"}}</template>
<template #description>{{ i18n
"pages.settings.subRoutingRulesDesc"}}</template>
<template #control>
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
<a-textarea v-model="allSetting.subRoutingRules"
placeholder="happ://routing/add/..."></a-textarea>
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subCertPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subKeyPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
</template>
@ -135,9 +167,11 @@
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
<template #description>{{ i18n
"pages.settings.subUpdatesDesc"}}</template>
<template #control>
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
<a-input-number :min="1" v-model="allSetting.subUpdates"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
</a-collapse-panel>

View file

@ -1,8 +1,8 @@
{{define "settings/panel/subscription/json"}}
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPath"}}</template>
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subJsonPath"
@ -11,14 +11,32 @@
placeholder="/json/"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subURI"}}</template>
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
<template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subJsonURI"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subClashPath"
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
placeholder="/clash/"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
<template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subClashURI"></a-input>
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
<a-setting-list-item paddings="small">

View file

@ -83,7 +83,7 @@
<a-form-item>
<a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>{{ i18n
@ -112,6 +112,19 @@
</tr-qr-bg>
</tr-qr-box>
</a-col>
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>Clash / Mihomo</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
@click="copy(app.subClashUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
</a-row>
</a-space>
</a-form-item>
@ -242,7 +255,7 @@
</a-layout>
<!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"

View file

@ -14,7 +14,7 @@
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500"
tip='{{ i18n "loading"}}'>
tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
:style="{ marginBottom: '10px' }"
@ -24,10 +24,7 @@
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
@ -262,6 +259,7 @@
refreshing: false,
restartResult: '',
showAlert: false,
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
advSettings: 'xraySetting',
obsSettings: '',
cm: null,
@ -1057,6 +1055,31 @@
},
showWarp() {
warpModal.show();
},
async loadCustomGeoAliases() {
try {
const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
if (!msg.success) {
console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
return;
}
if (!msg.obj) return;
const { geoip = [], geosite = [] } = msg.obj;
const geoSuffix = this.customGeoAliasLabelSuffix || '';
geoip.forEach((x) => {
this.settingsData.IPsOptions.push({
label: x.alias + geoSuffix,
value: x.extExample,
});
});
geosite.forEach((x) => {
const opt = { label: x.alias + geoSuffix, value: x.extExample };
this.settingsData.DomainsOptions.push(opt);
this.settingsData.BlockDomainsOptions.push(opt);
});
} catch (e) {
console.error('Failed to load custom geo aliases:', e);
}
}
},
async mounted() {
@ -1064,6 +1087,7 @@
this.showAlert = true;
}
await this.getXraySetting();
await this.loadCustomGeoAliases();
await this.getXrayResult();
await this.getOutboundsTraffic();
@ -1075,6 +1099,14 @@
this.$forceUpdate();
}
});
// Handle invalidate signals (sent when payload is too large for WebSocket,
// or when traffic job notifies about data changes)
window.wsClient.on('invalidate', (payload) => {
if (payload && payload.type === 'outbounds') {
this.refreshOutboundTraffic();
}
});
}
while (true) {

View file

@ -3,6 +3,7 @@ package job
import (
"bufio"
"encoding/json"
"errors"
"io"
"log"
"os"
@ -32,6 +33,8 @@ type CheckClientIpJob struct {
var job *CheckClientIpJob
const defaultXrayAPIPort = 62789
// NewCheckClientIpJob creates a new client IP monitoring job instance.
func NewCheckClientIpJob() *CheckClientIpJob {
job = new(CheckClientIpJob)
@ -355,6 +358,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
// Actually disconnect banned IPs by temporarily removing and re-adding user
// This forces Xray to drop existing connections from banned IPs
if len(bannedIps) > 0 {
j.disconnectClientTemporarily(inbound, clientEmail, clients)
}
// Update database with only the currently active (kept) IPs
jsonIps, _ := json.Marshal(keptIps)
inboundClientIps.Ips = string(jsonIps)
@ -378,6 +387,130 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return shouldCleanLog
}
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
var xrayAPI xray.XrayAPI
apiPort := j.resolveXrayAPIPort()
err := xrayAPI.Init(apiPort)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
return
}
defer xrayAPI.Close()
// Find the client config
var clientConfig map[string]any
for _, client := range clients {
if client.Email == clientEmail {
// Convert client to map for API
clientBytes, _ := json.Marshal(client)
json.Unmarshal(clientBytes, &clientConfig)
break
}
}
if clientConfig == nil {
return
}
// Only perform remove/re-add for protocols supported by XrayAPI.AddUser
protocol := string(inbound.Protocol)
switch protocol {
case "vmess", "vless", "trojan", "shadowsocks":
// supported protocols, continue
default:
logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
return
}
// For Shadowsocks, ensure the required "cipher" field is present by
// reading it from the inbound settings (e.g., settings["method"]).
if string(inbound.Protocol) == "shadowsocks" {
var inboundSettings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
} else {
if method, ok := inboundSettings["method"].(string); ok && method != "" {
clientConfig["cipher"] = method
}
}
}
// Remove user to disconnect all connections
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
return
}
// Wait a moment for disconnection to take effect
time.Sleep(100 * time.Millisecond)
// Re-add user to allow new connections
err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
}
}
// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
func (j *CheckClientIpJob) resolveXrayAPIPort() int {
var configErr error
var templateErr error
if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
return port
} else {
configErr = err
}
db := database.GetDB()
var template model.Setting
if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
return port
} else {
templateErr = parseErr
}
} else {
templateErr = err
}
logger.Warningf(
"[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
defaultXrayAPIPort,
configErr,
templateErr,
)
return defaultXrayAPIPort
}
func getAPIPortFromConfigPath(configPath string) (int, error) {
configData, err := os.ReadFile(configPath)
if err != nil {
return 0, err
}
return getAPIPortFromConfigData(configData)
}
func getAPIPortFromConfigData(configData []byte) (int, error) {
xrayConfig := &xray.Config{}
if err := json.Unmarshal(configData, xrayConfig); err != nil {
return 0, err
}
for _, inboundConfig := range xrayConfig.InboundConfigs {
if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
return inboundConfig.Port, nil
}
}
return 0, errors.New("api inbound port not found")
}
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}

View file

@ -37,7 +37,7 @@ func (j *PeriodicTrafficResetJob) Run() {
resetCount := 0
for _, inbound := range inbounds {
resetInboundErr := j.inboundService.ResetAllTraffics()
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
if resetInboundErr != nil {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
}

View file

@ -50,7 +50,13 @@ func (j *XrayTrafficJob) Run() {
j.xrayService.SetToNeedRestart()
}
// Get online clients and last online map for real-time status updates
// If no frontend client is connected, skip all WebSocket broadcasting routines,
// including expensive DB queries for online clients and JSON marshaling.
if !websocket.HasClients() {
return
}
// Update online clients list and map
onlineClients := j.inboundService.GetOnlineClients()
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
@ -58,8 +64,17 @@ func (j *XrayTrafficJob) Run() {
lastOnlineMap = make(map[string]int64)
}
// Broadcast traffic update (deltas and online stats) via WebSocket
trafficUpdate := map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"lastOnlineMap": lastOnlineMap,
}
websocket.BroadcastTraffic(trafficUpdate)
// Fetch updated inbounds from database with accumulated traffic values
// This ensures frontend receives the actual total traffic, not just delta values
// This ensures frontend receives the actual total traffic for real-time UI refresh.
updatedInbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("get all inbounds for websocket failed:", err)
@ -70,16 +85,8 @@ func (j *XrayTrafficJob) Run() {
logger.Warning("get all outbounds for websocket failed:", err)
}
// Broadcast traffic update via WebSocket with accumulated values from database
trafficUpdate := map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"lastOnlineMap": lastOnlineMap,
}
websocket.BroadcastTraffic(trafficUpdate)
// Broadcast full inbounds update for real-time UI refresh
// The WebSocket hub will automatically check the payload size.
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
if updatedInbounds != nil {
websocket.BroadcastInbounds(updatedInbounds)
}
@ -87,7 +94,6 @@ func (j *XrayTrafficJob) Run() {
if updatedOutbounds != nil {
websocket.BroadcastOutbounds(updatedOutbounds)
}
}
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {

760
web/service/custom_geo.go Normal file
View file

@ -0,0 +1,760 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
)
const (
customGeoTypeGeosite = "geosite"
customGeoTypeGeoip = "geoip"
minDatBytes = 64
customGeoProbeTimeout = 12 * time.Second
)
var (
customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
reservedCustomAliases = map[string]struct{}{
"geoip": {}, "geosite": {},
"geoip_ir": {}, "geosite_ir": {},
"geoip_ru": {}, "geosite_ru": {},
}
ErrCustomGeoInvalidType = errors.New("custom_geo_invalid_type")
ErrCustomGeoAliasRequired = errors.New("custom_geo_alias_required")
ErrCustomGeoAliasPattern = errors.New("custom_geo_alias_pattern")
ErrCustomGeoAliasReserved = errors.New("custom_geo_alias_reserved")
ErrCustomGeoURLRequired = errors.New("custom_geo_url_required")
ErrCustomGeoInvalidURL = errors.New("custom_geo_invalid_url")
ErrCustomGeoURLScheme = errors.New("custom_geo_url_scheme")
ErrCustomGeoURLHost = errors.New("custom_geo_url_host")
ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
ErrCustomGeoDownload = errors.New("custom_geo_download")
ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked")
ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal")
)
type CustomGeoUpdateAllItem struct {
Id int `json:"id"`
Alias string `json:"alias"`
FileName string `json:"fileName"`
}
type CustomGeoUpdateAllFailure struct {
Id int `json:"id"`
Alias string `json:"alias"`
FileName string `json:"fileName"`
Err string `json:"error"`
}
type CustomGeoUpdateAllResult struct {
Succeeded []CustomGeoUpdateAllItem `json:"succeeded"`
Failed []CustomGeoUpdateAllFailure `json:"failed"`
}
type CustomGeoService struct {
serverService *ServerService
updateAllGetAll func() ([]model.CustomGeoResource, error)
updateAllApply func(id int, onStartup bool) (string, error)
updateAllRestart func() error
}
func NewCustomGeoService() *CustomGeoService {
s := &CustomGeoService{
serverService: &ServerService{},
}
s.updateAllGetAll = s.GetAll
s.updateAllApply = s.applyDownloadAndPersist
s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
return s
}
func NormalizeAliasKey(alias string) string {
return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
}
func (s *CustomGeoService) fileNameFor(typ, alias string) string {
if typ == customGeoTypeGeoip {
return fmt.Sprintf("geoip_%s.dat", alias)
}
return fmt.Sprintf("geosite_%s.dat", alias)
}
func (s *CustomGeoService) validateType(typ string) error {
if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
return ErrCustomGeoInvalidType
}
return nil
}
func (s *CustomGeoService) validateAlias(alias string) error {
if alias == "" {
return ErrCustomGeoAliasRequired
}
if !customGeoAliasPattern.MatchString(alias) {
return ErrCustomGeoAliasPattern
}
if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
return ErrCustomGeoAliasReserved
}
return nil
}
func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
if raw == "" {
return "", ErrCustomGeoURLRequired
}
u, err := url.Parse(raw)
if err != nil {
return "", ErrCustomGeoInvalidURL
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", ErrCustomGeoURLScheme
}
if u.Host == "" {
return "", ErrCustomGeoURLHost
}
if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
return "", err
}
// Reconstruct URL from parsed components to break taint propagation.
clean := &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: u.Path,
RawPath: u.RawPath,
RawQuery: u.RawQuery,
Fragment: u.Fragment,
}
return clean.String(), nil
}
func localDatFileNeedsRepair(path string) bool {
safePath, err := sanitizeDestPath(path)
if err != nil {
return true
}
fi, err := os.Stat(safePath)
if err != nil {
return true
}
if fi.IsDir() {
return true
}
return fi.Size() < int64(minDatBytes)
}
func CustomGeoLocalFileNeedsRepair(path string) bool {
return localDatFileNeedsRepair(path)
}
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
func checkSSRFDefault(ctx context.Context, hostname string) error {
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
}
}
return nil
}
// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
var checkSSRF = checkSSRFDefault
func ssrfSafeTransport() http.RoundTripper {
base, ok := http.DefaultTransport.(*http.Transport)
if !ok {
base = &http.Transport{}
}
cloned := base.Clone()
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
}
if err := checkSSRF(ctx, host); err != nil {
return nil, err
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
return cloned
}
func probeCustomGeoURLWithGET(rawURL string) error {
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
if err != nil {
return err
}
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
if err != nil {
return err
}
req.Header.Set("Range", "bytes=0-0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
switch resp.StatusCode {
case http.StatusOK, http.StatusPartialContent:
return nil
default:
return fmt.Errorf("get range status %d", resp.StatusCode)
}
}
func probeCustomGeoURL(rawURL string) error {
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
if err != nil {
return err
}
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
sc := resp.StatusCode
if sc >= 200 && sc < 300 {
return nil
}
if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
return probeCustomGeoURLWithGET(rawURL)
}
return fmt.Errorf("head status %d", sc)
}
func (s *CustomGeoService) EnsureOnStartup() {
list, err := s.GetAll()
if err != nil {
logger.Warning("custom geo startup: load list:", err)
return
}
n := len(list)
if n == 0 {
logger.Info("custom geo startup: no custom geofiles configured")
return
}
logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
for i := range list {
r := &list[i]
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
continue
}
r.Url = sanitizedURL
s.syncLocalPath(r)
localPath := r.LocalPath
if !localDatFileNeedsRepair(localPath) {
logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
continue
}
logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
if err := probeCustomGeoURL(r.Url); err != nil {
logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
}
_, _ = s.applyDownloadAndPersist(r.Id, true)
}
}
func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
safeDestPath, err := sanitizeDestPath(destPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
if err != nil {
return false, "", err
}
if skipped {
if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
return true, lm, nil
}
return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
}
return false, lm, nil
}
// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
// It resolves symlinks to prevent symlink-based escapes.
// Returns the cleaned absolute path that is safe to use in file operations.
func sanitizeDestPath(destPath string) (string, error) {
baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
}
// Resolve symlinks in base directory to get the real path.
if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
baseDirAbs = resolved
}
destPathAbs, err := filepath.Abs(destPath)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
}
// Resolve symlinks for the parent directory of the destination path.
destDir := filepath.Dir(destPathAbs)
if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
}
// Verify the resolved path is within the safe base directory using prefix check.
safeDirPrefix := baseDirAbs + string(filepath.Separator)
if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
return "", ErrCustomGeoPathTraversal
}
return destPathAbs, nil
}
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
safeDestPath, err := sanitizeDestPath(destPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
sanitizedURL, err := s.sanitizeURL(resourceURL)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
var req *http.Request
req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
if !forceFull {
if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
if !fi.ModTime().IsZero() {
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
} else if lastModifiedHeader != "" {
if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
}
}
}
}
client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
// lgtm[go/request-forgery]
resp, err := client.Do(req)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
defer resp.Body.Close()
var serverModTime time.Time
if lm := resp.Header.Get("Last-Modified"); lm != "" {
if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
serverModTime = parsed
newLastModified = lm
}
}
updateModTime := func() {
if !serverModTime.IsZero() {
_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
}
}
if resp.StatusCode == http.StatusNotModified {
if forceFull {
return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
}
updateModTime()
return true, newLastModified, nil
}
if resp.StatusCode != http.StatusOK {
return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
}
binDir := filepath.Dir(safeDestPath)
if err = os.MkdirAll(binDir, 0o755); err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
out, err := os.Create(safeTmpPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
n, err := io.Copy(out, resp.Body)
closeErr := out.Close()
if err != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
if closeErr != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
}
if n < minDatBytes {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
}
if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
updateModTime()
if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
newLastModified = resp.Header.Get("Last-Modified")
}
return false, newLastModified, nil
}
func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
if r.LocalPath != "" {
return r.LocalPath
}
return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
}
func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
r.LocalPath = p
}
func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
s.syncLocalPath(r)
safePath, err := sanitizeDestPath(r.LocalPath)
if err != nil {
return err
}
r.LocalPath = safePath
return nil
}
func removeSafePathIfExists(path string) error {
safePath, err := sanitizeDestPath(path)
if err != nil {
return err
}
if _, err := os.Stat(safePath); err == nil {
if err := os.Remove(safePath); err != nil {
return err
}
}
return nil
}
func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
if err := s.validateType(r.Type); err != nil {
return err
}
if err := s.validateAlias(r.Alias); err != nil {
return err
}
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
return err
}
r.Url = sanitizedURL
var existing int64
database.GetDB().Model(&model.CustomGeoResource{}).
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
if existing > 0 {
return ErrCustomGeoDuplicateAlias
}
if err := s.syncAndSanitizeLocalPath(r); err != nil {
return err
}
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
if err != nil {
return err
}
now := time.Now().Unix()
r.LastUpdatedAt = now
r.LastModified = lm
if err = database.GetDB().Create(r).Error; err != nil {
_ = removeSafePathIfExists(r.LocalPath)
return err
}
logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo create: restart xray:", err)
}
return nil
}
func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
var cur model.CustomGeoResource
if err := database.GetDB().First(&cur, id).Error; err != nil {
if database.IsNotFound(err) {
return ErrCustomGeoNotFound
}
return err
}
if err := s.validateType(r.Type); err != nil {
return err
}
if err := s.validateAlias(r.Alias); err != nil {
return err
}
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
return err
}
r.Url = sanitizedURL
if cur.Type != r.Type || cur.Alias != r.Alias {
var cnt int64
database.GetDB().Model(&model.CustomGeoResource{}).
Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
Count(&cnt)
if cnt > 0 {
return ErrCustomGeoDuplicateAlias
}
}
oldPath := s.resolveDestPath(&cur)
r.Id = id
if err := s.syncAndSanitizeLocalPath(r); err != nil {
return err
}
if oldPath != r.LocalPath && oldPath != "" {
if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
}
}
_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
if err != nil {
return err
}
r.LastUpdatedAt = time.Now().Unix()
r.LastModified = lm
err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
"geo_type": r.Type,
"alias": r.Alias,
"url": r.Url,
"local_path": r.LocalPath,
"last_updated_at": r.LastUpdatedAt,
"last_modified": r.LastModified,
}).Error
if err != nil {
return err
}
logger.Infof("custom geo updated id=%d", id)
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo update: restart xray:", err)
}
return nil
}
func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
var r model.CustomGeoResource
if err := database.GetDB().First(&r, id).Error; err != nil {
if database.IsNotFound(err) {
return "", ErrCustomGeoNotFound
}
return "", err
}
displayName = s.fileNameFor(r.Type, r.Alias)
p := s.resolveDestPath(&r)
if _, err := sanitizeDestPath(p); err != nil {
return displayName, err
}
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
return displayName, err
}
if p != "" {
if err := removeSafePathIfExists(p); err != nil {
logger.Warningf("custom geo delete file %s: %v", p, err)
}
}
logger.Infof("custom geo deleted id=%d", id)
if err := s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo delete: restart xray:", err)
}
return displayName, nil
}
func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
var list []model.CustomGeoResource
err := database.GetDB().Order("id asc").Find(&list).Error
return list, err
}
func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
var r model.CustomGeoResource
if err := database.GetDB().First(&r, id).Error; err != nil {
if database.IsNotFound(err) {
return "", ErrCustomGeoNotFound
}
return "", err
}
displayName = s.fileNameFor(r.Type, r.Alias)
if err := s.syncAndSanitizeLocalPath(&r); err != nil {
return displayName, err
}
sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
if sanitizeErr != nil {
return displayName, sanitizeErr
}
skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
if err != nil {
if onStartup {
logger.Warningf("custom geo startup download id=%d: %v", id, err)
} else {
logger.Warningf("custom geo manual update id=%d: %v", id, err)
}
return displayName, err
}
now := time.Now().Unix()
updates := map[string]any{
"last_modified": lm,
"local_path": r.LocalPath,
"last_updated_at": now,
}
if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
if onStartup {
logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
} else {
logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
}
return displayName, err
}
if skipped {
if onStartup {
logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
} else {
logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
}
} else {
if onStartup {
logger.Infof("custom geo startup download ok id=%d", id)
} else {
logger.Infof("custom geo manual update ok id=%d", id)
}
}
return displayName, nil
}
func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
displayName, err := s.applyDownloadAndPersist(id, false)
if err != nil {
return displayName, err
}
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo manual update: restart xray:", err)
}
return displayName, nil
}
func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
var list []model.CustomGeoResource
var err error
if s.updateAllGetAll != nil {
list, err = s.updateAllGetAll()
} else {
list, err = s.GetAll()
}
if err != nil {
return nil, err
}
res := &CustomGeoUpdateAllResult{}
if len(list) == 0 {
return res, nil
}
for _, r := range list {
var name string
var applyErr error
if s.updateAllApply != nil {
name, applyErr = s.updateAllApply(r.Id, false)
} else {
name, applyErr = s.applyDownloadAndPersist(r.Id, false)
}
if applyErr != nil {
res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
})
continue
}
res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
Id: r.Id, Alias: r.Alias, FileName: name,
})
}
if len(res.Succeeded) > 0 {
var restartErr error
if s.updateAllRestart != nil {
restartErr = s.updateAllRestart()
} else {
restartErr = s.serverService.RestartXrayService()
}
if restartErr != nil {
logger.Warning("custom geo update all: restart xray:", restartErr)
}
}
return res, nil
}
type CustomGeoAliasItem struct {
Alias string `json:"alias"`
Type string `json:"type"`
FileName string `json:"fileName"`
ExtExample string `json:"extExample"`
}
type CustomGeoAliasesResponse struct {
Geosite []CustomGeoAliasItem `json:"geosite"`
Geoip []CustomGeoAliasItem `json:"geoip"`
}
func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
list, err := s.GetAll()
if err != nil {
logger.Warning("custom geo GetAliasesForUI:", err)
return CustomGeoAliasesResponse{}, err
}
var out CustomGeoAliasesResponse
for _, r := range list {
fn := s.fileNameFor(r.Type, r.Alias)
ex := fmt.Sprintf("ext:%s:tag", fn)
item := CustomGeoAliasItem{
Alias: r.Alias,
Type: r.Type,
FileName: fn,
ExtExample: ex,
}
if r.Type == customGeoTypeGeoip {
out.Geoip = append(out.Geoip, item)
} else {
out.Geosite = append(out.Geosite, item)
}
}
return out, nil
}

View file

@ -0,0 +1,348 @@
package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v2/database/model"
)
// disableSSRFCheck disables the SSRF guard for the duration of a test,
// allowing httptest servers on localhost. It restores the original on cleanup.
func disableSSRFCheck(t *testing.T) {
t.Helper()
orig := checkSSRF
checkSSRF = func(_ context.Context, _ string) error { return nil }
t.Cleanup(func() { checkSSRF = orig })
}
func TestNormalizeAliasKey(t *testing.T) {
if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
t.Fatalf("got %q", got)
}
if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
t.Fatalf("got %q", got)
}
}
func TestNewCustomGeoService(t *testing.T) {
s := NewCustomGeoService()
if err := s.validateAlias("ok_alias-1"); err != nil {
t.Fatal(err)
}
}
func TestTriggerUpdateAllAllSuccess(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "a"},
{Id: 2, Alias: "b"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
return fmt.Sprintf("geo_%d.dat", id), nil
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 1 {
t.Fatalf("expected 1 restart, got %d", restartCalls)
}
}
func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "ok"},
{Id: 2, Alias: "bad"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
if id == 2 {
return "geo_2.dat", ErrCustomGeoDownload
}
return "geo_1.dat", nil
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 1 {
t.Fatalf("expected 1 restart, got %d", restartCalls)
}
}
func TestTriggerUpdateAllAllFailure(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "a"},
{Id: 2, Alias: "b"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 0 {
t.Fatalf("expected 0 restart, got %d", restartCalls)
}
}
func TestCustomGeoValidateAlias(t *testing.T) {
s := CustomGeoService{}
if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
t.Fatal("empty alias")
}
if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
t.Fatal("uppercase")
}
if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
t.Fatal("space")
}
if err := s.validateAlias("ok_alias-1"); err != nil {
t.Fatal(err)
}
if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
t.Fatal("reserved")
}
}
func TestCustomGeoValidateURL(t *testing.T) {
s := CustomGeoService{}
if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
t.Fatal("empty")
}
if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
t.Fatal("ftp")
}
if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
t.Fatal(err)
} else if sanitized != "https://example.com/a.dat" {
t.Fatalf("unexpected sanitized URL: %s", sanitized)
}
}
func TestCustomGeoValidateType(t *testing.T) {
s := CustomGeoService{}
if err := s.validateType("geosite"); err != nil {
t.Fatal(err)
}
if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
t.Fatal("bad type")
}
}
func TestCustomGeoDownloadToPath(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "1")
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_t.dat")
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, "")
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("expected download")
}
st, err := os.Stat(dest)
if err != nil || st.Size() < minDatBytes {
t.Fatalf("file %v", err)
}
skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
if err2 != nil || !skipped2 {
t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
}
}
func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
disableSSRFCheck(t)
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Last-Modified", lm)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_rebuild.dat")
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("must not treat as not-modified when local file is missing")
}
if _, err := os.Stat(dest); err != nil {
t.Fatal("file should exist after container-style rebuild")
}
}
func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
disableSSRFCheck(t)
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Last-Modified", lm)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_bad.dat")
if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
t.Fatal(err)
}
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("corrupt local file must be re-downloaded, not 304")
}
st, err := os.Stat(dest)
if err != nil || st.Size() < minDatBytes {
t.Fatalf("file repaired: %v", err)
}
}
func TestCustomGeoFileNameFor(t *testing.T) {
s := CustomGeoService{}
if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
t.Fatal("geoip name")
}
if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
t.Fatal("geosite name")
}
}
func TestLocalDatFileNeedsRepair(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
t.Fatal("missing")
}
smallPath := filepath.Join(dir, "small.dat")
if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
t.Fatal(err)
}
if !localDatFileNeedsRepair(smallPath) {
t.Fatal("small")
}
okPath := filepath.Join(dir, "ok.dat")
if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
t.Fatal(err)
}
if localDatFileNeedsRepair(okPath) {
t.Fatal("ok size")
}
dirPath := filepath.Join(dir, "isdir.dat")
if err := os.Mkdir(dirPath, 0o755); err != nil {
t.Fatal(err)
}
if !localDatFileNeedsRepair(dirPath) {
t.Fatal("dir should need repair")
}
if !CustomGeoLocalFileNeedsRepair(dirPath) {
t.Fatal("exported wrapper dir")
}
if CustomGeoLocalFileNeedsRepair(okPath) {
t.Fatal("exported wrapper ok file")
}
}
func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
if err := probeCustomGeoURL(ts.URL); err != nil {
t.Fatal(err)
}
}
func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte{0})
return
}
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
if err := probeCustomGeoURL(ts.URL); err != nil {
t.Fatal(err)
}
}

View file

@ -1891,6 +1891,16 @@ func (s *InboundService) ResetAllTraffics() error {
return err
}
func (s *InboundService) ResetInboundTraffic(id int) error {
db := database.GetDB()
result := db.Model(model.Inbound{}).
Where("id = ?", id).
Updates(map[string]any{"up": 0, "down": 0})
return result.Error
}
func (s *InboundService) DelDepletedClients(id int) (err error) {
db := database.GetDB()
tx := db.Begin()

View file

@ -34,6 +34,8 @@ import (
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
"github.com/xtls/xray-core/app/router"
"google.golang.org/protobuf/proto"
)
// ProcessState represents the current state of a system process.
@ -1055,6 +1057,48 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
return matched
}
// NormalizeGeositeCountryCodes reads a geosite .dat file, uppercases all
// country_code fields, and writes it back. This works around a case-sensitivity
// mismatch in Xray-core: the router normalizes codes to uppercase before lookup,
// but the find() function compares bytes case-sensitively. Some geosite.dat
// providers (e.g. Loyalsoldier) store codes in lowercase, causing lookup failures.
func NormalizeGeositeCountryCodes(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read geosite file %s: %w", path, err)
}
var list router.GeoSiteList
if err := proto.Unmarshal(data, &list); err != nil {
return fmt.Errorf("failed to parse geosite file %s: %w", path, err)
}
changed := false
for _, entry := range list.Entry {
upper := strings.ToUpper(entry.CountryCode)
if entry.CountryCode != upper {
entry.CountryCode = upper
changed = true
}
}
if !changed {
return nil
}
normalized, err := proto.Marshal(&list)
if err != nil {
return fmt.Errorf("failed to serialize normalized geosite file %s: %w", path, err)
}
if err := os.WriteFile(path, normalized, 0o644); err != nil {
return fmt.Errorf("failed to write normalized geosite file %s: %w", path, err)
}
logger.Infof("Normalized country codes to uppercase in %s (%d entries)", path, len(list.Entry))
return nil
}
func (s *ServerService) UpdateGeofile(fileName string) error {
type geofileEntry struct {
URL string
@ -1146,12 +1190,22 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
var errorMessages []string
normalizeIfGeosite := func(destPath, name string) {
if strings.Contains(name, "geosite") {
if err := NormalizeGeositeCountryCodes(destPath); err != nil {
logger.Warningf("Failed to normalize geosite country codes in %s: %v", name, err)
}
}
}
if fileName == "" {
// Download all geofiles
for _, entry := range geofileAllowlist {
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
if err := downloadFile(entry.URL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
} else {
normalizeIfGeosite(destPath, entry.FileName)
}
}
} else {
@ -1159,6 +1213,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
if err := downloadFile(entry.URL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
} else {
normalizeIfGeosite(destPath, entry.FileName)
}
}

View file

@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
"subURI": "",
"subJsonPath": "/json/",
"subJsonURI": "",
"subClashEnable": "true",
"subClashPath": "/clash/",
"subClashURI": "",
"subJsonFragment": "",
"subJsonNoises": "",
"subJsonMux": "",
@ -555,6 +558,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
return s.getString("subJsonURI")
}
func (s *SettingService) GetSubClashEnable() (bool, error) {
return s.getBool("subClashEnable")
}
func (s *SettingService) GetSubClashPath() (string, error) {
return s.getString("subClashPath")
}
func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
func (s *SettingService) GetSubJsonFragment() (string, error) {
return s.getString("subJsonFragment")
}
@ -743,20 +758,22 @@ func extractHostname(host string) string {
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error)
settings := map[string]settingFunc{
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
}
result := make(map[string]any)
@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
subJsonEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
subClashEnable := false
if v, ok := result["subClashEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subClashEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
subURI := ""
subTitle, _ := s.GetSubTitle()
subPort, _ := s.GetSubPort()
subPath, _ := s.GetSubPath()
subJsonPath, _ := s.GetSubJsonPath()
subClashPath, _ := s.GetSubClashPath()
subDomain, _ := s.GetSubDomain()
subKeyFile, _ := s.GetSubKeyFile()
subCertFile, _ := s.GetSubCertFile()
@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
if subJsonEnable && result["subJsonURI"].(string) == "" {
result["subJsonURI"] = subURI + subJsonPath
}
if subClashEnable && result["subClashURI"].(string) == "" {
result["subClashURI"] = subURI + subClashPath
}
}
return result, nil

View file

@ -118,31 +118,35 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
json.Unmarshal([]byte(inbound.Settings), &settings)
clients, ok := settings["clients"].([]any)
if ok {
// check users active or not
// Fast O(N) lookup map for client traffic enablement
clientStats := inbound.ClientStats
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
indexDecrease := 0
for index, client := range clients {
c := client.(map[string]any)
if c["email"] == clientTraffic.Email {
if !clientTraffic.Enable {
clients = RemoveIndex(clients, index-indexDecrease)
indexDecrease++
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c["email"])
}
}
}
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
// clear client config for additional parameters
// filter and clean clients
var final_clients []any
for _, client := range clients {
c := client.(map[string]any)
if c["enable"] != nil {
if enable, ok := c["enable"].(bool); ok && !enable {
continue
}
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
// check users active or not via stats
if enable, exists := enableMap[email]; exists && !enable {
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
continue
}
// check manual disabled flag
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
// clear client config for additional parameters
for key := range c {
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
delete(c, key)

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
"getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
"customGeoTitle" = "GeoSite / GeoIP مخصص"
"customGeoAdd" = "إضافة"
"customGeoType" = "النوع"
"customGeoAlias" = "الاسم المستعار"
"customGeoUrl" = "URL"
"customGeoEnabled" = "مفعّل"
"customGeoLastUpdated" = "آخر تحديث"
"customGeoExtColumn" = "التوجيه (ext:…)"
"customGeoToastUpdateAll" = "تم تحديث جميع المصادر المخصصة"
"customGeoActions" = "إجراءات"
"customGeoEdit" = "تعديل"
"customGeoDelete" = "حذف"
"customGeoDownload" = "تحديث الآن"
"customGeoModalAdd" = "إضافة geo مخصص"
"customGeoModalEdit" = "تعديل geo مخصص"
"customGeoModalSave" = "حفظ"
"customGeoDeleteConfirm" = "حذف مصدر geo المخصص هذا؟"
"customGeoRoutingHint" = "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag)."
"customGeoInvalidId" = "معرّف المورد غير صالح"
"customGeoAliasesError" = "تعذّر تحميل أسماء geo المخصصة"
"customGeoValidationAlias" = "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط"
"customGeoValidationUrl" = "يجب أن يبدأ الرابط بـ http:// أو https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (مخصص)"
"customGeoToastList" = "قائمة geo المخصص"
"customGeoToastAdd" = "إضافة geo مخصص"
"customGeoToastUpdate" = "تحديث geo مخصص"
"customGeoToastDelete" = "تم حذف geofile «{{ .fileName }}» المخصص"
"customGeoToastDownload" = "تم تحديث geofile «{{ .fileName }}»"
"customGeoErrInvalidType" = "يجب أن يكون النوع geosite أو geoip"
"customGeoErrAliasRequired" = "الاسم المستعار مطلوب"
"customGeoErrAliasPattern" = "الاسم المستعار يحتوي على أحرف غير مسموحة"
"customGeoErrAliasReserved" = "هذا الاسم محجوز"
"customGeoErrUrlRequired" = "الرابط مطلوب"
"customGeoErrInvalidUrl" = "الرابط غير صالح"
"customGeoErrUrlScheme" = "يجب أن يستخدم الرابط http أو https"
"customGeoErrUrlHost" = "مضيف الرابط غير صالح"
"customGeoErrDuplicateAlias" = "هذا الاسم مستخدم مسبقاً لهذا النوع"
"customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
"customGeoErrDownload" = "فشل التنزيل"
"customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
[pages.inbounds]
"allTimeTraffic" = "إجمالي حركة المرور"
@ -271,6 +312,7 @@
"daily" = "يومياً"
"weekly" = "أسبوعياً"
"monthly" = "شهرياً"
"hourly" = "كل ساعة"
[pages.inbounds.toasts]
"obtain" = "تم الحصول عليه"

View file

@ -150,6 +150,47 @@
"geofilesUpdateDialogDesc" = "This will update all geofiles."
"geofilesUpdateAll" = "Update all"
"geofileUpdatePopover" = "Geofile updated successfully"
"customGeoTitle" = "Custom GeoSite / GeoIP"
"customGeoAdd" = "Add"
"customGeoType" = "Type"
"customGeoAlias" = "Alias"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Enabled"
"customGeoLastUpdated" = "Last updated"
"customGeoExtColumn" = "Routing (ext:…)"
"customGeoToastUpdateAll" = "All custom geo sources updated"
"customGeoActions" = "Actions"
"customGeoEdit" = "Edit"
"customGeoDelete" = "Delete"
"customGeoDownload" = "Update now"
"customGeoModalAdd" = "Add custom geo"
"customGeoModalEdit" = "Edit custom geo"
"customGeoModalSave" = "Save"
"customGeoDeleteConfirm" = "Delete this custom geo source?"
"customGeoRoutingHint" = "In routing rules use the value column as ext:file.dat:tag (replace tag)."
"customGeoInvalidId" = "Invalid resource id"
"customGeoAliasesError" = "Failed to load custom geo aliases"
"customGeoValidationAlias" = "Alias may only contain lowercase letters, digits, - and _"
"customGeoValidationUrl" = "URL must start with http:// or https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (custom)"
"customGeoToastList" = "Custom geo list"
"customGeoToastAdd" = "Add custom geo"
"customGeoToastUpdate" = "Update custom geo"
"customGeoToastDelete" = "Custom geo file “{{ .fileName }}” deleted"
"customGeoToastDownload" = "Geofile “{{ .fileName }}” updated"
"customGeoErrInvalidType" = "Type must be geosite or geoip"
"customGeoErrAliasRequired" = "Alias is required"
"customGeoErrAliasPattern" = "Alias must match allowed characters"
"customGeoErrAliasReserved" = "This alias is reserved"
"customGeoErrUrlRequired" = "URL is required"
"customGeoErrInvalidUrl" = "URL is invalid"
"customGeoErrUrlScheme" = "URL must use http or https"
"customGeoErrUrlHost" = "URL host is invalid"
"customGeoErrDuplicateAlias" = "This alias is already used for this type"
"customGeoErrNotFound" = "Custom geo source not found"
"customGeoErrDownload" = "Download failed"
"customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
"dontRefresh" = "Installation is in progress, please do not refresh this page"
"logs" = "Logs"
"config" = "Config"
@ -271,6 +312,7 @@
"daily" = "Daily"
"weekly" = "Weekly"
"monthly" = "Monthly"
"hourly" = "Hourly"
[pages.inbounds.toasts]
"obtain" = "Obtain"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Ocurrió un error al leer la base de datos"
"getDatabaseError" = "Ocurrió un error al obtener la base de datos"
"getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
"customGeoTitle" = "GeoSite / GeoIP personalizados"
"customGeoAdd" = "Añadir"
"customGeoType" = "Tipo"
"customGeoAlias" = "Alias"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Activado"
"customGeoLastUpdated" = "Última actualización"
"customGeoExtColumn" = "Enrutamiento (ext:…)"
"customGeoToastUpdateAll" = "Todas las fuentes personalizadas se actualizaron"
"customGeoActions" = "Acciones"
"customGeoEdit" = "Editar"
"customGeoDelete" = "Eliminar"
"customGeoDownload" = "Actualizar ahora"
"customGeoModalAdd" = "Añadir geo personalizado"
"customGeoModalEdit" = "Editar geo personalizado"
"customGeoModalSave" = "Guardar"
"customGeoDeleteConfirm" = "¿Eliminar esta fuente geo personalizada?"
"customGeoRoutingHint" = "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta)."
"customGeoInvalidId" = "Id de recurso no válido"
"customGeoAliasesError" = "No se pudieron cargar los alias geo personalizados"
"customGeoValidationAlias" = "El alias solo puede contener letras minúsculas, dígitos, - y _"
"customGeoValidationUrl" = "La URL debe comenzar con http:// o https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (personalizado)"
"customGeoToastList" = "Lista de geo personalizado"
"customGeoToastAdd" = "Añadir geo personalizado"
"customGeoToastUpdate" = "Actualizar geo personalizado"
"customGeoToastDelete" = "Geofile personalizado «{{ .fileName }}» eliminado"
"customGeoToastDownload" = "Geofile «{{ .fileName }}» actualizado"
"customGeoErrInvalidType" = "El tipo debe ser geosite o geoip"
"customGeoErrAliasRequired" = "El alias es obligatorio"
"customGeoErrAliasPattern" = "El alias contiene caracteres no permitidos"
"customGeoErrAliasReserved" = "Este alias está reservado"
"customGeoErrUrlRequired" = "La URL es obligatoria"
"customGeoErrInvalidUrl" = "La URL no es válida"
"customGeoErrUrlScheme" = "La URL debe usar http o https"
"customGeoErrUrlHost" = "El host de la URL no es válido"
"customGeoErrDuplicateAlias" = "Este alias ya se usa para este tipo"
"customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
"customGeoErrDownload" = "Error de descarga"
"customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
[pages.inbounds]
"allTimeTraffic" = "Tráfico Total"
@ -271,6 +312,7 @@
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensualmente"
"hourly" = "Cada hora"
[pages.inbounds.toasts]
"obtain" = "Recibir"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "خطا در خواندن پایگاه داده"
"getDatabaseError" = "خطا در دریافت پایگاه داده"
"getConfigError" = "خطا در دریافت فایل پیکربندی"
"customGeoTitle" = "GeoSite / GeoIP سفارشی"
"customGeoAdd" = "افزودن"
"customGeoType" = "نوع"
"customGeoAlias" = "نام مستعار"
"customGeoUrl" = "URL"
"customGeoEnabled" = "فعال"
"customGeoLastUpdated" = "آخرین به‌روزرسانی"
"customGeoExtColumn" = "مسیریابی (ext:…)"
"customGeoToastUpdateAll" = "همه منابع سفارشی به‌روزرسانی شدند"
"customGeoActions" = "اقدامات"
"customGeoEdit" = "ویرایش"
"customGeoDelete" = "حذف"
"customGeoDownload" = "به‌روزرسانی اکنون"
"customGeoModalAdd" = "افزودن geo سفارشی"
"customGeoModalEdit" = "ویرایش geo سفارشی"
"customGeoModalSave" = "ذخیره"
"customGeoDeleteConfirm" = "این منبع geo سفارشی حذف شود؟"
"customGeoRoutingHint" = "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید)."
"customGeoInvalidId" = "شناسه منبع نامعتبر است"
"customGeoAliasesError" = "بارگذاری نام مستعارهای geo سفارشی ناموفق بود"
"customGeoValidationAlias" = "نام مستعار فقط حروف کوچک، اعداد، - و _"
"customGeoValidationUrl" = "URL باید با http:// یا https:// شروع شود"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (سفارشی)"
"customGeoToastList" = "فهرست geo سفارشی"
"customGeoToastAdd" = "افزودن geo سفارشی"
"customGeoToastUpdate" = "به‌روزرسانی geo سفارشی"
"customGeoToastDelete" = "geofile سفارشی «{{ .fileName }}» حذف شد"
"customGeoToastDownload" = "geofile «{{ .fileName }}» به‌روزرسانی شد"
"customGeoErrInvalidType" = "نوع باید geosite یا geoip باشد"
"customGeoErrAliasRequired" = "نام مستعار لازم است"
"customGeoErrAliasPattern" = "نام مستعار دارای نویسه نامجاز است"
"customGeoErrAliasReserved" = "این نام مستعار رزرو است"
"customGeoErrUrlRequired" = "URL لازم است"
"customGeoErrInvalidUrl" = "URL نامعتبر است"
"customGeoErrUrlScheme" = "URL باید http یا https باشد"
"customGeoErrUrlHost" = "میزبان URL نامعتبر است"
"customGeoErrDuplicateAlias" = "این نام مستعار برای این نوع قبلاً استفاده شده است"
"customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
"customGeoErrDownload" = "بارگیری ناموفق بود"
"customGeoErrUpdateAllIncomplete" = "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
[pages.inbounds]
"allTimeTraffic" = "کل ترافیک"
@ -271,6 +312,7 @@
"daily" = "روزانه"
"weekly" = "هفتگی"
"monthly" = "ماهانه"
"hourly" = "هر ساعت"
[pages.inbounds.toasts]
"obtain" = "فراهم‌سازی"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Terjadi kesalahan saat membaca database"
"getDatabaseError" = "Terjadi kesalahan saat mengambil database"
"getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
"customGeoTitle" = "GeoSite / GeoIP kustom"
"customGeoAdd" = "Tambah"
"customGeoType" = "Jenis"
"customGeoAlias" = "Alias"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Aktif"
"customGeoLastUpdated" = "Terakhir diperbarui"
"customGeoExtColumn" = "Routing (ext:…)"
"customGeoToastUpdateAll" = "Semua sumber kustom telah diperbarui"
"customGeoActions" = "Aksi"
"customGeoEdit" = "Edit"
"customGeoDelete" = "Hapus"
"customGeoDownload" = "Perbarui sekarang"
"customGeoModalAdd" = "Tambah geo kustom"
"customGeoModalEdit" = "Edit geo kustom"
"customGeoModalSave" = "Simpan"
"customGeoDeleteConfirm" = "Hapus sumber geo kustom ini?"
"customGeoRoutingHint" = "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag)."
"customGeoInvalidId" = "ID sumber tidak valid"
"customGeoAliasesError" = "Gagal memuat alias geo kustom"
"customGeoValidationAlias" = "Alias hanya huruf kecil, angka, - dan _"
"customGeoValidationUrl" = "URL harus diawali http:// atau https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (kustom)"
"customGeoToastList" = "Daftar geo kustom"
"customGeoToastAdd" = "Tambah geo kustom"
"customGeoToastUpdate" = "Perbarui geo kustom"
"customGeoToastDelete" = "Geofile kustom “{{ .fileName }}” dihapus"
"customGeoToastDownload" = "Geofile “{{ .fileName }}” diperbarui"
"customGeoErrInvalidType" = "Jenis harus geosite atau geoip"
"customGeoErrAliasRequired" = "Alias wajib diisi"
"customGeoErrAliasPattern" = "Alias berisi karakter yang tidak diizinkan"
"customGeoErrAliasReserved" = "Alias ini dicadangkan"
"customGeoErrUrlRequired" = "URL wajib diisi"
"customGeoErrInvalidUrl" = "URL tidak valid"
"customGeoErrUrlScheme" = "URL harus memakai http atau https"
"customGeoErrUrlHost" = "Host URL tidak valid"
"customGeoErrDuplicateAlias" = "Alias ini sudah dipakai untuk jenis ini"
"customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
"customGeoErrDownload" = "Unduh gagal"
"customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
[pages.inbounds]
"allTimeTraffic" = "Total Lalu Lintas"
@ -271,6 +312,7 @@
"daily" = "Harian"
"weekly" = "Mingguan"
"monthly" = "Bulanan"
"hourly" = "Setiap jam"
[pages.inbounds.toasts]
"obtain" = "Dapatkan"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
"getDatabaseError" = "データベースの取得中にエラーが発生しました"
"getConfigError" = "設定ファイルの取得中にエラーが発生しました"
"customGeoTitle" = "カスタム GeoSite / GeoIP"
"customGeoAdd" = "追加"
"customGeoType" = "種類"
"customGeoAlias" = "エイリアス"
"customGeoUrl" = "URL"
"customGeoEnabled" = "有効"
"customGeoLastUpdated" = "最終更新"
"customGeoExtColumn" = "ルーティング (ext:…)"
"customGeoToastUpdateAll" = "すべてのカスタムソースを更新しました"
"customGeoActions" = "操作"
"customGeoEdit" = "編集"
"customGeoDelete" = "削除"
"customGeoDownload" = "今すぐ更新"
"customGeoModalAdd" = "カスタム geo を追加"
"customGeoModalEdit" = "カスタム geo を編集"
"customGeoModalSave" = "保存"
"customGeoDeleteConfirm" = "このカスタム geo ソースを削除しますか?"
"customGeoRoutingHint" = "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。"
"customGeoInvalidId" = "無効なリソース ID"
"customGeoAliasesError" = "カスタム geo エイリアスの読み込みに失敗しました"
"customGeoValidationAlias" = "エイリアスは小文字・数字・- と _ のみ使用できます"
"customGeoValidationUrl" = "URL は http:// または https:// で始めてください"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = "(カスタム)"
"customGeoToastList" = "カスタム geo 一覧"
"customGeoToastAdd" = "カスタム geo を追加"
"customGeoToastUpdate" = "カスタム geo を更新"
"customGeoToastDelete" = "カスタム geofile「{{ .fileName }}」を削除しました"
"customGeoToastDownload" = "geofile「{{ .fileName }}」を更新しました"
"customGeoErrInvalidType" = "種類は geosite または geoip である必要があります"
"customGeoErrAliasRequired" = "エイリアスが必要です"
"customGeoErrAliasPattern" = "エイリアスに使用できない文字が含まれています"
"customGeoErrAliasReserved" = "このエイリアスは予約されています"
"customGeoErrUrlRequired" = "URL が必要です"
"customGeoErrInvalidUrl" = "URL が無効です"
"customGeoErrUrlScheme" = "URL は http または https を使用してください"
"customGeoErrUrlHost" = "URL のホストが無効です"
"customGeoErrDuplicateAlias" = "この種類ですでにこのエイリアスが使われています"
"customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
"customGeoErrDownload" = "ダウンロードに失敗しました"
"customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
[pages.inbounds]
"allTimeTraffic" = "総トラフィック"
@ -271,6 +312,7 @@
"daily" = "毎日"
"weekly" = "毎週"
"monthly" = "毎月"
"hourly" = "毎時"
[pages.inbounds.toasts]
"obtain" = "取得"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
"getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
"getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
"customGeoTitle" = "GeoSite / GeoIP personalizados"
"customGeoAdd" = "Adicionar"
"customGeoType" = "Tipo"
"customGeoAlias" = "Alias"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Ativado"
"customGeoLastUpdated" = "Última atualização"
"customGeoExtColumn" = "Roteamento (ext:…)"
"customGeoToastUpdateAll" = "Todas as fontes personalizadas foram atualizadas"
"customGeoActions" = "Ações"
"customGeoEdit" = "Editar"
"customGeoDelete" = "Excluir"
"customGeoDownload" = "Atualizar agora"
"customGeoModalAdd" = "Adicionar geo personalizado"
"customGeoModalEdit" = "Editar geo personalizado"
"customGeoModalSave" = "Salvar"
"customGeoDeleteConfirm" = "Excluir esta fonte geo personalizada?"
"customGeoRoutingHint" = "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag)."
"customGeoInvalidId" = "ID de recurso inválido"
"customGeoAliasesError" = "Falha ao carregar aliases geo personalizados"
"customGeoValidationAlias" = "O alias só pode conter letras minúsculas, dígitos, - e _"
"customGeoValidationUrl" = "A URL deve começar com http:// ou https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (personalizado)"
"customGeoToastList" = "Lista de geo personalizado"
"customGeoToastAdd" = "Adicionar geo personalizado"
"customGeoToastUpdate" = "Atualizar geo personalizado"
"customGeoToastDelete" = "Geofile personalizado “{{ .fileName }}” excluído"
"customGeoToastDownload" = "Geofile “{{ .fileName }}” atualizado"
"customGeoErrInvalidType" = "O tipo deve ser geosite ou geoip"
"customGeoErrAliasRequired" = "Alias é obrigatório"
"customGeoErrAliasPattern" = "O alias contém caracteres não permitidos"
"customGeoErrAliasReserved" = "Este alias é reservado"
"customGeoErrUrlRequired" = "URL é obrigatória"
"customGeoErrInvalidUrl" = "URL inválida"
"customGeoErrUrlScheme" = "A URL deve usar http ou https"
"customGeoErrUrlHost" = "Host da URL inválido"
"customGeoErrDuplicateAlias" = "Este alias já está em uso para este tipo"
"customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
"customGeoErrDownload" = "Falha no download"
"customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
[pages.inbounds]
"allTimeTraffic" = "Tráfego Total"
@ -271,6 +312,7 @@
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensalmente"
"hourly" = "A cada hora"
[pages.inbounds.toasts]
"obtain" = "Obter"

View file

@ -150,6 +150,47 @@
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
"geofilesUpdateAll" = "Обновить все"
"geofileUpdatePopover" = "Геофайлы успешно обновлены"
"customGeoTitle" = "Пользовательские GeoSite / GeoIP"
"customGeoAdd" = "Добавить"
"customGeoType" = "Тип"
"customGeoAlias" = "Псевдоним"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Включено"
"customGeoLastUpdated" = "Обновлено"
"customGeoExtColumn" = "Маршрутизация (ext:…)"
"customGeoToastUpdateAll" = "Все пользовательские источники обновлены"
"customGeoActions" = "Действия"
"customGeoEdit" = "Изменить"
"customGeoDelete" = "Удалить"
"customGeoDownload" = "Обновить сейчас"
"customGeoModalAdd" = "Добавить источник"
"customGeoModalEdit" = "Изменить источник"
"customGeoModalSave" = "Сохранить"
"customGeoDeleteConfirm" = "Удалить этот пользовательский источник?"
"customGeoRoutingHint" = "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег)."
"customGeoInvalidId" = "Некорректный идентификатор"
"customGeoAliasesError" = "Не удалось загрузить список пользовательских geo"
"customGeoValidationAlias" = "Псевдоним: только a-z, цифры, - и _"
"customGeoValidationUrl" = "URL должен начинаться с http:// или https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (свой)"
"customGeoToastList" = "Список пользовательских geo"
"customGeoToastAdd" = "Добавить пользовательский geo"
"customGeoToastUpdate" = "Изменить пользовательский geo"
"customGeoToastDelete" = "Пользовательский geo-файл «{{ .fileName }}» удалён"
"customGeoToastDownload" = "Geofile «{{ .fileName }}» обновлен"
"customGeoErrInvalidType" = "Тип должен быть geosite или geoip"
"customGeoErrAliasRequired" = "Укажите псевдоним"
"customGeoErrAliasPattern" = "Псевдоним содержит недопустимые символы"
"customGeoErrAliasReserved" = "Этот псевдоним зарезервирован"
"customGeoErrUrlRequired" = "Укажите URL"
"customGeoErrInvalidUrl" = "Некорректный URL"
"customGeoErrUrlScheme" = "URL должен использовать http или https"
"customGeoErrUrlHost" = "Некорректный хост URL"
"customGeoErrDuplicateAlias" = "Такой псевдоним уже используется для этого типа"
"customGeoErrNotFound" = "Источник не найден"
"customGeoErrDownload" = "Ошибка загрузки"
"customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
"logs" = "Журнал"
"config" = "Конфигурация"
@ -271,6 +312,7 @@
"daily" = "Ежедневно"
"weekly" = "Еженедельно"
"monthly" = "Ежемесячно"
"hourly" = "Ежечасно"
[pages.inbounds.toasts]
"obtain" = "Получить"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
"getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
"getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
"customGeoTitle" = "Özel GeoSite / GeoIP"
"customGeoAdd" = "Ekle"
"customGeoType" = "Tür"
"customGeoAlias" = "Takma ad"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Etkin"
"customGeoLastUpdated" = "Son güncelleme"
"customGeoExtColumn" = "Yönlendirme (ext:…)"
"customGeoToastUpdateAll" = "Tüm özel kaynaklar güncellendi"
"customGeoActions" = "İşlemler"
"customGeoEdit" = "Düzenle"
"customGeoDelete" = "Sil"
"customGeoDownload" = "Şimdi güncelle"
"customGeoModalAdd" = "Özel geo ekle"
"customGeoModalEdit" = "Özel geo düzenle"
"customGeoModalSave" = "Kaydet"
"customGeoDeleteConfirm" = "Bu özel geo kaynağını silinsin mi?"
"customGeoRoutingHint" = "Yönlendirme kurallarında değer sütununu ext:dosya.dat:etiket olarak kullanın (etiketi değiştirin)."
"customGeoInvalidId" = "Geçersiz kaynak kimliği"
"customGeoAliasesError" = "Özel geo takma adları yüklenemedi"
"customGeoValidationAlias" = "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir"
"customGeoValidationUrl" = "URL http:// veya https:// ile başlamalıdır"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (özel)"
"customGeoToastList" = "Özel geo listesi"
"customGeoToastAdd" = "Özel geo ekle"
"customGeoToastUpdate" = "Özel geo güncelle"
"customGeoToastDelete" = "Özel geofile \"{{ .fileName }}\" silindi"
"customGeoToastDownload" = "\"{{ .fileName }}\" geofile güncellendi"
"customGeoErrInvalidType" = "Tür geosite veya geoip olmalıdır"
"customGeoErrAliasRequired" = "Takma ad gerekli"
"customGeoErrAliasPattern" = "Takma ad izin verilmeyen karakterler içeriyor"
"customGeoErrAliasReserved" = "Bu takma ad ayrılmış"
"customGeoErrUrlRequired" = "URL gerekli"
"customGeoErrInvalidUrl" = "URL geçersiz"
"customGeoErrUrlScheme" = "URL http veya https kullanmalıdır"
"customGeoErrUrlHost" = "URL ana bilgisayarı geçersiz"
"customGeoErrDuplicateAlias" = "Bu takma ad bu tür için zaten kullanılıyor"
"customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
"customGeoErrDownload" = "İndirme başarısız"
"customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
[pages.inbounds]
"allTimeTraffic" = "Toplam Trafik"
@ -271,6 +312,7 @@
"daily" = "Günlük"
"weekly" = "Haftalık"
"monthly" = "Aylık"
"hourly" = "Saatlik"
[pages.inbounds.toasts]
"obtain" = "Elde Et"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Виникла помилка під час читання бази даних"
"getDatabaseError" = "Виникла помилка під час отримання бази даних"
"getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
"customGeoTitle" = "Користувацькі GeoSite / GeoIP"
"customGeoAdd" = "Додати"
"customGeoType" = "Тип"
"customGeoAlias" = "Псевдонім"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Увімкнено"
"customGeoLastUpdated" = "Оновлено"
"customGeoExtColumn" = "Маршрутизація (ext:…)"
"customGeoToastUpdateAll" = "Усі користувацькі джерела оновлено"
"customGeoActions" = "Дії"
"customGeoEdit" = "Змінити"
"customGeoDelete" = "Видалити"
"customGeoDownload" = "Оновити зараз"
"customGeoModalAdd" = "Додати користувацький geo"
"customGeoModalEdit" = "Змінити користувацький geo"
"customGeoModalSave" = "Зберегти"
"customGeoDeleteConfirm" = "Видалити це джерело geo?"
"customGeoRoutingHint" = "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег)."
"customGeoInvalidId" = "Некоректний ідентифікатор ресурсу"
"customGeoAliasesError" = "Не вдалося завантажити псевдоніми geo"
"customGeoValidationAlias" = "Псевдонім: лише a-z, цифри, - і _"
"customGeoValidationUrl" = "URL має починатися з http:// або https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (власний)"
"customGeoToastList" = "Список користувацьких geo"
"customGeoToastAdd" = "Додати користувацький geo"
"customGeoToastUpdate" = "Оновити користувацький geo"
"customGeoToastDelete" = "Користувацький geofile «{{ .fileName }}» видалено"
"customGeoToastDownload" = "Geofile «{{ .fileName }}» оновлено"
"customGeoErrInvalidType" = "Тип має бути geosite або geoip"
"customGeoErrAliasRequired" = "Потрібен псевдонім"
"customGeoErrAliasPattern" = "Псевдонім містить недопустимі символи"
"customGeoErrAliasReserved" = "Цей псевдонім зарезервовано"
"customGeoErrUrlRequired" = "Потрібен URL"
"customGeoErrInvalidUrl" = "Некоректний URL"
"customGeoErrUrlScheme" = "URL має використовувати http або https"
"customGeoErrUrlHost" = "Некоректний хост URL"
"customGeoErrDuplicateAlias" = "Цей псевдонім уже використовується для цього типу"
"customGeoErrNotFound" = "Джерело geo не знайдено"
"customGeoErrDownload" = "Помилка завантаження"
"customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
[pages.inbounds]
"allTimeTraffic" = "Загальний трафік"
@ -271,6 +312,7 @@
"daily" = "Щодня"
"weekly" = "Щотижня"
"monthly" = "Щомісяця"
"hourly" = "Щогодини"
[pages.inbounds.toasts]
"obtain" = "Отримати"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
"getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
"getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
"customGeoTitle" = "GeoSite / GeoIP tùy chỉnh"
"customGeoAdd" = "Thêm"
"customGeoType" = "Loại"
"customGeoAlias" = "Bí danh"
"customGeoUrl" = "URL"
"customGeoEnabled" = "Bật"
"customGeoLastUpdated" = "Cập nhật lần cuối"
"customGeoExtColumn" = "Định tuyến (ext:…)"
"customGeoToastUpdateAll" = "Đã cập nhật tất cả nguồn tùy chỉnh"
"customGeoActions" = "Thao tác"
"customGeoEdit" = "Sửa"
"customGeoDelete" = "Xóa"
"customGeoDownload" = "Cập nhật ngay"
"customGeoModalAdd" = "Thêm geo tùy chỉnh"
"customGeoModalEdit" = "Sửa geo tùy chỉnh"
"customGeoModalSave" = "Lưu"
"customGeoDeleteConfirm" = "Xóa nguồn geo tùy chỉnh này?"
"customGeoRoutingHint" = "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag)."
"customGeoInvalidId" = "ID tài nguyên không hợp lệ"
"customGeoAliasesError" = "Không tải được bí danh geo tùy chỉnh"
"customGeoValidationAlias" = "Bí danh chỉ gồm chữ thường, số, - và _"
"customGeoValidationUrl" = "URL phải bắt đầu bằng http:// hoặc https://"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = " (tùy chỉnh)"
"customGeoToastList" = "Danh sách geo tùy chỉnh"
"customGeoToastAdd" = "Thêm geo tùy chỉnh"
"customGeoToastUpdate" = "Cập nhật geo tùy chỉnh"
"customGeoToastDelete" = "Đã xóa geofile tùy chỉnh “{{ .fileName }}”"
"customGeoToastDownload" = "Đã cập nhật geofile “{{ .fileName }}”"
"customGeoErrInvalidType" = "Loại phải là geosite hoặc geoip"
"customGeoErrAliasRequired" = "Cần bí danh"
"customGeoErrAliasPattern" = "Bí danh có ký tự không hợp lệ"
"customGeoErrAliasReserved" = "Bí danh này được dành riêng"
"customGeoErrUrlRequired" = "Cần URL"
"customGeoErrInvalidUrl" = "URL không hợp lệ"
"customGeoErrUrlScheme" = "URL phải dùng http hoặc https"
"customGeoErrUrlHost" = "Máy chủ URL không hợp lệ"
"customGeoErrDuplicateAlias" = "Bí danh này đã dùng cho loại này"
"customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
"customGeoErrDownload" = "Tải xuống thất bại"
"customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
[pages.inbounds]
"allTimeTraffic" = "Tổng Lưu Lượng"
@ -271,6 +312,7 @@
"daily" = "Hàng ngày"
"weekly" = "Hàng tuần"
"monthly" = "Hàng tháng"
"hourly" = "Hàng giờ"
[pages.inbounds.toasts]
"obtain" = "Nhận"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "读取数据库时出错"
"getDatabaseError" = "检索数据库时出错"
"getConfigError" = "检索配置文件时出错"
"customGeoTitle" = "自定义 GeoSite / GeoIP"
"customGeoAdd" = "添加"
"customGeoType" = "类型"
"customGeoAlias" = "别名"
"customGeoUrl" = "URL"
"customGeoEnabled" = "启用"
"customGeoLastUpdated" = "上次更新"
"customGeoExtColumn" = "路由 (ext:…)"
"customGeoToastUpdateAll" = "所有自定义来源已更新"
"customGeoActions" = "操作"
"customGeoEdit" = "编辑"
"customGeoDelete" = "删除"
"customGeoDownload" = "立即更新"
"customGeoModalAdd" = "添加自定义 geo"
"customGeoModalEdit" = "编辑自定义 geo"
"customGeoModalSave" = "保存"
"customGeoDeleteConfirm" = "删除此自定义 geo 源?"
"customGeoRoutingHint" = "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。"
"customGeoInvalidId" = "无效的资源 ID"
"customGeoAliasesError" = "加载自定义 geo 别名失败"
"customGeoValidationAlias" = "别名只能包含小写字母、数字、- 和 _"
"customGeoValidationUrl" = "URL 必须以 http:// 或 https:// 开头"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = "(自定义)"
"customGeoToastList" = "自定义 geo 列表"
"customGeoToastAdd" = "添加自定义 geo"
"customGeoToastUpdate" = "更新自定义 geo"
"customGeoToastDelete" = "自定义 geofile「{{ .fileName }}」已删除"
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
"customGeoErrInvalidType" = "类型必须是 geosite 或 geoip"
"customGeoErrAliasRequired" = "请填写别名"
"customGeoErrAliasPattern" = "别名包含不允许的字符"
"customGeoErrAliasReserved" = "该别名已保留"
"customGeoErrUrlRequired" = "请填写 URL"
"customGeoErrInvalidUrl" = "URL 无效"
"customGeoErrUrlScheme" = "URL 必须使用 http 或 https"
"customGeoErrUrlHost" = "URL 主机无效"
"customGeoErrDuplicateAlias" = "此类型下已使用该别名"
"customGeoErrNotFound" = "未找到自定义 geo 源"
"customGeoErrDownload" = "下载失败"
"customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
[pages.inbounds]
"allTimeTraffic" = "累计总流量"
@ -271,6 +312,7 @@
"daily" = "每日"
"weekly" = "每周"
"monthly" = "每月"
"hourly" = "每小时"
[pages.inbounds.toasts]
"obtain" = "获取"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "讀取資料庫時發生錯誤"
"getDatabaseError" = "檢索資料庫時發生錯誤"
"getConfigError" = "檢索設定檔時發生錯誤"
"customGeoTitle" = "自訂 GeoSite / GeoIP"
"customGeoAdd" = "新增"
"customGeoType" = "類型"
"customGeoAlias" = "別名"
"customGeoUrl" = "URL"
"customGeoEnabled" = "啟用"
"customGeoLastUpdated" = "上次更新"
"customGeoExtColumn" = "路由 (ext:…)"
"customGeoToastUpdateAll" = "所有自訂來源已更新"
"customGeoActions" = "操作"
"customGeoEdit" = "編輯"
"customGeoDelete" = "刪除"
"customGeoDownload" = "立即更新"
"customGeoModalAdd" = "新增自訂 geo"
"customGeoModalEdit" = "編輯自訂 geo"
"customGeoModalSave" = "儲存"
"customGeoDeleteConfirm" = "刪除此自訂 geo 來源?"
"customGeoRoutingHint" = "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。"
"customGeoInvalidId" = "無效的資源 ID"
"customGeoAliasesError" = "載入自訂 geo 別名失敗"
"customGeoValidationAlias" = "別名只能包含小寫字母、數字、- 和 _"
"customGeoValidationUrl" = "URL 必須以 http:// 或 https:// 開頭"
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
"customGeoAliasLabelSuffix" = "(自訂)"
"customGeoToastList" = "自訂 geo 清單"
"customGeoToastAdd" = "新增自訂 geo"
"customGeoToastUpdate" = "更新自訂 geo"
"customGeoToastDelete" = "自訂 geofile「{{ .fileName }}」已刪除"
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
"customGeoErrInvalidType" = "類型必須是 geosite 或 geoip"
"customGeoErrAliasRequired" = "請填寫別名"
"customGeoErrAliasPattern" = "別名包含不允許的字元"
"customGeoErrAliasReserved" = "此別名已保留"
"customGeoErrUrlRequired" = "請填寫 URL"
"customGeoErrInvalidUrl" = "URL 無效"
"customGeoErrUrlScheme" = "URL 必須使用 http 或 https"
"customGeoErrUrlHost" = "URL 主機無效"
"customGeoErrDuplicateAlias" = "此類型已使用該別名"
"customGeoErrNotFound" = "找不到自訂 geo 來源"
"customGeoErrDownload" = "下載失敗"
"customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
[pages.inbounds]
"allTimeTraffic" = "累計總流量"
@ -271,6 +312,7 @@
"daily" = "每日"
"weekly" = "每週"
"monthly" = "每月"
"hourly" = "每小時"
[pages.inbounds.toasts]
"obtain" = "獲取"

View file

@ -12,6 +12,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -101,9 +102,10 @@ type Server struct {
api *controller.APIController
ws *controller.WebSocketController
xrayService service.XrayService
settingService service.SettingService
tgbotService service.Tgbot
xrayService service.XrayService
settingService service.SettingService
tgbotService service.Tgbot
customGeoService *service.CustomGeoService
wsHub *websocket.Hub
@ -268,7 +270,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.index = controller.NewIndexController(g)
s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g)
s.api = controller.NewAPIController(g, s.customGeoService)
// Initialize WebSocket hub
s.wsHub = websocket.NewHub()
@ -292,9 +294,27 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return engine, nil
}
// normalizeExistingGeositeFiles normalizes country codes in all geosite .dat
// files found in the bin directory so Xray-core can locate entries correctly.
func normalizeExistingGeositeFiles() {
binDir := config.GetBinFolderPath()
matches, err := filepath.Glob(filepath.Join(binDir, "geosite*.dat"))
if err != nil {
logger.Warningf("Failed to glob geosite files: %v", err)
return
}
for _, path := range matches {
if err := service.NormalizeGeositeCountryCodes(path); err != nil {
logger.Warningf("Failed to normalize geosite country codes in %s: %v", path, err)
}
}
}
// startTask schedules background jobs (Xray checks, traffic jobs, cron
// jobs) which the panel relies on for periodic maintenance and monitoring.
func (s *Server) startTask() {
normalizeExistingGeositeFiles()
s.customGeoService.EnsureOnStartup()
err := s.xrayService.RestartXray(true)
if err != nil {
logger.Warning("start xray failed:", err)
@ -325,6 +345,8 @@ func (s *Server) startTask() {
s.cron.AddJob("@daily", job.NewClearLogsJob())
// Inbound traffic reset jobs
// Run every hour
s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
// Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
// Run once a week, midnight between Sat/Sun
@ -388,6 +410,8 @@ func (s *Server) Start() (err error) {
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
s.cron.Start()
s.customGeoService = service.NewCustomGeoService()
engine, err := s.initRouter()
if err != nil {
return err

View file

@ -21,6 +21,7 @@ const (
MessageTypeNotification MessageType = "notification" // System notification
MessageTypeXrayState MessageType = "xray_state" // Xray state change
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
MessageTypeInvalidate MessageType = "invalidate" // Lightweight signal telling frontend to re-fetch data via REST
)
// Message represents a WebSocket message
@ -32,10 +33,11 @@ type Message struct {
// Client represents a WebSocket client connection
type Client struct {
ID string
Send chan []byte
Hub *Hub
Topics map[MessageType]bool // Subscribed topics
ID string
Send chan []byte
Hub *Hub
Topics map[MessageType]bool // Subscribed topics
closeOnce sync.Once // Ensures Send channel is closed exactly once
}
// Hub maintains the set of active clients and broadcasts messages to them
@ -61,7 +63,6 @@ type Hub struct {
// Worker pool for parallel broadcasting
workerPoolSize int
broadcastWg sync.WaitGroup
}
// NewHub creates a new WebSocket hub
@ -104,20 +105,12 @@ func (h *Hub) Run() {
// Graceful shutdown: close all clients
h.mu.Lock()
for client := range h.clients {
// Safely close channel (avoid double close panic)
select {
case _, stillOpen := <-client.Send:
if stillOpen {
close(client.Send)
}
default:
client.closeOnce.Do(func() {
close(client.Send)
}
})
}
h.clients = make(map[*Client]bool)
h.mu.Unlock()
// Wait for all broadcast workers to finish
h.broadcastWg.Wait()
logger.Info("WebSocket hub stopped gracefully")
return
@ -138,19 +131,9 @@ func (h *Hub) Run() {
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
// Safely close channel (avoid double close panic)
// Check if channel is already closed by trying to read from it
select {
case _, stillOpen := <-client.Send:
if stillOpen {
// Channel was open and had data, now it's empty, safe to close
close(client.Send)
}
// If stillOpen is false, channel was already closed, do nothing
default:
// Channel is empty and open, safe to close
client.closeOnce.Do(func() {
close(client.Send)
}
})
}
count := len(h.clients)
h.mu.Unlock()
@ -220,11 +203,12 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
}
close(clientChan)
// Start workers for parallel processing
h.broadcastWg.Add(h.workerPoolSize)
// Use a local WaitGroup to avoid blocking hub shutdown
var wg sync.WaitGroup
wg.Add(h.workerPoolSize)
for i := 0; i < h.workerPoolSize; i++ {
go func() {
defer h.broadcastWg.Done()
defer wg.Done()
for client := range clientChan {
func() {
defer func() {
@ -246,7 +230,7 @@ func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
}
// Wait for all workers to finish
h.broadcastWg.Wait()
wg.Wait()
}
// Broadcast sends a message to all connected clients
@ -259,6 +243,11 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
return
}
// Skip all work if no clients are connected
if h.GetClientCount() == 0 {
return
}
msg := Message{
Type: messageType,
Payload: payload,
@ -271,10 +260,12 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
return
}
// Limit message size to prevent memory issues
const maxMessageSize = 1024 * 1024 // 1MB
// If message exceeds size limit, send a lightweight invalidate notification
// instead of dropping it entirely — the frontend will re-fetch via REST API
const maxMessageSize = 10 * 1024 * 1024 // 10MB
if len(data) > maxMessageSize {
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
h.broadcastInvalidate(messageType)
return
}
@ -298,6 +289,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
return
}
// Skip all work if no clients are connected
if h.GetClientCount() == 0 {
return
}
msg := Message{
Type: messageType,
Payload: payload,
@ -310,10 +306,11 @@ func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
return
}
// Limit message size to prevent memory issues
const maxMessageSize = 1024 * 1024 // 1MB
// If message exceeds size limit, send a lightweight invalidate notification
const maxMessageSize = 10 * 1024 * 1024 // 10MB
if len(data) > maxMessageSize {
logger.Warningf("WebSocket message too large: %d bytes, dropping", len(data))
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
h.broadcastInvalidate(messageType)
return
}
@ -374,6 +371,31 @@ func (h *Hub) Stop() {
}
}
// broadcastInvalidate sends a lightweight invalidate message to all clients,
// telling them to re-fetch the specified data type via REST API.
// This is used when the full payload exceeds the WebSocket message size limit.
func (h *Hub) broadcastInvalidate(originalType MessageType) {
msg := Message{
Type: MessageTypeInvalidate,
Payload: map[string]string{"type": string(originalType)},
Time: getCurrentTimestamp(),
}
data, err := json.Marshal(msg)
if err != nil {
logger.Error("Failed to marshal invalidate message:", err)
return
}
// Non-blocking send with timeout
select {
case h.broadcast <- data:
case <-time.After(100 * time.Millisecond):
logger.Warning("WebSocket broadcast channel is full, dropping invalidate message")
case <-h.ctx.Done():
}
}
// getCurrentTimestamp returns current Unix timestamp in milliseconds
func getCurrentTimestamp() int64 {
return time.Now().UnixMilli()

View file

@ -24,6 +24,16 @@ func GetHub() *Hub {
return wsHub
}
// HasClients returns true if there are any WebSocket clients connected.
// Use this to skip expensive work (DB queries, serialization) when no browser is open.
func HasClients() bool {
hub := GetHub()
if hub == nil {
return false
}
return hub.GetClientCount() > 0
}
// BroadcastStatus broadcasts server status update to all connected clients
func BroadcastStatus(status any) {
hub := GetHub()
@ -80,3 +90,14 @@ func BroadcastXrayState(state string, errorMsg string) {
hub.Broadcast(MessageTypeXrayState, stateUpdate)
}
}
// BroadcastInvalidate sends a lightweight invalidate signal for the given data type,
// telling connected frontends to re-fetch data via REST API.
// Use this instead of BroadcastInbounds/BroadcastOutbounds when you know the payload
// will be too large, to avoid wasting resources on serialization.
func BroadcastInvalidate(dataType MessageType) {
hub := GetHub()
if hub != nil {
hub.broadcastInvalidate(dataType)
}
}

59
x-ui.sh
View file

@ -1371,14 +1371,15 @@ ssl_cert_issue() {
break
done
LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}"
# check if there already exists a certificate
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
if [ "${currentCert}" == "${domain}" ]; then
local certInfo=$(~/.acme.sh/acme.sh --list)
LOGE "System already has certificates for this domain. Cannot issue again. Current certificate details:"
LOGI "$certInfo"
exit 1
# detect existing certificate and reuse it if present
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
LOGI "Existing certificate found for ${domain}, will reuse it."
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
else
LOGI "Your domain is ready for issuing certificates now..."
fi
@ -1401,15 +1402,19 @@ ssl_cert_issue() {
fi
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs."
rm -rf ~/.acme.sh/${domain}
exit 1
if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs."
rm -rf ~/.acme.sh/${domain}
exit 1
else
LOGE "Issuing certificate succeeded, installing certificates..."
fi
else
LOGE "Issuing certificate succeeded, installing certificates..."
LOGI "Using existing certificate, installing certificates..."
fi
reloadCmd="x-ui restart"
@ -1439,16 +1444,26 @@ ssl_cert_issue() {
fi
# install the certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
local installOutput=""
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
local installRc=$?
echo "${installOutput}"
if [ $? -ne 0 ]; then
LOGE "Installing certificate failed, exiting."
rm -rf ~/.acme.sh/${domain}
exit 1
else
local installWroteFiles=0
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
installWroteFiles=1
fi
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
LOGI "Installing certificate succeeded, enabling auto renew..."
else
LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
fi
exit 1
fi
# enable auto-renew