{t('pages.index.importDatabase')}
diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
index ec37b827..88b6e160 100644
--- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
+++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
updateSetting({ subRoutingRules: e.target.value })} />
+
+ Clash / Mihomo
+
+
+ updateSetting({ subClashEnableRouting: v })} />
+
+
+ updateSetting({ subClashRules: e.target.value })}
+ />
+
>
),
},
diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts
index bb9a4143..89830ee8 100644
--- a/frontend/src/schemas/client.ts
+++ b/frontend/src/schemas/client.ts
@@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1),
emailPrefix: z.string(),
emailPostfix: z.string(),
- quantity: z.number().int().min(1).max(100),
+ quantity: z.number().int().min(1).max(1000),
subId: z.string(),
group: z.string(),
comment: z.string(),
diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts
index 569e80ff..a6c153ec 100644
--- a/frontend/src/schemas/setting.ts
+++ b/frontend/src/schemas/setting.ts
@@ -59,6 +59,8 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(),
subJsonURI: z.string().optional(),
subClashURI: z.string().optional(),
+ subClashEnableRouting: z.boolean().optional(),
+ subClashRules: z.string().optional(),
subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(),
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index 6b654c12..a9a61a35 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -858,13 +858,13 @@ export class LanguageManager {
});
if (LanguageManager.isSupportLanguage(lang)) {
- CookieManager.setCookie('lang', lang);
+ CookieManager.setCookie('lang', lang, 365);
} else {
- CookieManager.setCookie('lang', 'en-US');
+ CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
} else {
- CookieManager.setCookie('lang', 'en-US');
+ CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
@@ -875,7 +875,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US';
}
- CookieManager.setCookie('lang', language);
+ CookieManager.setCookie('lang', language, 365);
window.location.reload();
}
diff --git a/install.sh b/install.sh
index 1b2b114d..6d791bb0 100644
--- a/install.sh
+++ b/install.sh
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
- rm -rf ~/.acme.sh/${domain} 2> /dev/null
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
rm -rf "$certPath" 2> /dev/null
return 1
fi
@@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
- [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+ rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+ [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
- [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+ rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+ [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}"
- # detect existing certificate and reuse it if present
+ # detect existing certificate and reuse it only if its files are actually
+ # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+ # certs under ${domain}; a failed issuance can leave a domain entry in --list
+ # with no usable cert files, which must not be reused (it produces a 0-byte
+ # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
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
+ local acmeCertDir=""
+ if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}_ecc
+ elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}
+ fi
+ if [[ -n "${acmeCertDir}" ]]; 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 "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+ fi
+ fi
+ if [[ ${cert_exists} -eq 0 ]]; then
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi
@@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.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}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
else
@@ -617,7 +633,7 @@ ssl_cert_issue() {
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
diff --git a/main.go b/main.go
index 0e7fb31e..3914016d 100644
--- a/main.go
+++ b/main.go
@@ -466,8 +466,14 @@ func main() {
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
var migrateDsn string
var migrateSrc string
+ var migrateDump string
+ var migrateRestore string
+ var migrateOut string
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
+ migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
+ migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
+ migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
var port int
@@ -512,7 +518,7 @@ func main() {
fmt.Println("Commands:")
fmt.Println(" run run web panel")
fmt.Println(" migrate migrate form other/old x-ui")
- fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database")
+ fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
fmt.Println(" setting set settings")
}
@@ -541,13 +547,30 @@ func main() {
if src == "" {
src = config.GetDBPath()
}
- if migrateDsn == "" {
- fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
- return
- }
- if err := database.MigrateData(src, migrateDsn); err != nil {
- fmt.Println("migration failed:", err)
- os.Exit(1)
+ switch {
+ case migrateDump != "":
+ if err := database.DumpSQLite(src, migrateDump); err != nil {
+ fmt.Println("dump failed:", err)
+ os.Exit(1)
+ }
+ fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
+ case migrateRestore != "":
+ if migrateOut == "" {
+ fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
+ return
+ }
+ if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
+ fmt.Println("restore failed:", err)
+ os.Exit(1)
+ }
+ fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
+ case migrateDsn != "":
+ if err := database.MigrateData(src, migrateDsn); err != nil {
+ fmt.Println("migration failed:", err)
+ os.Exit(1)
+ }
+ default:
+ fmt.Println("nothing to do: pass --dump , --restore --out , or --dsn ")
}
case "setting":
err := settingCmd.Parse(os.Args[2:])
diff --git a/sub/sub.go b/sub/sub.go
index 52b7a3ea..76d0e4dc 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -133,6 +133,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
+ SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
+ if err != nil {
+ SubClashEnableRouting = false
+ }
+
+ SubClashRules, err := s.settingService.GetSubClashRules()
+ if err != nil {
+ SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle()
@@ -221,7 +229,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
- SubJsonMux, SubJsonRules, SubJsonFinalMask, SubTitle, SubSupportUrl,
+ SubJsonMux, SubJsonRules, SubJsonFinalMask,SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil
diff --git a/sub/subClashService.go b/sub/subClashService.go
index 1dc61d67..c15639bf 100644
--- a/sub/subClashService.go
+++ b/sub/subClashService.go
@@ -15,17 +15,13 @@ import (
type SubClashService struct {
inboundService service.InboundService
+ enableRouting bool
+ clashRules string
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 NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
+ return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
proxyNames = append(proxyNames, "DIRECT")
- config := ClashConfig{
- Proxies: proxies,
- ProxyGroups: []map[string]any{{
+ config := map[string]any{
+ "proxies": proxies,
+ "proxy-groups": []map[string]any{{
"name": "PROXY",
"type": "select",
"proxies": proxyNames,
}},
- Rules: []string{"MATCH,PROXY"},
+ "rules": []string{"MATCH,PROXY"},
+ }
+
+ if s.enableRouting {
+ if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
+ return "", "", err
+ }
}
finalYAML, err := yaml.Marshal(config)
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src)
return dst
}
+
+func mergeClashRulesYAML(base map[string]any, raw string) error {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+
+ var custom any
+ if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
+ mergeClashRules(base, linesToClashRules(raw))
+ return nil
+ }
+
+ switch typed := custom.(type) {
+ case []any:
+ mergeClashRules(base, typed)
+ case map[string]any:
+ if rules, ok := typed["rules"]; ok {
+ if ruleList, ok := asAnySlice(rules); ok {
+ mergeClashRules(base, ruleList)
+ }
+ }
+ default:
+ mergeClashRules(base, linesToClashRules(raw))
+ }
+
+ return nil
+}
+
+func mergeClashRules(base map[string]any, customRules []any) {
+ if len(customRules) == 0 {
+ return
+ }
+
+ baseRules, _ := asAnySlice(base["rules"])
+ if hasClashMatchRule(customRules) {
+ base["rules"] = customRules
+ return
+ }
+
+ merged := make([]any, 0, len(customRules)+len(baseRules))
+ merged = append(merged, customRules...)
+ merged = append(merged, baseRules...)
+ base["rules"] = merged
+}
+
+func asAnySlice(value any) ([]any, bool) {
+ switch typed := value.(type) {
+ case []any:
+ return typed, true
+ case []string:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ out = append(out, item)
+ }
+ return out, true
+ case []map[string]any:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ out = append(out, item)
+ }
+ return out, true
+ default:
+ return nil, false
+ }
+}
+
+func hasClashMatchRule(rules []any) bool {
+ for _, rule := range rules {
+ ruleText, ok := rule.(string)
+ if !ok {
+ continue
+ }
+ parts := strings.SplitN(ruleText, ",", 2)
+ if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
+ return true
+ }
+ }
+ return false
+}
+
+func linesToClashRules(raw string) []any {
+ lines := strings.Split(raw, "\n")
+ rules := make([]any, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ rules = append(rules, line)
+ }
+ return rules
+}
diff --git a/sub/subController.go b/sub/subController.go
index 109a22e5..70a02dc6 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -65,6 +65,8 @@ func NewSUBController(
jsonMux string,
jsonRules string,
jsonFinalMask string,
+ clashEnableRouting bool,
+ clashRules string,
subTitle string,
subSupportUrl string,
subProfileUrl string,
@@ -90,7 +92,7 @@ func NewSUBController(
subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
- subClashService: NewSubClashService(sub),
+ subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
}
a.initRouter(g)
return a
diff --git a/web/controller/client.go b/web/controller/client.go
index cb2165f4..36d02f04 100644
--- a/web/controller/client.go
+++ b/web/controller/client.go
@@ -3,6 +3,8 @@ package controller
import (
"encoding/json"
"fmt"
+ "strconv"
+ "strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
+func parseInboundIdsQuery(raw string) []int {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ ids := make([]int, 0, len(parts))
+ for _, p := range parts {
+ if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ return ids
+}
+
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
@@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
- needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
+ inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
+ needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
diff --git a/web/controller/server.go b/web/controller/server.go
index 6c70de74..3ea6fe88 100644
--- a/web/controller/server.go
+++ b/web/controller/server.go
@@ -53,6 +53,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb)
+ g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
@@ -300,6 +301,24 @@ func (a *ServerController) getDb(c *gin.Context) {
c.Writer.Write(db)
}
+// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
+// .db SQLite database on PostgreSQL, so the data can seed the other backend.
+func (a *ServerController) getMigration(c *gin.Context) {
+ data, filename, err := a.serverService.GetMigration()
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
+ return
+ }
+ if !filenameRegex.MatchString(filename) {
+ c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
+ return
+ }
+
+ c.Header("Content-Type", "application/octet-stream")
+ c.Header("Content-Disposition", "attachment; filename="+filename)
+ c.Writer.Write(data)
+}
+
// importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) {
file, _, err := c.Request.FormFile("db")
diff --git a/web/entity/entity.go b/web/entity/entity.go
index 4f8e4d3a..c220bad6 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -83,6 +83,8 @@ type AllSetting struct {
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
+ SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
+ SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
diff --git a/web/runtime/remote.go b/web/runtime/remote.go
index b525ba5f..1e5ba422 100644
--- a/web/runtime/remote.go
+++ b/web/runtime/remote.go
@@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil
}
-// DeleteUser is idempotent: master's per-inbound Delete loop may call it
-// multiple times for the same node, and "not found" on the follow-ups is
-// the expected success path.
-func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
- _, err := r.do(ctx, http.MethodPost,
- "panel/api/clients/del/"+url.PathEscape(email), nil)
+ id, err := r.resolveRemoteID(ctx, ib.Tag)
+ if err != nil {
+ return nil
+ }
+ body := map[string]any{"inboundIds": []int{id}}
+ _, err = r.do(ctx, http.MethodPost,
+ "panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil {
return nil
}
@@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err
}
-func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" {
oldEmail = payload.Email
}
- if _, err := r.do(ctx, http.MethodPost,
- "panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+ id, err := r.resolveRemoteID(ctx, ib.Tag)
+ if err != nil {
+ return err
+ }
+ path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
+ "?inboundIds=" + strconv.Itoa(id)
+ if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err
}
return nil
diff --git a/web/service/api_scale_postgres_test.go b/web/service/api_scale_postgres_test.go
new file mode 100644
index 00000000..ce0c0442
--- /dev/null
+++ b/web/service/api_scale_postgres_test.go
@@ -0,0 +1,216 @@
+package service
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+ xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
+ "github.com/mhsanaei/3x-ui/v3/xray"
+
+ "github.com/op/go-logging"
+)
+
+func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
+ t.Helper()
+ db := database.GetDB()
+ rows := make([]xray.ClientTraffic, len(clients))
+ for i := range clients {
+ rows[i] = xray.ClientTraffic{
+ InboundId: inboundId,
+ Email: clients[i].Email,
+ Enable: true,
+ Total: clients[i].TotalGB,
+ ExpiryTime: clients[i].ExpiryTime,
+ }
+ }
+ if err := db.CreateInBatches(rows, 1000).Error; err != nil {
+ t.Fatalf("seed client_traffics: %v", err)
+ }
+}
+
+// TestAllAPIsPostgresScale exercises every client/inbound/group service method
+// reachable from the REST API at 100k/200k clients, asserting none crash on the
+// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
+func TestAllAPIsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ xuilogger.InitLogger(logging.ERROR)
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ settingSvc := &SettingService{}
+ const userId = 1
+ const m = 2000
+ sizes := []int{50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+ for i := range clients {
+ clients[i].ExpiryTime = exp
+ clients[i].TotalGB = 100 << 30
+ }
+ ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+ if err := db.Create(ib2).Error; err != nil {
+ t.Fatalf("create inbound2: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ run := func(name string, fn func() error) {
+ start := time.Now()
+ if err := fn(); err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
+ }
+
+ run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+
+ seedClientTraffics(t, ib.Id, clients)
+ db.Exec("ANALYZE")
+
+ emails := make([]string, n)
+ for i := 0; i < n; i++ {
+ emails[i] = clients[i].Email
+ }
+ emailsM := emails[:m]
+
+ run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
+ run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
+ run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+ run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
+ run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
+ run("ListPaged+search", func() error {
+ _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
+ return err
+ })
+ run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
+ run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
+ run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
+
+ run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
+ run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
+ run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
+ run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
+ run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
+
+ run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
+ run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
+ run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
+ run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
+
+ run("UpdateByEmail", func() error {
+ upd := clients[n/3]
+ upd.Comment = "touched"
+ _, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
+ return err
+ })
+ run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+ run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+
+ depEmails := emails[:1000]
+ for _, batch := range chunkStrings(depEmails, sqlInChunk) {
+ if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
+ t.Fatalf("mark depleted: %v", err)
+ }
+ }
+ run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
+
+ run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
+ })
+ }
+}
+
+// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
+// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
+// find one client) vs new path (UUID/subId read from the indexed clients table).
+func TestGetClientTrafficByEmailABScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ xuilogger.InitLogger(logging.ERROR)
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ const reps = 10
+ sizes := []int{50000, 100000, 200000}
+
+ oldImpl := func(email string) error {
+ tr, client, err := inboundSvc.GetClientByEmail(email)
+ if err != nil {
+ return err
+ }
+ if tr != nil && client != nil {
+ tr.UUID = client.ID
+ tr.SubId = client.SubID
+ }
+ return nil
+ }
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ seedClientTraffics(t, ib.Id, clients)
+ db.Exec("ANALYZE")
+
+ targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
+
+ start := time.Now()
+ for i := 0; i < reps; i++ {
+ if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
+ t.Fatalf("new GetClientTrafficByEmail: %v", err)
+ }
+ }
+ newDur := time.Since(start) / reps
+
+ start = time.Now()
+ for i := 0; i < reps; i++ {
+ if err := oldImpl(targets[i%len(targets)]); err != nil {
+ t.Fatalf("old GetClientTrafficByEmail: %v", err)
+ }
+ }
+ oldDur := time.Since(start) / reps
+
+ t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
+ newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
+ float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
+ })
+ }
+}
diff --git a/web/service/bulk_traffic_test.go b/web/service/bulk_traffic_test.go
new file mode 100644
index 00000000..0e6c92fe
--- /dev/null
+++ b/web/service/bulk_traffic_test.go
@@ -0,0 +1,149 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+ "github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
+ t.Helper()
+ row := xray.ClientTraffic{
+ InboundId: inboundId,
+ Email: email,
+ Up: up,
+ Down: down,
+ Total: total,
+ ExpiryTime: expiry,
+ Enable: enable,
+ }
+ if err := database.GetDB().Create(&row).Error; err != nil {
+ t.Fatalf("create traffic %s: %v", email, err)
+ }
+}
+
+func trafficOf(t *testing.T, email string) xray.ClientTraffic {
+ t.Helper()
+ var row xray.ClientTraffic
+ if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
+ t.Fatalf("load traffic %s: %v", email, err)
+ }
+ return row
+}
+
+func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ {Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+ {Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+ }
+ ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
+ mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
+ mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
+
+ affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
+ if err != nil {
+ t.Fatalf("BulkResetTraffic: %v", err)
+ }
+ if affected != 2 {
+ t.Fatalf("expected 2 affected, got %d", affected)
+ }
+
+ for _, e := range []string{"alice@x", "bob@x"} {
+ tr := trafficOf(t, e)
+ if tr.Up != 0 || tr.Down != 0 {
+ t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
+ }
+ if !tr.Enable {
+ t.Fatalf("%s: expected re-enabled", e)
+ }
+ }
+
+ carol := trafficOf(t, "carol@x")
+ if carol.Up != 7 {
+ t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
+ }
+}
+
+func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ {Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+ {Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+ }
+ ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ past := time.Now().Add(-time.Hour).UnixMilli()
+ mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
+ mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
+ mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
+
+ deleted, _, err := svc.DelDepleted(inboundSvc)
+ if err != nil {
+ t.Fatalf("DelDepleted: %v", err)
+ }
+ if deleted != 2 {
+ t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
+ }
+
+ if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
+ t.Fatalf("bob should survive: %v", err)
+ }
+ for _, e := range []string{"alice@x", "carol@x"} {
+ if _, err := svc.GetRecordByEmail(nil, e); err == nil {
+ t.Fatalf("%s should be deleted", e)
+ }
+ }
+
+ reloaded, _ := inboundSvc.GetInbound(ib.Id)
+ jsonClients, _ := inboundSvc.GetClients(reloaded)
+ if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
+ t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
+ }
+}
+
+func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ }
+ ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
+
+ tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
+ if err != nil {
+ t.Fatalf("GetClientTrafficByEmail: %v", err)
+ }
+ if tr == nil {
+ t.Fatalf("expected traffic, got nil")
+ }
+ if tr.UUID != "11111111-1111-1111-1111-111111111111" {
+ t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
+ }
+ if tr.SubId != "sa" {
+ t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
+ }
+}
diff --git a/web/service/client.go b/web/service/client.go
index de5fde65..cf26344e 100644
--- a/web/service/client.go
+++ b/web/service/client.go
@@ -124,19 +124,23 @@ func compactOrphans(db *gorm.DB, clients []any) []any {
if len(emails) == 0 {
return clients
}
- var existingEmails []string
- if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
- logger.Warning("compactOrphans pluck:", err)
+ existing := make(map[string]struct{}, len(emails))
+ const orphanChunk = 400
+ for start := 0; start < len(emails); start += orphanChunk {
+ end := min(start+orphanChunk, len(emails))
+ var found []string
+ if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails[start:end]).Pluck("email", &found).Error; err != nil {
+ logger.Warning("compactOrphans pluck:", err)
+ return clients
+ }
+ for _, e := range found {
+ existing[e] = struct{}{}
+ }
+ }
+ if len(existing) == len(emails) {
return clients
}
- if len(existingEmails) == len(emails) {
- return clients
- }
- existing := make(map[string]struct{}, len(existingEmails))
- for _, e := range existingEmails {
- existing[e] = struct{}{}
- }
- out := make([]any, 0, len(existingEmails))
+ out := make([]any, 0, len(existing))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
@@ -170,6 +174,26 @@ func tombstoneClientEmail(email string) {
}
}
+func tombstoneClientEmails(emails []string) {
+ if len(emails) == 0 {
+ return
+ }
+ now := time.Now()
+ cutoff := now.Add(-deleteTombstoneTTL)
+ recentlyDeletedMu.Lock()
+ defer recentlyDeletedMu.Unlock()
+ for _, email := range emails {
+ if email != "" {
+ recentlyDeleted[email] = now
+ }
+ }
+ for e, ts := range recentlyDeleted {
+ if ts.Before(cutoff) {
+ delete(recentlyDeleted, e)
+ }
+ }
+}
+
func isClientEmailTombstoned(email string) bool {
if email == "" {
return false
@@ -196,73 +220,134 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
return err
}
+ emails := make([]string, 0, len(clients))
+ seen := make(map[string]struct{}, len(clients))
for i := range clients {
- c := clients[i]
- email := strings.TrimSpace(c.Email)
+ email := strings.TrimSpace(clients[i].Email)
+ if email == "" {
+ continue
+ }
+ if _, ok := seen[email]; ok {
+ continue
+ }
+ seen[email] = struct{}{}
+ emails = append(emails, email)
+ }
+
+ existing := make(map[string]*model.ClientRecord, len(emails))
+ const selectChunk = 400
+ for start := 0; start < len(emails); start += selectChunk {
+ end := min(start+selectChunk, len(emails))
+ var rows []model.ClientRecord
+ if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
+ return err
+ }
+ for i := range rows {
+ r := rows[i]
+ existing[r.Email] = &r
+ }
+ }
+
+ idByEmail := make(map[string]int, len(emails))
+ pending := make(map[string]*model.ClientRecord, len(emails))
+ toCreate := make([]*model.ClientRecord, 0, len(emails))
+ for i := range clients {
+ email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
- incoming := c.ToRecord()
- row := &model.ClientRecord{}
- err := tx.Where("email = ?", email).First(row).Error
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- return err
- }
- if errors.Is(err, gorm.ErrRecordNotFound) {
- if err := tx.Create(incoming).Error; err != nil {
- return err
- }
- row = incoming
- } else {
- if incoming.UUID != "" {
- row.UUID = incoming.UUID
- }
- if incoming.Password != "" {
- row.Password = incoming.Password
- }
- if incoming.Auth != "" {
- row.Auth = incoming.Auth
- }
- row.Flow = incoming.Flow
- if incoming.Security != "" {
- row.Security = incoming.Security
- }
- if incoming.Reverse != "" {
- row.Reverse = incoming.Reverse
- }
- row.SubID = incoming.SubID
- row.LimitIP = incoming.LimitIP
- row.TotalGB = incoming.TotalGB
- row.ExpiryTime = incoming.ExpiryTime
- row.Enable = incoming.Enable
- row.TgID = incoming.TgID
- if incoming.Group != "" {
- row.Group = incoming.Group
- }
- row.Comment = incoming.Comment
- row.Reset = incoming.Reset
- if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
- row.CreatedAt = incoming.CreatedAt
- }
- preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
- row.UpdatedAt = preservedUpdatedAt
- if err := tx.Save(row).Error; err != nil {
- return err
- }
- if err := tx.Model(&model.ClientRecord{}).
- Where("id = ?", row.Id).
- UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
- return err
+ incoming := clients[i].ToRecord()
+ row, ok := existing[email]
+ if !ok {
+ if _, dup := pending[email]; !dup {
+ pending[email] = incoming
+ toCreate = append(toCreate, incoming)
}
+ continue
}
- link := model.ClientInbound{
- ClientId: row.Id,
- InboundId: inboundId,
- FlowOverride: c.Flow,
+ before := *row
+ if incoming.UUID != "" {
+ row.UUID = incoming.UUID
}
- if err := tx.Create(&link).Error; err != nil {
+ if incoming.Password != "" {
+ row.Password = incoming.Password
+ }
+ if incoming.Auth != "" {
+ row.Auth = incoming.Auth
+ }
+ row.Flow = incoming.Flow
+ if incoming.Security != "" {
+ row.Security = incoming.Security
+ }
+ if incoming.Reverse != "" {
+ row.Reverse = incoming.Reverse
+ }
+ row.SubID = incoming.SubID
+ row.LimitIP = incoming.LimitIP
+ row.TotalGB = incoming.TotalGB
+ row.ExpiryTime = incoming.ExpiryTime
+ row.Enable = incoming.Enable
+ row.TgID = incoming.TgID
+ if incoming.Group != "" {
+ row.Group = incoming.Group
+ }
+ row.Comment = incoming.Comment
+ row.Reset = incoming.Reset
+ if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+ row.CreatedAt = incoming.CreatedAt
+ }
+ preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+ row.UpdatedAt = preservedUpdatedAt
+
+ idByEmail[email] = row.Id
+
+ if *row == before {
+ continue
+ }
+ if err := tx.Save(row).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("id = ?", row.Id).
+ UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+ return err
+ }
+ }
+
+ if len(toCreate) > 0 {
+ if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
+ return err
+ }
+ for _, rec := range toCreate {
+ idByEmail[rec.Email] = rec.Id
+ }
+ }
+
+ links := make([]model.ClientInbound, 0, len(clients))
+ linked := make(map[int]struct{}, len(clients))
+ for i := range clients {
+ email := strings.TrimSpace(clients[i].Email)
+ if email == "" {
+ continue
+ }
+ id, ok := idByEmail[email]
+ if !ok {
+ continue
+ }
+ if _, dup := linked[id]; dup {
+ continue
+ }
+ linked[id] = struct{}{}
+ links = append(links, model.ClientInbound{
+ ClientId: id,
+ InboundId: inboundId,
+ FlowOverride: clients[i].Flow,
+ })
+ }
+ if len(links) > 0 {
+ if err := tx.CreateInBatches(links, 200).Error; err != nil {
return err
}
}
@@ -397,20 +482,26 @@ func (s *ClientService) List() ([]ClientWithAttachments, error) {
}
}
- var links []model.ClientInbound
- if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
- return nil, err
- }
attachments := make(map[int][]int, len(rows))
- for _, l := range links {
- attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+ for _, batch := range chunkInts(clientIds, sqlInChunk) {
+ var links []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
+ return nil, err
+ }
+ for _, l := range links {
+ attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+ }
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
- if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
- return nil, err
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var batchStats []xray.ClientTraffic
+ if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
+ return nil, err
+ }
+ stats = append(stats, batchStats...)
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
@@ -634,7 +725,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
}
}
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
@@ -643,6 +734,19 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err != nil {
return false, err
}
+ if len(inboundFilter) > 0 {
+ allow := make(map[int]struct{}, len(inboundFilter))
+ for _, fid := range inboundFilter {
+ allow[fid] = struct{}{}
+ }
+ filtered := inboundIds[:0:0]
+ for _, ibId := range inboundIds {
+ if _, ok := allow[ibId]; ok {
+ filtered = append(filtered, ibId)
+ }
+ }
+ inboundIds = filtered
+ }
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
@@ -1170,13 +1274,25 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
}
oldInbound.Settings = string(newSettings)
+ var sharedSet map[string]bool
+ if !keepTraffic {
+ removedEmails := make([]string, 0, len(removed))
+ for _, r := range removed {
+ if r.email != "" {
+ removedEmails = append(removedEmails, r.email)
+ }
+ }
+ var sharedErr error
+ sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(removedEmails, inboundId)
+ if sharedErr != nil {
+ return false, sharedErr
+ }
+ }
+
needRestart := false
for _, r := range removed {
email := r.email
- emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
- if err != nil {
- return needRestart, err
- }
+ emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
if !emailShared && !keepTraffic {
if err := inboundSvc.DelClientIPs(db, email); err != nil {
logger.Error("Error in delete client IPs")
@@ -1317,7 +1433,7 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
return out, nil
}
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
@@ -1325,7 +1441,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
if err != nil {
return false, err
}
- return s.Update(inboundSvc, rec.Id, updated)
+ return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
}
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
@@ -1631,14 +1747,43 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
if len(emails) == 0 {
return 0, nil
}
- count := 0
- for _, email := range emails {
- if _, err := s.ResetTrafficByEmail(inboundSvc, email); err != nil {
- return count, err
+ seen := map[string]struct{}{}
+ cleanEmails := make([]string, 0, len(emails))
+ for _, e := range emails {
+ e = strings.TrimSpace(e)
+ if e == "" {
+ continue
}
- count++
+ if _, ok := seen[e]; ok {
+ continue
+ }
+ seen[e] = struct{}{}
+ cleanEmails = append(cleanEmails, e)
}
- return count, nil
+ if len(cleanEmails) == 0 {
+ return 0, nil
+ }
+
+ affected := 0
+ err := submitTrafficWrite(func() error {
+ db := database.GetDB()
+ return db.Transaction(func(tx *gorm.DB) error {
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ res := tx.Model(xray.ClientTraffic{}).
+ Where("email IN ?", batch).
+ Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+ if res.Error != nil {
+ return res.Error
+ }
+ affected += int(res.RowsAffected)
+ }
+ return nil
+ })
+ })
+ if err != nil {
+ return 0, err
+ }
+ return affected, nil
}
func (s *ClientService) CreateGroup(name string) error {
@@ -1710,8 +1855,12 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
}
var records []model.ClientRecord
- if err := db.Where("email IN ?", emails).Find(&records).Error; err != nil {
- return 0, err
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return 0, err
+ }
+ records = append(records, rows...)
}
if len(records) == 0 {
return 0, nil
@@ -1722,21 +1871,33 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
}
tx := db.Begin()
- if err := tx.Model(&model.ClientRecord{}).
- Where("email IN ?", affectedEmails).
- UpdateColumn("group_name", group).Error; err != nil {
- tx.Rollback()
- return 0, err
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("email IN ?", batch).
+ UpdateColumn("group_name", group).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
}
var inboundIDs []int
- if err := tx.Table("client_inbounds").
- Joins("JOIN clients ON clients.id = client_inbounds.client_id").
- Where("clients.email IN ?", affectedEmails).
- Distinct("client_inbounds.inbound_id").
- Pluck("inbound_id", &inboundIDs).Error; err != nil {
- tx.Rollback()
- return 0, err
+ inboundIDSeen := make(map[int]struct{})
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ var ids []int
+ if err := tx.Table("client_inbounds").
+ Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+ Where("clients.email IN ?", batch).
+ Distinct("client_inbounds.inbound_id").
+ Pluck("inbound_id", &ids).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ for _, id := range ids {
+ if _, ok := inboundIDSeen[id]; !ok {
+ inboundIDSeen[id] = struct{}{}
+ inboundIDs = append(inboundIDs, id)
+ }
+ }
}
emailSet := make(map[string]struct{}, len(affectedEmails))
@@ -1828,13 +1989,23 @@ func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error)
}
var inboundIDs []int
- if err := tx.Table("client_inbounds").
- Joins("JOIN clients ON clients.id = client_inbounds.client_id").
- Where("clients.email IN ?", affectedEmails).
- Distinct("client_inbounds.inbound_id").
- Pluck("inbound_id", &inboundIDs).Error; err != nil {
- tx.Rollback()
- return 0, err
+ inboundIDSeen := make(map[int]struct{})
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ var ids []int
+ if err := tx.Table("client_inbounds").
+ Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+ Where("clients.email IN ?", batch).
+ Distinct("client_inbounds.inbound_id").
+ Pluck("inbound_id", &ids).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ for _, id := range ids {
+ if _, ok := inboundIDSeen[id]; !ok {
+ inboundIDSeen[id] = struct{}{}
+ inboundIDs = append(inboundIDs, id)
+ }
+ }
}
for _, ibID := range inboundIDs {
@@ -2304,8 +2475,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
db := database.GetDB()
var records []model.ClientRecord
- if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ records = append(records, rows...)
}
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
for i := range records {
@@ -2381,8 +2556,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
var mappings []model.ClientInbound
- if err := db.Where("client_id IN ?", plannedIds).Find(&mappings).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkInts(plannedIds, sqlInChunk) {
+ var rows []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ mappings = append(mappings, rows...)
}
emailsByInbound := map[int][]string{}
for _, m := range mappings {
@@ -2570,20 +2749,22 @@ func (s *ClientService) bulkAdjustInboundClients(
}
db := database.GetDB()
- if err := db.Save(oldInbound).Error; err != nil {
+ txErr := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Save(oldInbound).Error; err != nil {
+ return err
+ }
+ finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+ if gcErr != nil {
+ return gcErr
+ }
+ return s.SyncInbound(tx, inboundId, finalClients)
+ })
+ if txErr != nil {
for email := range foundEmails {
if _, skip := res.perEmailSkipped[email]; !skip {
- res.perEmailSkipped[email] = err.Error()
+ res.perEmailSkipped[email] = txErr.Error()
}
}
- return res
- }
-
- finalClients, gcErr := inboundSvc.GetClients(oldInbound)
- if gcErr == nil {
- if syncErr := s.SyncInbound(db, inboundId, finalClients); syncErr != nil {
- logger.Warning("bulkAdjust SyncInbound:", syncErr)
- }
}
return res
@@ -2601,6 +2782,8 @@ type BulkDeleteReport struct {
Reason string `json:"reason"`
}
+const sqlInChunk = 400
+
// BulkDelete removes every client in the list in one optimized pass.
// Instead of running the full single-delete pipeline N times (which would
// re-read, re-parse, and re-write each inbound's settings JSON for every
@@ -2631,14 +2814,20 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
db := database.GetDB()
var records []model.ClientRecord
- if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ records = append(records, rows...)
}
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
+ tombstoneEmails := make([]string, 0, len(records))
for i := range records {
recordsByEmail[records[i].Email] = &records[i]
- tombstoneClientEmail(records[i].Email)
+ tombstoneEmails = append(tombstoneEmails, records[i].Email)
}
+ tombstoneClientEmails(tombstoneEmails)
skippedReasons := map[string]string{}
for _, email := range cleanEmails {
@@ -2657,8 +2846,12 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
emailsByInbound := map[int][]string{}
if len(clientIds) > 0 {
var mappings []model.ClientInbound
- if err := db.Where("client_id IN ?", clientIds).Find(&mappings).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkInts(clientIds, sqlInChunk) {
+ var rows []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ mappings = append(mappings, rows...)
}
for _, m := range mappings {
email, ok := recordIdToEmail[m.ClientId]
@@ -2693,19 +2886,25 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
}
if len(successIds) > 0 {
- if err := db.Where("client_id IN ?", successIds).Delete(&model.ClientInbound{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkInts(successIds, sqlInChunk) {
+ if err := db.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
if !keepTraffic && len(successEmails) > 0 {
- if err := db.Where("email IN ?", successEmails).Delete(&xray.ClientTraffic{}).Error; err != nil {
- return result, needRestart, err
- }
- if err := db.Where("client_email IN ?", successEmails).Delete(&model.InboundClientIps{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+ if err := db.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
+ return result, needRestart, err
+ }
+ if err := db.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
}
- if err := db.Where("id IN ?", successIds).Delete(&model.ClientRecord{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkInts(successIds, sqlInChunk) {
+ if err := db.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
}
@@ -2835,38 +3034,52 @@ func (s *ClientService) bulkDelInboundClients(
Email string
Enable bool
}
- var rows []trafficRow
- if err := db.Model(xray.ClientTraffic{}).
- Where("email IN ?", foundList).
- Select("email, enable").
- Scan(&rows).Error; err == nil {
- for _, r := range rows {
- notDepletedByEmail[r.Email] = r.Enable
+ for _, batch := range chunkStrings(foundList, sqlInChunk) {
+ var rows []trafficRow
+ if err := db.Model(xray.ClientTraffic{}).
+ Where("email IN ?", batch).
+ Select("email, enable").
+ Scan(&rows).Error; err == nil {
+ for _, r := range rows {
+ notDepletedByEmail[r.Email] = r.Enable
+ }
}
}
}
- for email := range foundEmails {
- shared, sharedErr := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+ var sharedSet map[string]bool
+ if !keepTraffic {
+ var sharedErr error
+ sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(foundList, inboundId)
if sharedErr != nil {
- res.perEmailSkipped[email] = sharedErr.Error()
- delete(foundEmails, email)
- continue
+ for email := range foundEmails {
+ res.perEmailSkipped[email] = sharedErr.Error()
+ delete(foundEmails, email)
+ }
+ return res
}
- if shared || keepTraffic {
- continue
+ }
+ if !keepTraffic {
+ purge := make([]string, 0, len(foundEmails))
+ for email := range foundEmails {
+ if !sharedSet[strings.ToLower(strings.TrimSpace(email))] {
+ purge = append(purge, email)
+ }
}
- if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
- logger.Error("Error in delete client IPs")
- res.perEmailSkipped[email] = delErr.Error()
- delete(foundEmails, email)
- continue
- }
- if delErr := inboundSvc.DelClientStat(db, email); delErr != nil {
- logger.Error("Delete stats Data Error")
- res.perEmailSkipped[email] = delErr.Error()
- delete(foundEmails, email)
- continue
+ if len(purge) > 0 {
+ if delErr := inboundSvc.delClientIPsByEmails(db, purge); delErr != nil {
+ logger.Error("Error in delete client IPs")
+ for _, email := range purge {
+ res.perEmailSkipped[email] = delErr.Error()
+ delete(foundEmails, email)
+ }
+ } else if delErr := inboundSvc.delClientStatsByEmails(db, purge); delErr != nil {
+ logger.Error("Delete stats Data Error")
+ for _, email := range purge {
+ res.perEmailSkipped[email] = delErr.Error()
+ delete(foundEmails, email)
+ }
+ }
}
}
@@ -2907,21 +3120,22 @@ func (s *ClientService) bulkDelInboundClients(
}
}
- if err := db.Save(oldInbound).Error; err != nil {
+ txErr := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Save(oldInbound).Error; err != nil {
+ return err
+ }
+ finalClients, err := inboundSvc.GetClients(oldInbound)
+ if err != nil {
+ return err
+ }
+ return s.SyncInbound(tx, inboundId, finalClients)
+ })
+ if txErr != nil {
for email := range foundEmails {
if _, skip := res.perEmailSkipped[email]; !skip {
- res.perEmailSkipped[email] = err.Error()
+ res.perEmailSkipped[email] = txErr.Error()
}
}
- return res
- }
-
- finalClients, err := inboundSvc.GetClients(oldInbound)
- if err != nil {
- return res
- }
- if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
- return res
}
return res
@@ -2938,27 +3152,200 @@ type BulkCreateReport struct {
Reason string `json:"reason"`
}
-// BulkCreate iterates payloads sequentially. Each item is the same shape
-// the single-create endpoint accepts, so callers can submit a heterogeneous
-// list (different inboundIds, plans, etc.) in one round-trip.
func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
result := BulkCreateResult{}
- needRestart := false
+ if len(payloads) == 0 {
+ return result, false, nil
+ }
+
+ skip := func(email, reason string) {
+ if strings.TrimSpace(email) == "" {
+ email = "(missing email)"
+ }
+ result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
+ }
+
+ emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
+ if err != nil {
+ emailSubIDs = nil
+ }
+
+ type prepared struct {
+ client model.Client
+ inboundIds []int
+ }
+ prep := make([]prepared, 0, len(payloads))
+ emails := make([]string, 0, len(payloads))
+ subIDs := make([]string, 0, len(payloads))
+ seenEmail := make(map[string]struct{}, len(payloads))
+ seenSubID := make(map[string]string, len(payloads))
+
for i := range payloads {
- p := payloads[i]
- email := strings.TrimSpace(p.Client.Email)
- nr, err := s.Create(inboundSvc, &p)
- if err != nil {
- if email == "" {
- email = "(missing email)"
- }
- result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: err.Error()})
+ client := payloads[i].Client
+ email := strings.TrimSpace(client.Email)
+ if email == "" {
+ skip("", "client email is required")
continue
}
- if nr {
- needRestart = true
+ if verr := validateClientEmail(email); verr != nil {
+ skip(email, verr.Error())
+ continue
+ }
+ if verr := validateClientSubID(client.SubID); verr != nil {
+ skip(email, verr.Error())
+ continue
+ }
+ if len(payloads[i].InboundIds) == 0 {
+ skip(email, "at least one inbound is required")
+ continue
+ }
+
+ client.Email = email
+ if client.SubID == "" {
+ client.SubID = uuid.NewString()
+ }
+ if !client.Enable {
+ client.Enable = true
+ }
+ now := time.Now().UnixMilli()
+ if client.CreatedAt == 0 {
+ client.CreatedAt = now
+ }
+ client.UpdatedAt = now
+
+ le := strings.ToLower(email)
+ if _, dup := seenEmail[le]; dup {
+ skip(email, "email already in use: "+email)
+ continue
+ }
+ if owner, ok := seenSubID[client.SubID]; ok && owner != le {
+ skip(email, "subId already in use: "+client.SubID)
+ continue
+ }
+ seenEmail[le] = struct{}{}
+ seenSubID[client.SubID] = le
+
+ prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
+ emails = append(emails, email)
+ subIDs = append(subIDs, client.SubID)
+ }
+
+ if len(prep) == 0 {
+ return result, false, nil
+ }
+
+ db := database.GetDB()
+ const lookupChunk = 400
+ existingEmailSub := make(map[string]string, len(emails))
+ for start := 0; start < len(emails); start += lookupChunk {
+ end := min(start+lookupChunk, len(emails))
+ var rows []model.ClientRecord
+ if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil {
+ return result, false, e
+ }
+ for i := range rows {
+ existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
+ }
+ }
+ existingSubOwner := make(map[string]string, len(subIDs))
+ for start := 0; start < len(subIDs); start += lookupChunk {
+ end := min(start+lookupChunk, len(subIDs))
+ var rows []model.ClientRecord
+ if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil {
+ return result, false, e
+ }
+ for i := range rows {
+ existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email)
+ }
+ }
+
+ inboundCache := make(map[int]*model.Inbound)
+ getIb := func(id int) (*model.Inbound, error) {
+ if ib, ok := inboundCache[id]; ok {
+ return ib, nil
+ }
+ ib, e := inboundSvc.GetInbound(id)
+ if e != nil {
+ return nil, e
+ }
+ inboundCache[id] = ib
+ return ib, nil
+ }
+
+ byInbound := make(map[int][]model.Client)
+ idxByInbound := make(map[int][]int)
+ inboundOrder := make([]int, 0)
+ failed := make([]bool, len(prep))
+ reason := make([]string, len(prep))
+
+ for idx := range prep {
+ le := strings.ToLower(prep[idx].client.Email)
+ if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
+ failed[idx] = true
+ reason[idx] = "email already in use: " + prep[idx].client.Email
+ continue
+ }
+ if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
+ failed[idx] = true
+ reason[idx] = "subId already in use: " + prep[idx].client.SubID
+ continue
+ }
+
+ ok := true
+ for _, ibId := range prep[idx].inboundIds {
+ ib, e := getIb(ibId)
+ if e != nil {
+ failed[idx] = true
+ reason[idx] = e.Error()
+ ok = false
+ break
+ }
+ if e := s.fillProtocolDefaults(&prep[idx].client, ib); e != nil {
+ failed[idx] = true
+ reason[idx] = e.Error()
+ ok = false
+ break
+ }
+ }
+ if !ok {
+ continue
+ }
+ for _, ibId := range prep[idx].inboundIds {
+ ib, _ := getIb(ibId)
+ if _, seen := byInbound[ibId]; !seen {
+ inboundOrder = append(inboundOrder, ibId)
+ }
+ byInbound[ibId] = append(byInbound[ibId], clientWithInboundFlow(prep[idx].client, ib))
+ idxByInbound[ibId] = append(idxByInbound[ibId], idx)
+ }
+ }
+
+ needRestart := false
+ for _, ibId := range inboundOrder {
+ payload, e := json.Marshal(map[string][]model.Client{"clients": byInbound[ibId]})
+ if e == nil {
+ var nr bool
+ nr, e = s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
+ if e == nil && nr {
+ needRestart = true
+ }
+ }
+ if e != nil {
+ for _, idx := range idxByInbound[ibId] {
+ failed[idx] = true
+ if reason[idx] == "" {
+ reason[idx] = e.Error()
+ }
+ }
+ }
+ }
+
+ for idx := range prep {
+ if failed[idx] {
+ skip(prep[idx].client.Email, reason[idx])
+ } else {
+ result.Created++
}
- result.Created++
}
return result, needRestart, nil
}
@@ -2976,33 +3363,27 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
return 0, false, nil
}
- emails := make(map[string]struct{}, len(rows))
+ seen := make(map[string]struct{}, len(rows))
+ emails := make([]string, 0, len(rows))
for _, r := range rows {
- if r.Email != "" {
- emails[r.Email] = struct{}{}
+ if r.Email == "" {
+ continue
}
+ if _, ok := seen[r.Email]; ok {
+ continue
+ }
+ seen[r.Email] = struct{}{}
+ emails = append(emails, r.Email)
+ }
+ if len(emails) == 0 {
+ return 0, false, nil
}
- needRestart := false
- deleted := 0
- for email := range emails {
- var rec model.ClientRecord
- if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- continue
- }
- return deleted, needRestart, err
- }
- nr, err := s.Delete(inboundSvc, rec.Id, false)
- if err != nil {
- return deleted, needRestart, err
- }
- if nr {
- needRestart = true
- }
- deleted++
+ res, needRestart, err := s.BulkDelete(inboundSvc, emails, false)
+ if err != nil {
+ return res.Deleted, needRestart, err
}
- return deleted, needRestart, nil
+ return res.Deleted, needRestart, nil
}
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 1358b3fb..3e513bc3 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
emails = append(emails, e)
}
var extra []xray.ClientTraffic
- if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
- logger.Warning("enrichClientStats:", err)
+ var loadErr error
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var page []xray.ClientTraffic
+ if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
+ loadErr = err
+ break
+ }
+ extra = append(extra, page...)
+ }
+ if loadErr != nil {
+ logger.Warning("enrichClientStats:", loadErr)
} else {
byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra {
@@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
return count > 0, nil
}
+func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
+ shared := make(map[string]bool, len(emails))
+ want := make(map[string]struct{}, len(emails))
+ for _, e := range emails {
+ e = strings.ToLower(strings.TrimSpace(e))
+ if e != "" {
+ want[e] = struct{}{}
+ }
+ }
+ if len(want) == 0 {
+ return shared, nil
+ }
+ db := database.GetDB()
+ var rows []string
+ query := fmt.Sprintf(
+ "SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
+ database.JSONFieldText("client.value", "email"),
+ database.JSONClientsFromInbound(),
+ )
+ if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ for _, e := range rows {
+ e = strings.ToLower(strings.TrimSpace(e))
+ if _, ok := want[e]; ok {
+ shared[e] = true
+ }
+ }
+ return shared, nil
+}
+
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@@ -2438,6 +2478,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
}
+func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
+ const chunk = 400
+ for start := 0; start < len(emails); start += chunk {
+ end := min(start+chunk, len(emails))
+ batch := emails[start:end]
+ if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
+ const chunk = 400
+ for start := 0; start < len(emails); start += chunk {
+ end := min(start+chunk, len(emails))
+ if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
@@ -2991,16 +3057,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
}
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
- // Prefer retrieving along with client to reflect actual enabled state from inbound settings
- t, client, err := s.GetClientByEmail(email)
+ db := database.GetDB()
+ var traffics []*xray.ClientTraffic
+ if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
+ logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
+ return nil, err
+ }
+ if len(traffics) == 0 {
+ return nil, nil
+ }
+ t := traffics[0]
+
+ if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
+ c := rec.ToClient()
+ t.UUID = c.ID
+ t.SubId = c.SubID
+ return t, nil
+ }
+
+ t2, client, err := s.GetClientByEmail(email)
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
- if t != nil && client != nil {
- t.UUID = client.ID
- t.SubId = client.SubID
- return t, nil
+ if t2 != nil && client != nil {
+ t2.UUID = client.ID
+ t2.SubId = client.SubID
+ return t2, nil
}
return nil, nil
}
@@ -3329,6 +3412,9 @@ func (s *InboundService) MigrateDB() {
}
func (s *InboundService) GetOnlineClients() []string {
+ if p == nil {
+ return []string{}
+ }
return p.GetOnlineClients()
}
diff --git a/web/service/server.go b/web/service/server.go
index dcbe5e51..6ec1fac8 100644
--- a/web/service/server.go
+++ b/web/service/server.go
@@ -1156,6 +1156,41 @@ func (s *ServerService) GetDb() ([]byte, error) {
return fileContents, nil
}
+// GetMigration produces a cross-engine migration file plus its filename: on a
+// SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
+// it returns a .db SQLite database built from the live data. Either output can
+// then seed a panel running on the other backend.
+func (s *ServerService) GetMigration() ([]byte, string, error) {
+ if database.IsPostgres() {
+ tmp, err := os.CreateTemp("", "x-ui-migration-*.db")
+ if err != nil {
+ return nil, "", err
+ }
+ tmpPath := tmp.Name()
+ tmp.Close()
+ defer os.Remove(tmpPath)
+
+ if err := database.ExportPostgresToSQLite(config.GetDBDSN(), tmpPath); err != nil {
+ return nil, "", err
+ }
+ data, err := os.ReadFile(tmpPath)
+ if err != nil {
+ return nil, "", err
+ }
+ return data, "x-ui.db", nil
+ }
+
+ // SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
+ if err := database.Checkpoint(); err != nil {
+ return nil, "", err
+ }
+ data, err := database.DumpSQLiteToBytes(config.GetDBPath())
+ if err != nil {
+ return nil, "", err
+ }
+ return data, "x-ui.dump", nil
+}
+
func (s *ServerService) ImportDB(file multipart.File) error {
if database.IsPostgres() {
return s.importPostgresDB(file)
diff --git a/web/service/setting.go b/web/service/setting.go
index c42278a4..fe8ac918 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -79,6 +79,8 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
+ "subClashEnableRouting": "false",
+ "subClashRules": "",
"subJsonMux": "",
"subJsonRules": "",
"subJsonFinalMask": "",
@@ -657,6 +659,14 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
+func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
+ return s.getBool("subClashEnableRouting")
+}
+
+func (s *SettingService) GetSubClashRules() (string, error) {
+ return s.getString("subClashRules")
+}
+
func (s *SettingService) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux")
}
diff --git a/web/service/sync_scale_postgres_test.go b/web/service/sync_scale_postgres_test.go
new file mode 100644
index 00000000..35a990ab
--- /dev/null
+++ b/web/service/sync_scale_postgres_test.go
@@ -0,0 +1,431 @@
+package service
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+
+ "gorm.io/gorm"
+)
+
+func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
+ if tx == nil {
+ tx = database.GetDB()
+ }
+ if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
+ return err
+ }
+ for i := range clients {
+ c := clients[i]
+ email := strings.TrimSpace(c.Email)
+ if email == "" {
+ continue
+ }
+ incoming := c.ToRecord()
+ row := &model.ClientRecord{}
+ err := tx.Where("email = ?", email).First(row).Error
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ if err := tx.Create(incoming).Error; err != nil {
+ return err
+ }
+ row = incoming
+ } else {
+ row.Flow = incoming.Flow
+ row.SubID = incoming.SubID
+ row.LimitIP = incoming.LimitIP
+ row.TotalGB = incoming.TotalGB
+ row.ExpiryTime = incoming.ExpiryTime
+ row.Enable = incoming.Enable
+ row.TgID = incoming.TgID
+ row.Comment = incoming.Comment
+ row.Reset = incoming.Reset
+ preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+ row.UpdatedAt = preservedUpdatedAt
+ if err := tx.Save(row).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("id = ?", row.Id).
+ UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+ return err
+ }
+ }
+ link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
+ if err := tx.Create(&link).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func makeScaleClients(n int) []model.Client {
+ out := make([]model.Client, n)
+ for i := 0; i < n; i++ {
+ out[i] = model.Client{
+ ID: uuid.NewString(),
+ Email: fmt.Sprintf("user-%07d@scale", i),
+ SubID: fmt.Sprintf("sub-%07d", i),
+ Enable: true,
+ }
+ }
+ return out
+}
+
+func TestSyncInboundPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{
+ Tag: fmt.Sprintf("scale-%d", n),
+ Enable: true,
+ Port: 40000,
+ Protocol: model.VLESS,
+ Settings: clientsSettings(t, clients),
+ }
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+
+ start := time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ seed := time.Since(start)
+
+ clients[n/2].Enable = !clients[n/2].Enable
+ start = time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("toggle SyncInbound (new): %v", err)
+ }
+ toggleNew := time.Since(start)
+
+ start = time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("noop SyncInbound (new): %v", err)
+ }
+ noopNew := time.Since(start)
+
+ toggleOld := time.Duration(0)
+ if n <= 10000 {
+ clients[n/2].Enable = !clients[n/2].Enable
+ start = time.Now()
+ if err := syncInboundOld(db, ib.Id, clients); err != nil {
+ t.Fatalf("toggle SyncInbound (old): %v", err)
+ }
+ toggleOld = time.Since(start)
+ }
+
+ var linkCount, recCount int64
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ if int(linkCount) != n || int(recCount) != n {
+ t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
+ }
+
+ oldStr, speedup := "skipped", ""
+ if toggleOld > 0 {
+ oldStr = toggleOld.Round(time.Millisecond).String()
+ speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
+ }
+ t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
+ n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
+ noopNew.Round(time.Millisecond), oldStr, speedup)
+ })
+ }
+}
+
+func maxDur(d, floor time.Duration) time.Duration {
+ if d < floor {
+ return floor
+ }
+ return d
+}
+
+func TestAddDelClientPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 20000, 50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{
+ Tag: fmt.Sprintf("adddel-%d", n),
+ Enable: true,
+ Port: 40000,
+ Protocol: model.VLESS,
+ Settings: clientsSettings(t, clients),
+ }
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ newC := model.Client{
+ ID: uuid.NewString(),
+ Email: "added-client@scale",
+ SubID: "added-sub",
+ Enable: true,
+ }
+ addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
+ start := time.Now()
+ if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
+ t.Fatalf("AddInboundClient: %v", err)
+ }
+ addDur := time.Since(start)
+
+ delId := clients[n/2].ID
+ start = time.Now()
+ if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
+ t.Fatalf("DelInboundClient: %v", err)
+ }
+ delDur := time.Since(start)
+
+ var recCount, linkCount int64
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+
+ t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
+ addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
+ })
+ }
+}
+
+func TestGroupAndListPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ sizes := []int{5000, 100000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ db.Exec("ANALYZE")
+ emails := make([]string, n)
+ for i := 0; i < n; i++ {
+ emails[i] = clients[i].Email
+ }
+
+ start := time.Now()
+ if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
+ t.Fatalf("AddToGroup: %v", err)
+ }
+ addDur := time.Since(start)
+
+ start = time.Now()
+ if _, err := svc.RemoveFromGroup(emails); err != nil {
+ t.Fatalf("RemoveFromGroup: %v", err)
+ }
+ rmDur := time.Since(start)
+
+ start = time.Now()
+ list, err := svc.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ listDur := time.Since(start)
+ if len(list) != n {
+ t.Fatalf("List returned %d, want %d", len(list), n)
+ }
+
+ t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
+ addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
+ })
+ }
+}
+
+func TestDelAllClientsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 50000, 100000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ emails, err := inboundSvc.EmailsByInbound(ib.Id)
+ if err != nil {
+ t.Fatalf("EmailsByInbound: %v", err)
+ }
+ start := time.Now()
+ res, _, err := svc.BulkDelete(inboundSvc, emails, false)
+ if err != nil {
+ t.Fatalf("BulkDelete: %v", err)
+ }
+ dur := time.Since(start)
+
+ var recCount, linkCount int64
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+ if recCount != 0 || linkCount != 0 {
+ t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
+ }
+ t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
+ })
+ }
+}
+
+func TestBulkOpsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 20000, 50000, 100000}
+ const m = 2000
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+ for i := range clients {
+ clients[i].ExpiryTime = exp
+ clients[i].TotalGB = 100 << 30
+ }
+ ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+ if err := db.Create(ib2).Error; err != nil {
+ t.Fatalf("create inbound2: %v", err)
+ }
+
+ emailsM := make([]string, m)
+ for i := 0; i < m; i++ {
+ emailsM[i] = clients[i].Email
+ }
+
+ t0 := time.Now()
+ if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
+ t.Fatalf("BulkAdjust: %v", err)
+ }
+ adjustDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+ t.Fatalf("BulkAttach: %v", err)
+ }
+ attachDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+ t.Fatalf("BulkDetach: %v", err)
+ }
+ detachDur := time.Since(t0)
+
+ payloads := make([]ClientCreatePayload, m)
+ for i := 0; i < m; i++ {
+ payloads[i] = ClientCreatePayload{
+ Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
+ InboundIds: []int{ib.Id},
+ }
+ }
+ t0 = time.Now()
+ if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
+ t.Fatalf("BulkCreate: %v", err)
+ }
+ createDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
+ t.Fatalf("BulkDelete: %v", err)
+ }
+ deleteDur := time.Since(t0)
+
+ t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
+ adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
+ createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
+ })
+ }
+}
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index dcb6cb2c..81a0de4e 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ if !t.isCommandForCurrentBot(&message) {
+ return nil
+ }
+
// Use goroutine with worker pool for concurrent command processing
go func() {
messageWorkerPool <- struct{}{} // Acquire worker
@@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
}
}
+func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
+ return isCommandForBot(message.Text, botUsername())
+}
+
+func botUsername() string {
+ if bot == nil {
+ return ""
+ }
+ return bot.Username()
+}
+
+func isCommandForBot(text string, username string) bool {
+ _, commandUsername, _ := tu.ParseCommand(text)
+ return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
+}
+
// sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage {
diff --git a/web/service/tgbot_test.go b/web/service/tgbot_test.go
index 36e17e78..a3c346b0 100644
--- a/web/service/tgbot_test.go
+++ b/web/service/tgbot_test.go
@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
t.Fatal("Dial must be nil when no proxy is configured")
}
}
+
+func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
+ if !isCommandForBot("/status", "panel_bot") {
+ t.Fatal("untargeted commands must remain accepted")
+ }
+}
+
+func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
+ if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
+ t.Fatal("commands targeted to this bot must be accepted")
+ }
+}
+
+func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
+ if isCommandForBot("/status@other_bot", "panel_bot") {
+ t.Fatal("commands targeted to another bot must be ignored")
+ }
+}
+
+func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
+ if !isCommandForBot("/status@panel_bot", "") {
+ t.Fatal("commands must remain accepted when the current bot username is unavailable")
+ }
+}
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index 1c282279..668ceb37 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -277,7 +277,10 @@
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
"backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
- "importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية."
+ "importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
+ "migrationDownload": "تنزيل ملف الترحيل",
+ "migrationDownloadDesc": "انقر لتنزيل تصدير .dump محمول (نص SQL) لقاعدة بيانات SQLite الخاصة بك.",
+ "migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
},
"inbounds": {
"title": "الواردات",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه",
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
+ "subClashEnableRouting": "تفعيل التوجيه",
+ "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
+ "subClashRoutingRules": "قواعد التوجيه العامة",
+ "subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
"subListen": "IP الاستماع",
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"subPort": "بورت الاستماع",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 76f6111c..27429ecf 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -277,7 +277,10 @@
"getConfigError": "An error occurred while retrieving the config file.",
"backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
- "importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data."
+ "importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data.",
+ "migrationDownload": "Download Migration",
+ "migrationDownloadDesc": "Click to download a portable .dump (SQL text) export of your SQLite database.",
+ "migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
},
"inbounds": {
"title": "Inbounds",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules",
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
+ "subClashEnableRouting": "Enable routing",
+ "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
+ "subClashRoutingRules": "Global routing rules",
+ "subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
"subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index 482c1c86..d4e84936 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -277,7 +277,10 @@
"getConfigError": "Ocurrió un error al obtener el archivo de configuración",
"backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
- "importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales."
+ "importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales.",
+ "migrationDownload": "Descargar migración",
+ "migrationDownloadDesc": "Haz clic para descargar una exportación portable .dump (texto SQL) de tu base de datos SQLite.",
+ "migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
},
"inbounds": {
"title": "Entradas",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento",
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
+ "subClashEnableRouting": "Habilitar enrutamiento",
+ "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
+ "subClashRoutingRules": "Reglas globales de enrutamiento",
+ "subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
"subListen": "Listening IP",
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"subPort": "Puerto de Suscripción",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index 256e82dc..cfa5d27a 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -277,7 +277,10 @@
"getConfigError": "خطا در دریافت فایل پیکربندی",
"backupPostgresNote": "این پنل روی PostgreSQL اجرا میشود. «پشتیبانگیری» یک آرشیو pg_dump (.dump) دانلود میکند و «بازیابی» آن را با pg_restore بازمیگرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
- "importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند."
+ "importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
+ "migrationDownload": "دانلود فایل مهاجرت",
+ "migrationDownloadDesc": "برای دانلود یک خروجی قابلحمل .dump (متن SQL) از پایگاهدادهٔ SQLite خود کلیک کنید.",
+ "migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
},
"inbounds": {
"title": "ورودیها",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی",
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
+ "subClashEnableRouting": "فعالسازی مسیریابی",
+ "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراکهای YAML تولیدشده وارد کن.",
+ "subClashRoutingRules": "قوانین مسیریابی سراسری",
+ "subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده میشوند.",
"subListen": "آدرس آیپی",
"subListenDesc": "آدرس آیپی برای سرویس سابسکریپشن. برای گوش دادن بهتمام آیپیها خالیبگذارید",
"subPort": "پورت",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 9f4a1dda..74c7878b 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -277,7 +277,10 @@
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
"backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
- "importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini."
+ "importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini.",
+ "migrationDownload": "Unduh migrasi",
+ "migrationDownloadDesc": "Klik untuk mengunduh ekspor .dump (teks SQL) portabel dari basis data SQLite Anda.",
+ "migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
},
"inbounds": {
"title": "Inbound",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing",
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
+ "subClashEnableRouting": "Aktifkan routing",
+ "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
+ "subClashRoutingRules": "Aturan routing global",
+ "subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
"subListen": "IP Pendengar",
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
"subPort": "Port Pendengar",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index b79d8ec4..9f14c4f1 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -277,7 +277,10 @@
"getConfigError": "設定ファイルの取得中にエラーが発生しました",
"backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
- "importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。"
+ "importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
+ "migrationDownload": "移行ファイルをダウンロード",
+ "migrationDownloadDesc": "SQLite データベースのポータブルな .dump(SQL テキスト)エクスポートをダウンロードするにはクリックします。",
+ "migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
},
"inbounds": {
"title": "インバウンド",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール",
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
+ "subClashEnableRouting": "ルーティングを有効化",
+ "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
+ "subClashRoutingRules": "グローバルルーティングルール",
+ "subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
"subListen": "監視IP",
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
"subPort": "監視ポート",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index bd2a8ea7..b7ccc449 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -277,7 +277,10 @@
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
"backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
- "importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais."
+ "importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais.",
+ "migrationDownload": "Baixar migração",
+ "migrationDownloadDesc": "Clique para baixar uma exportação portátil .dump (texto SQL) do seu banco de dados SQLite.",
+ "migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
},
"inbounds": {
"title": "Entradas",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento",
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
+ "subClashEnableRouting": "Ativar roteamento",
+ "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
+ "subClashRoutingRules": "Regras globais de roteamento",
+ "subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
"subListen": "IP de Escuta",
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
"subPort": "Porta de Escuta",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index c5880a79..bdda60b5 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -277,7 +277,10 @@
"getConfigError": "Произошла ошибка при получении конфигурационного файла",
"backupPostgresNote": "Эта панель работает на PostgreSQL. «Резервная копия» скачивает архив pg_dump (.dump), а «Восстановление» загружает его обратно через pg_restore. На сервере должны быть установлены клиентские инструменты PostgreSQL (pg_dump и pg_restore).",
"exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
- "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные."
+ "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные.",
+ "migrationDownload": "Скачать файл миграции",
+ "migrationDownloadDesc": "Нажмите, чтобы скачать переносимый экспорт .dump (текст SQL) вашей базы данных SQLite.",
+ "migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
},
"inbounds": {
"title": "Входящие",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации",
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
+ "subClashEnableRouting": "Включить маршрутизацию",
+ "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
+ "subClashRoutingRules": "Глобальные правила маршрутизации",
+ "subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
"subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 405bb76b..f8f8f5c9 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -277,7 +277,10 @@
"getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu",
"backupPostgresNote": "Bu panel PostgreSQL üzerinde çalışıyor. «Yedekle» bir pg_dump arşivi (.dump) indirir, «Geri Yükle» ise onu pg_restore ile geri yükler. Sunucuda PostgreSQL istemci araçlarının (pg_dump ve pg_restore) kurulu olması gerekir.",
"exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
- "importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır."
+ "importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
+ "migrationDownload": "Geçiş dosyasını indir",
+ "migrationDownloadDesc": "SQLite veritabanınızın taşınabilir .dump (SQL metni) dışa aktarımını indirmek için tıklayın.",
+ "migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
},
"inbounds": {
"title": "Gelenler",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
"subRoutingRules": "Yönlendirme kuralları",
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
+ "subClashEnableRouting": "Yönlendirmeyi etkinleştir",
+ "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
+ "subClashRoutingRules": "Genel yönlendirme kuralları",
+ "subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
"subListen": "Dinleme IP",
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"subPort": "Dinleme Portu",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index b5ed1df7..8c602380 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -277,7 +277,10 @@
"getConfigError": "Виникла помилка під час отримання файлу конфігурації",
"backupPostgresNote": "Ця панель працює на PostgreSQL. «Резервна копія» завантажує архів pg_dump (.dump), а «Відновлення» завантажує його назад через pg_restore. На сервері мають бути встановлені клієнтські інструменти PostgreSQL (pg_dump і pg_restore).",
"exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
- "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані."
+ "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані.",
+ "migrationDownload": "Завантажити файл міграції",
+ "migrationDownloadDesc": "Натисніть, щоб завантажити переносний експорт .dump (текст SQL) вашої бази даних SQLite.",
+ "migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
},
"inbounds": {
"title": "Вхідні",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації",
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
+ "subClashEnableRouting": "Увімкнути маршрутизацію",
+ "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
+ "subClashRoutingRules": "Глобальні правила маршрутизації",
+ "subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
"subListen": "Слухати IP",
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
"subPort": "Слухати порт",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index e1830125..a56cfb21 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -277,7 +277,10 @@
"getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình",
"backupPostgresNote": "Bảng điều khiển này chạy trên PostgreSQL. «Sao lưu» tải xuống một tệp lưu trữ pg_dump (.dump) và «Khôi phục» nạp lại bằng pg_restore. Máy chủ cần cài đặt các công cụ máy khách PostgreSQL (pg_dump và pg_restore).",
"exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
- "importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại."
+ "importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
+ "migrationDownload": "Tải tệp di trú",
+ "migrationDownloadDesc": "Nhấp để tải xuống bản xuất .dump (văn bản SQL) di động của cơ sở dữ liệu SQLite của bạn.",
+ "migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
},
"inbounds": {
"title": "Inbound",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subRoutingRules": "Quy tắc định tuyến",
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
+ "subClashEnableRouting": "Bật định tuyến",
+ "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
+ "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
+ "subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
"subListen": "Listening IP",
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
"subPort": "Cổng gói đăng ký",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index 0f874f6a..9b759532 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -277,7 +277,10 @@
"getConfigError": "检索配置文件时出错",
"backupPostgresNote": "此面板运行在 PostgreSQL 上。「备份」会下载一个 pg_dump 归档(.dump),「恢复」会通过 pg_restore 重新载入。服务器需要安装 PostgreSQL 客户端工具(pg_dump 和 pg_restore)。",
"exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
- "importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。"
+ "importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。",
+ "migrationDownload": "下载迁移文件",
+ "migrationDownloadDesc": "点击下载当前 SQLite 数据库的可移植 .dump(SQL 文本)导出文件。",
+ "migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
},
"inbounds": {
"title": "入站",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
"subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+ "subClashEnableRouting": "启用路由",
+ "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
+ "subClashRoutingRules": "全局路由规则",
+ "subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
"subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
"subPort": "监听端口",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index c94471a3..157f75d5 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -277,7 +277,10 @@
"getConfigError": "檢索設定檔時發生錯誤",
"backupPostgresNote": "此面板執行於 PostgreSQL 上。「備份」會下載一個 pg_dump 封存檔(.dump),「還原」會透過 pg_restore 重新載入。伺服器需要安裝 PostgreSQL 用戶端工具(pg_dump 與 pg_restore)。",
"exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
- "importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。"
+ "importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。",
+ "migrationDownload": "下載遷移檔案",
+ "migrationDownloadDesc": "點擊下載目前 SQLite 資料庫的可攜式 .dump(SQL 文字)匯出檔。",
+ "migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
},
"inbounds": {
"title": "入站",
@@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
"subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+ "subClashEnableRouting": "啟用路由",
+ "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
+ "subClashRoutingRules": "全域路由規則",
+ "subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
"subListen": "監聽 IP",
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
"subPort": "監聽埠",
diff --git a/x-ui.sh b/x-ui.sh
index 9265e3af..ee0f3763 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
echo "Panel paths set for domain: $domain"
echo " - Certificate File: $webCertFile"
echo " - Private Key File: $webKeyFile"
+ # Register the acme.sh install-cert hook so auto-renewal copies the
+ # renewed cert to these paths and reloads the panel. Without it acme.sh
+ # renews but never updates /root/cert, silently serving a stale cert.
+ if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
+ ~/.acme.sh/acme.sh --installcert -d "${domain}" \
+ --key-file "${webKeyFile}" \
+ --fullchain-file "${webCertFile}" \
+ --reloadcmd "x-ui restart" 2>&1 || true
+ echo "Registered acme.sh auto-renewal hook for ${domain}."
+ fi
restart
else
echo "Certificate or private key not found for domain: $domain."
@@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
LOGE "Failed to issue certificate for IP: ${server_ip}"
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
- [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+ rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+ [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
else
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
LOGE "Certificate files not found after installation"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
- [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+ rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+ [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
fi
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}"
- # detect existing certificate and reuse it if present
+ # detect existing certificate and reuse it only if its files are actually
+ # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+ # certs under ${domain}; a failed issuance can leave a domain entry in --list
+ # with no usable cert files, which must not be reused (it produces a 0-byte
+ # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
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
+ local acmeCertDir=""
+ if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}_ecc
+ elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}
+ fi
+ if [[ -n "${acmeCertDir}" ]]; 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
+ LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+ fi
+ fi
+ if [[ ${cert_exists} -eq 0 ]]; then
LOGI "Your domain is ready for issuing certificates now..."
fi
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
~/.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}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
exit 1
else
LOGE "Issuing certificate succeeded, installing certificates..."
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
else
LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
exit 1
fi
@@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnect
ignoreregex =
EOF
+ # Ports to exempt from the ban so an over-limit proxy client can never lock
+ # the administrator out of SSH or the panel. The ban still covers every other
+ # TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
+ # added later without regenerating these files.
+ local ssh_ports
+ ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
+ [[ -z "${ssh_ports}" ]] && ssh_ports="22"
+ local panel_port
+ panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
+ local exempt_ports="${ssh_ports}"
+ [[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
+
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES]
before = iptables-allports.conf
@@ -2263,16 +2301,17 @@ actionstop = -D -p -j f2b-
actioncheck = -n -L | grep -q 'f2b-[ \t]'
-actionban = -I f2b- 1 -s -j
+actionban = -I f2b- 1 -s -p -m multiport ! --dports -j
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path}
-actionunban = -D f2b- -s -j
+actionunban = -D f2b- -s -p -m multiport ! --dports -j
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default
protocol = tcp
chain = INPUT
+exemptports = ${exempt_ports}
EOF
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
@@ -2690,7 +2729,7 @@ migrate_to_postgres() {
echo ""
echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
- echo -e "${yellow}The destination PostgreSQL database must be empty.${plain}"
+ echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
confirm "Continue?" "n" || return 0
local dsn="" pg_mode