diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts
index fcbe1ec1..047dcca6 100644
--- a/frontend/src/models/setting.ts
+++ b/frontend/src/models/setting.ts
@@ -55,10 +55,11 @@ export class AllSetting {
subURI = '';
subJsonURI = '';
subClashURI = '';
- subJsonFragment = '';
- subJsonNoises = '';
+ subClashEnableRouting = false;
+ subClashRules = '';
subJsonMux = '';
subJsonRules = '';
+ subJsonFinalMask = '';
timeLocation = 'Local';
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/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/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 ? (
- }
- aria-label={t('delete')}
- onClick={() => removeNoise(index)}
- />
- ) : null}
- styles={{ body: { padding: 0 } }}
- >
-
-
-
- updateNoiseField(index, 'packet', e.target.value)} />
-
-
- updateNoiseField(index, 'delay', e.target.value)} />
-
-
-
-
- ))}
- } onClick={addNoise}>
- {t('pages.settings.subFormats.addNoise')}
-
-
- )}
- >
- ),
- },
- {
- key: '4',
label: catTabLabel(, t('pages.settings.mux'), isMobile),
children: (
<>
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
),
},
{
- key: '5',
+ key: '4',
label: catTabLabel(, t('pages.settings.direct'), isMobile),
children: (
<>
diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
index ec37b827..88b6e160 100644
--- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
+++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
updateSetting({ subRoutingRules: e.target.value })} />
+
+ Clash / Mihomo
+
+
+ updateSetting({ subClashEnableRouting: v })} />
+
+
+ updateSetting({ subClashRules: e.target.value })}
+ />
+
>
),
},
diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts
index bb9a4143..89830ee8 100644
--- a/frontend/src/schemas/client.ts
+++ b/frontend/src/schemas/client.ts
@@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1),
emailPrefix: z.string(),
emailPostfix: z.string(),
- quantity: z.number().int().min(1).max(100),
+ quantity: z.number().int().min(1).max(1000),
subId: z.string(),
group: z.string(),
comment: z.string(),
diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts
index 66d061df..a6c153ec 100644
--- a/frontend/src/schemas/setting.ts
+++ b/frontend/src/schemas/setting.ts
@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(),
subJsonURI: z.string().optional(),
subClashURI: z.string().optional(),
- subJsonFragment: z.string().optional(),
- subJsonNoises: z.string().optional(),
+ subClashEnableRouting: z.boolean().optional(),
+ subClashRules: z.string().optional(),
subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(),
+ subJsonFinalMask: z.string().optional(),
timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(),
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index 6b654c12..a9a61a35 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -858,13 +858,13 @@ export class LanguageManager {
});
if (LanguageManager.isSupportLanguage(lang)) {
- CookieManager.setCookie('lang', lang);
+ CookieManager.setCookie('lang', lang, 365);
} else {
- CookieManager.setCookie('lang', 'en-US');
+ CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
} else {
- CookieManager.setCookie('lang', 'en-US');
+ CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
@@ -875,7 +875,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US';
}
- CookieManager.setCookie('lang', language);
+ CookieManager.setCookie('lang', language, 365);
window.location.reload();
}
diff --git a/install.sh b/install.sh
index 1b2b114d..6d791bb0 100644
--- a/install.sh
+++ b/install.sh
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
- rm -rf ~/.acme.sh/${domain} 2> /dev/null
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
rm -rf "$certPath" 2> /dev/null
return 1
fi
@@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
- [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+ rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+ [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
- [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+ rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+ [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}"
- # detect existing certificate and reuse it if present
+ # detect existing certificate and reuse it only if its files are actually
+ # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+ # certs under ${domain}; a failed issuance can leave a domain entry in --list
+ # with no usable cert files, which must not be reused (it produces a 0-byte
+ # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
- cert_exists=1
- local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
- echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
- [[ -n "${certInfo}" ]] && echo "$certInfo"
- else
+ local acmeCertDir=""
+ if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}_ecc
+ elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}
+ fi
+ if [[ -n "${acmeCertDir}" ]]; then
+ cert_exists=1
+ local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
+ echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
+ [[ -n "${certInfo}" ]] && echo "$certInfo"
+ else
+ echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+ fi
+ fi
+ if [[ ${cert_exists} -eq 0 ]]; then
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi
@@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
else
@@ -617,7 +633,7 @@ ssl_cert_issue() {
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
diff --git a/sub/sub.go b/sub/sub.go
index 667ea006..a97943d5 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10"
}
- SubJsonFragment, err := s.settingService.GetSubJsonFragment()
- if err != nil {
- SubJsonFragment = ""
- }
-
- SubJsonNoises, err := s.settingService.GetSubJsonNoises()
- if err != nil {
- SubJsonNoises = ""
- }
-
SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil {
SubJsonMux = ""
@@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = ""
}
+ SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
+ if err != nil {
+ SubJsonFinalMask = ""
+ }
+
+ SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
+ if err != nil {
+ SubClashEnableRouting = false
+ }
+
+ SubClashRules, err := s.settingService.GetSubClashRules()
+ if err != nil {
+ SubClashRules = ""
+ }
+
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
@@ -226,7 +231,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,
+ SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil
diff --git a/sub/subClashService.go b/sub/subClashService.go
index 1dc61d67..c15639bf 100644
--- a/sub/subClashService.go
+++ b/sub/subClashService.go
@@ -15,17 +15,13 @@ import (
type SubClashService struct {
inboundService service.InboundService
+ enableRouting bool
+ clashRules string
SubService *SubService
}
-type ClashConfig struct {
- Proxies []map[string]any `yaml:"proxies"`
- ProxyGroups []map[string]any `yaml:"proxy-groups"`
- Rules []string `yaml:"rules"`
-}
-
-func NewSubClashService(subService *SubService) *SubClashService {
- return &SubClashService{SubService: subService}
+func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
+ return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
proxyNames = append(proxyNames, "DIRECT")
- config := ClashConfig{
- Proxies: proxies,
- ProxyGroups: []map[string]any{{
+ config := map[string]any{
+ "proxies": proxies,
+ "proxy-groups": []map[string]any{{
"name": "PROXY",
"type": "select",
"proxies": proxyNames,
}},
- Rules: []string{"MATCH,PROXY"},
+ "rules": []string{"MATCH,PROXY"},
+ }
+
+ if s.enableRouting {
+ if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
+ return "", "", err
+ }
}
finalYAML, err := yaml.Marshal(config)
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src)
return dst
}
+
+func mergeClashRulesYAML(base map[string]any, raw string) error {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+
+ var custom any
+ if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
+ mergeClashRules(base, linesToClashRules(raw))
+ return nil
+ }
+
+ switch typed := custom.(type) {
+ case []any:
+ mergeClashRules(base, typed)
+ case map[string]any:
+ if rules, ok := typed["rules"]; ok {
+ if ruleList, ok := asAnySlice(rules); ok {
+ mergeClashRules(base, ruleList)
+ }
+ }
+ default:
+ mergeClashRules(base, linesToClashRules(raw))
+ }
+
+ return nil
+}
+
+func mergeClashRules(base map[string]any, customRules []any) {
+ if len(customRules) == 0 {
+ return
+ }
+
+ baseRules, _ := asAnySlice(base["rules"])
+ if hasClashMatchRule(customRules) {
+ base["rules"] = customRules
+ return
+ }
+
+ merged := make([]any, 0, len(customRules)+len(baseRules))
+ merged = append(merged, customRules...)
+ merged = append(merged, baseRules...)
+ base["rules"] = merged
+}
+
+func asAnySlice(value any) ([]any, bool) {
+ switch typed := value.(type) {
+ case []any:
+ return typed, true
+ case []string:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ out = append(out, item)
+ }
+ return out, true
+ case []map[string]any:
+ out := make([]any, 0, len(typed))
+ for _, item := range typed {
+ out = append(out, item)
+ }
+ return out, true
+ default:
+ return nil, false
+ }
+}
+
+func hasClashMatchRule(rules []any) bool {
+ for _, rule := range rules {
+ ruleText, ok := rule.(string)
+ if !ok {
+ continue
+ }
+ parts := strings.SplitN(ruleText, ",", 2)
+ if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
+ return true
+ }
+ }
+ return false
+}
+
+func linesToClashRules(raw string) []any {
+ lines := strings.Split(raw, "\n")
+ rules := make([]any, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ rules = append(rules, line)
+ }
+ return rules
+}
diff --git a/sub/subController.go b/sub/subController.go
index 05569a54..70a02dc6 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -62,10 +62,11 @@ func NewSUBController(
showInfo bool,
rModel string,
update string,
- jsonFragment string,
- jsonNoise string,
jsonMux string,
jsonRules string,
+ jsonFinalMask string,
+ clashEnableRouting bool,
+ clashRules string,
subTitle string,
subSupportUrl string,
subProfileUrl string,
@@ -90,8 +91,8 @@ func NewSUBController(
updateInterval: update,
subService: sub,
- subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
- subClashService: NewSubClashService(sub),
+ subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
+ subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
}
a.initRouter(g)
return a
diff --git a/sub/subJsonService.go b/sub/subJsonService.go
index 72886051..42dd7358 100644
--- a/sub/subJsonService.go
+++ b/sub/subJsonService.go
@@ -21,7 +21,7 @@ var defaultJson string
type SubJsonService struct {
configJson map[string]any
defaultOutbounds []json_util.RawMessage
- fragmentOrNoises bool
+ finalMask string
mux string
inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
}
// NewSubJsonService creates a new JSON subscription service with the given configuration.
-func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
+func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
}
}
- fragmentOrNoises := false
- if fragment != "" || noises != "" {
- fragmentOrNoises = true
- defaultOutboundsSettings := map[string]any{
- "domainStrategy": "UseIP",
- "redirect": "",
- }
-
- if fragment != "" {
- defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
- }
-
- if noises != "" {
- defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
- }
-
- defaultDirectOutbound := map[string]any{
- "protocol": "freedom",
- "settings": defaultOutboundsSettings,
- "tag": "direct_out",
- }
- jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
- defaultOutbounds = append(defaultOutbounds, jsonBytes)
- }
-
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
- fragmentOrNoises: fragmentOrNoises,
+ finalMask: finalMask,
mux: mux,
SubService: subService,
}
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
}
delete(streamSettings, "sockopt")
- if s.fragmentOrNoises {
- streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
+ if s.finalMask != "" {
+ s.applyGlobalFinalMask(streamSettings)
}
// remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
return streamSettings
}
+func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
+ var fm map[string]any
+ if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
+ return
+ }
+ merged := mergeFinalMask(streamSettings["finalmask"], fm)
+ if len(merged) > 0 {
+ streamSettings["finalmask"] = merged
+ }
+}
+
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any)
if ok {
@@ -307,17 +293,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
outbound := Outbound{}
- usersData := make([]UserVnext, 1)
-
- usersData[0].ID = client.ID
- usersData[0].Email = client.Email
- usersData[0].Security = client.Security
- vnextData := make([]VnextSetting, 1)
- vnextData[0] = VnextSetting{
- Address: inbound.Listen,
- Port: inbound.Port,
- Users: usersData,
- }
outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy"
@@ -325,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
+
+ security := client.Security
+ if security == "" {
+ security = "auto"
+ }
outbound.Settings = map[string]any{
- "vnext": vnextData,
+ "address": inbound.Listen,
+ "port": inbound.Port,
+ "id": client.ID,
+ "security": security,
+ "level": 8,
}
result, _ := json.MarshalIndent(outbound, "", " ")
@@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string)
- user := map[string]any{
+ settings := map[string]any{
+ "address": inbound.Listen,
+ "port": inbound.Port,
"id": client.ID,
- "level": 8,
"encryption": encryption,
+ "level": 8,
}
if client.Flow != "" {
- user["flow"] = client.Flow
- }
-
- vnext := map[string]any{
- "address": inbound.Listen,
- "port": inbound.Port,
- "users": []any{user},
- }
-
- outbound.Settings = map[string]any{
- "vnext": []any{vnext},
+ settings["flow"] = client.Flow
}
+ outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
@@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
- outbound.Settings = map[string]any{
- "servers": serverData,
+
+ settings := map[string]any{
+ "address": serverData[0].Address,
+ "port": serverData[0].Port,
+ "password": serverData[0].Password,
+ "level": 8,
}
+ if inbound.Protocol == model.Shadowsocks {
+ settings["method"] = serverData[0].Method
+ }
+ outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
@@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
- newStream["finalmask"] = finalmask
+ newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
}
newStream["network"] = "hysteria"
@@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
return result
}
+func mergeFinalMask(base any, extra map[string]any) map[string]any {
+ merged := map[string]any{}
+ if baseMap, ok := base.(map[string]any); ok {
+ for key, value := range baseMap {
+ switch key {
+ case "tcp", "udp":
+ if masks, ok := value.([]any); ok {
+ merged[key] = append([]any(nil), masks...)
+ }
+ default:
+ merged[key] = value
+ }
+ }
+ }
+
+ for key, value := range extra {
+ switch key {
+ case "tcp", "udp":
+ baseMasks, _ := merged[key].([]any)
+ extraMasks, _ := value.([]any)
+ if len(extraMasks) > 0 {
+ merged[key] = append(baseMasks, extraMasks...)
+ }
+ case "quicParams":
+ if _, exists := merged[key]; !exists {
+ merged[key] = value
+ }
+ default:
+ merged[key] = value
+ }
+ }
+
+ return merged
+}
+
type Outbound struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
@@ -462,18 +482,6 @@ type Outbound struct {
Settings map[string]any `json:"settings,omitempty"`
}
-type VnextSetting struct {
- Address string `json:"address"`
- Port int `json:"port"`
- Users []UserVnext `json:"users"`
-}
-
-type UserVnext struct {
- ID string `json:"id"`
- Email string `json:"email,omitempty"`
- Security string `json:"security,omitempty"`
-}
-
type ServerSetting struct {
Password string `json:"password"`
Level int `json:"level"`
diff --git a/sub/subJsonService_test.go b/sub/subJsonService_test.go
new file mode 100644
index 00000000..79aeb287
--- /dev/null
+++ b/sub/subJsonService_test.go
@@ -0,0 +1,148 @@
+package sub
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func hasDirectOutOutbound(svc *SubJsonService) bool {
+ for _, raw := range svc.defaultOutbounds {
+ var outbound map[string]any
+ if err := json.Unmarshal(raw, &outbound); err != nil {
+ continue
+ }
+ if outbound["tag"] == "direct_out" {
+ return true
+ }
+ }
+ return false
+}
+
+func outboundSettings(t *testing.T, raw []byte) map[string]any {
+ t.Helper()
+ var parsed map[string]any
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ t.Fatalf("failed to unmarshal outbound: %v", err)
+ }
+ settings, _ := parsed["settings"].(map[string]any)
+ if settings == nil {
+ t.Fatal("outbound has no settings")
+ }
+ return settings
+}
+
+func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
+ finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
+ svc := NewSubJsonService("", "", finalMask, nil)
+
+ if hasDirectOutOutbound(svc) {
+ t.Fatal("direct_out outbound must never be emitted")
+ }
+
+ stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+ if _, ok := stream["sockopt"]; ok {
+ t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
+ }
+
+ finalmask, _ := stream["finalmask"].(map[string]any)
+ if finalmask == nil {
+ t.Fatal("streamSettings is missing finalmask")
+ }
+
+ tcp, _ := finalmask["tcp"].([]any)
+ if len(tcp) != 1 {
+ t.Fatalf("tcp masks len = %d, want 1", len(tcp))
+ }
+ if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
+ t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
+ }
+
+ udp, _ := finalmask["udp"].([]any)
+ if len(udp) != 1 {
+ t.Fatalf("udp masks len = %d, want 1", len(udp))
+ }
+
+ quic, _ := finalmask["quicParams"].(map[string]any)
+ if quic == nil || quic["congestion"] != "bbr" {
+ t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
+ }
+}
+
+func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
+ finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
+ svc := NewSubJsonService("", "", finalMask, nil)
+
+ stream := svc.streamData(`{
+ "network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
+ "finalmask":{"tcp":[{"type":"sudoku"}]}
+ }`)
+
+ finalmask, _ := stream["finalmask"].(map[string]any)
+ tcp, _ := finalmask["tcp"].([]any)
+ if len(tcp) != 2 {
+ t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
+ }
+ a, _ := tcp[0].(map[string]any)
+ b, _ := tcp[1].(map[string]any)
+ if a["type"] != "sudoku" || b["type"] != "fragment" {
+ t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
+ }
+}
+
+func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
+ svc := NewSubJsonService("", "", "", nil)
+ stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+ if _, ok := stream["finalmask"]; ok {
+ t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
+ }
+ if _, ok := stream["sockopt"]; ok {
+ t.Fatal("legacy direct_out sockopt must never be set")
+ }
+}
+
+func TestSubJsonServiceVlessFlattened(t *testing.T) {
+ inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+ client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
+
+ settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
+ if _, ok := settings["vnext"]; ok {
+ t.Fatal("vless outbound must not use vnext")
+ }
+ if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
+ t.Fatalf("flat vless settings wrong: %#v", settings)
+ }
+}
+
+func TestSubJsonServiceVmessFlattened(t *testing.T) {
+ inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
+ client := model.Client{ID: "uuid-2"}
+
+ settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
+ if _, ok := settings["vnext"]; ok {
+ t.Fatal("vmess outbound must not use vnext")
+ }
+ if settings["id"] != "uuid-2" || settings["security"] != "auto" {
+ t.Fatalf("flat vmess settings wrong: %#v", settings)
+ }
+}
+
+func TestSubJsonServiceServerFlattened(t *testing.T) {
+ trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
+ client := model.Client{Password: "p4ss"}
+
+ settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
+ if _, ok := settings["servers"]; ok {
+ t.Fatal("trojan outbound must not use servers array")
+ }
+ if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
+ t.Fatalf("flat trojan settings wrong: %#v", settings)
+ }
+
+ ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
+ ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
+ if ssSettings["method"] != "aes-256-gcm" {
+ t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
+ }
+}
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/entity/entity.go b/web/entity/entity.go
index ef9a39e9..c220bad6 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -83,10 +83,11 @@ 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
- SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
- SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
+ SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
+ SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
+ SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
diff --git a/web/runtime/remote.go b/web/runtime/remote.go
index b525ba5f..1e5ba422 100644
--- a/web/runtime/remote.go
+++ b/web/runtime/remote.go
@@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil
}
-// DeleteUser is idempotent: master's per-inbound Delete loop may call it
-// multiple times for the same node, and "not found" on the follow-ups is
-// the expected success path.
-func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
- _, err := r.do(ctx, http.MethodPost,
- "panel/api/clients/del/"+url.PathEscape(email), nil)
+ id, err := r.resolveRemoteID(ctx, ib.Tag)
+ if err != nil {
+ return nil
+ }
+ body := map[string]any{"inboundIds": []int{id}}
+ _, err = r.do(ctx, http.MethodPost,
+ "panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil {
return nil
}
@@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err
}
-func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" {
oldEmail = payload.Email
}
- if _, err := r.do(ctx, http.MethodPost,
- "panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+ id, err := r.resolveRemoteID(ctx, ib.Tag)
+ if err != nil {
+ return err
+ }
+ path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
+ "?inboundIds=" + strconv.Itoa(id)
+ if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err
}
return nil
diff --git a/web/service/api_scale_postgres_test.go b/web/service/api_scale_postgres_test.go
new file mode 100644
index 00000000..ce0c0442
--- /dev/null
+++ b/web/service/api_scale_postgres_test.go
@@ -0,0 +1,216 @@
+package service
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+ xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
+ "github.com/mhsanaei/3x-ui/v3/xray"
+
+ "github.com/op/go-logging"
+)
+
+func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
+ t.Helper()
+ db := database.GetDB()
+ rows := make([]xray.ClientTraffic, len(clients))
+ for i := range clients {
+ rows[i] = xray.ClientTraffic{
+ InboundId: inboundId,
+ Email: clients[i].Email,
+ Enable: true,
+ Total: clients[i].TotalGB,
+ ExpiryTime: clients[i].ExpiryTime,
+ }
+ }
+ if err := db.CreateInBatches(rows, 1000).Error; err != nil {
+ t.Fatalf("seed client_traffics: %v", err)
+ }
+}
+
+// TestAllAPIsPostgresScale exercises every client/inbound/group service method
+// reachable from the REST API at 100k/200k clients, asserting none crash on the
+// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
+func TestAllAPIsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ xuilogger.InitLogger(logging.ERROR)
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ settingSvc := &SettingService{}
+ const userId = 1
+ const m = 2000
+ sizes := []int{50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+ for i := range clients {
+ clients[i].ExpiryTime = exp
+ clients[i].TotalGB = 100 << 30
+ }
+ ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+ if err := db.Create(ib2).Error; err != nil {
+ t.Fatalf("create inbound2: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ run := func(name string, fn func() error) {
+ start := time.Now()
+ if err := fn(); err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
+ }
+
+ run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+
+ seedClientTraffics(t, ib.Id, clients)
+ db.Exec("ANALYZE")
+
+ emails := make([]string, n)
+ for i := 0; i < n; i++ {
+ emails[i] = clients[i].Email
+ }
+ emailsM := emails[:m]
+
+ run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
+ run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
+ run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+ run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
+ run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
+ run("ListPaged+search", func() error {
+ _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
+ return err
+ })
+ run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
+ run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
+ run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
+
+ run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
+ run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
+ run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
+ run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
+ run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
+
+ run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
+ run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
+ run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
+ run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
+
+ run("UpdateByEmail", func() error {
+ upd := clients[n/3]
+ upd.Comment = "touched"
+ _, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
+ return err
+ })
+ run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+ run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+
+ depEmails := emails[:1000]
+ for _, batch := range chunkStrings(depEmails, sqlInChunk) {
+ if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
+ t.Fatalf("mark depleted: %v", err)
+ }
+ }
+ run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
+
+ run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
+ })
+ }
+}
+
+// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
+// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
+// find one client) vs new path (UUID/subId read from the indexed clients table).
+func TestGetClientTrafficByEmailABScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ xuilogger.InitLogger(logging.ERROR)
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ const reps = 10
+ sizes := []int{50000, 100000, 200000}
+
+ oldImpl := func(email string) error {
+ tr, client, err := inboundSvc.GetClientByEmail(email)
+ if err != nil {
+ return err
+ }
+ if tr != nil && client != nil {
+ tr.UUID = client.ID
+ tr.SubId = client.SubID
+ }
+ return nil
+ }
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ seedClientTraffics(t, ib.Id, clients)
+ db.Exec("ANALYZE")
+
+ targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
+
+ start := time.Now()
+ for i := 0; i < reps; i++ {
+ if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
+ t.Fatalf("new GetClientTrafficByEmail: %v", err)
+ }
+ }
+ newDur := time.Since(start) / reps
+
+ start = time.Now()
+ for i := 0; i < reps; i++ {
+ if err := oldImpl(targets[i%len(targets)]); err != nil {
+ t.Fatalf("old GetClientTrafficByEmail: %v", err)
+ }
+ }
+ oldDur := time.Since(start) / reps
+
+ t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
+ newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
+ float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
+ })
+ }
+}
diff --git a/web/service/bulk_traffic_test.go b/web/service/bulk_traffic_test.go
new file mode 100644
index 00000000..0e6c92fe
--- /dev/null
+++ b/web/service/bulk_traffic_test.go
@@ -0,0 +1,149 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+ "github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
+ t.Helper()
+ row := xray.ClientTraffic{
+ InboundId: inboundId,
+ Email: email,
+ Up: up,
+ Down: down,
+ Total: total,
+ ExpiryTime: expiry,
+ Enable: enable,
+ }
+ if err := database.GetDB().Create(&row).Error; err != nil {
+ t.Fatalf("create traffic %s: %v", email, err)
+ }
+}
+
+func trafficOf(t *testing.T, email string) xray.ClientTraffic {
+ t.Helper()
+ var row xray.ClientTraffic
+ if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
+ t.Fatalf("load traffic %s: %v", email, err)
+ }
+ return row
+}
+
+func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ {Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+ {Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+ }
+ ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
+ mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
+ mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
+
+ affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
+ if err != nil {
+ t.Fatalf("BulkResetTraffic: %v", err)
+ }
+ if affected != 2 {
+ t.Fatalf("expected 2 affected, got %d", affected)
+ }
+
+ for _, e := range []string{"alice@x", "bob@x"} {
+ tr := trafficOf(t, e)
+ if tr.Up != 0 || tr.Down != 0 {
+ t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
+ }
+ if !tr.Enable {
+ t.Fatalf("%s: expected re-enabled", e)
+ }
+ }
+
+ carol := trafficOf(t, "carol@x")
+ if carol.Up != 7 {
+ t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
+ }
+}
+
+func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ {Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+ {Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+ }
+ ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ past := time.Now().Add(-time.Hour).UnixMilli()
+ mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
+ mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
+ mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
+
+ deleted, _, err := svc.DelDepleted(inboundSvc)
+ if err != nil {
+ t.Fatalf("DelDepleted: %v", err)
+ }
+ if deleted != 2 {
+ t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
+ }
+
+ if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
+ t.Fatalf("bob should survive: %v", err)
+ }
+ for _, e := range []string{"alice@x", "carol@x"} {
+ if _, err := svc.GetRecordByEmail(nil, e); err == nil {
+ t.Fatalf("%s should be deleted", e)
+ }
+ }
+
+ reloaded, _ := inboundSvc.GetInbound(ib.Id)
+ jsonClients, _ := inboundSvc.GetClients(reloaded)
+ if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
+ t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
+ }
+}
+
+func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ source := []model.Client{
+ {Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+ }
+ ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
+ if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+ t.Fatalf("seed linkage: %v", err)
+ }
+ mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
+
+ tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
+ if err != nil {
+ t.Fatalf("GetClientTrafficByEmail: %v", err)
+ }
+ if tr == nil {
+ t.Fatalf("expected traffic, got nil")
+ }
+ if tr.UUID != "11111111-1111-1111-1111-111111111111" {
+ t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
+ }
+ if tr.SubId != "sa" {
+ t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
+ }
+}
diff --git a/web/service/client.go b/web/service/client.go
index de5fde65..cf26344e 100644
--- a/web/service/client.go
+++ b/web/service/client.go
@@ -124,19 +124,23 @@ func compactOrphans(db *gorm.DB, clients []any) []any {
if len(emails) == 0 {
return clients
}
- var existingEmails []string
- if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
- logger.Warning("compactOrphans pluck:", err)
+ existing := make(map[string]struct{}, len(emails))
+ const orphanChunk = 400
+ for start := 0; start < len(emails); start += orphanChunk {
+ end := min(start+orphanChunk, len(emails))
+ var found []string
+ if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails[start:end]).Pluck("email", &found).Error; err != nil {
+ logger.Warning("compactOrphans pluck:", err)
+ return clients
+ }
+ for _, e := range found {
+ existing[e] = struct{}{}
+ }
+ }
+ if len(existing) == len(emails) {
return clients
}
- if len(existingEmails) == len(emails) {
- return clients
- }
- existing := make(map[string]struct{}, len(existingEmails))
- for _, e := range existingEmails {
- existing[e] = struct{}{}
- }
- out := make([]any, 0, len(existingEmails))
+ out := make([]any, 0, len(existing))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
@@ -170,6 +174,26 @@ func tombstoneClientEmail(email string) {
}
}
+func tombstoneClientEmails(emails []string) {
+ if len(emails) == 0 {
+ return
+ }
+ now := time.Now()
+ cutoff := now.Add(-deleteTombstoneTTL)
+ recentlyDeletedMu.Lock()
+ defer recentlyDeletedMu.Unlock()
+ for _, email := range emails {
+ if email != "" {
+ recentlyDeleted[email] = now
+ }
+ }
+ for e, ts := range recentlyDeleted {
+ if ts.Before(cutoff) {
+ delete(recentlyDeleted, e)
+ }
+ }
+}
+
func isClientEmailTombstoned(email string) bool {
if email == "" {
return false
@@ -196,73 +220,134 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
return err
}
+ emails := make([]string, 0, len(clients))
+ seen := make(map[string]struct{}, len(clients))
for i := range clients {
- c := clients[i]
- email := strings.TrimSpace(c.Email)
+ email := strings.TrimSpace(clients[i].Email)
+ if email == "" {
+ continue
+ }
+ if _, ok := seen[email]; ok {
+ continue
+ }
+ seen[email] = struct{}{}
+ emails = append(emails, email)
+ }
+
+ existing := make(map[string]*model.ClientRecord, len(emails))
+ const selectChunk = 400
+ for start := 0; start < len(emails); start += selectChunk {
+ end := min(start+selectChunk, len(emails))
+ var rows []model.ClientRecord
+ if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
+ return err
+ }
+ for i := range rows {
+ r := rows[i]
+ existing[r.Email] = &r
+ }
+ }
+
+ idByEmail := make(map[string]int, len(emails))
+ pending := make(map[string]*model.ClientRecord, len(emails))
+ toCreate := make([]*model.ClientRecord, 0, len(emails))
+ for i := range clients {
+ email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
- incoming := c.ToRecord()
- row := &model.ClientRecord{}
- err := tx.Where("email = ?", email).First(row).Error
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- return err
- }
- if errors.Is(err, gorm.ErrRecordNotFound) {
- if err := tx.Create(incoming).Error; err != nil {
- return err
- }
- row = incoming
- } else {
- if incoming.UUID != "" {
- row.UUID = incoming.UUID
- }
- if incoming.Password != "" {
- row.Password = incoming.Password
- }
- if incoming.Auth != "" {
- row.Auth = incoming.Auth
- }
- row.Flow = incoming.Flow
- if incoming.Security != "" {
- row.Security = incoming.Security
- }
- if incoming.Reverse != "" {
- row.Reverse = incoming.Reverse
- }
- row.SubID = incoming.SubID
- row.LimitIP = incoming.LimitIP
- row.TotalGB = incoming.TotalGB
- row.ExpiryTime = incoming.ExpiryTime
- row.Enable = incoming.Enable
- row.TgID = incoming.TgID
- if incoming.Group != "" {
- row.Group = incoming.Group
- }
- row.Comment = incoming.Comment
- row.Reset = incoming.Reset
- if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
- row.CreatedAt = incoming.CreatedAt
- }
- preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
- row.UpdatedAt = preservedUpdatedAt
- if err := tx.Save(row).Error; err != nil {
- return err
- }
- if err := tx.Model(&model.ClientRecord{}).
- Where("id = ?", row.Id).
- UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
- return err
+ incoming := clients[i].ToRecord()
+ row, ok := existing[email]
+ if !ok {
+ if _, dup := pending[email]; !dup {
+ pending[email] = incoming
+ toCreate = append(toCreate, incoming)
}
+ continue
}
- link := model.ClientInbound{
- ClientId: row.Id,
- InboundId: inboundId,
- FlowOverride: c.Flow,
+ before := *row
+ if incoming.UUID != "" {
+ row.UUID = incoming.UUID
}
- if err := tx.Create(&link).Error; err != nil {
+ if incoming.Password != "" {
+ row.Password = incoming.Password
+ }
+ if incoming.Auth != "" {
+ row.Auth = incoming.Auth
+ }
+ row.Flow = incoming.Flow
+ if incoming.Security != "" {
+ row.Security = incoming.Security
+ }
+ if incoming.Reverse != "" {
+ row.Reverse = incoming.Reverse
+ }
+ row.SubID = incoming.SubID
+ row.LimitIP = incoming.LimitIP
+ row.TotalGB = incoming.TotalGB
+ row.ExpiryTime = incoming.ExpiryTime
+ row.Enable = incoming.Enable
+ row.TgID = incoming.TgID
+ if incoming.Group != "" {
+ row.Group = incoming.Group
+ }
+ row.Comment = incoming.Comment
+ row.Reset = incoming.Reset
+ if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+ row.CreatedAt = incoming.CreatedAt
+ }
+ preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+ row.UpdatedAt = preservedUpdatedAt
+
+ idByEmail[email] = row.Id
+
+ if *row == before {
+ continue
+ }
+ if err := tx.Save(row).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("id = ?", row.Id).
+ UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+ return err
+ }
+ }
+
+ if len(toCreate) > 0 {
+ if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
+ return err
+ }
+ for _, rec := range toCreate {
+ idByEmail[rec.Email] = rec.Id
+ }
+ }
+
+ links := make([]model.ClientInbound, 0, len(clients))
+ linked := make(map[int]struct{}, len(clients))
+ for i := range clients {
+ email := strings.TrimSpace(clients[i].Email)
+ if email == "" {
+ continue
+ }
+ id, ok := idByEmail[email]
+ if !ok {
+ continue
+ }
+ if _, dup := linked[id]; dup {
+ continue
+ }
+ linked[id] = struct{}{}
+ links = append(links, model.ClientInbound{
+ ClientId: id,
+ InboundId: inboundId,
+ FlowOverride: clients[i].Flow,
+ })
+ }
+ if len(links) > 0 {
+ if err := tx.CreateInBatches(links, 200).Error; err != nil {
return err
}
}
@@ -397,20 +482,26 @@ func (s *ClientService) List() ([]ClientWithAttachments, error) {
}
}
- var links []model.ClientInbound
- if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
- return nil, err
- }
attachments := make(map[int][]int, len(rows))
- for _, l := range links {
- attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+ for _, batch := range chunkInts(clientIds, sqlInChunk) {
+ var links []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
+ return nil, err
+ }
+ for _, l := range links {
+ attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+ }
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
- if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
- return nil, err
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var batchStats []xray.ClientTraffic
+ if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
+ return nil, err
+ }
+ stats = append(stats, batchStats...)
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
@@ -634,7 +725,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
}
}
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
@@ -643,6 +734,19 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err != nil {
return false, err
}
+ if len(inboundFilter) > 0 {
+ allow := make(map[int]struct{}, len(inboundFilter))
+ for _, fid := range inboundFilter {
+ allow[fid] = struct{}{}
+ }
+ filtered := inboundIds[:0:0]
+ for _, ibId := range inboundIds {
+ if _, ok := allow[ibId]; ok {
+ filtered = append(filtered, ibId)
+ }
+ }
+ inboundIds = filtered
+ }
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
@@ -1170,13 +1274,25 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
}
oldInbound.Settings = string(newSettings)
+ var sharedSet map[string]bool
+ if !keepTraffic {
+ removedEmails := make([]string, 0, len(removed))
+ for _, r := range removed {
+ if r.email != "" {
+ removedEmails = append(removedEmails, r.email)
+ }
+ }
+ var sharedErr error
+ sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(removedEmails, inboundId)
+ if sharedErr != nil {
+ return false, sharedErr
+ }
+ }
+
needRestart := false
for _, r := range removed {
email := r.email
- emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
- if err != nil {
- return needRestart, err
- }
+ emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
if !emailShared && !keepTraffic {
if err := inboundSvc.DelClientIPs(db, email); err != nil {
logger.Error("Error in delete client IPs")
@@ -1317,7 +1433,7 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
return out, nil
}
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
@@ -1325,7 +1441,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
if err != nil {
return false, err
}
- return s.Update(inboundSvc, rec.Id, updated)
+ return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
}
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
@@ -1631,14 +1747,43 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
if len(emails) == 0 {
return 0, nil
}
- count := 0
- for _, email := range emails {
- if _, err := s.ResetTrafficByEmail(inboundSvc, email); err != nil {
- return count, err
+ seen := map[string]struct{}{}
+ cleanEmails := make([]string, 0, len(emails))
+ for _, e := range emails {
+ e = strings.TrimSpace(e)
+ if e == "" {
+ continue
}
- count++
+ if _, ok := seen[e]; ok {
+ continue
+ }
+ seen[e] = struct{}{}
+ cleanEmails = append(cleanEmails, e)
}
- return count, nil
+ if len(cleanEmails) == 0 {
+ return 0, nil
+ }
+
+ affected := 0
+ err := submitTrafficWrite(func() error {
+ db := database.GetDB()
+ return db.Transaction(func(tx *gorm.DB) error {
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ res := tx.Model(xray.ClientTraffic{}).
+ Where("email IN ?", batch).
+ Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+ if res.Error != nil {
+ return res.Error
+ }
+ affected += int(res.RowsAffected)
+ }
+ return nil
+ })
+ })
+ if err != nil {
+ return 0, err
+ }
+ return affected, nil
}
func (s *ClientService) CreateGroup(name string) error {
@@ -1710,8 +1855,12 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
}
var records []model.ClientRecord
- if err := db.Where("email IN ?", emails).Find(&records).Error; err != nil {
- return 0, err
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return 0, err
+ }
+ records = append(records, rows...)
}
if len(records) == 0 {
return 0, nil
@@ -1722,21 +1871,33 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
}
tx := db.Begin()
- if err := tx.Model(&model.ClientRecord{}).
- Where("email IN ?", affectedEmails).
- UpdateColumn("group_name", group).Error; err != nil {
- tx.Rollback()
- return 0, err
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("email IN ?", batch).
+ UpdateColumn("group_name", group).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
}
var inboundIDs []int
- if err := tx.Table("client_inbounds").
- Joins("JOIN clients ON clients.id = client_inbounds.client_id").
- Where("clients.email IN ?", affectedEmails).
- Distinct("client_inbounds.inbound_id").
- Pluck("inbound_id", &inboundIDs).Error; err != nil {
- tx.Rollback()
- return 0, err
+ inboundIDSeen := make(map[int]struct{})
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ var ids []int
+ if err := tx.Table("client_inbounds").
+ Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+ Where("clients.email IN ?", batch).
+ Distinct("client_inbounds.inbound_id").
+ Pluck("inbound_id", &ids).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ for _, id := range ids {
+ if _, ok := inboundIDSeen[id]; !ok {
+ inboundIDSeen[id] = struct{}{}
+ inboundIDs = append(inboundIDs, id)
+ }
+ }
}
emailSet := make(map[string]struct{}, len(affectedEmails))
@@ -1828,13 +1989,23 @@ func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error)
}
var inboundIDs []int
- if err := tx.Table("client_inbounds").
- Joins("JOIN clients ON clients.id = client_inbounds.client_id").
- Where("clients.email IN ?", affectedEmails).
- Distinct("client_inbounds.inbound_id").
- Pluck("inbound_id", &inboundIDs).Error; err != nil {
- tx.Rollback()
- return 0, err
+ inboundIDSeen := make(map[int]struct{})
+ for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+ var ids []int
+ if err := tx.Table("client_inbounds").
+ Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+ Where("clients.email IN ?", batch).
+ Distinct("client_inbounds.inbound_id").
+ Pluck("inbound_id", &ids).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ for _, id := range ids {
+ if _, ok := inboundIDSeen[id]; !ok {
+ inboundIDSeen[id] = struct{}{}
+ inboundIDs = append(inboundIDs, id)
+ }
+ }
}
for _, ibID := range inboundIDs {
@@ -2304,8 +2475,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
db := database.GetDB()
var records []model.ClientRecord
- if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ records = append(records, rows...)
}
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
for i := range records {
@@ -2381,8 +2556,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
var mappings []model.ClientInbound
- if err := db.Where("client_id IN ?", plannedIds).Find(&mappings).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkInts(plannedIds, sqlInChunk) {
+ var rows []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ mappings = append(mappings, rows...)
}
emailsByInbound := map[int][]string{}
for _, m := range mappings {
@@ -2570,20 +2749,22 @@ func (s *ClientService) bulkAdjustInboundClients(
}
db := database.GetDB()
- if err := db.Save(oldInbound).Error; err != nil {
+ txErr := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Save(oldInbound).Error; err != nil {
+ return err
+ }
+ finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+ if gcErr != nil {
+ return gcErr
+ }
+ return s.SyncInbound(tx, inboundId, finalClients)
+ })
+ if txErr != nil {
for email := range foundEmails {
if _, skip := res.perEmailSkipped[email]; !skip {
- res.perEmailSkipped[email] = err.Error()
+ res.perEmailSkipped[email] = txErr.Error()
}
}
- return res
- }
-
- finalClients, gcErr := inboundSvc.GetClients(oldInbound)
- if gcErr == nil {
- if syncErr := s.SyncInbound(db, inboundId, finalClients); syncErr != nil {
- logger.Warning("bulkAdjust SyncInbound:", syncErr)
- }
}
return res
@@ -2601,6 +2782,8 @@ type BulkDeleteReport struct {
Reason string `json:"reason"`
}
+const sqlInChunk = 400
+
// BulkDelete removes every client in the list in one optimized pass.
// Instead of running the full single-delete pipeline N times (which would
// re-read, re-parse, and re-write each inbound's settings JSON for every
@@ -2631,14 +2814,20 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
db := database.GetDB()
var records []model.ClientRecord
- if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+ var rows []model.ClientRecord
+ if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ records = append(records, rows...)
}
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
+ tombstoneEmails := make([]string, 0, len(records))
for i := range records {
recordsByEmail[records[i].Email] = &records[i]
- tombstoneClientEmail(records[i].Email)
+ tombstoneEmails = append(tombstoneEmails, records[i].Email)
}
+ tombstoneClientEmails(tombstoneEmails)
skippedReasons := map[string]string{}
for _, email := range cleanEmails {
@@ -2657,8 +2846,12 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
emailsByInbound := map[int][]string{}
if len(clientIds) > 0 {
var mappings []model.ClientInbound
- if err := db.Where("client_id IN ?", clientIds).Find(&mappings).Error; err != nil {
- return result, false, err
+ for _, batch := range chunkInts(clientIds, sqlInChunk) {
+ var rows []model.ClientInbound
+ if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+ return result, false, err
+ }
+ mappings = append(mappings, rows...)
}
for _, m := range mappings {
email, ok := recordIdToEmail[m.ClientId]
@@ -2693,19 +2886,25 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
}
if len(successIds) > 0 {
- if err := db.Where("client_id IN ?", successIds).Delete(&model.ClientInbound{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkInts(successIds, sqlInChunk) {
+ if err := db.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
if !keepTraffic && len(successEmails) > 0 {
- if err := db.Where("email IN ?", successEmails).Delete(&xray.ClientTraffic{}).Error; err != nil {
- return result, needRestart, err
- }
- if err := db.Where("client_email IN ?", successEmails).Delete(&model.InboundClientIps{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+ if err := db.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
+ return result, needRestart, err
+ }
+ if err := db.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
}
- if err := db.Where("id IN ?", successIds).Delete(&model.ClientRecord{}).Error; err != nil {
- return result, needRestart, err
+ for _, batch := range chunkInts(successIds, sqlInChunk) {
+ if err := db.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
+ return result, needRestart, err
+ }
}
}
@@ -2835,38 +3034,52 @@ func (s *ClientService) bulkDelInboundClients(
Email string
Enable bool
}
- var rows []trafficRow
- if err := db.Model(xray.ClientTraffic{}).
- Where("email IN ?", foundList).
- Select("email, enable").
- Scan(&rows).Error; err == nil {
- for _, r := range rows {
- notDepletedByEmail[r.Email] = r.Enable
+ for _, batch := range chunkStrings(foundList, sqlInChunk) {
+ var rows []trafficRow
+ if err := db.Model(xray.ClientTraffic{}).
+ Where("email IN ?", batch).
+ Select("email, enable").
+ Scan(&rows).Error; err == nil {
+ for _, r := range rows {
+ notDepletedByEmail[r.Email] = r.Enable
+ }
}
}
}
- for email := range foundEmails {
- shared, sharedErr := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+ var sharedSet map[string]bool
+ if !keepTraffic {
+ var sharedErr error
+ sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(foundList, inboundId)
if sharedErr != nil {
- res.perEmailSkipped[email] = sharedErr.Error()
- delete(foundEmails, email)
- continue
+ for email := range foundEmails {
+ res.perEmailSkipped[email] = sharedErr.Error()
+ delete(foundEmails, email)
+ }
+ return res
}
- if shared || keepTraffic {
- continue
+ }
+ if !keepTraffic {
+ purge := make([]string, 0, len(foundEmails))
+ for email := range foundEmails {
+ if !sharedSet[strings.ToLower(strings.TrimSpace(email))] {
+ purge = append(purge, email)
+ }
}
- if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
- logger.Error("Error in delete client IPs")
- res.perEmailSkipped[email] = delErr.Error()
- delete(foundEmails, email)
- continue
- }
- if delErr := inboundSvc.DelClientStat(db, email); delErr != nil {
- logger.Error("Delete stats Data Error")
- res.perEmailSkipped[email] = delErr.Error()
- delete(foundEmails, email)
- continue
+ if len(purge) > 0 {
+ if delErr := inboundSvc.delClientIPsByEmails(db, purge); delErr != nil {
+ logger.Error("Error in delete client IPs")
+ for _, email := range purge {
+ res.perEmailSkipped[email] = delErr.Error()
+ delete(foundEmails, email)
+ }
+ } else if delErr := inboundSvc.delClientStatsByEmails(db, purge); delErr != nil {
+ logger.Error("Delete stats Data Error")
+ for _, email := range purge {
+ res.perEmailSkipped[email] = delErr.Error()
+ delete(foundEmails, email)
+ }
+ }
}
}
@@ -2907,21 +3120,22 @@ func (s *ClientService) bulkDelInboundClients(
}
}
- if err := db.Save(oldInbound).Error; err != nil {
+ txErr := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Save(oldInbound).Error; err != nil {
+ return err
+ }
+ finalClients, err := inboundSvc.GetClients(oldInbound)
+ if err != nil {
+ return err
+ }
+ return s.SyncInbound(tx, inboundId, finalClients)
+ })
+ if txErr != nil {
for email := range foundEmails {
if _, skip := res.perEmailSkipped[email]; !skip {
- res.perEmailSkipped[email] = err.Error()
+ res.perEmailSkipped[email] = txErr.Error()
}
}
- return res
- }
-
- finalClients, err := inboundSvc.GetClients(oldInbound)
- if err != nil {
- return res
- }
- if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
- return res
}
return res
@@ -2938,27 +3152,200 @@ type BulkCreateReport struct {
Reason string `json:"reason"`
}
-// BulkCreate iterates payloads sequentially. Each item is the same shape
-// the single-create endpoint accepts, so callers can submit a heterogeneous
-// list (different inboundIds, plans, etc.) in one round-trip.
func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
result := BulkCreateResult{}
- needRestart := false
+ if len(payloads) == 0 {
+ return result, false, nil
+ }
+
+ skip := func(email, reason string) {
+ if strings.TrimSpace(email) == "" {
+ email = "(missing email)"
+ }
+ result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
+ }
+
+ emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
+ if err != nil {
+ emailSubIDs = nil
+ }
+
+ type prepared struct {
+ client model.Client
+ inboundIds []int
+ }
+ prep := make([]prepared, 0, len(payloads))
+ emails := make([]string, 0, len(payloads))
+ subIDs := make([]string, 0, len(payloads))
+ seenEmail := make(map[string]struct{}, len(payloads))
+ seenSubID := make(map[string]string, len(payloads))
+
for i := range payloads {
- p := payloads[i]
- email := strings.TrimSpace(p.Client.Email)
- nr, err := s.Create(inboundSvc, &p)
- if err != nil {
- if email == "" {
- email = "(missing email)"
- }
- result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: err.Error()})
+ client := payloads[i].Client
+ email := strings.TrimSpace(client.Email)
+ if email == "" {
+ skip("", "client email is required")
continue
}
- if nr {
- needRestart = true
+ if verr := validateClientEmail(email); verr != nil {
+ skip(email, verr.Error())
+ continue
+ }
+ if verr := validateClientSubID(client.SubID); verr != nil {
+ skip(email, verr.Error())
+ continue
+ }
+ if len(payloads[i].InboundIds) == 0 {
+ skip(email, "at least one inbound is required")
+ continue
+ }
+
+ client.Email = email
+ if client.SubID == "" {
+ client.SubID = uuid.NewString()
+ }
+ if !client.Enable {
+ client.Enable = true
+ }
+ now := time.Now().UnixMilli()
+ if client.CreatedAt == 0 {
+ client.CreatedAt = now
+ }
+ client.UpdatedAt = now
+
+ le := strings.ToLower(email)
+ if _, dup := seenEmail[le]; dup {
+ skip(email, "email already in use: "+email)
+ continue
+ }
+ if owner, ok := seenSubID[client.SubID]; ok && owner != le {
+ skip(email, "subId already in use: "+client.SubID)
+ continue
+ }
+ seenEmail[le] = struct{}{}
+ seenSubID[client.SubID] = le
+
+ prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
+ emails = append(emails, email)
+ subIDs = append(subIDs, client.SubID)
+ }
+
+ if len(prep) == 0 {
+ return result, false, nil
+ }
+
+ db := database.GetDB()
+ const lookupChunk = 400
+ existingEmailSub := make(map[string]string, len(emails))
+ for start := 0; start < len(emails); start += lookupChunk {
+ end := min(start+lookupChunk, len(emails))
+ var rows []model.ClientRecord
+ if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil {
+ return result, false, e
+ }
+ for i := range rows {
+ existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
+ }
+ }
+ existingSubOwner := make(map[string]string, len(subIDs))
+ for start := 0; start < len(subIDs); start += lookupChunk {
+ end := min(start+lookupChunk, len(subIDs))
+ var rows []model.ClientRecord
+ if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil {
+ return result, false, e
+ }
+ for i := range rows {
+ existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email)
+ }
+ }
+
+ inboundCache := make(map[int]*model.Inbound)
+ getIb := func(id int) (*model.Inbound, error) {
+ if ib, ok := inboundCache[id]; ok {
+ return ib, nil
+ }
+ ib, e := inboundSvc.GetInbound(id)
+ if e != nil {
+ return nil, e
+ }
+ inboundCache[id] = ib
+ return ib, nil
+ }
+
+ byInbound := make(map[int][]model.Client)
+ idxByInbound := make(map[int][]int)
+ inboundOrder := make([]int, 0)
+ failed := make([]bool, len(prep))
+ reason := make([]string, len(prep))
+
+ for idx := range prep {
+ le := strings.ToLower(prep[idx].client.Email)
+ if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
+ failed[idx] = true
+ reason[idx] = "email already in use: " + prep[idx].client.Email
+ continue
+ }
+ if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
+ failed[idx] = true
+ reason[idx] = "subId already in use: " + prep[idx].client.SubID
+ continue
+ }
+
+ ok := true
+ for _, ibId := range prep[idx].inboundIds {
+ ib, e := getIb(ibId)
+ if e != nil {
+ failed[idx] = true
+ reason[idx] = e.Error()
+ ok = false
+ break
+ }
+ if e := s.fillProtocolDefaults(&prep[idx].client, ib); e != nil {
+ failed[idx] = true
+ reason[idx] = e.Error()
+ ok = false
+ break
+ }
+ }
+ if !ok {
+ continue
+ }
+ for _, ibId := range prep[idx].inboundIds {
+ ib, _ := getIb(ibId)
+ if _, seen := byInbound[ibId]; !seen {
+ inboundOrder = append(inboundOrder, ibId)
+ }
+ byInbound[ibId] = append(byInbound[ibId], clientWithInboundFlow(prep[idx].client, ib))
+ idxByInbound[ibId] = append(idxByInbound[ibId], idx)
+ }
+ }
+
+ needRestart := false
+ for _, ibId := range inboundOrder {
+ payload, e := json.Marshal(map[string][]model.Client{"clients": byInbound[ibId]})
+ if e == nil {
+ var nr bool
+ nr, e = s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
+ if e == nil && nr {
+ needRestart = true
+ }
+ }
+ if e != nil {
+ for _, idx := range idxByInbound[ibId] {
+ failed[idx] = true
+ if reason[idx] == "" {
+ reason[idx] = e.Error()
+ }
+ }
+ }
+ }
+
+ for idx := range prep {
+ if failed[idx] {
+ skip(prep[idx].client.Email, reason[idx])
+ } else {
+ result.Created++
}
- result.Created++
}
return result, needRestart, nil
}
@@ -2976,33 +3363,27 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
return 0, false, nil
}
- emails := make(map[string]struct{}, len(rows))
+ seen := make(map[string]struct{}, len(rows))
+ emails := make([]string, 0, len(rows))
for _, r := range rows {
- if r.Email != "" {
- emails[r.Email] = struct{}{}
+ if r.Email == "" {
+ continue
}
+ if _, ok := seen[r.Email]; ok {
+ continue
+ }
+ seen[r.Email] = struct{}{}
+ emails = append(emails, r.Email)
+ }
+ if len(emails) == 0 {
+ return 0, false, nil
}
- needRestart := false
- deleted := 0
- for email := range emails {
- var rec model.ClientRecord
- if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- continue
- }
- return deleted, needRestart, err
- }
- nr, err := s.Delete(inboundSvc, rec.Id, false)
- if err != nil {
- return deleted, needRestart, err
- }
- if nr {
- needRestart = true
- }
- deleted++
+ res, needRestart, err := s.BulkDelete(inboundSvc, emails, false)
+ if err != nil {
+ return res.Deleted, needRestart, err
}
- return deleted, needRestart, nil
+ return res.Deleted, needRestart, nil
}
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 8d5bf556..09b2a8bc 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
emails = append(emails, e)
}
var extra []xray.ClientTraffic
- if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
- logger.Warning("enrichClientStats:", err)
+ var loadErr error
+ for _, batch := range chunkStrings(emails, sqlInChunk) {
+ var page []xray.ClientTraffic
+ if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
+ loadErr = err
+ break
+ }
+ extra = append(extra, page...)
+ }
+ if loadErr != nil {
+ logger.Warning("enrichClientStats:", loadErr)
} else {
byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra {
@@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
return count > 0, nil
}
+func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
+ shared := make(map[string]bool, len(emails))
+ want := make(map[string]struct{}, len(emails))
+ for _, e := range emails {
+ e = strings.ToLower(strings.TrimSpace(e))
+ if e != "" {
+ want[e] = struct{}{}
+ }
+ }
+ if len(want) == 0 {
+ return shared, nil
+ }
+ db := database.GetDB()
+ var rows []string
+ query := fmt.Sprintf(
+ "SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
+ database.JSONFieldText("client.value", "email"),
+ database.JSONClientsFromInbound(),
+ )
+ if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ for _, e := range rows {
+ e = strings.ToLower(strings.TrimSpace(e))
+ if _, ok := want[e]; ok {
+ shared[e] = true
+ }
+ }
+ return shared, nil
+}
+
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@@ -2445,6 +2485,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
@@ -2998,16 +3064,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
}
@@ -3336,6 +3419,9 @@ func (s *InboundService) MigrateDB() {
}
func (s *InboundService) GetOnlineClients() []string {
+ if p == nil {
+ return []string{}
+ }
return p.GetOnlineClients()
}
diff --git a/web/service/setting.go b/web/service/setting.go
index 098ec039..fe8ac918 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
- "subJsonFragment": "",
- "subJsonNoises": "",
+ "subClashEnableRouting": "false",
+ "subClashRules": "",
"subJsonMux": "",
"subJsonRules": "",
+ "subJsonFinalMask": "",
"datepicker": "gregorian",
"warp": "",
"nord": "",
@@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
-func (s *SettingService) GetSubJsonFragment() (string, error) {
- return s.getString("subJsonFragment")
+func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
+ return s.getBool("subClashEnableRouting")
}
-func (s *SettingService) GetSubJsonNoises() (string, error) {
- return s.getString("subJsonNoises")
+func (s *SettingService) GetSubClashRules() (string, error) {
+ return s.getString("subClashRules")
}
func (s *SettingService) GetSubJsonMux() (string, error) {
@@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules")
}
+func (s *SettingService) GetSubJsonFinalMask() (string, error) {
+ return s.getString("subJsonFinalMask")
+}
+
func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker")
}
diff --git a/web/service/sync_scale_postgres_test.go b/web/service/sync_scale_postgres_test.go
new file mode 100644
index 00000000..35a990ab
--- /dev/null
+++ b/web/service/sync_scale_postgres_test.go
@@ -0,0 +1,431 @@
+package service
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+
+ "gorm.io/gorm"
+)
+
+func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
+ if tx == nil {
+ tx = database.GetDB()
+ }
+ if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
+ return err
+ }
+ for i := range clients {
+ c := clients[i]
+ email := strings.TrimSpace(c.Email)
+ if email == "" {
+ continue
+ }
+ incoming := c.ToRecord()
+ row := &model.ClientRecord{}
+ err := tx.Where("email = ?", email).First(row).Error
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ if err := tx.Create(incoming).Error; err != nil {
+ return err
+ }
+ row = incoming
+ } else {
+ row.Flow = incoming.Flow
+ row.SubID = incoming.SubID
+ row.LimitIP = incoming.LimitIP
+ row.TotalGB = incoming.TotalGB
+ row.ExpiryTime = incoming.ExpiryTime
+ row.Enable = incoming.Enable
+ row.TgID = incoming.TgID
+ row.Comment = incoming.Comment
+ row.Reset = incoming.Reset
+ preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+ row.UpdatedAt = preservedUpdatedAt
+ if err := tx.Save(row).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.ClientRecord{}).
+ Where("id = ?", row.Id).
+ UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+ return err
+ }
+ }
+ link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
+ if err := tx.Create(&link).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func makeScaleClients(n int) []model.Client {
+ out := make([]model.Client, n)
+ for i := 0; i < n; i++ {
+ out[i] = model.Client{
+ ID: uuid.NewString(),
+ Email: fmt.Sprintf("user-%07d@scale", i),
+ SubID: fmt.Sprintf("sub-%07d", i),
+ Enable: true,
+ }
+ }
+ return out
+}
+
+func TestSyncInboundPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{
+ Tag: fmt.Sprintf("scale-%d", n),
+ Enable: true,
+ Port: 40000,
+ Protocol: model.VLESS,
+ Settings: clientsSettings(t, clients),
+ }
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+
+ start := time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ seed := time.Since(start)
+
+ clients[n/2].Enable = !clients[n/2].Enable
+ start = time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("toggle SyncInbound (new): %v", err)
+ }
+ toggleNew := time.Since(start)
+
+ start = time.Now()
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("noop SyncInbound (new): %v", err)
+ }
+ noopNew := time.Since(start)
+
+ toggleOld := time.Duration(0)
+ if n <= 10000 {
+ clients[n/2].Enable = !clients[n/2].Enable
+ start = time.Now()
+ if err := syncInboundOld(db, ib.Id, clients); err != nil {
+ t.Fatalf("toggle SyncInbound (old): %v", err)
+ }
+ toggleOld = time.Since(start)
+ }
+
+ var linkCount, recCount int64
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ if int(linkCount) != n || int(recCount) != n {
+ t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
+ }
+
+ oldStr, speedup := "skipped", ""
+ if toggleOld > 0 {
+ oldStr = toggleOld.Round(time.Millisecond).String()
+ speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
+ }
+ t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
+ n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
+ noopNew.Round(time.Millisecond), oldStr, speedup)
+ })
+ }
+}
+
+func maxDur(d, floor time.Duration) time.Duration {
+ if d < floor {
+ return floor
+ }
+ return d
+}
+
+func TestAddDelClientPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 20000, 50000, 100000, 200000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{
+ Tag: fmt.Sprintf("adddel-%d", n),
+ Enable: true,
+ Port: 40000,
+ Protocol: model.VLESS,
+ Settings: clientsSettings(t, clients),
+ }
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ newC := model.Client{
+ ID: uuid.NewString(),
+ Email: "added-client@scale",
+ SubID: "added-sub",
+ Enable: true,
+ }
+ addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
+ start := time.Now()
+ if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
+ t.Fatalf("AddInboundClient: %v", err)
+ }
+ addDur := time.Since(start)
+
+ delId := clients[n/2].ID
+ start = time.Now()
+ if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
+ t.Fatalf("DelInboundClient: %v", err)
+ }
+ delDur := time.Since(start)
+
+ var recCount, linkCount int64
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+
+ t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
+ addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
+ })
+ }
+}
+
+func TestGroupAndListPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ sizes := []int{5000, 100000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ db.Exec("ANALYZE")
+ emails := make([]string, n)
+ for i := 0; i < n; i++ {
+ emails[i] = clients[i].Email
+ }
+
+ start := time.Now()
+ if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
+ t.Fatalf("AddToGroup: %v", err)
+ }
+ addDur := time.Since(start)
+
+ start = time.Now()
+ if _, err := svc.RemoveFromGroup(emails); err != nil {
+ t.Fatalf("RemoveFromGroup: %v", err)
+ }
+ rmDur := time.Since(start)
+
+ start = time.Now()
+ list, err := svc.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ listDur := time.Since(start)
+ if len(list) != n {
+ t.Fatalf("List returned %d, want %d", len(list), n)
+ }
+
+ t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
+ addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
+ })
+ }
+}
+
+func TestDelAllClientsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 50000, 100000}
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+ clients := makeScaleClients(n)
+ ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+
+ emails, err := inboundSvc.EmailsByInbound(ib.Id)
+ if err != nil {
+ t.Fatalf("EmailsByInbound: %v", err)
+ }
+ start := time.Now()
+ res, _, err := svc.BulkDelete(inboundSvc, emails, false)
+ if err != nil {
+ t.Fatalf("BulkDelete: %v", err)
+ }
+ dur := time.Since(start)
+
+ var recCount, linkCount int64
+ db.Model(&model.ClientRecord{}).Count(&recCount)
+ db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+ if recCount != 0 || linkCount != 0 {
+ t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
+ }
+ t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
+ })
+ }
+}
+
+func TestBulkOpsPostgresScale(t *testing.T) {
+ if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+ t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+ }
+ if err := database.InitDB(""); err != nil {
+ t.Fatalf("InitDB: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ sizes := []int{5000, 20000, 50000, 100000}
+ const m = 2000
+
+ for _, n := range sizes {
+ t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+ db := database.GetDB()
+ if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ clients := makeScaleClients(n)
+ exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+ for i := range clients {
+ clients[i].ExpiryTime = exp
+ clients[i].TotalGB = 100 << 30
+ }
+ ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+ if err := db.Create(ib).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+ t.Fatalf("seed SyncInbound: %v", err)
+ }
+ ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+ if err := db.Create(ib2).Error; err != nil {
+ t.Fatalf("create inbound2: %v", err)
+ }
+
+ emailsM := make([]string, m)
+ for i := 0; i < m; i++ {
+ emailsM[i] = clients[i].Email
+ }
+
+ t0 := time.Now()
+ if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
+ t.Fatalf("BulkAdjust: %v", err)
+ }
+ adjustDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+ t.Fatalf("BulkAttach: %v", err)
+ }
+ attachDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+ t.Fatalf("BulkDetach: %v", err)
+ }
+ detachDur := time.Since(t0)
+
+ payloads := make([]ClientCreatePayload, m)
+ for i := 0; i < m; i++ {
+ payloads[i] = ClientCreatePayload{
+ Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
+ InboundIds: []int{ib.Id},
+ }
+ }
+ t0 = time.Now()
+ if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
+ t.Fatalf("BulkCreate: %v", err)
+ }
+ createDur := time.Since(t0)
+
+ t0 = time.Now()
+ if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
+ t.Fatalf("BulkDelete: %v", err)
+ }
+ deleteDur := time.Since(t0)
+
+ t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
+ adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
+ createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
+ })
+ }
+}
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index dcb6cb2c..81a0de4e 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ if !t.isCommandForCurrentBot(&message) {
+ return nil
+ }
+
// Use goroutine with worker pool for concurrent command processing
go func() {
messageWorkerPool <- struct{}{} // Acquire worker
@@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
}
}
+func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
+ return isCommandForBot(message.Text, botUsername())
+}
+
+func botUsername() string {
+ if bot == nil {
+ return ""
+ }
+ return bot.Username()
+}
+
+func isCommandForBot(text string, username string) bool {
+ _, commandUsername, _ := tu.ParseCommand(text)
+ return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
+}
+
// sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage {
diff --git a/web/service/tgbot_test.go b/web/service/tgbot_test.go
index 36e17e78..a3c346b0 100644
--- a/web/service/tgbot_test.go
+++ b/web/service/tgbot_test.go
@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
t.Fatal("Dial must be nil when no proxy is configured")
}
}
+
+func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
+ if !isCommandForBot("/status", "panel_bot") {
+ t.Fatal("untargeted commands must remain accepted")
+ }
+}
+
+func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
+ if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
+ t.Fatal("commands targeted to this bot must be accepted")
+ }
+}
+
+func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
+ if isCommandForBot("/status@other_bot", "panel_bot") {
+ t.Fatal("commands targeted to another bot must be ignored")
+ }
+}
+
+func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
+ if !isCommandForBot("/status@panel_bot", "") {
+ t.Fatal("commands must remain accepted when the current bot username is unavailable")
+ }
+}
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index f7e25337..668ceb37 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": "بورت الاستماع",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "حد IP الافتراضي"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
"packets": "الحزم",
"length": "الطول",
"interval": "الفاصل",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 443a8f0e..27429ecf 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",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Default IP limit"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
"packets": "Packets",
"length": "Length",
"interval": "Interval",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index 2283e34a..d4e84936 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",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Límite IP por defecto"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
"packets": "Paquetes",
"length": "Longitud",
"interval": "Intervalo",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index 87f181f7..cfa5d27a 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": "پورت",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "محدودیت IP پیشفرض"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
"packets": "بستهها",
"length": "طول",
"interval": "بازه",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 523ad689..74c7878b 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",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Batas IP default"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
"packets": "Paket",
"length": "Panjang",
"interval": "Interval",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 8cc74116..9f14c4f1 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": "監視ポート",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "デフォルト IP 制限"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
"packets": "パケット",
"length": "長さ",
"interval": "間隔",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index 7bc5a7ef..b7ccc449 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",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Limite de IP padrão"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
"packets": "Pacotes",
"length": "Comprimento",
"interval": "Intervalo",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index 226df950..bdda60b5 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": "Порт подписки",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Лимит IP по умолчанию"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
"packets": "Пакеты",
"length": "Длина",
"interval": "Интервал",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 69b52e5e..f8f8f5c9 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",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Varsayılan IP limiti"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
"packets": "Paketler",
"length": "Uzunluk",
"interval": "Aralık",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index b386306a..8c602380 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": "Слухати порт",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Ліміт IP за замовч."
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
"packets": "Пакети",
"length": "Довжина",
"interval": "Інтервал",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 12c620af..a56cfb21 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ý",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Giới hạn IP mặc định"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
"packets": "Gói",
"length": "Độ dài",
"interval": "Khoảng",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index d7076703..9b759532 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": "监听端口",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "默认 IP 限制"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
"packets": "数据包",
"length": "长度",
"interval": "间隔",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 813f53c2..157f75d5 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": "監聽埠",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "預設 IP 限制"
},
"subFormats": {
+ "finalMask": "Final Mask",
+ "finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
"packets": "封包",
"length": "長度",
"interval": "間隔",
diff --git a/x-ui.sh b/x-ui.sh
index 7bcc5022..ee0f3763 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
echo "Panel paths set for domain: $domain"
echo " - Certificate File: $webCertFile"
echo " - Private Key File: $webKeyFile"
+ # Register the acme.sh install-cert hook so auto-renewal copies the
+ # renewed cert to these paths and reloads the panel. Without it acme.sh
+ # renews but never updates /root/cert, silently serving a stale cert.
+ if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
+ ~/.acme.sh/acme.sh --installcert -d "${domain}" \
+ --key-file "${webKeyFile}" \
+ --fullchain-file "${webCertFile}" \
+ --reloadcmd "x-ui restart" 2>&1 || true
+ echo "Registered acme.sh auto-renewal hook for ${domain}."
+ fi
restart
else
echo "Certificate or private key not found for domain: $domain."
@@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
LOGE "Failed to issue certificate for IP: ${server_ip}"
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
- [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+ rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+ [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
else
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
LOGE "Certificate files not found after installation"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
- rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
- [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+ rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+ [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
fi
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}"
- # detect existing certificate and reuse it if present
+ # detect existing certificate and reuse it only if its files are actually
+ # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+ # certs under ${domain}; a failed issuance can leave a domain entry in --list
+ # with no usable cert files, which must not be reused (it produces a 0-byte
+ # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
- cert_exists=1
- local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
- LOGI "Existing certificate found for ${domain}, will reuse it."
- [[ -n "${certInfo}" ]] && LOGI "${certInfo}"
- else
+ local acmeCertDir=""
+ if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}_ecc
+ elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+ acmeCertDir=~/.acme.sh/${domain}
+ fi
+ if [[ -n "${acmeCertDir}" ]]; then
+ cert_exists=1
+ local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
+ LOGI "Existing certificate found for ${domain}, will reuse it."
+ [[ -n "${certInfo}" ]] && LOGI "${certInfo}"
+ else
+ LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+ fi
+ fi
+ if [[ ${cert_exists} -eq 0 ]]; then
LOGI "Your domain is ready for issuing certificates now..."
fi
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs."
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
exit 1
else
LOGE "Issuing certificate succeeded, installing certificates..."
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
else
LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then
- rm -rf ~/.acme.sh/${domain}
+ rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
exit 1
fi
@@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnect
ignoreregex =
EOF
+ # Ports to exempt from the ban so an over-limit proxy client can never lock
+ # the administrator out of SSH or the panel. The ban still covers every other
+ # TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
+ # added later without regenerating these files.
+ local ssh_ports
+ ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
+ [[ -z "${ssh_ports}" ]] && ssh_ports="22"
+ local panel_port
+ panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
+ local exempt_ports="${ssh_ports}"
+ [[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
+
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES]
before = iptables-allports.conf
@@ -2263,16 +2301,17 @@ actionstop = -D -p -j f2b-
actioncheck = -n -L | grep -q 'f2b-[ \t]'
-actionban = -I f2b- 1 -s -j
+actionban = -I f2b- 1 -s -p -m multiport ! --dports -j
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path}
-actionunban = -D f2b- -s -j
+actionunban = -D f2b- -s -p -m multiport ! --dports -j
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default
protocol = tcp
chain = INPUT
+exemptports = ${exempt_ports}
EOF
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"