From db86007ab8016226502628bfe905033bf99b619e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 16:45:40 +0200 Subject: [PATCH 01/13] fix(multi-node): scope remote client update/delete to one inbound (#4892) UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them. Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound. --- web/controller/client.go | 20 +++++++++++++++++++- web/runtime/remote.go | 25 ++++++++++++++++--------- web/service/client.go | 19 ++++++++++++++++--- 3 files changed, 51 insertions(+), 13 deletions(-) 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/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/client.go b/web/service/client.go index de5fde65..e7131ddd 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -634,7 +634,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 +643,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") @@ -1317,7 +1330,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 +1338,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) { From 14e2d4954a9df20ecef6ec18f10139f3fd26dd9e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 16:57:09 +0200 Subject: [PATCH 02/13] fix(migrate-db): drop legacy client_traffics FK before Postgres copy (#4882) AutoMigrate re-creates the client_traffics -> inbounds foreign key, but the running panel drops it and tolerates client_traffics rows whose inbound was deleted. Migrating a DB with such orphaned rows failed with an fk_inbounds_client_stats violation. Drop the constraint on the destination right after AutoMigrate so the copy matches runtime behavior. --- database/migrate_data.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/database/migrate_data.go b/database/migrate_data.go index d4e6cdec..89c8387c 100644 --- a/database/migrate_data.go +++ b/database/migrate_data.go @@ -86,6 +86,14 @@ func MigrateData(srcPath, dstDSN string) error { } } + // AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key, + // but the running panel drops it (see dropLegacyForeignKeys) and tolerates + // client_traffics rows whose inbound was deleted. Drop it here too so copying + // such orphaned rows can't fail with an fk_inbounds_client_stats violation. + if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil { + return fmt.Errorf("drop legacy foreign key: %w", err) + } + // Empty the destination tables so the migration is idempotent: a fresh // PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior // panel start, and a partially-failed earlier run leaves rows behind. Either From b1d079fc24fa765afe6a2189892a963cebb9d55b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 17:05:27 +0200 Subject: [PATCH 03/13] fix(fail2ban): exempt SSH and panel ports from IP-limit ban (#4896) The 3x-ipl action used iptables-allports, so a banned IP lost all TCP access including SSH and the panel, locking admins out (especially with dynamic-IP clients). The ban now blocks every TCP port except the SSH and panel ports via a multiport negation, derived at jail-creation time in both x-ui.sh and DockerEntrypoint.sh. This keeps IP-limit working for all current and future inbounds without per-port config. --- DockerEntrypoint.sh | 15 +++++++++++++-- x-ui.sh | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 38786b14..9105f965 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -27,6 +27,16 @@ 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. + SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -) + [ -z "$SSH_PORTS" ] && SSH_PORTS="22" + PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}') + EXEMPT_PORTS="$SSH_PORTS" + [ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT" + cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF [INCLUDES] before = iptables-allports.conf @@ -42,16 +52,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." >> $LOG_FOLDER/3xipl-banned.log -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." >> $LOG_FOLDER/3xipl-banned.log [Init] name = default protocol = tcp chain = INPUT +exemptports = $EXEMPT_PORTS EOF fail2ban-client -x start diff --git a/x-ui.sh b/x-ui.sh index 7bcc5022..9b79d933 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -2248,6 +2248,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 +2275,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}" From 44291de989806a41f35d44b427f6656cdadbe89c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 17:15:33 +0200 Subject: [PATCH 04/13] fix(ssl): clean ECC state, guard cert reuse, register renew hook (#4875) - Cleanup on issuance/install failure now also removes the acme.sh ${domain}_ecc (and ${ip}_ecc) directory, not just ${domain}, so a failed run no longer leaves partial state behind. - The 'existing certificate' check only reuses a cert when its fullchain.cer and key files are actually present and non-empty; otherwise the broken state is removed and issuance proceeds. This fixes the 0-byte fullchain.pem produced by reusing failed state. - Menu option 5 (set cert paths) now registers the acme.sh --installcert hook with --reloadcmd 'x-ui restart' when acme.sh knows the domain, so auto-renewal copies the renewed cert and reloads the panel. --- install.sh | 42 +++++++++++++++++++++++++++++------------- x-ui.sh | 50 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 25 deletions(-) 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/x-ui.sh b/x-ui.sh index 9b79d933..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 From 756746dbca190fa6a42197fa94e651ab06c71363 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 18:14:25 +0200 Subject: [PATCH 05/13] perf(clients): make SyncInbound bulk to fix large-inbound timeouts (#4885) Every client mutation funnels through SyncInbound, which ran O(n) DB round-trips per call: one SELECT per client, a Save+UpdateColumn per client, and a per-row junction INSERT. Toggling a single client on a large inbound issued thousands of queries and timed out, badly so on PostgreSQL where each round-trip pays TCP latency. SyncInbound now: - loads existing records with a single chunked SELECT ... email IN (...) instead of one query per client - writes only the records that actually changed (skips no-op Saves), so toggling/editing one client writes one row, not all of them - batch-creates new records and batch-inserts the junction rows Merge and sticky-field semantics are unchanged. Measured on PostgreSQL 16: a single-client toggle on a 50k-client inbound drops from ~8m54s to ~0.9s, and seeding 50k clients from ~2m48s to ~1.6s; 200k clients sync in seconds. A skip-gated benchmark (web/service/sync_scale_postgres_test.go, run with XUI_DB_TYPE=postgres) reproduces and verifies the scaling. --- web/service/client.go | 177 ++++++++++++------ web/service/sync_scale_postgres_test.go | 234 ++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 58 deletions(-) create mode 100644 web/service/sync_scale_postgres_test.go diff --git a/web/service/client.go b/web/service/client.go index e7131ddd..15c077bd 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -196,73 +196,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 } } diff --git a/web/service/sync_scale_postgres_test.go b/web/service/sync_scale_postgres_test.go new file mode 100644 index 00000000..6058df9e --- /dev/null +++ b/web/service/sync_scale_postgres_test.go @@ -0,0 +1,234 @@ +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 int64 + db.Model(&model.ClientRecord{}).Count(&recCount) + if int(recCount) != n { + t.Fatalf("record count after add+del = %d, want %d", recCount, n) + } + + t.Logf("N=%-7d add=%-10v del=%-10v", n, addDur.Round(time.Millisecond), delDur.Round(time.Millisecond)) + }) + } +} From f185d3315c53147f370a18e8693ff6545cc3385b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 19:41:00 +0200 Subject: [PATCH 06/13] perf(clients): scale add/delete and bulk client operations Follow-up to the SyncInbound bulk rewrite, fixing the remaining O(M*N) and O(M)-round-trip behaviour in the add/delete and bulk paths that made them time out on large inbounds (worst case minutes), especially on PostgreSQL. - compactOrphans: chunk the "email IN (...)" lookup (400/batch) instead of binding every email at once. A single huge IN exceeded PostgreSQL's 65535-parameter limit (and SQLite's) and made the planner pathological, so add/delete failed outright past ~100k clients. - emailsUsedByOtherInbounds: new batched form used by delInboundClients (BulkDetach) and bulkDelInboundClients (BulkDelete), replacing a per-email global JSON scan (O(M*N)) with one scan, and skipped entirely when keepTraffic is set. - BulkCreate: rewritten to validate/dedup in one pass, then group clients by inbound and add them in a single addInboundClient call per inbound (one getAllEmailSubIDs, one settings rewrite, one SyncInbound) instead of running the full single-create pipeline per client. - Bulk delete/adjust: batch DelClientStat/DelClientIPs with IN deletes and wrap the settings Save + SyncInbound in one transaction, so the per-row writes share a single fsync instead of one per row. Measured on PostgreSQL 16 (one inbound, M=2000 affected clients): - create: 8m35s (M=500) -> ~1-5s - detach: 52s -> ~4s (flat in N) - delete: ~16s -> ~1-4s - adjust: ~20s -> ~7-10s add/delete of a single client on a 200k-client inbound stays in seconds. sync_scale_postgres_test.go adds skip-gated benchmarks (XUI_DB_TYPE= postgres) for the single add/delete and the five bulk operations. --- .../src/pages/clients/ClientBulkAddModal.tsx | 2 +- frontend/src/pages/clients/ClientsPage.tsx | 6 +- frontend/src/schemas/client.ts | 2 +- web/service/client.go | 342 ++++++++++++++---- web/service/inbound.go | 57 +++ web/service/sync_scale_postgres_test.go | 97 ++++- 6 files changed, 426 insertions(+), 80 deletions(-) diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index aae6aeb4..b5aaf082 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -249,7 +249,7 @@ export default function ClientBulkAddModal({ )} {form.emailMethod < 2 && ( - update('quantity', Number(v) || 1)} /> + update('quantity', Number(v) || 1)} /> )} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 6d6740ff..eecfd828 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -71,6 +71,7 @@ import type { ClientFilters } from './filters'; import './ClientsPage.css'; const FILTER_STATE_KEY = 'clientsFilterState'; +const DISABLED_PAGE_SIZE = 200; function UngroupIcon() { return ( @@ -276,10 +277,7 @@ export default function ClientsPage() { const activeCount = activeFilterCount(filters); useEffect(() => { - if (pageSize > 0) { - - setTablePageSize(pageSize); - } + setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE); }, [pageSize]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); 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/web/service/client.go b/web/service/client.go index 15c077bd..7e110300 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 { @@ -1244,13 +1248,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") @@ -2644,20 +2660,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 @@ -2920,27 +2938,39 @@ func (s *ClientService) bulkDelInboundClients( } } - 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) + } + } } } @@ -2981,21 +3011,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 @@ -3012,27 +3043,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 } diff --git a/web/service/inbound.go b/web/service/inbound.go index 1358b3fb..9978fc32 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -438,6 +438,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 +2469,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 diff --git a/web/service/sync_scale_postgres_test.go b/web/service/sync_scale_postgres_test.go index 6058df9e..1897172b 100644 --- a/web/service/sync_scale_postgres_test.go +++ b/web/service/sync_scale_postgres_test.go @@ -222,13 +222,100 @@ func TestAddDelClientPostgresScale(t *testing.T) { } delDur := time.Since(start) - var recCount int64 + var recCount, linkCount int64 db.Model(&model.ClientRecord{}).Count(&recCount) - if int(recCount) != n { - t.Fatalf("record count after add+del = %d, want %d", recCount, n) - } + db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount) - t.Logf("N=%-7d add=%-10v del=%-10v", n, addDur.Round(time.Millisecond), delDur.Round(time.Millisecond)) + t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n, + addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount) + }) + } +} + +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)) }) } } From d1e733b9e96725b3b8f07c867469b3853afe30cc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 20:35:30 +0200 Subject: [PATCH 07/13] perf(clients): chunk IN queries and de-quadratic bulk delete/group/list Bulk client operations bound their entire working set in a single WHERE x IN (...) clause, which exceeds PostgreSQL's 65535-parameter limit (and SQLite's 32766) and gives the planner a pathological query, so they failed outright on inbounds/selections larger than the limit. Every such query is now chunked at 400 items: - BulkDelete / delete-all-clients: six IN queries chunked, and the per-row delete tombstone (which swept the whole in-memory map on every call, O(N^2)) replaced with a single bulk sweep. - BulkAdjust: record and inbound-mapping lookups chunked. - AddToGroup / RemoveFromGroup (bulk add/remove to group): three IN queries chunked. - replaceGroupValue (rename/delete group): inbound-mapping lookup chunked. - List (all-clients listing): link and traffic lookups chunked. Measured on PostgreSQL 16: delete-all-clients on a 100k-client inbound now completes in ~7s (previously crashed at the parameter limit); bulk add/remove to group ~6s and full client list ~1s at 100k. sync_scale_postgres_test.go adds skip-gated benchmarks for delete-all, group add/remove, and list. --- web/service/client.go | 188 +++++++++++++++++------- web/service/sync_scale_postgres_test.go | 110 ++++++++++++++ 2 files changed, 244 insertions(+), 54 deletions(-) diff --git a/web/service/client.go b/web/service/client.go index 7e110300..b269a570 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -174,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 @@ -462,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] @@ -1800,8 +1826,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 @@ -1812,21 +1842,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)) @@ -1918,13 +1960,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 { @@ -2394,8 +2446,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 { @@ -2471,8 +2527,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 { @@ -2693,6 +2753,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 @@ -2723,14 +2785,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 { @@ -2749,8 +2817,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] @@ -2785,19 +2857,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 + } } } @@ -2927,13 +3005,15 @@ 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 + } } } } diff --git a/web/service/sync_scale_postgres_test.go b/web/service/sync_scale_postgres_test.go index 1897172b..35a990ab 100644 --- a/web/service/sync_scale_postgres_test.go +++ b/web/service/sync_scale_postgres_test.go @@ -232,6 +232,116 @@ func TestAddDelClientPostgresScale(t *testing.T) { } } +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") From d3db828b46ac2579e4f6f7eeef0c2170499b2b5a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 21:32:15 +0200 Subject: [PATCH 08/13] perf(clients): scale-audit remaining client/inbound endpoints to 200k Drive every client/inbound/group endpoint at 100k-200k clients on PostgreSQL and fix the latent issues found in previously-unbenchmarked paths: - enrichClientStats: chunk the email IN lookup (was an unchunked bind that crashed past 65535 clients without traffic rows, taking down GetInbounds/GetInboundDetail/GetAllInbounds) - GetOnlineClients: add the missing nil-process guard its siblings already have, so ListPaged no longer panics before xray starts - GetClientTrafficByEmail: read UUID/subId from the indexed clients table instead of parsing the inbound's full settings JSON (439ms to ~1.5ms, flat in N) - BulkResetTraffic: replace the per-email serialized loop with one chunked bulk UPDATE in a single transaction - DelDepleted: delegate to the already-batched BulkDelete instead of deleting each depleted client one by one Adds a postgres-gated full endpoint sweep plus an A/B benchmark, and SQLite correctness tests for the changed methods. --- frontend/public/openapi.json | 30 ++++ web/service/api_scale_postgres_test.go | 216 +++++++++++++++++++++++++ web/service/bulk_traffic_test.go | 149 +++++++++++++++++ web/service/client.go | 79 +++++---- web/service/inbound.go | 45 +++++- 5 files changed, 483 insertions(+), 36 deletions(-) create mode 100644 web/service/api_scale_postgres_test.go create mode 100644 web/service/bulk_traffic_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index dab418d5..e926b679 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1495,6 +1495,36 @@ } } }, + "/panel/api/server/getMigration": { + "get": { + "tags": [ + "Server" + ], + "summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.", + "operationId": "get_panel_api_server_getMigration", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/server/getNewUUID": { "get": { "tags": [ 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 b269a570..cf26344e 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -1747,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 { @@ -3334,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 9978fc32..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 { @@ -3048,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 } @@ -3386,6 +3412,9 @@ func (s *InboundService) MigrateDB() { } func (s *InboundService) GetOnlineClients() []string { + if p == nil { + return []string{} + } return p.GetOnlineClients() } From a4b3e999a1610a96d3ff7d46f9d039d2f420c913 Mon Sep 17 00:00:00 2001 From: lim-kim930 <72185892+lim-kim930@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:38:15 -0700 Subject: [PATCH 09/13] fix(i18n): add 1-year expiration to language cookie (#4890) --- frontend/src/utils/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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(); } From 73ce11508ed4c0e16dbd1fc378e5afb21ac1b776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BA=B7=E5=8E=9A=E8=B6=85?= Date: Fri, 5 Jun 2026 04:45:44 +0900 Subject: [PATCH 10/13] fix(tgbot): ignore commands for other bots (#4894) Telegram group chats can contain multiple bots. Commands addressed to another bot, such as /status@other_bot, should not be handled by the 3x-ui bot. Closes #4893 --- web/service/tgbot.go | 20 ++++++++++++++++++++ web/service/tgbot_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) 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") + } +} From ba63fa856995406e3c5014c8d464a71f8e9ea8be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:46:11 +0200 Subject: [PATCH 11/13] chore(deps): bump i18next from 26.3.0 to 26.3.1 in /frontend (#4901) Bumps [i18next](https://github.com/i18next/i18next) from 26.3.0 to 26.3.1. - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1) --- updated-dependencies: - dependency-name: i18next dependency-version: 26.3.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 38 ++++---------------------------------- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 35 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e72950fc..9eff4db4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,7 @@ "axios": "^1.17.0", "codemirror": "^6.0.2", "dayjs": "^1.11.21", - "i18next": "^26.3.0", + "i18next": "^26.3.1", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", @@ -1934,9 +1934,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1954,9 +1951,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1974,9 +1968,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1994,9 +1985,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2014,9 +2002,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2034,9 +2019,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5087,9 +5069,9 @@ } }, "node_modules/i18next": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", - "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", "funding": [ { "type": "individual", @@ -5615,9 +5597,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5639,9 +5618,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5663,9 +5639,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5687,9 +5660,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 1d3452bc..1589ea28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "axios": "^1.17.0", "codemirror": "^6.0.2", "dayjs": "^1.11.21", - "i18next": "^26.3.0", + "i18next": "^26.3.1", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", From f947fbd6c654cc7eefe3b7e8ad9ef11fe0dde30e Mon Sep 17 00:00:00 2001 From: Misfit-s Date: Thu, 4 Jun 2026 22:55:51 +0300 Subject: [PATCH 12/13] feat(Clash): Add routing rules and enable routing option for Clash subscriptions (#4904) * feat(clash): add routing rules and enable routing option for Clash/Mihomo subscriptions Allows adding custom YAML blocks and placeholders to Clash exports. Why: Shifting routing to the client prevents server IP exposure for DIRECT traffic and reduces unnecessary server bandwidth/CPU usage. * fix --------- Co-authored-by: Misfit-s <> --- frontend/src/generated/types.ts | 4 + frontend/src/generated/zod.ts | 4 + frontend/src/models/setting.ts | 2 + frontend/src/pages/api-docs/endpoints.ts | 2 +- .../pages/settings/SubscriptionGeneralTab.tsx | 14 +++ frontend/src/schemas/setting.ts | 2 + sub/sub.go | 12 +- sub/subClashService.go | 119 ++++++++++++++++-- sub/subController.go | 4 +- web/entity/entity.go | 2 + web/service/setting.go | 10 ++ web/translation/ar-EG.json | 4 + web/translation/en-US.json | 4 + web/translation/es-ES.json | 4 + web/translation/fa-IR.json | 4 + web/translation/id-ID.json | 4 + web/translation/ja-JP.json | 4 + web/translation/pt-BR.json | 4 + web/translation/ru-RU.json | 4 + web/translation/tr-TR.json | 4 + web/translation/uk-UA.json | 4 + web/translation/vi-VN.json | 4 + web/translation/zh-CN.json | 4 + web/translation/zh-TW.json | 4 + 24 files changed, 212 insertions(+), 15 deletions(-) diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 23da8193..5e85e98c 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -34,7 +34,9 @@ export interface AllSetting { subAnnounce: string; subCertFile: string; subClashEnable: boolean; + subClashEnableRouting: boolean; subClashPath: string; + subClashRules: string; subClashURI: string; subDomain: string; subEmailInRemark: boolean; @@ -121,7 +123,9 @@ export interface AllSettingView { subAnnounce: string; subCertFile: string; subClashEnable: boolean; + subClashEnableRouting: boolean; subClashPath: string; + subClashRules: string; subClashURI: string; subDomain: string; subEmailInRemark: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e64c26f9..470eb756 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({ subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), + subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRules: z.string(), subClashURI: z.string(), subDomain: z.string(), subEmailInRemark: z.boolean(), @@ -124,7 +126,9 @@ export const AllSettingViewSchema = z.object({ subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), + subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRules: z.string(), subClashURI: z.string(), subDomain: z.string(), subEmailInRemark: z.boolean(), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index fcbe1ec1..a8b40d44 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -55,6 +55,8 @@ export class AllSetting { subURI = ''; subJsonURI = ''; subClashURI = ''; + subClashEnableRouting = false; + subClashRules = ''; subJsonFragment = ''; subJsonNoises = ''; subJsonMux = ''; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 2233e211..3d6e4c02 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [ { method: 'GET', path: '/{clashPath}:subid', - summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', + summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', params: [ { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, ], 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/setting.ts b/frontend/src/schemas/setting.ts index 66d061df..b2341569 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(), subJsonFragment: z.string().optional(), subJsonNoises: z.string().optional(), subJsonMux: z.string().optional(), diff --git a/sub/sub.go b/sub/sub.go index 667ea006..9109947a 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -140,6 +140,16 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubJsonRules = "" } + SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting() + if err != nil { + SubClashEnableRouting = false + } + + SubClashRules, err := s.settingService.GetSubClashRules() + if err != nil { + SubClashRules = "" + } + SubTitle, err := s.settingService.GetSubTitle() if err != nil { SubTitle = "" @@ -226,7 +236,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { s.sub = NewSUBController( g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, - SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, + SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, 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 05569a54..77609992 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -66,6 +66,8 @@ func NewSUBController( jsonNoise string, jsonMux string, jsonRules string, + clashEnableRouting bool, + clashRules string, subTitle string, subSupportUrl string, subProfileUrl string, @@ -91,7 +93,7 @@ func NewSUBController( subService: sub, subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), - subClashService: NewSubClashService(sub), + subClashService: NewSubClashService(clashEnableRouting, clashRules, sub), } a.initRouter(g) return a diff --git a/web/entity/entity.go b/web/entity/entity.go index ef9a39e9..de32f15b 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 SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration diff --git a/web/service/setting.go b/web/service/setting.go index 098ec039..dced01e7 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": "", "subJsonFragment": "", "subJsonNoises": "", "subJsonMux": "", @@ -658,6 +660,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) GetSubJsonFragment() (string, error) { return s.getString("subJsonFragment") } diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index f7e25337..13ac8f65 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -1004,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 443a8f0e..70720c9e 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -1004,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 2283e34a..408cd506 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -1004,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 87f181f7..07ba6131 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -1004,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 523ad689..9d30abaf 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -1004,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 8cc74116..110519ff 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -1004,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 7bc5a7ef..6f650b7b 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -1004,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 226df950..edc652f6 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -1004,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 69b52e5e..e61de644 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -1004,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 b386306a..91606d6d 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -1004,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 12c620af..db45b23a 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -1004,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 d7076703..5df7df7a 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -1004,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 813f53c2..0090f40b 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -1004,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": "監聽埠", From 97f88fb1a9ef8d8524ff8baa0d0344e927dbd8f6 Mon Sep 17 00:00:00 2001 From: biohazardous-man <268262823+biohazardous-man@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:51:48 +0300 Subject: [PATCH 13/13] feat(sub): modern xray JSON format with unified finalmask editor (#4912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sub): add finalmask support to JSON subscriptions * feat(sub): modern xray JSON format with unified finalmask editor Drop the legacy JSON subscription format entirely and always emit the modern xray shape: - Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/ shadowsocks; hysteria was already flat. - Express fragment/noise via streamSettings.finalmask instead of the legacy direct_out freedom dialer + dialerProxy sockopt. The global finalmask (tcp/udp masks + quicParams) is stored as a single setting (subJsonFinalMask) and merged into every generated stream, replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams settings. Reuse the existing FinalMaskForm (used by inbound/outbound) for the settings UI via a small bridge component; add a showAll prop so all TCP/UDP/QUIC sections render for the global case. This supersedes the hand-rolled Fragment/Noises/quicParams tabs with the full mask editor (all mask types). Note: this is a breaking change — JSON subscriptions now require a recent xray client on the consumer side. * fix --------- Co-authored-by: biohazardous-man Co-authored-by: MHSanaei --- frontend/public/openapi.json | 2 +- frontend/src/generated/types.ts | 6 +- frontend/src/generated/zod.ts | 6 +- .../xray/forms/transport/FinalMaskForm.tsx | 36 ++-- frontend/src/models/setting.ts | 3 +- .../pages/settings/SubJsonFinalMaskForm.tsx | 55 ++++++ .../pages/settings/SubscriptionFormatsTab.tsx | 156 +----------------- frontend/src/schemas/setting.ts | 3 +- sub/sub.go | 17 +- sub/subController.go | 5 +- sub/subJsonService.go | 148 +++++++++-------- sub/subJsonService_test.go | 148 +++++++++++++++++ web/entity/entity.go | 3 +- web/service/setting.go | 15 +- web/translation/ar-EG.json | 2 + web/translation/en-US.json | 2 + web/translation/es-ES.json | 2 + web/translation/fa-IR.json | 2 + web/translation/id-ID.json | 2 + web/translation/ja-JP.json | 2 + web/translation/pt-BR.json | 2 + web/translation/ru-RU.json | 2 + web/translation/tr-TR.json | 2 + web/translation/uk-UA.json | 2 + web/translation/vi-VN.json | 2 + web/translation/zh-CN.json | 2 + web/translation/zh-TW.json | 2 + 27 files changed, 352 insertions(+), 277 deletions(-) create mode 100644 frontend/src/pages/settings/SubJsonFinalMaskForm.tsx create mode 100644 sub/subJsonService_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e926b679..1beb1632 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5791,7 +5791,7 @@ "tags": [ "Subscription Server" ], - "summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.", + "summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.", "operationId": "get_clashPath_subid", "parameters": [ { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 5e85e98c..ccb8ebab 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -44,9 +44,8 @@ export interface AllSetting { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -133,9 +132,8 @@ export interface AllSettingView { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 470eb756..68fdf192 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -46,9 +46,8 @@ export const AllSettingSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), @@ -136,9 +135,8 @@ export const AllSettingViewSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index f400620f..457f9a85 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { OutboundProtocols } from '@/schemas/primitives'; -// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute -// paths under `name`; the parent modal owns the Form instance. -// -// Naming convention inside Form.List: AntD prefixes Form.Item `name` -// with the Form.List's own `name`. So Form.Items inside the render -// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested -// Form.Lists also use relative names. Using absolute paths here would -// double up the prefix and silently route reads/writes to the wrong -// storage path. - export interface FinalMaskFormProps { name: NamePath; network: string; protocol: string; form: FormInstance; + // When true, all sections (TCP / UDP / QUIC) are shown regardless of + // network/protocol. Used by the global sub-JSON finalmask editor where + // the masks apply to every stream rather than one specific transport. + showAll?: boolean; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; @@ -99,12 +93,12 @@ function defaultUdpHop(): Record { return { ports: '20000-50000', interval: '5-10' }; } -export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) { +export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) { const base = asPath(name); const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; - const showTcp = TCP_NETWORKS.includes(network); - const showUdp = isHysteria || network === 'kcp'; - const showQuic = isHysteria || network === 'xhttp'; + const showTcp = showAll || TCP_NETWORKS.includes(network); + const showUdp = showAll || isHysteria || network === 'kcp'; + const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -392,13 +386,13 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ - { value: 'mkcp-legacy', label: 'mKCP Legacy' }, - { value: 'xdns', label: 'xDNS' }, - { value: 'xicmp', label: 'xICMP' }, - { value: 'realm', label: 'Realm' }, - { value: 'header-custom', label: 'Header Custom' }, - { value: 'noise', label: 'Noise' }, - ]; + { value: 'mkcp-legacy', label: 'mKCP Legacy' }, + { value: 'xdns', label: 'xDNS' }, + { value: 'xicmp', label: 'xICMP' }, + { value: 'realm', label: 'Realm' }, + { value: 'header-custom', label: 'Header Custom' }, + { value: 'noise', label: 'Noise' }, + ]; return (
diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index a8b40d44..047dcca6 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -57,10 +57,9 @@ export class AllSetting { subClashURI = ''; subClashEnableRouting = false; subClashRules = ''; - subJsonFragment = ''; - subJsonNoises = ''; subJsonMux = ''; subJsonRules = ''; + subJsonFinalMask = ''; timeLocation = 'Local'; diff --git a/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx new file mode 100644 index 00000000..09f588a7 --- /dev/null +++ b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; +import { Form } from 'antd'; + +import { FinalMaskForm } from '@/lib/xray/forms/transport'; +import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask'; + +interface SubJsonFinalMaskFormProps { + value: string; + onChange: (next: string) => void; +} + +function hasValue(v: unknown): boolean { + if (v == null) return false; + if (Array.isArray(v)) return v.some(hasValue); + if (typeof v === 'object') return Object.values(v as Record).some(hasValue); + if (typeof v === 'string') return v.length > 0; + return true; +} + +function parseFinalMask(raw: string): FinalMaskStreamSettings { + try { + if (raw) return JSON.parse(raw) as FinalMaskStreamSettings; + } catch { + return { tcp: [], udp: [] }; + } + return { tcp: [], udp: [] }; +} + +export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) { + const [form] = Form.useForm(); + const [initial] = useState(() => parseFinalMask(value)); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined; + + useEffect(() => { + if (finalmask === undefined) return; + const next = hasValue(finalmask) ? JSON.stringify(finalmask) : ''; + if (next !== value) onChangeRef.current(next); + }, [finalmask, value]); + + return ( +
+ + + ); +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index 7cfe45f1..14c9aafe 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -1,8 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { - Button, - Card, Input, InputNumber, Select, @@ -10,19 +8,17 @@ import { Tabs, } from 'antd'; import { - DeleteOutlined, PartitionOutlined, - PlusOutlined, - ScissorOutlined, + RocketOutlined, SendOutlined, SettingOutlined, - ThunderboltOutlined, } from '@ant-design/icons'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath, normalizePath } from './uriPath'; +import SubJsonFinalMaskForm from './SubJsonFinalMaskForm'; import './SubscriptionFormatsTab.css'; interface SubscriptionFormatsTabProps { @@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps { updateSetting: (patch: Partial) => void; } -const DEFAULT_FRAGMENT = { - packets: 'tlshello', - length: '100-200', - interval: '10-20', - maxSplit: '300-400', -}; -const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [ - { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }, -]; const DEFAULT_MUX = { enabled: true, concurrency: 8, @@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su const { t } = useTranslation(); const { isMobile } = useMediaQuery(); - const fragment = allSetting.subJsonFragment !== ''; - const noisesEnabled = allSetting.subJsonNoises !== ''; const muxEnabled = allSetting.subJsonMux !== ''; const directEnabled = allSetting.subJsonRules !== ''; - const fragmentObj = useMemo( - () => (fragment ? readJson(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT), - [allSetting.subJsonFragment, fragment], - ); - - function setFragmentEnabled(v: boolean) { - updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' }); - } - - function setFragmentField(key: K, value: string) { - if (value === '') return; - const next = { ...fragmentObj, [key]: value }; - updateSetting({ subJsonFragment: JSON.stringify(next) }); - } - - const noisesArray = useMemo( - () => (noisesEnabled ? readJson(allSetting.subJsonNoises, DEFAULT_NOISES) : []), - [allSetting.subJsonNoises, noisesEnabled], - ); - - function setNoisesEnabled(v: boolean) { - updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' }); - } - - function setNoisesArray(next: typeof DEFAULT_NOISES) { - if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) }); - } - - function addNoise() { - setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]); - } - - function removeNoise(index: number) { - const next = [...noisesArray]; - next.splice(index, 1); - setNoisesArray(next); - } - - function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) { - const next = [...noisesArray]; - next[index] = { ...next[index], [field]: value }; - setNoisesArray(next); - } - const muxObj = useMemo( () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), [allSetting.subJsonMux, muxEnabled], @@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su }, { key: '2', - label: catTabLabel(, t('pages.settings.fragment'), isMobile), + label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), children: ( <> - - - - {fragment && ( -
- - setFragmentField('packets', e.target.value)} /> - - - setFragmentField('length', e.target.value)} /> - - - setFragmentField('interval', e.target.value)} /> - - - setFragmentField('maxSplit', e.target.value)} /> - -
- )} + + updateSetting({ subJsonFinalMask: v })} + /> ), }, { key: '3', - label: catTabLabel(, t('pages.settings.subFormats.noises'), isMobile), - children: ( - <> - - - - {noisesEnabled && ( -
- {noisesArray.map((noise, index) => ( - 1 ? ( -