1
0

9 Коммиты 9c8cd08f90 ... 60c54827aa

Автор SHA1 Сообщение Дата
  Nikan Zeyaei 60c54827aa feat: ldap skip tls verify (#5637) 9 часов назад
  n0ctal aef35ee0de fix(sync): mark node dirty inside the mutation transaction (atomic ConfigDirty) (#5611) 12 часов назад
  n0ctal 2b10808fbd fix(settings): require re-2FA confirmation for sensitive setting changes (#5610) 12 часов назад
  nima1024m 25a86b9ee2 feat(balancers): tabbed Observatory/Burst Observatory form (#5627) 12 часов назад
  nima1024m 51ffba5961 fix(balancers): defer validation errors until touched or save (#5626) 12 часов назад
  n0ctal 5713c09980 fix(runtime): refresh cached node remotes on identity change (#5614) 12 часов назад
  n0ctal 7f8cbf4c4b fix(web): tighten database restore body-cap exemption (#5609) 12 часов назад
  MHSanaei bbfbd7eba6 Bump minimum eligible Xray version 12 часов назад
  MHSanaei 79069d2b64 fix(wireguard): allocate client IPs in the existing peer subnet 13 часов назад
57 измененных файлов с 1562 добавлено и 356 удалено
  1. 8 0
      frontend/public/openapi.json
  2. 9 4
      frontend/src/api/queries/useAllSettings.ts
  3. 2 0
      frontend/src/generated/examples.ts
  4. 8 0
      frontend/src/generated/schemas.ts
  5. 2 0
      frontend/src/generated/types.ts
  6. 2 0
      frontend/src/generated/zod.ts
  7. 1 0
      frontend/src/models/setting.ts
  8. 11 0
      frontend/src/pages/settings/GeneralTab.tsx
  9. 21 9
      frontend/src/pages/settings/SecurityTab.tsx
  10. 2 1
      frontend/src/pages/settings/SettingsPage.tsx
  11. 19 7
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  12. 62 85
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  13. 238 0
      frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx
  14. 17 0
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  15. 34 0
      frontend/src/schemas/observatory.ts
  16. 1 0
      frontend/src/schemas/setting.ts
  17. 1 1
      frontend/src/test/api-token-date.test.tsx
  18. 58 0
      frontend/src/test/balancer-form-modal.test.tsx
  19. 66 1
      frontend/src/test/balancer-observatory-sync.test.ts
  20. 20 0
      frontend/src/test/setting-ldap-skip-verify.test.ts
  21. 1 1
      go.mod
  22. 1 0
      internal/sub/service_test.go
  23. 18 17
      internal/util/ldap/ldap.go
  24. 22 0
      internal/util/ldap/ldap_test.go
  25. 23 6
      internal/web/controller/setting.go
  26. 12 11
      internal/web/entity/entity.go
  27. 12 11
      internal/web/job/ldap_sync_job.go
  28. 4 5
      internal/web/middleware/bodylimit.go
  29. 12 10
      internal/web/middleware/bodylimit_test.go
  30. 40 3
      internal/web/runtime/manager.go
  31. 122 0
      internal/web/runtime/manager_convergence_test.go
  32. 24 36
      internal/web/service/client_bulk.go
  33. 32 51
      internal/web/service/client_inbound_apply.go
  34. 15 1
      internal/web/service/client_wireguard.go
  35. 32 0
      internal/web/service/client_wireguard_test.go
  36. 35 34
      internal/web/service/inbound.go
  37. 25 23
      internal/web/service/inbound_traffic.go
  38. 14 5
      internal/web/service/node.go
  39. 40 0
      internal/web/service/node_dirty_test.go
  40. 10 8
      internal/web/service/panel/user.go
  41. 1 1
      internal/web/service/server.go
  42. 45 22
      internal/web/service/setting.go
  43. 21 0
      internal/web/service/setting_security_test.go
  44. 32 0
      internal/web/translation/ar-EG.json
  45. 32 0
      internal/web/translation/en-US.json
  46. 32 0
      internal/web/translation/es-ES.json
  47. 32 0
      internal/web/translation/fa-IR.json
  48. 32 0
      internal/web/translation/id-ID.json
  49. 32 0
      internal/web/translation/ja-JP.json
  50. 32 0
      internal/web/translation/pt-BR.json
  51. 32 0
      internal/web/translation/ru-RU.json
  52. 32 0
      internal/web/translation/tr-TR.json
  53. 32 0
      internal/web/translation/uk-UA.json
  54. 32 0
      internal/web/translation/vi-VN.json
  55. 32 0
      internal/web/translation/zh-CN.json
  56. 32 0
      internal/web/translation/zh-TW.json
  57. 3 3
      internal/web/web.go

+ 8 - 0
frontend/public/openapi.json

@@ -84,6 +84,9 @@
           "ldapInboundTags": {
           "ldapInboundTags": {
             "type": "string"
             "type": "string"
           },
           },
+          "ldapInsecureSkipVerify": {
+            "type": "boolean"
+          },
           "ldapInvertFlag": {
           "ldapInvertFlag": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -429,6 +432,7 @@
           "ldapFlagField",
           "ldapFlagField",
           "ldapHost",
           "ldapHost",
           "ldapInboundTags",
           "ldapInboundTags",
+          "ldapInsecureSkipVerify",
           "ldapInvertFlag",
           "ldapInvertFlag",
           "ldapPassword",
           "ldapPassword",
           "ldapPort",
           "ldapPort",
@@ -589,6 +593,9 @@
           "ldapInboundTags": {
           "ldapInboundTags": {
             "type": "string"
             "type": "string"
           },
           },
+          "ldapInsecureSkipVerify": {
+            "type": "boolean"
+          },
           "ldapInvertFlag": {
           "ldapInvertFlag": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -941,6 +948,7 @@
           "ldapFlagField",
           "ldapFlagField",
           "ldapHost",
           "ldapHost",
           "ldapInboundTags",
           "ldapInboundTags",
+          "ldapInsecureSkipVerify",
           "ldapInvertFlag",
           "ldapInvertFlag",
           "ldapPassword",
           "ldapPassword",
           "ldapPort",
           "ldapPort",

+ 9 - 4
frontend/src/api/queries/useAllSettings.ts

@@ -7,6 +7,8 @@ import { AllSetting } from '@/models/setting';
 import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
 import { keys } from '@/api/queryKeys';
 
 
+type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
+
 async function fetchAllSetting(): Promise<AllSettingInput | null> {
 async function fetchAllSetting(): Promise<AllSettingInput | null> {
   const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
   const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
@@ -42,19 +44,21 @@ export function useAllSettings() {
   }, []);
   }, []);
 
 
   const saveMut = useMutation({
   const saveMut = useMutation({
-    mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
-      const body = AllSettingSchema.partial().safeParse(next);
+    mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
+      const payload = { ...next };
+      const body = AllSettingSchema.partial().safeParse(payload);
       if (!body.success) {
       if (!body.success) {
         console.warn('[zod] setting/update body failed validation', body.error.issues);
         console.warn('[zod] setting/update body failed validation', body.error.issues);
       }
       }
-      return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
+      return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
     },
     },
     onSuccess: (msg) => {
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
     },
     },
   });
   });
 
 
-  const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
+  const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
+  const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
   const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
   const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
 
 
   return {
   return {
@@ -65,5 +69,6 @@ export function useAllSettings() {
     setSpinning: setExtraSpinning,
     setSpinning: setExtraSpinning,
     saveDisabled,
     saveDisabled,
     saveAll,
     saveAll,
+    savePayload,
   };
   };
 }
 }

+ 2 - 0
frontend/src/generated/examples.ts

@@ -16,6 +16,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapFlagField": "",
     "ldapFlagField": "",
     "ldapHost": "",
     "ldapHost": "",
     "ldapInboundTags": "",
     "ldapInboundTags": "",
+    "ldapInsecureSkipVerify": false,
     "ldapInvertFlag": false,
     "ldapInvertFlag": false,
     "ldapPassword": "",
     "ldapPassword": "",
     "ldapPort": 0,
     "ldapPort": 0,
@@ -118,6 +119,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapFlagField": "",
     "ldapFlagField": "",
     "ldapHost": "",
     "ldapHost": "",
     "ldapInboundTags": "",
     "ldapInboundTags": "",
+    "ldapInsecureSkipVerify": false,
     "ldapInvertFlag": false,
     "ldapInvertFlag": false,
     "ldapPassword": "",
     "ldapPassword": "",
     "ldapPort": 0,
     "ldapPort": 0,

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -58,6 +58,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapInboundTags": {
       "ldapInboundTags": {
         "type": "string"
         "type": "string"
       },
       },
+      "ldapInsecureSkipVerify": {
+        "type": "boolean"
+      },
       "ldapInvertFlag": {
       "ldapInvertFlag": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -403,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapFlagField",
       "ldapFlagField",
       "ldapHost",
       "ldapHost",
       "ldapInboundTags",
       "ldapInboundTags",
+      "ldapInsecureSkipVerify",
       "ldapInvertFlag",
       "ldapInvertFlag",
       "ldapPassword",
       "ldapPassword",
       "ldapPort",
       "ldapPort",
@@ -563,6 +567,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapInboundTags": {
       "ldapInboundTags": {
         "type": "string"
         "type": "string"
       },
       },
+      "ldapInsecureSkipVerify": {
+        "type": "boolean"
+      },
       "ldapInvertFlag": {
       "ldapInvertFlag": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -915,6 +922,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapFlagField",
       "ldapFlagField",
       "ldapHost",
       "ldapHost",
       "ldapInboundTags",
       "ldapInboundTags",
+      "ldapInsecureSkipVerify",
       "ldapInvertFlag",
       "ldapInvertFlag",
       "ldapPassword",
       "ldapPassword",
       "ldapPort",
       "ldapPort",

+ 2 - 0
frontend/src/generated/types.ts

@@ -22,6 +22,7 @@ export interface AllSetting {
   ldapFlagField: string;
   ldapFlagField: string;
   ldapHost: string;
   ldapHost: string;
   ldapInboundTags: string;
   ldapInboundTags: string;
+  ldapInsecureSkipVerify: boolean;
   ldapInvertFlag: boolean;
   ldapInvertFlag: boolean;
   ldapPassword: string;
   ldapPassword: string;
   ldapPort: number;
   ldapPort: number;
@@ -125,6 +126,7 @@ export interface AllSettingView {
   ldapFlagField: string;
   ldapFlagField: string;
   ldapHost: string;
   ldapHost: string;
   ldapInboundTags: string;
   ldapInboundTags: string;
+  ldapInsecureSkipVerify: boolean;
   ldapInvertFlag: boolean;
   ldapInvertFlag: boolean;
   ldapPassword: string;
   ldapPassword: string;
   ldapPort: number;
   ldapPort: number;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -34,6 +34,7 @@ export const AllSettingSchema = z.object({
   ldapFlagField: z.string(),
   ldapFlagField: z.string(),
   ldapHost: z.string(),
   ldapHost: z.string(),
   ldapInboundTags: z.string(),
   ldapInboundTags: z.string(),
+  ldapInsecureSkipVerify: z.boolean(),
   ldapInvertFlag: z.boolean(),
   ldapInvertFlag: z.boolean(),
   ldapPassword: z.string(),
   ldapPassword: z.string(),
   ldapPort: z.number().int().min(0).max(65535),
   ldapPort: z.number().int().min(0).max(65535),
@@ -138,6 +139,7 @@ export const AllSettingViewSchema = z.object({
   ldapFlagField: z.string(),
   ldapFlagField: z.string(),
   ldapHost: z.string(),
   ldapHost: z.string(),
   ldapInboundTags: z.string(),
   ldapInboundTags: z.string(),
+  ldapInsecureSkipVerify: z.boolean(),
   ldapInvertFlag: z.boolean(),
   ldapInvertFlag: z.boolean(),
   ldapPassword: z.string(),
   ldapPassword: z.string(),
   ldapPort: z.number().int().min(0).max(65535),
   ldapPort: z.number().int().min(0).max(65535),

+ 1 - 0
frontend/src/models/setting.ts

@@ -68,6 +68,7 @@ export class AllSetting {
   ldapHost = '';
   ldapHost = '';
   ldapPort = 389;
   ldapPort = 389;
   ldapUseTLS = false;
   ldapUseTLS = false;
+  ldapInsecureSkipVerify = false;
   ldapBindDN = '';
   ldapBindDN = '';
   ldapPassword = '';
   ldapPassword = '';
   ldapBaseDN = '';
   ldapBaseDN = '';

+ 11 - 0
frontend/src/pages/settings/GeneralTab.tsx

@@ -312,6 +312,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             <SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
               <Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
               <Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
             </SettingListItem>
             </SettingListItem>
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.ldap.skipTlsVerify')}
+              description={t('pages.settings.ldap.skipTlsVerifyDesc')}
+            >
+              <Switch
+                checked={allSetting.ldapInsecureSkipVerify}
+                disabled={!allSetting.ldapUseTLS}
+                onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
+              />
+            </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
               <Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
               <Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
             </SettingListItem>
             </SettingListItem>

+ 21 - 9
frontend/src/pages/settings/SecurityTab.tsx

@@ -37,6 +37,7 @@ interface ApiTokenRow {
 interface SecurityTabProps {
 interface SecurityTabProps {
   allSetting: AllSetting;
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
   updateSetting: (patch: Partial<AllSetting>) => void;
+  saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
 }
 }
 
 
 const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
 const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
@@ -65,7 +66,7 @@ const TFA_INITIAL: TfaState = {
   onConfirm: () => {},
   onConfirm: () => {},
 };
 };
 
 
-export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
+export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
   const { isMobile } = useMediaQuery();
   const [modal, modalContextHolder] = Modal.useModal();
   const [modal, modalContextHolder] = Modal.useModal();
@@ -99,10 +100,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     setUser((prev) => ({ ...prev, [key]: value }));
     setUser((prev) => ({ ...prev, [key]: value }));
   }
   }
 
 
-  const sendUpdateUser = useCallback(async () => {
+  const sendUpdateUser = useCallback(async (twoFactorCode = '') => {
     setUpdating(true);
     setUpdating(true);
     try {
     try {
-      const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
+      const msg = await HttpUtil.post('/panel/api/setting/updateUser', { ...user, twoFactorCode }) as ApiMsg;
       if (msg?.success) {
       if (msg?.success) {
         await HttpUtil.post('/logout');
         await HttpUtil.post('/logout');
         const basePath = window.X_UI_BASE_PATH || '/';
         const basePath = window.X_UI_BASE_PATH || '/';
@@ -118,9 +119,11 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
       openTfa({
       openTfa({
         title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
         title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
         description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
         description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
-        token: allSetting.twoFactorToken,
+        token: '',
         type: 'confirm',
         type: 'confirm',
-        onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); },
+        onConfirm: (ok: boolean, code?: string) => {
+          if (ok) sendUpdateUser(code || '');
+        },
       });
       });
     } else {
     } else {
       sendUpdateUser();
       sendUpdateUser();
@@ -224,12 +227,21 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
       openTfa({
       openTfa({
         title: t('pages.settings.security.twoFactorModalDeleteTitle'),
         title: t('pages.settings.security.twoFactorModalDeleteTitle'),
         description: t('pages.settings.security.twoFactorModalRemoveStep'),
         description: t('pages.settings.security.twoFactorModalRemoveStep'),
-        token: allSetting.twoFactorToken,
+        token: '',
         type: 'confirm',
         type: 'confirm',
-        onConfirm: (ok: boolean) => {
+        onConfirm: async (ok: boolean, code?: string) => {
           if (!ok) return;
           if (!ok) return;
-          messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
-          updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
+          const next = {
+            ...allSetting,
+            twoFactorEnable: false,
+            twoFactorToken: '',
+            twoFactorCode: code || '',
+          };
+          const msg = await saveSetting(next) as ApiMsg;
+          if (msg?.success) {
+            messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
+            updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
+          }
         },
         },
       });
       });
     }
     }

+ 2 - 1
frontend/src/pages/settings/SettingsPage.tsx

@@ -76,6 +76,7 @@ export default function SettingsPage() {
     setSpinning,
     setSpinning,
     saveDisabled,
     saveDisabled,
     saveAll,
     saveAll,
+    savePayload,
   } = useAllSettings();
   } = useAllSettings();
 
 
   const [entryHost, setEntryHost] = useState('');
   const [entryHost, setEntryHost] = useState('');
@@ -196,7 +197,7 @@ export default function SettingsPage() {
 
 
   const categoryBody = useMemo(() => {
   const categoryBody = useMemo(() => {
     switch (activeSlug) {
     switch (activeSlug) {
-      case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} saveSetting={savePayload} />;
       case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;

+ 19 - 7
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -68,10 +68,14 @@ export default function BalancerFormModal({
 }: BalancerFormModalProps) {
 }: BalancerFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [state, setState] = useState<FormState>(() => initialState(balancer));
   const [state, setState] = useState<FormState>(() => initialState(balancer));
+  const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
+  const [submitAttempted, setSubmitAttempted] = useState(false);
   const isEdit = balancer != null;
   const isEdit = balancer != null;
 
 
-  const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
+  const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
+    setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
     setState((prev) => ({ ...prev, [key]: value }));
     setState((prev) => ({ ...prev, [key]: value }));
+  };
 
 
   const parsed = useMemo(
   const parsed = useMemo(
     () => BalancerFormSchema.safeParse(state),
     () => BalancerFormSchema.safeParse(state),
@@ -89,8 +93,17 @@ export default function BalancerFormModal({
     return map;
     return map;
   }, [parsed, t]);
   }, [parsed, t]);
 
 
+  const showTagIssue = submitAttempted || !!touched.tag;
+  const showSelectorIssue = submitAttempted || !!touched.selector;
+  const tagError = showTagIssue ? issues.tag : '';
+  const selectorError = showSelectorIssue ? issues.selector : '';
+  const showDuplicate = showTagIssue && duplicateTag;
+
   function submit() {
   function submit() {
-    if (!parsed.success || duplicateTag) return;
+    if (!parsed.success || duplicateTag) {
+      setSubmitAttempted(true);
+      return;
+    }
     const values = { ...parsed.data };
     const values = { ...parsed.data };
     if (values.strategy !== 'leastLoad') delete values.settings;
     if (values.strategy !== 'leastLoad') delete values.settings;
     onConfirm(values);
     onConfirm(values);
@@ -128,7 +141,6 @@ export default function BalancerFormModal({
       title={title}
       title={title}
       okText={okText}
       okText={okText}
       cancelText={t('close')}
       cancelText={t('close')}
-      okButtonProps={{ disabled: !parsed.success || duplicateTag }}
       mask={{ closable: false }}
       mask={{ closable: false }}
       onOk={submit}
       onOk={submit}
       onCancel={onClose}
       onCancel={onClose}
@@ -137,8 +149,8 @@ export default function BalancerFormModal({
         <Form.Item
         <Form.Item
           label={t('pages.xray.balancer.tag')}
           label={t('pages.xray.balancer.tag')}
           required
           required
-          validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
-          help={issues.tag || (duplicateTag ? t('pages.xray.balancer.tagDuplicate') : '')}
+          validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
+          help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
           hasFeedback
           hasFeedback
         >
         >
           <Input
           <Input
@@ -157,8 +169,8 @@ export default function BalancerFormModal({
         <Form.Item
         <Form.Item
           label={t('pages.xray.balancer.selector')}
           label={t('pages.xray.balancer.selector')}
           required
           required
-          validateStatus={issues.selector ? 'error' : ''}
-          help={issues.selector || ''}
+          validateStatus={selectorError ? 'error' : ''}
+          help={selectorError || ''}
           hasFeedback
           hasFeedback
         >
         >
           <Select
           <Select

+ 62 - 85
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -1,13 +1,14 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Dropdown, Empty, Modal, Radio, Select, Space, Table, Tag, Tooltip } from 'antd';
-import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined } from '@ant-design/icons';
+import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip } from 'antd';
+import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 
 
 import BalancerFormModal from './BalancerFormModal';
 import BalancerFormModal from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
-import { syncObservatories } from './balancer-helpers';
-import { JsonEditor } from '@/components/form';
+import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
+import ObservatorySettingsTab from './ObservatorySettingsTab';
+import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { HttpUtil } from '@/utils';
 import { HttpUtil } from '@/utils';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type {
 import type {
@@ -184,8 +185,15 @@ export default function BalancersTab({
   }
   }
 
 
   function confirmDelete(idx: number) {
   function confirmDelete(idx: number) {
+    const removed = templateSettings
+      ? observersRemovedByDeletingBalancer(templateSettings, idx)
+      : { observatory: false, burst: false };
+    const warnings: string[] = [];
+    if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
+    if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
     modal.confirm({
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
       title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
+      content: warnings.length ? warnings.join(' ') : undefined,
       okText: t('delete'),
       okText: t('delete'),
       okType: 'danger',
       okType: 'danger',
       cancelText: t('cancel'),
       cancelText: t('cancel'),
@@ -316,92 +324,61 @@ export default function BalancersTab({
     },
     },
   ];
   ];
 
 
-  const hasObservatory = !!templateSettings?.observatory;
-  const hasBurstObservatory = !!templateSettings?.burstObservatory;
-  const showObsEditor = hasObservatory || hasBurstObservatory;
-
-  const [obsView, setObsView] = useState<'observatory' | 'burstObservatory'>('observatory');
-
-  useEffect(() => {
-    if (obsView === 'observatory' && !hasObservatory && hasBurstObservatory) {
-      setObsView('burstObservatory');
-    } else if (obsView === 'burstObservatory' && !hasBurstObservatory && hasObservatory) {
-      setObsView('observatory');
-    }
-  }, [obsView, hasObservatory, hasBurstObservatory]);
-
-  const obsText = useMemo(() => {
-    const src = obsView === 'observatory' ? templateSettings?.observatory : templateSettings?.burstObservatory;
-    return src ? JSON.stringify(src, null, 2) : '';
-  }, [obsView, templateSettings?.observatory, templateSettings?.burstObservatory]);
-
-  function onObsTextChange(next: string) {
-    let parsed;
-    try {
-      parsed = JSON.parse(next);
-    } catch {
-      return;
-    }
-    mutate((tt) => {
-      if (obsView === 'observatory') tt.observatory = parsed;
-      else tt.burstObservatory = parsed;
-    });
-  }
-
-  return (
-    <>
-      {modalContextHolder}
-      <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
-        {rows.length === 0 ? (
-          <Empty description={t('emptyBalancersDesc')}>
+  const balancerSettingsTab = (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      {rows.length === 0 ? (
+        <Empty description={t('emptyBalancersDesc')}>
+          <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
+            {t('pages.xray.Balancers')}
+          </Button>
+        </Empty>
+      ) : (
+        <>
+          <Space>
             <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
             <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
               {t('pages.xray.Balancers')}
               {t('pages.xray.Balancers')}
             </Button>
             </Button>
-          </Empty>
-        ) : (
-          <>
-            <Space>
-              <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
-                {t('pages.xray.Balancers')}
-              </Button>
-              <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
-                <Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
-              </Tooltip>
-            </Space>
+            <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
+              <Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
+            </Tooltip>
+          </Space>
 
 
-            <Table
-              columns={columns}
-              dataSource={rows}
-              rowKey={(r) => r.key}
-              pagination={false}
-              size="small"
-              scroll={{ x: 700 }}
-            />
+          <Table
+            columns={columns}
+            dataSource={rows}
+            rowKey={(r) => r.key}
+            pagination={false}
+            size="small"
+            scroll={{ x: 700 }}
+          />
+        </>
+      )}
+    </Space>
+  );
 
 
-            {showObsEditor && (
-              <>
-                <Divider style={{ margin: '8px 0' }} />
-                <Radio.Group
-                  value={obsView}
-                  onChange={(e) => setObsView(e.target.value)}
-                  optionType="button"
-                  buttonStyle="solid"
-                  size="small"
-                >
-                  {hasObservatory && <Radio.Button value="observatory">Observatory</Radio.Button>}
-                  {hasBurstObservatory && <Radio.Button value="burstObservatory">Burst Observatory</Radio.Button>}
-                </Radio.Group>
-                <JsonEditor
-                  value={obsText}
-                  onChange={onObsTextChange}
-                  minHeight="220px"
-                  maxHeight="480px"
-                />
-              </>
-            )}
-          </>
-        )}
-      </Space>
+  return (
+    <>
+      {modalContextHolder}
+      <Tabs
+        items={[
+          {
+            key: 'balancers',
+            label: catTabLabel(<DeploymentUnitOutlined />, t('pages.xray.tabBalancerSettings'), isMobile),
+            children: balancerSettingsTab,
+          },
+          {
+            key: 'observatory',
+            label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
+            children: (
+              <ObservatorySettingsTab
+                templateSettings={templateSettings}
+                mutate={mutate}
+                isMobile={isMobile}
+              />
+            ),
+          },
+        ]}
+      />
 
 
       <BalancerFormModal
       <BalancerFormModal
         key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
         key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}

+ 238 - 0
frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx

@@ -0,0 +1,238 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd';
+
+import { SettingListItem } from '@/components/ui';
+import {
+  BurstObservatorySchema,
+  ObservatoryHttpMethodSchema,
+  ObservatorySchema,
+  type BurstObservatoryObject,
+  type ObservatoryHttpMethod,
+  type ObservatoryObject,
+  type PingConfigObject,
+} from '@/schemas/observatory';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+interface ObservatorySettingsTabProps {
+  templateSettings: XraySettingsValue | null;
+  mutate: (mutator: (next: XraySettingsValue) => void) => void;
+  isMobile: boolean;
+}
+
+const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
+const BURST_DEFAULTS = BurstObservatorySchema.parse({});
+
+function asObject(value: unknown): Record<string, unknown> {
+  return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
+}
+
+function SelectorTags({ tags }: { tags: string[] }) {
+  if (!tags || tags.length === 0) return <Tag>—</Tag>;
+  return (
+    <>
+      {tags.map((sel) => (
+        <Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4, marginBottom: 4 }}>
+          {sel}
+        </Tag>
+      ))}
+    </>
+  );
+}
+
+export default function ObservatorySettingsTab({
+  templateSettings,
+  mutate,
+  isMobile,
+}: ObservatorySettingsTabProps) {
+  const { t } = useTranslation();
+
+  const observatory = useMemo<ObservatoryObject | null>(() => {
+    const raw = templateSettings?.observatory;
+    if (raw == null) return null;
+    return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
+  }, [templateSettings?.observatory]);
+
+  const burst = useMemo<BurstObservatoryObject | null>(() => {
+    const raw = templateSettings?.burstObservatory;
+    if (raw == null) return null;
+    const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
+    merged.pingConfig = { ...BURST_DEFAULTS.pingConfig, ...asObject(merged.pingConfig) } as PingConfigObject;
+    return merged;
+  }, [templateSettings?.burstObservatory]);
+
+  const hasObservatory = observatory != null;
+  const hasBurst = burst != null;
+
+  const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
+  const effectiveView = !hasObservatory && hasBurst
+    ? 'burstObservatory'
+    : !hasBurst && hasObservatory
+      ? 'observatory'
+      : view;
+
+  function patchObservatory(patch: Partial<ObservatoryObject>) {
+    mutate((tt) => {
+      tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
+    });
+  }
+
+  function patchPingConfig(patch: Partial<PingConfigObject>) {
+    mutate((tt) => {
+      const current = asObject(tt.burstObservatory);
+      const currentPing = asObject(current.pingConfig);
+      tt.burstObservatory = {
+        ...BURST_DEFAULTS,
+        ...current,
+        pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
+      };
+    });
+  }
+
+  if (!hasObservatory && !hasBurst) {
+    return <Empty description={t('pages.xray.observatory.emptyHint')} />;
+  }
+
+  const observatorySection = observatory && (
+    <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.subjectSelector')}
+        description={t('pages.xray.observatory.subjectSelectorDesc')}
+      >
+        <SelectorTags tags={observatory.subjectSelector} />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.probeURL')}
+        description={t('pages.xray.observatory.probeURLDesc')}
+      >
+        <Input
+          value={observatory.probeURL}
+          onChange={(e) => patchObservatory({ probeURL: e.target.value })}
+          placeholder="https://www.google.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.probeInterval')}
+        description={t('pages.xray.observatory.probeIntervalDesc')}
+      >
+        <Input
+          value={observatory.probeInterval}
+          onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
+          placeholder="1m"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.enableConcurrency')}
+        description={t('pages.xray.observatory.enableConcurrencyDesc')}
+      >
+        <Switch
+          checked={observatory.enableConcurrency}
+          onChange={(v) => patchObservatory({ enableConcurrency: v })}
+        />
+      </SettingListItem>
+    </>
+  );
+
+  const burstSection = burst && (
+    <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.subjectSelector')}
+        description={t('pages.xray.observatory.subjectSelectorDesc')}
+      >
+        <SelectorTags tags={burst.subjectSelector} />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.destination')}
+        description={t('pages.xray.observatory.destinationDesc')}
+      >
+        <Input
+          value={burst.pingConfig.destination}
+          onChange={(e) => patchPingConfig({ destination: e.target.value })}
+          placeholder="https://www.google.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.connectivity')}
+        description={t('pages.xray.observatory.connectivityDesc')}
+      >
+        <Input
+          value={burst.pingConfig.connectivity}
+          allowClear
+          onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
+          placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.interval')}
+        description={t('pages.xray.observatory.intervalDesc')}
+      >
+        <Input
+          value={burst.pingConfig.interval}
+          onChange={(e) => patchPingConfig({ interval: e.target.value })}
+          placeholder="1m"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.timeout')}
+        description={t('pages.xray.observatory.timeoutDesc')}
+      >
+        <Input
+          value={burst.pingConfig.timeout}
+          onChange={(e) => patchPingConfig({ timeout: e.target.value })}
+          placeholder="5s"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.sampling')}
+        description={t('pages.xray.observatory.samplingDesc')}
+      >
+        <InputNumber
+          min={1}
+          value={burst.pingConfig.sampling}
+          onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
+          style={{ width: '100%' }}
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.httpMethod')}
+        description={t('pages.xray.observatory.httpMethodDesc')}
+      >
+        <Select<ObservatoryHttpMethod>
+          value={burst.pingConfig.httpMethod}
+          onChange={(v) => patchPingConfig({ httpMethod: v })}
+          options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
+          style={{ width: '100%' }}
+        />
+      </SettingListItem>
+    </>
+  );
+
+  return (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      <Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
+      {hasObservatory && hasBurst && (
+        <Segmented
+          block={isMobile}
+          value={effectiveView}
+          onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
+          options={[
+            { label: t('pages.xray.observatory.title'), value: 'observatory' },
+            { label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
+          ]}
+        />
+      )}
+      <div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
+    </Space>
+  );
+}

+ 17 - 0
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -16,6 +16,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
     connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
     connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
     timeout: '5s',
     timeout: '5s',
     sampling: 2,
     sampling: 2,
+    httpMethod: 'HEAD',
   },
   },
 });
 });
 
 
@@ -71,3 +72,19 @@ export function syncObservatories(t: XraySettingsValue) {
     delete t.burstObservatory;
     delete t.burstObservatory;
   }
   }
 }
 }
+
+export function observersRemovedByDeletingBalancer(
+  t: XraySettingsValue,
+  idx: number,
+): { observatory: boolean; burst: boolean } {
+  const hadObservatory = !!t.observatory;
+  const hadBurst = !!t.burstObservatory;
+  if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
+  const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
+  if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
+  syncObservatories(clone);
+  return {
+    observatory: hadObservatory && !clone.observatory,
+    burst: hadBurst && !clone.burstObservatory,
+  };
+}

+ 34 - 0
frontend/src/schemas/observatory.ts

@@ -0,0 +1,34 @@
+import { z } from 'zod';
+
+export const ObservatorySchema = z
+  .object({
+    subjectSelector: z.array(z.string()).default([]),
+    probeURL: z.string().default('https://www.google.com/generate_204'),
+    probeInterval: z.string().default('1m'),
+    enableConcurrency: z.boolean().default(true),
+  })
+  .loose();
+export type ObservatoryObject = z.infer<typeof ObservatorySchema>;
+
+export const ObservatoryHttpMethodSchema = z.enum(['HEAD', 'GET']);
+export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
+
+export const PingConfigSchema = z
+  .object({
+    destination: z.string().default('https://www.google.com/generate_204'),
+    connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'),
+    interval: z.string().default('1m'),
+    timeout: z.string().default('5s'),
+    sampling: z.number().int().min(1).default(2),
+    httpMethod: ObservatoryHttpMethodSchema.default('HEAD'),
+  })
+  .loose();
+export type PingConfigObject = z.infer<typeof PingConfigSchema>;
+
+export const BurstObservatorySchema = z
+  .object({
+    subjectSelector: z.array(z.string()).default([]),
+    pingConfig: PingConfigSchema.default(PingConfigSchema.parse({})),
+  })
+  .loose();
+export type BurstObservatoryObject = z.infer<typeof BurstObservatorySchema>;

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -69,6 +69,7 @@ export const AllSettingSchema = z.object({
   ldapHost: z.string().optional(),
   ldapHost: z.string().optional(),
   ldapPort: port.optional(),
   ldapPort: port.optional(),
   ldapUseTLS: z.boolean().optional(),
   ldapUseTLS: z.boolean().optional(),
+  ldapInsecureSkipVerify: z.boolean().optional(),
   ldapBindDN: z.string().optional(),
   ldapBindDN: z.string().optional(),
   ldapPassword: z.string().optional(),
   ldapPassword: z.string().optional(),
   ldapBaseDN: z.string().optional(),
   ldapBaseDN: z.string().optional(),

+ 1 - 1
frontend/src/test/api-token-date.test.tsx

@@ -26,7 +26,7 @@ describe('API token creation date', () => {
       ],
       ],
     });
     });
 
 
-    render(<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} />);
+    render(<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />);
     fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
     fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
 
 
     expect(await screen.findByText('seconds-token')).toBeTruthy();
     expect(await screen.findByText('seconds-token')).toBeTruthy();

+ 58 - 0
frontend/src/test/balancer-form-modal.test.tsx

@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent } from '@testing-library/react';
+
+import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
+import { renderWithProviders } from './test-utils';
+
+function renderModal(onConfirm = vi.fn()) {
+  renderWithProviders(
+    <BalancerFormModal
+      open
+      balancer={null}
+      outboundTags={['proxy', 'direct']}
+      otherTags={['existing']}
+      onClose={() => {}}
+      onConfirm={onConfirm}
+    />,
+  );
+  return { onConfirm };
+}
+
+function erroredItemCount(): number {
+  return document.querySelectorAll('.ant-form-item-has-error').length;
+}
+
+function explainText(): string {
+  return Array.from(document.querySelectorAll('.ant-form-item-explain'))
+    .map((el) => (el.textContent ?? '').trim())
+    .join(' | ');
+}
+
+function createButton(): HTMLElement {
+  const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
+  if (!btn) throw new Error('Create button not found');
+  return btn as HTMLElement;
+}
+
+describe('BalancerFormModal', () => {
+  it('shows no validation errors when freshly opened in add mode', () => {
+    renderModal();
+    expect(document.querySelector('.ant-modal')).toBeTruthy();
+    expect(erroredItemCount()).toBe(0);
+    expect(explainText()).not.toContain('Tag is required');
+    expect(explainText()).not.toContain('Pick at least one outbound');
+    expect(createButton().hasAttribute('disabled')).toBe(false);
+  });
+
+  it('reveals required-field errors only after a save attempt, without confirming', () => {
+    const { onConfirm } = renderModal();
+    expect(erroredItemCount()).toBe(0);
+
+    fireEvent.click(createButton());
+
+    expect(erroredItemCount()).toBe(2);
+    expect(explainText()).toContain('Tag is required');
+    expect(explainText()).toContain('Pick at least one outbound');
+    expect(onConfirm).not.toHaveBeenCalled();
+  });
+});

+ 66 - 1
frontend/src/test/balancer-observatory-sync.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
-import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
+import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
 
 
 function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
 function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
@@ -111,4 +111,69 @@ describe('syncObservatories', () => {
     expect(t.observatory).toBeUndefined();
     expect(t.observatory).toBeUndefined();
     expect(t.burstObservatory).toBeUndefined();
     expect(t.burstObservatory).toBeUndefined();
   });
   });
+
+  it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
+    expect(burst.pingConfig.httpMethod).toBe('HEAD');
+    expect(burst.pingConfig.sampling).toBe(2);
+  });
+
+  it('drops only the prefixes no remaining balancer uses (note #2)', () => {
+    const t = tpl({
+      balancers: [
+        { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
+        { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
+      ],
+    });
+    syncObservatories(t);
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['prefixA', 'prefixB', 'prefixC']),
+    );
+    (t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
+    syncObservatories(t);
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['prefixC', 'prefixB']),
+    );
+  });
+});
+
+describe('observersRemovedByDeletingBalancer', () => {
+  it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
+  });
+
+  it('keeps the burst observer when another balancer still needs it (overlap)', () => {
+    const t = tpl({
+      balancers: [
+        { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
+        { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
+      ],
+    });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
+  });
+
+  it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
+  });
+
+  it('reports nothing removed when the balancer never had an observer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
+  });
+
+  it('does not mutate the template it inspects', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    const before = JSON.stringify(t);
+    observersRemovedByDeletingBalancer(t, 0);
+    expect(JSON.stringify(t)).toBe(before);
+  });
 });
 });

+ 20 - 0
frontend/src/test/setting-ldap-skip-verify.test.ts

@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+import { AllSetting } from '@/models/setting';
+
+describe('ldapInsecureSkipVerify', () => {
+  it('parses through the Zod schema', () => {
+    const r = AllSettingSchema.safeParse({ ldapInsecureSkipVerify: true });
+    expect(r.success).toBe(true);
+    expect(r.success && r.data.ldapInsecureSkipVerify).toBe(true);
+  });
+
+  it('rejects non-boolean values', () => {
+    expect(AllSettingSchema.safeParse({ ldapInsecureSkipVerify: 'yes' }).success).toBe(false);
+  });
+
+  it('defaults to false on the model and clones from payload', () => {
+    expect(new AllSetting().ldapInsecureSkipVerify).toBe(false);
+    expect(new AllSetting({ ldapInsecureSkipVerify: true }).ldapInsecureSkipVerify).toBe(true);
+  });
+});

+ 1 - 1
go.mod

@@ -109,7 +109,7 @@ require (
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
-	google.golang.org/protobuf v1.36.11 // indirect
+	google.golang.org/protobuf v1.36.11
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )
 )

+ 1 - 0
internal/sub/service_test.go

@@ -95,6 +95,7 @@ func TestListenIsInternalOnly(t *testing.T) {
 }
 }
 
 
 func TestResolveInboundAddress(t *testing.T) {
 func TestResolveInboundAddress(t *testing.T) {
+	initSubDB(t)
 	const reqHost = "sub.example.com"
 	const reqHost = "sub.example.com"
 
 
 	// A routable bind Listen (a real IP or hostname the operator set as the
 	// A routable bind Listen (a real IP or hostname the operator set as the

+ 18 - 17
internal/util/ldap/ldap.go

@@ -9,17 +9,22 @@ import (
 )
 )
 
 
 type Config struct {
 type Config struct {
-	Host       string
-	Port       int
-	UseTLS     bool
-	BindDN     string
-	Password   string
-	BaseDN     string
-	UserFilter string
-	UserAttr   string
-	FlagField  string
-	TruthyVals []string
-	Invert     bool
+	Host               string
+	Port               int
+	UseTLS             bool
+	InsecureSkipVerify bool
+	BindDN             string
+	Password           string
+	BaseDN             string
+	UserFilter         string
+	UserAttr           string
+	FlagField          string
+	TruthyVals         []string
+	Invert             bool
+}
+
+func tlsConfig(cfg Config) *tls.Config {
+	return &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
 }
 }
 
 
 // FetchVlessFlags returns map[email]enabled
 // FetchVlessFlags returns map[email]enabled
@@ -35,9 +40,7 @@ func FetchVlessFlags(cfg Config) (map[string]bool, error) {
 
 
 	var opts []ldap.DialOpt
 	var opts []ldap.DialOpt
 	if cfg.UseTLS {
 	if cfg.UseTLS {
-		opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
-			InsecureSkipVerify: false,
-		}))
+		opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
 	}
 	}
 
 
 	conn, err := ldap.DialURL(ldapURL, opts...)
 	conn, err := ldap.DialURL(ldapURL, opts...)
@@ -105,9 +108,7 @@ func AuthenticateUser(cfg Config, username, password string) (bool, error) {
 
 
 	var opts []ldap.DialOpt
 	var opts []ldap.DialOpt
 	if cfg.UseTLS {
 	if cfg.UseTLS {
-		opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
-			InsecureSkipVerify: false,
-		}))
+		opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
 	}
 	}
 
 
 	conn, err := ldap.DialURL(ldapURL, opts...)
 	conn, err := ldap.DialURL(ldapURL, opts...)

+ 22 - 0
internal/util/ldap/ldap_test.go

@@ -0,0 +1,22 @@
+package ldaputil
+
+import "testing"
+
+func TestTLSConfig_InsecureSkipVerifyPropagates(t *testing.T) {
+	cases := []struct {
+		name string
+		skip bool
+		want bool
+	}{
+		{"default verifies", false, false},
+		{"skip flows through", true, true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := tlsConfig(Config{InsecureSkipVerify: c.skip})
+			if got.InsecureSkipVerify != c.want {
+				t.Fatalf("InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, c.want)
+			}
+		})
+	}
+}

+ 23 - 6
internal/web/controller/setting.go

@@ -19,10 +19,16 @@ import (
 
 
 // updateUserForm represents the form for updating user credentials.
 // updateUserForm represents the form for updating user credentials.
 type updateUserForm struct {
 type updateUserForm struct {
-	OldUsername string `json:"oldUsername" form:"oldUsername"`
-	OldPassword string `json:"oldPassword" form:"oldPassword"`
-	NewUsername string `json:"newUsername" form:"newUsername"`
-	NewPassword string `json:"newPassword" form:"newPassword"`
+	OldUsername   string `json:"oldUsername" form:"oldUsername"`
+	OldPassword   string `json:"oldPassword" form:"oldPassword"`
+	NewUsername   string `json:"newUsername" form:"newUsername"`
+	NewPassword   string `json:"newPassword" form:"newPassword"`
+	TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
+}
+
+type updateSettingForm struct {
+	entity.AllSetting
+	TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
 }
 }
 
 
 // SettingController handles settings and user management operations.
 // SettingController handles settings and user management operations.
@@ -82,23 +88,30 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
 
 
 // updateSetting updates all settings with the provided data.
 // updateSetting updates all settings with the provided data.
 func (a *SettingController) updateSetting(c *gin.Context) {
 func (a *SettingController) updateSetting(c *gin.Context) {
-	allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
+	form, ok := middleware.BindAndValidate[updateSettingForm](c)
 	if !ok {
 	if !ok {
 		return
 		return
 	}
 	}
+	allSetting := &form.AllSetting
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
 	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
 	oldTgEnable, _ := a.settingService.GetTgbotEnabled()
 	oldTgEnable, _ := a.settingService.GetTgbotEnabled()
 	oldTgToken, _ := a.settingService.GetTgBotToken()
 	oldTgToken, _ := a.settingService.GetTgBotToken()
 	oldTgChatId, _ := a.settingService.GetTgBotChatId()
 	oldTgChatId, _ := a.settingService.GetTgBotChatId()
 	oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
 	oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
+	if twoFactorErr == nil && oldTwoFactor && !allSetting.TwoFactorEnable {
+		if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+			return
+		}
+	}
 	err := a.settingService.UpdateAllSetting(allSetting)
 	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr
 			err = bumpErr
 		}
 		}
 	}
 	}
-	if err == nil && allSetting.PanelOutbound != oldPanelOutbound {
+	if err == nil && form.PanelOutbound != oldPanelOutbound {
 		// The egress bridge lives in the generated config; reconcile the
 		// The egress bridge lives in the generated config; reconcile the
 		// running core. One SOCKS inbound plus one routing rule — both
 		// running core. One SOCKS inbound plus one routing rule — both
 		// hot-appliable, so this normally does not restart Xray.
 		// hot-appliable, so this normally does not restart Xray.
@@ -136,6 +149,10 @@ func (a *SettingController) updateUser(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
 		return
 		return
 	}
 	}
+	if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), err)
+		return
+	}
 	err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
 	err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
 	if err == nil {
 	if err == nil {
 		user.Username = form.NewUsername
 		user.Username = form.NewUsername

+ 12 - 11
internal/web/entity/entity.go

@@ -105,17 +105,18 @@ type AllSetting struct {
 	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`   // Hide server settings in happ subscription (Only for Happ)
 	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`   // Hide server settings in happ subscription (Only for Happ)
 
 
 	// LDAP settings
 	// LDAP settings
-	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`
-	LdapHost       string `json:"ldapHost" form:"ldapHost"`
-	LdapPort       int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
-	LdapUseTLS     bool   `json:"ldapUseTLS" form:"ldapUseTLS"`
-	LdapBindDN     string `json:"ldapBindDN" form:"ldapBindDN"`
-	LdapPassword   string `json:"ldapPassword" form:"ldapPassword"`
-	LdapBaseDN     string `json:"ldapBaseDN" form:"ldapBaseDN"`
-	LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
-	LdapUserAttr   string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
-	LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
-	LdapSyncCron   string `json:"ldapSyncCron" form:"ldapSyncCron"`
+	LdapEnable             bool   `json:"ldapEnable" form:"ldapEnable"`
+	LdapHost               string `json:"ldapHost" form:"ldapHost"`
+	LdapPort               int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
+	LdapUseTLS             bool   `json:"ldapUseTLS" form:"ldapUseTLS"`
+	LdapInsecureSkipVerify bool   `json:"ldapInsecureSkipVerify" form:"ldapInsecureSkipVerify"`
+	LdapBindDN             string `json:"ldapBindDN" form:"ldapBindDN"`
+	LdapPassword           string `json:"ldapPassword" form:"ldapPassword"`
+	LdapBaseDN             string `json:"ldapBaseDN" form:"ldapBaseDN"`
+	LdapUserFilter         string `json:"ldapUserFilter" form:"ldapUserFilter"`
+	LdapUserAttr           string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
+	LdapVlessField         string `json:"ldapVlessField" form:"ldapVlessField"`
+	LdapSyncCron           string `json:"ldapSyncCron" form:"ldapSyncCron"`
 	// Generic flag configuration
 	// Generic flag configuration
 	LdapFlagField         string `json:"ldapFlagField" form:"ldapFlagField"`
 	LdapFlagField         string `json:"ldapFlagField" form:"ldapFlagField"`
 	LdapTruthyValues      string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
 	LdapTruthyValues      string `json:"ldapTruthyValues" form:"ldapTruthyValues"`

+ 12 - 11
internal/web/job/ldap_sync_job.go

@@ -69,17 +69,18 @@ func (j *LdapSyncJob) Run() {
 
 
 	// --- LDAP fetch ---
 	// --- LDAP fetch ---
 	cfg := ldaputil.Config{
 	cfg := ldaputil.Config{
-		Host:       mustGetString(j.settingService.GetLdapHost),
-		Port:       mustGetInt(j.settingService.GetLdapPort),
-		UseTLS:     mustGetBool(j.settingService.GetLdapUseTLS),
-		BindDN:     mustGetString(j.settingService.GetLdapBindDN),
-		Password:   mustGetString(j.settingService.GetLdapPassword),
-		BaseDN:     mustGetString(j.settingService.GetLdapBaseDN),
-		UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
-		UserAttr:   mustGetString(j.settingService.GetLdapUserAttr),
-		FlagField:  mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
-		TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
-		Invert:     mustGetBool(j.settingService.GetLdapInvertFlag),
+		Host:               mustGetString(j.settingService.GetLdapHost),
+		Port:               mustGetInt(j.settingService.GetLdapPort),
+		UseTLS:             mustGetBool(j.settingService.GetLdapUseTLS),
+		InsecureSkipVerify: mustGetBool(j.settingService.GetLdapInsecureSkipVerify),
+		BindDN:             mustGetString(j.settingService.GetLdapBindDN),
+		Password:           mustGetString(j.settingService.GetLdapPassword),
+		BaseDN:             mustGetString(j.settingService.GetLdapBaseDN),
+		UserFilter:         mustGetString(j.settingService.GetLdapUserFilter),
+		UserAttr:           mustGetString(j.settingService.GetLdapUserAttr),
+		FlagField:          mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
+		TruthyVals:         splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
+		Invert:             mustGetBool(j.settingService.GetLdapInvertFlag),
 	}
 	}
 
 
 	flags, err := ldaputil.FetchVlessFlags(cfg)
 	flags, err := ldaputil.FetchVlessFlags(cfg)

+ 4 - 5
internal/web/middleware/bodylimit.go

@@ -23,7 +23,7 @@ func MaxBodyBytes(limit int64, skipSuffixes ...string) gin.HandlerFunc {
 			switch c.Request.Method {
 			switch c.Request.Method {
 			case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
 			case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
 			default:
 			default:
-				if c.Request.Body != nil && !hasSuffix(c.Request.URL.Path, skipSuffixes) {
+				if c.Request.Body != nil && !hasAnySuffix(c.Request.URL.Path, skipSuffixes) {
 					c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
 					c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
 				}
 				}
 			}
 			}
@@ -32,10 +32,9 @@ func MaxBodyBytes(limit int64, skipSuffixes ...string) gin.HandlerFunc {
 	}
 	}
 }
 }
 
 
-// hasSuffix reports whether path ends in any of the given suffixes.
-func hasSuffix(path string, suffixes []string) bool {
-	for _, s := range suffixes {
-		if strings.HasSuffix(path, s) {
+func hasAnySuffix(path string, suffixes []string) bool {
+	for _, suffix := range suffixes {
+		if suffix != "" && strings.HasSuffix(path, suffix) {
 			return true
 			return true
 		}
 		}
 	}
 	}

+ 12 - 10
internal/web/middleware/bodylimit_test.go

@@ -50,7 +50,7 @@ func TestMaxBodyBytes(t *testing.T) {
 
 
 func TestMaxBodyBytesSkipSuffix(t *testing.T) {
 func TestMaxBodyBytesSkipSuffix(t *testing.T) {
 	gin.SetMode(gin.TestMode)
 	gin.SetMode(gin.TestMode)
-	const limit = 16
+	const limit = 10 << 20
 
 
 	r := gin.New()
 	r := gin.New()
 	r.Use(MaxBodyBytes(limit, "/server/importDB"))
 	r.Use(MaxBodyBytes(limit, "/server/importDB"))
@@ -61,20 +61,22 @@ func TestMaxBodyBytesSkipSuffix(t *testing.T) {
 		}
 		}
 		c.String(http.StatusOK, "ok")
 		c.String(http.StatusOK, "ok")
 	}
 	}
-	r.POST("/server/importDB", read)
+	r.POST("/prefix/panel/api/server/importDB", read)
+	r.POST("/prefix/panel/api/server/importDB/other", read)
 	r.POST("/x", read)
 	r.POST("/x", read)
 
 
-	// Exempt route reads an over-limit body without error.
+	large := bytes.Repeat([]byte("x"), limit+1)
 	w := httptest.NewRecorder()
 	w := httptest.NewRecorder()
-	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/server/importDB", bytes.NewReader(make([]byte, limit*4))))
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/prefix/panel/api/server/importDB", bytes.NewReader(large)))
 	if w.Code != http.StatusOK {
 	if w.Code != http.StatusOK {
-		t.Errorf("exempt route should pass through over-limit body, got %d", w.Code)
+		t.Fatalf("restore route should accept an over-limit body, got %d", w.Code)
 	}
 	}
 
 
-	// Non-exempt route is still capped.
-	w = httptest.NewRecorder()
-	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(make([]byte, limit*4))))
-	if w.Code == http.StatusOK {
-		t.Errorf("non-exempt over-limit POST should not succeed, got 200")
+	for _, path := range []string{"/x", "/prefix/panel/api/server/importDB/other"} {
+		w = httptest.NewRecorder()
+		r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(large)))
+		if w.Code == http.StatusOK {
+			t.Fatalf("non-exempt path %q accepted an over-limit body", path)
+		}
 	}
 	}
 }
 }

+ 40 - 3
internal/web/runtime/manager.go

@@ -99,21 +99,58 @@ func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
 	}
 	}
 	m.mu.RLock()
 	m.mu.RLock()
 	if rt, ok := m.remotes[node.Id]; ok {
 	if rt, ok := m.remotes[node.Id]; ok {
+		if sameRemoteIdentity(rt.node, node) {
+			m.mu.RUnlock()
+			return rt, nil
+		}
+		m.mu.RUnlock()
+	} else {
 		m.mu.RUnlock()
 		m.mu.RUnlock()
-		return rt, nil
 	}
 	}
-	m.mu.RUnlock()
 
 
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 	if rt, ok := m.remotes[node.Id]; ok {
 	if rt, ok := m.remotes[node.Id]; ok {
+		if sameRemoteIdentity(rt.node, node) {
+			return rt, nil
+		}
+	} else {
+		rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
+		m.remotes[node.Id] = rt
 		return rt, nil
 		return rt, nil
 	}
 	}
-	rt := NewRemote(node, m.egressResolver)
+	rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
 	m.remotes[node.Id] = rt
 	m.remotes[node.Id] = rt
 	return rt, nil
 	return rt, nil
 }
 }
 
 
+func cloneRemoteNode(n *model.Node) *model.Node {
+	if n == nil {
+		return nil
+	}
+	clone := *n
+	if n.InboundTags != nil {
+		clone.InboundTags = append([]string(nil), n.InboundTags...)
+	}
+	return &clone
+}
+
+func sameRemoteIdentity(a, b *model.Node) bool {
+	if a == nil || b == nil {
+		return a == b
+	}
+	return a.Id == b.Id &&
+		a.Scheme == b.Scheme &&
+		a.Address == b.Address &&
+		a.Port == b.Port &&
+		a.BasePath == b.BasePath &&
+		a.ApiToken == b.ApiToken &&
+		a.AllowPrivateAddress == b.AllowPrivateAddress &&
+		a.TlsVerifyMode == b.TlsVerifyMode &&
+		a.PinnedCertSha256 == b.PinnedCertSha256 &&
+		a.OutboundTag == b.OutboundTag
+}
+
 func (m *Manager) InvalidateNode(nodeID int) {
 func (m *Manager) InvalidateNode(nodeID int) {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()

+ 122 - 0
internal/web/runtime/manager_convergence_test.go

@@ -0,0 +1,122 @@
+package runtime
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
+	m := NewManager(LocalDeps{})
+	first, err := m.RemoteFor(&model.Node{
+		Id:       1,
+		Name:     "node",
+		Scheme:   "https",
+		Address:  "node.example.com",
+		Port:     2053,
+		BasePath: "/",
+		ApiToken: "old-token",
+	})
+	if err != nil {
+		t.Fatalf("first RemoteFor: %v", err)
+	}
+	second, err := m.RemoteFor(&model.Node{
+		Id:       1,
+		Name:     "node",
+		Scheme:   "https",
+		Address:  "node.example.com",
+		Port:     2053,
+		BasePath: "/",
+		ApiToken: "new-token",
+	})
+	if err != nil {
+		t.Fatalf("second RemoteFor: %v", err)
+	}
+	if second == first {
+		t.Fatal("RemoteFor reused stale Remote after ApiToken changed")
+	}
+	if got := second.node.ApiToken; got != "new-token" {
+		t.Fatalf("cached Remote token = %q, want new-token", got)
+	}
+}
+
+func TestManagerRemoteForIdentityFields(t *testing.T) {
+	base := model.Node{
+		Id:                  7,
+		Name:                "node-a",
+		Remark:              "old remark",
+		Scheme:              "https",
+		Address:             "node.example.com",
+		Port:                2053,
+		BasePath:            "/",
+		ApiToken:            "token",
+		AllowPrivateAddress: true,
+		TlsVerifyMode:       "pin",
+		PinnedCertSha256:    "sha",
+		OutboundTag:         "warp",
+		Status:              "online",
+		InboundCount:        1,
+	}
+
+	cases := []struct {
+		name    string
+		mutate  func(*model.Node)
+		refresh bool
+	}{
+		{"same", func(*model.Node) {}, false},
+		{"name does not churn", func(n *model.Node) { n.Name = "renamed" }, false},
+		{"remark does not churn", func(n *model.Node) { n.Remark = "new remark" }, false},
+		{"status does not churn", func(n *model.Node) { n.Status = "offline" }, false},
+		{"metrics do not churn", func(n *model.Node) { n.InboundCount = 99 }, false},
+		{"scheme", func(n *model.Node) { n.Scheme = "http" }, true},
+		{"address", func(n *model.Node) { n.Address = "other.example.com" }, true},
+		{"port", func(n *model.Node) { n.Port = 8443 }, true},
+		{"base path", func(n *model.Node) { n.BasePath = "/x/" }, true},
+		{"api token", func(n *model.Node) { n.ApiToken = "next" }, true},
+		{"allow private", func(n *model.Node) { n.AllowPrivateAddress = false }, true},
+		{"tls verify mode", func(n *model.Node) { n.TlsVerifyMode = "skip" }, true},
+		{"pin", func(n *model.Node) { n.PinnedCertSha256 = "other" }, true},
+		{"outbound tag", func(n *model.Node) { n.OutboundTag = "direct" }, true},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			m := NewManager(LocalDeps{})
+			firstNode := base
+			first, err := m.RemoteFor(&firstNode)
+			if err != nil {
+				t.Fatalf("first RemoteFor: %v", err)
+			}
+
+			nextNode := base
+			tc.mutate(&nextNode)
+			second, err := m.RemoteFor(&nextNode)
+			if err != nil {
+				t.Fatalf("second RemoteFor: %v", err)
+			}
+			if gotRefresh := second != first; gotRefresh != tc.refresh {
+				t.Fatalf("refresh = %v, want %v", gotRefresh, tc.refresh)
+			}
+		})
+	}
+}
+
+func TestManagerRemoteForClonesInputNode(t *testing.T) {
+	m := NewManager(LocalDeps{})
+	n := &model.Node{
+		Id:       9,
+		Scheme:   "https",
+		Address:  "node.example.com",
+		Port:     2053,
+		BasePath: "/",
+		ApiToken: "original",
+	}
+	rt, err := m.RemoteFor(n)
+	if err != nil {
+		t.Fatalf("RemoteFor: %v", err)
+	}
+	n.ApiToken = "mutated-after-cache"
+	if got := rt.node.ApiToken; got != "original" {
+		t.Fatalf("cached Remote observed caller mutation: %q", got)
+	}
+}

+ 24 - 36
internal/web/service/client_bulk.go

@@ -598,25 +598,19 @@ func (s *ClientService) bulkAdjustInboundClients(
 		res.needRestart = true
 		res.needRestart = true
 	}
 	}
 
 
-	markDirty := false
 	if oldInbound.NodeID != nil {
 	if oldInbound.NodeID != nil {
-		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			for email := range foundEmails {
 			for email := range foundEmails {
 				res.perEmailSkipped[email] = perr.Error()
 				res.perEmailSkipped[email] = perr.Error()
 				delete(foundEmails, email)
 				delete(foundEmails, email)
 			}
 			}
 		} else {
 		} else {
-			if dirty {
-				markDirty = true
-			}
 			if flowChanged {
 			if flowChanged {
-				markDirty = true
 				push = false
 				push = false
 			}
 			}
 			// Large batches collapse into one reconcile push rather than M updates.
 			// Large batches collapse into one reconcile push rather than M updates.
 			if push && len(foundEmails) > nodeBulkPushThreshold {
 			if push && len(foundEmails) > nodeBulkPushThreshold {
-				markDirty = true
 				push = false
 				push = false
 			}
 			}
 			if push {
 			if push {
@@ -632,7 +626,6 @@ func (s *ClientService) bulkAdjustInboundClients(
 					updated.UpdatedAt = nowMs
 					updated.UpdatedAt = nowMs
 					if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
 					if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
 						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
 						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-						markDirty = true
 					}
 					}
 				}
 				}
 			}
 			}
@@ -649,7 +642,13 @@ func (s *ClientService) bulkAdjustInboundClients(
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, inboundId, finalClients)
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	})
 	})
 	if txErr != nil {
 	if txErr != nil {
 		for email := range foundEmails {
 		for email := range foundEmails {
@@ -657,10 +656,6 @@ func (s *ClientService) bulkAdjustInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 			}
 		}
 		}
-	} else if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
 	}
 	}
 
 
 	return res
 	return res
@@ -973,7 +968,6 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 		}
 	}
 	}
 
 
-	markDirty := false
 	if oldInbound.NodeID == nil {
 	if oldInbound.NodeID == nil {
 		rt, rterr := inboundSvc.runtimeFor(oldInbound)
 		rt, rterr := inboundSvc.runtimeFor(oldInbound)
 		if rterr != nil {
 		if rterr != nil {
@@ -995,26 +989,21 @@ func (s *ClientService) bulkDelInboundClients(
 			}
 			}
 		}
 		}
 	} else {
 	} else {
-		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			for email := range foundEmails {
 			for email := range foundEmails {
 				res.perEmailSkipped[email] = perr.Error()
 				res.perEmailSkipped[email] = perr.Error()
 				delete(foundEmails, email)
 				delete(foundEmails, email)
 			}
 			}
 		} else {
 		} else {
-			if dirty {
-				markDirty = true
-			}
 			// Large batches collapse into one reconcile push rather than M deletes.
 			// Large batches collapse into one reconcile push rather than M deletes.
 			if push && len(foundEmails) > nodeBulkPushThreshold {
 			if push && len(foundEmails) > nodeBulkPushThreshold {
-				markDirty = true
 				push = false
 				push = false
 			}
 			}
 			if push {
 			if push {
 				for email := range foundEmails {
 				for email := range foundEmails {
 					if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 					if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 						logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 						logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
-						markDirty = true
 					}
 					}
 				}
 				}
 			}
 			}
@@ -1031,7 +1020,13 @@ func (s *ClientService) bulkDelInboundClients(
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-		return s.SyncInbound(tx, inboundId, finalClients)
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	})
 	})
 	if txErr != nil {
 	if txErr != nil {
 		for email := range foundEmails {
 		for email := range foundEmails {
@@ -1039,10 +1034,6 @@ func (s *ClientService) bulkDelInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 			}
 		}
 		}
-	} else if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
 	}
 	}
 
 
 	return res
 	return res
@@ -1512,16 +1503,14 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 	}
 	}
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
-	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+	rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 	if perr != nil {
 	if perr != nil {
 		for _, ch := range changed {
 		for _, ch := range changed {
 			res.perEmailSkipped[ch.email] = perr.Error()
 			res.perEmailSkipped[ch.email] = perr.Error()
 		}
 		}
 		return res
 		return res
 	}
 	}
-	markDirty := dirty
 	if oldInbound.NodeID != nil && push && len(changed) > nodeBulkPushThreshold {
 	if oldInbound.NodeID != nil && push && len(changed) > nodeBulkPushThreshold {
-		markDirty = true
 		push = false
 		push = false
 	}
 	}
 
 
@@ -1533,7 +1522,13 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, inboundId, finalClients)
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	})
 	})
 	if txErr != nil {
 	if txErr != nil {
 		for _, ch := range changed {
 		for _, ch := range changed {
@@ -1576,16 +1571,9 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 			updated.UpdatedAt = nowMs
 			updated.UpdatedAt = nowMs
 			if err1 := rt.UpdateUser(context.Background(), oldInbound, ch.email, updated); err1 != nil {
 			if err1 := rt.UpdateUser(context.Background(), oldInbound, ch.email, updated); err1 != nil {
 				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
 				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-				markDirty = true
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
-
 	return res
 	return res
 }
 }

+ 32 - 51
internal/web/service/client_inbound_apply.go

@@ -107,7 +107,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 	}
 	}
 
 
 	needRestart := false
 	needRestart := false
-	markDirty := false
 
 
 	// Read each client's live state before the DB write (DelClientStat would
 	// Read each client's live state before the DB write (DelClientStat would
 	// erase the enable flag we need to decide on a runtime removal).
 	// erase the enable flag we need to decide on a runtime removal).
@@ -158,7 +157,13 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, inboundId, finalClients)
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	}); txErr != nil {
 	}); txErr != nil {
 		return needRestart, txErr
 		return needRestart, txErr
 	}
 	}
@@ -167,17 +172,13 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 	var nodeRt runtime.Runtime
 	var nodeRt runtime.Runtime
 	nodePush := false
 	nodePush := false
 	if oldInbound.NodeID != nil {
 	if oldInbound.NodeID != nil {
-		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			return needRestart, perr
 			return needRestart, perr
 		}
 		}
-		if dirty {
-			markDirty = true
-		}
 		nodeRt, nodePush = rt, push
 		nodeRt, nodePush = rt, push
 		// Large batches collapse into one reconcile push rather than M deletes.
 		// Large batches collapse into one reconcile push rather than M deletes.
 		if nodePush && len(targets) > nodeBulkPushThreshold {
 		if nodePush && len(targets) > nodeBulkPushThreshold {
-			markDirty = true
 			nodePush = false
 			nodePush = false
 		}
 		}
 	}
 	}
@@ -202,16 +203,10 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 		} else if nodePush {
 		} else if nodePush {
 			if err1 := nodeRt.DeleteUser(context.Background(), oldInbound, t.email); err1 != nil {
 			if err1 := nodeRt.DeleteUser(context.Background(), oldInbound, t.email); err1 != nil {
 				logger.Warning("Error in deleting client on", nodeRt.Name(), ":", err1)
 				logger.Warning("Error in deleting client on", nodeRt.Name(), ":", err1)
-				markDirty = true
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
 	return needRestart, nil
 	return needRestart, nil
 }
 }
 
 
@@ -357,15 +352,11 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
 	needRestart := false
 	needRestart := false
-	markDirty := false
 
 
-	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+	rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 	if perr != nil {
 	if perr != nil {
 		return false, perr
 		return false, perr
 	}
 	}
-	if dirty {
-		markDirty = true
-	}
 
 
 	// Persist client stats + inbound atomically, serialized against the traffic
 	// Persist client stats + inbound atomically, serialized against the traffic
 	// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
 	// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
@@ -385,7 +376,13 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, oldInbound.Id, finalClients)
+		if err := s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	}); txErr != nil {
 	}); txErr != nil {
 		return false, txErr
 		return false, txErr
 	}
 	}
@@ -434,25 +431,18 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 		// settings already hold the final set, so mark dirty and let one reconcile
 		// settings already hold the final set, so mark dirty and let one reconcile
 		// push converge the node instead.
 		// push converge the node instead.
 		if push && len(clients) > nodeBulkPushThreshold {
 		if push && len(clients) > nodeBulkPushThreshold {
-			markDirty = true
 			push = false
 			push = false
 		}
 		}
 		for _, client := range clients {
 		for _, client := range clients {
 			if push {
 			if push {
 				if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
 				if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
 					logger.Warning("Error in adding client on", rt.Name(), ":", err1)
 					logger.Warning("Error in adding client on", rt.Name(), ":", err1)
-					markDirty = true
 					push = false
 					push = false
 				}
 				}
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
 	return needRestart, nil
 	return needRestart, nil
 }
 }
 
 
@@ -623,7 +613,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
 	needRestart := false
 	needRestart := false
-	markDirty := false
 
 
 	// Resolve the push plan before the DB write so a node-state lookup failure
 	// Resolve the push plan before the DB write so a node-state lookup failure
 	// still aborts the whole update without committing anything (it used to roll
 	// still aborts the whole update without committing anything (it used to roll
@@ -631,15 +620,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	var rt runtime.Runtime
 	var rt runtime.Runtime
 	var push bool
 	var push bool
 	if len(oldEmail) > 0 {
 	if len(oldEmail) > 0 {
-		var dirty bool
 		var perr error
 		var perr error
-		rt, push, dirty, perr = inboundSvc.nodePushPlan(oldInbound)
+		rt, push, _, perr = inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			return false, perr
 			return false, perr
 		}
 		}
-		if dirty {
-			markDirty = true
-		}
 	}
 	}
 
 
 	// Persist client stats + inbound atomically, serialized against the traffic
 	// Persist client stats + inbound atomically, serialized against the traffic
@@ -705,7 +690,13 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, oldInbound.Id, finalClients)
+		if err := s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	}); txErr != nil {
 	}); txErr != nil {
 		return false, txErr
 		return false, txErr
 	}
 	}
@@ -757,7 +748,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		} else if push {
 		} else if push {
 			if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
 			if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
 				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
 				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-				markDirty = true
 			}
 			}
 		}
 		}
 	} else {
 	} else {
@@ -765,11 +755,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		needRestart = true
 		needRestart = true
 	}
 	}
 
 
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
 	return needRestart, nil
 	return needRestart, nil
 }
 }
 
 
@@ -831,7 +816,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	}
 	}
 
 
 	needRestart := false
 	needRestart := false
-	markDirty := false
 
 
 	// Decide what to delete and the push plan before the serialized DB write —
 	// Decide what to delete and the push plan before the serialized DB write —
 	// these are reads, and nodePushPlan failing should abort before committing.
 	// these are reads, and nodePushPlan failing should abort before committing.
@@ -850,14 +834,11 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	var rt runtime.Runtime
 	var rt runtime.Runtime
 	var push bool
 	var push bool
 	if len(email) > 0 && (oldInbound.NodeID != nil || needApiDel) {
 	if len(email) > 0 && (oldInbound.NodeID != nil || needApiDel) {
-		r, p, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		r, p, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			return false, perr
 			return false, perr
 		}
 		}
 		rt, push = r, p
 		rt, push = r, p
-		if dirty {
-			markDirty = true
-		}
 	}
 	}
 
 
 	// Persist the deletion atomically, serialized against the traffic poll to
 	// Persist the deletion atomically, serialized against the traffic poll to
@@ -882,7 +863,13 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr
 		}
 		}
-		return s.SyncInbound(tx, inboundId, finalClients)
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
 	}); txErr != nil {
 	}); txErr != nil {
 		return false, txErr
 		return false, txErr
 	}
 	}
@@ -915,17 +902,11 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 			if push {
 			if push {
 				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
-					markDirty = true
 				}
 				}
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
 	return needRestart, nil
 	return needRestart, nil
 }
 }
 
 

+ 15 - 1
internal/web/service/client_wireguard.go

@@ -33,6 +33,19 @@ func wireguardHostAddr(s string) netip.Addr {
 	return netip.Addr{}
 	return netip.Addr{}
 }
 }
 
 
+func wireguardAllocationBase(used []string, fallback string) string {
+	for _, u := range used {
+		a := wireguardHostAddr(u)
+		if !a.IsValid() || !a.Is4() || a.IsUnspecified() {
+			continue
+		}
+		if p, err := a.Prefix(24); err == nil {
+			return p.String()
+		}
+	}
+	return fallback
+}
+
 // allocateWireguardAddress returns the first free /32 host address in base that
 // allocateWireguardAddress returns the first free /32 host address in base that
 // is not already present in used. The server holds the first host (.1), so
 // is not already present in used. The server holds the first host (.1), so
 // allocation starts at the second host (.2).
 // allocation starts at the second host (.2).
@@ -71,6 +84,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
 	for i := range existing {
 	for i := range existing {
 		used = append(used, existing[i].AllowedIPs...)
 		used = append(used, existing[i].AllowedIPs...)
 	}
 	}
+	base := wireguardAllocationBase(used, defaultWireguardBase)
 	for i := range clients {
 	for i := range clients {
 		c := &clients[i]
 		c := &clients[i]
 		if c.PrivateKey == "" && c.PublicKey == "" {
 		if c.PrivateKey == "" && c.PublicKey == "" {
@@ -88,7 +102,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
 			c.PublicKey = pub
 			c.PublicKey = pub
 		}
 		}
 		if len(c.AllowedIPs) == 0 {
 		if len(c.AllowedIPs) == 0 {
-			addr, err := allocateWireguardAddress(used, defaultWireguardBase)
+			addr, err := allocateWireguardAddress(used, base)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}

+ 32 - 0
internal/web/service/client_wireguard_test.go

@@ -98,6 +98,38 @@ func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestWireguardAllocationBase(t *testing.T) {
+	tests := []struct {
+		name     string
+		used     []string
+		fallback string
+		want     string
+	}{
+		{name: "no peers uses fallback", used: nil, fallback: "10.0.0.0/24", want: "10.0.0.0/24"},
+		{name: "derives subnet from existing peer", used: []string{"172.16.0.2/32"}, fallback: "10.0.0.0/24", want: "172.16.0.0/24"},
+		{name: "skips catch-all and ipv6", used: []string{"0.0.0.0/0", "::/0", "fd00::2/128", "192.168.5.7/32"}, fallback: "10.0.0.0/24", want: "192.168.5.0/24"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := wireguardAllocationBase(tt.used, tt.fallback); got != tt.want {
+				t.Fatalf("got %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
+	existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
+	clients := []model.Client{{Email: "new@wg"}}
+	ifaces := []any{map[string]any{"email": "new@wg"}}
+	if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
+		t.Fatalf("new client address = %q, want 172.16.0.3/32 in existing subnet", got)
+	}
+}
+
 func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
 func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
 	clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
 	clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
 	ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
 	ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}

+ 35 - 34
internal/web/service/inbound.go

@@ -659,12 +659,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			tx.Rollback()
 			tx.Rollback()
 			return
 			return
 		}
 		}
-		tx.Commit()
 		if markDirty && inbound.NodeID != nil {
 		if markDirty && inbound.NodeID != nil {
-			if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
-				logger.Warning("mark node dirty failed:", dErr)
+			if dErr := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); dErr != nil {
+				err = dErr
+				tx.Rollback()
+				return
 			}
 			}
 		}
 		}
+		tx.Commit()
 	}()
 	}()
 
 
 	// Omit the ClientStats has-many association: GORM's cascade would INSERT
 	// Omit the ClientStats has-many association: GORM's cascade would INSERT
@@ -809,17 +811,20 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		}
 		}
 	}
 	}
 
 
-	if err := db.Delete(model.Inbound{}, id).Error; err != nil {
-		return needRestart, err
-	}
-	// Hosts have no hard FK; drop the inbound's hosts alongside it.
-	if err := db.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
-		return needRestart, err
-	}
-	if markDirty && ib.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Delete(model.Inbound{}, id).Error; err != nil {
+			return err
+		}
+		// Hosts have no hard FK; drop the inbound's hosts alongside it.
+		if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
+			return err
+		}
+		if markDirty && ib.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
 		}
 		}
+		return nil
+	}); err != nil {
+		return needRestart, err
 	}
 	}
 	if !database.IsPostgres() {
 	if !database.IsPostgres() {
 		var count int64
 		var count int64
@@ -902,14 +907,22 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 	}
 	}
 
 
 	db := database.GetDB()
 	db := database.GetDB()
-	if err := db.Model(model.Inbound{}).Where("id = ?", id).
-		Update("enable", enable).Error; err != nil {
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(model.Inbound{}).Where("id = ?", id).
+			Update("enable", enable).Error; err != nil {
+			return err
+		}
+		if inbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
+		}
+		return nil
+	}); err != nil {
 		return false, err
 		return false, err
 	}
 	}
 	inbound.Enable = enable
 	inbound.Enable = enable
 
 
 	needRestart := false
 	needRestart := false
-	rt, push, dirty, perr := s.nodePushPlan(inbound)
+	rt, push, _, perr := s.nodePushPlan(inbound)
 	if perr != nil {
 	if perr != nil {
 		return false, perr
 		return false, perr
 	}
 	}
@@ -923,12 +936,6 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 		if push {
 		if push {
 			if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
 			if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
 				logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
 				logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
-				dirty = true
-			}
-		}
-		if dirty {
-			if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
-				logger.Warning("mark node dirty failed:", dErr)
 			}
 			}
 		}
 		}
 		return false, nil
 		return false, nil
@@ -991,7 +998,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
 	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
 
 
 	needRestart := false
 	needRestart := false
-	markDirty := false
 
 
 	// Persist the client-stat sync, settings munging, runtime push and inbound
 	// Persist the client-stat sync, settings munging, runtime push and inbound
 	// save as one transaction routed through the serial traffic writer, so it
 	// save as one transaction routed through the serial traffic writer, so it
@@ -1117,13 +1123,10 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Tag = resolvedTag
 		oldInbound.Tag = resolvedTag
 		inbound.Tag = oldInbound.Tag
 		inbound.Tag = oldInbound.Tag
 
 
-		rt, push, dirty, perr := s.nodePushPlan(oldInbound)
+		rt, push, _, perr := s.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
 			return perr
 			return perr
 		}
 		}
-		if dirty {
-			markDirty = true
-		}
 		if oldInbound.NodeID == nil {
 		if oldInbound.NodeID == nil {
 			if !push {
 			if !push {
 				needRestart = true
 				needRestart = true
@@ -1152,11 +1155,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			if !inbound.Enable {
 			if !inbound.Enable {
 				if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
 				if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
 					logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
 					logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
-					markDirty = true
 				}
 				}
 			} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
 			} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
 				logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
 				logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
-				markDirty = true
 			}
 			}
 		}
 		}
 
 
@@ -1179,6 +1180,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		if err := s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
 		if err := s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
 			return err
 			return err
 		}
 		}
+		if oldInbound.NodeID != nil {
+			if err := (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID); err != nil {
+				return err
+			}
+		}
 		// (Re)generate the Xray config whenever routing was or is now enabled, so
 		// (Re)generate the Xray config whenever routing was or is now enabled, so
 		// the egress SOCKS bridge is added, moved, or dropped to match the new
 		// the egress SOCKS bridge is added, moved, or dropped to match the new
 		// settings.
 		// settings.
@@ -1201,11 +1207,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			needRestart = true
 			needRestart = true
 		}
 		}
 	}
 	}
-	if markDirty && oldInbound.NodeID != nil {
-		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-			logger.Warning("mark node dirty failed:", dErr)
-		}
-	}
 	return inbound, needRestart, nil
 	return inbound, needRestart, nil
 }
 }
 
 

+ 25 - 23
internal/web/service/inbound_traffic.go

@@ -544,18 +544,12 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 		}
 		}
 		for _, client := range clients {
 		for _, client := range clients {
 			if client.Email == clientEmail && client.Enable {
 			if client.Email == clientEmail && client.Enable {
-				rt, push, dirty, perr := s.nodePushPlan(inbound)
+				rt, push, _, perr := s.nodePushPlan(inbound)
 				if perr != nil {
 				if perr != nil {
 					return false, perr
 					return false, perr
 				}
 				}
 				if !push {
 				if !push {
-					if inbound.NodeID != nil {
-						if dirty {
-							if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
-								logger.Warning("mark node dirty failed:", dErr)
-							}
-						}
-					} else {
+					if inbound.NodeID == nil {
 						needRestart = true
 						needRestart = true
 					}
 					}
 					break
 					break
@@ -582,9 +576,6 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 					logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
 					logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
 				} else if inbound.NodeID != nil {
 				} else if inbound.NodeID != nil {
 					logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
 					logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
-					if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
-						logger.Warning("mark node dirty failed:", dErr)
-					}
 				} else {
 				} else {
 					logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
 					logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
 					needRestart = true
 					needRestart = true
@@ -599,24 +590,35 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	traffic.Enable = true
 	traffic.Enable = true
 
 
 	db := database.GetDB()
 	db := database.GetDB()
-	err = db.Save(traffic).Error
+	now := time.Now().UnixMilli()
+	inbound, err := s.GetInbound(id)
 	if err != nil {
 	if err != nil {
 		return false, err
 		return false, err
 	}
 	}
-	if err := clearGlobalTraffic(db, clientEmail); err != nil {
-		return false, err
-	}
-	if err := db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Save(traffic).Error; err != nil {
+			return err
+		}
+		if err := clearGlobalTraffic(tx, clientEmail); err != nil {
+			return err
+		}
+		if err := tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+			return err
+		}
+		if err := tx.Model(model.Inbound{}).
+			Where("id = ?", id).
+			Update("last_traffic_reset_time", now).Error; err != nil {
+			return err
+		}
+		if inbound != nil && inbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
+		}
+		return nil
+	}); err != nil {
 		return false, err
 		return false, err
 	}
 	}
 
 
-	now := time.Now().UnixMilli()
-	_ = db.Model(model.Inbound{}).
-		Where("id = ?", id).
-		Update("last_traffic_reset_time", now).Error
-
-	inbound, err := s.GetInbound(id)
-	if err == nil && inbound != nil && inbound.NodeID != nil {
+	if inbound != nil && inbound.NodeID != nil {
 		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
 		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
 			if e := rt.ResetClientTraffic(context.Background(), inbound, clientEmail); e != nil {
 			if e := rt.ResetClientTraffic(context.Background(), inbound, clientEmail); e != nil {
 				logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
 				logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)

+ 14 - 5
internal/web/service/node.go

@@ -453,12 +453,14 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"inbound_tags":          string(inboundTagsJSON),
 		"inbound_tags":          string(inboundTagsJSON),
 		"outbound_tag":          in.OutboundTag,
 		"outbound_tag":          in.OutboundTag,
 	}
 	}
-	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+			return err
+		}
+		return s.MarkNodeDirtyTx(tx, id)
+	}); err != nil {
 		return err
 		return err
 	}
 	}
-	if dErr := s.MarkNodeDirty(id); dErr != nil {
-		logger.Warning("mark node dirty after update failed:", dErr)
-	}
 	if mgr := runtime.GetManager(); mgr != nil {
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 		mgr.InvalidateNode(id)
 	}
 	}
@@ -736,10 +738,17 @@ func (s *NodeService) warnOnDuplicateGuid(id int, guid string) {
 }
 }
 
 
 func (s *NodeService) MarkNodeDirty(id int) error {
 func (s *NodeService) MarkNodeDirty(id int) error {
+	return s.MarkNodeDirtyTx(database.GetDB(), id)
+}
+
+func (s *NodeService) MarkNodeDirtyTx(tx *gorm.DB, id int) error {
 	if id <= 0 {
 	if id <= 0 {
 		return nil
 		return nil
 	}
 	}
-	return database.GetDB().Model(model.Node{}).
+	if tx == nil {
+		return errors.New("nil db transaction")
+	}
+	return tx.Model(model.Node{}).
 		Where("id = ?", id).
 		Where("id = ?", id).
 		Updates(map[string]any{
 		Updates(map[string]any{
 			"config_dirty":    true,
 			"config_dirty":    true,

+ 40 - 0
internal/web/service/node_dirty_test.go

@@ -1,8 +1,11 @@
 package service
 package service
 
 
 import (
 import (
+	"errors"
 	"testing"
 	"testing"
 
 
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
@@ -145,6 +148,43 @@ func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMarkNodeDirtyTxRollsBackWithTransaction(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	node := &model.Node{Name: "n3", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	nodeSvc := NodeService{}
+	rollbackErr := errors.New("force rollback")
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := nodeSvc.MarkNodeDirtyTx(tx, node.Id); err != nil {
+			return err
+		}
+		return rollbackErr
+	}); !errors.Is(err, rollbackErr) {
+		t.Fatalf("rollback tx: got %v want %v", err, rollbackErr)
+	}
+	if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
+		t.Fatalf("NodeSyncState after rollback: %v", err)
+	} else if dirty {
+		t.Fatal("dirty flag escaped a rolled-back transaction")
+	}
+
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		return nodeSvc.MarkNodeDirtyTx(tx, node.Id)
+	}); err != nil {
+		t.Fatalf("commit tx: %v", err)
+	}
+	if _, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id); err != nil {
+		t.Fatalf("NodeSyncState after commit: %v", err)
+	} else if !dirty {
+		t.Fatal("dirty flag should commit with its transaction")
+	}
+}
+
 // Editing a node must mark it config-dirty so the next traffic-sync tick
 // Editing a node must mark it config-dirty so the next traffic-sync tick
 // reconciles (pushes the panel's inbounds to the remote) before pulling a
 // reconciles (pushes the panel's inbounds to the remote) before pulling a
 // snapshot. Without the dirty flag, re-pointing a node to a fresh server
 // snapshot. Without the dirty flag, re-pointing a node to a fresh server

+ 10 - 8
internal/web/service/panel/user.go

@@ -60,6 +60,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 		host, _ := s.settingService.GetLdapHost()
 		host, _ := s.settingService.GetLdapHost()
 		port, _ := s.settingService.GetLdapPort()
 		port, _ := s.settingService.GetLdapPort()
 		useTLS, _ := s.settingService.GetLdapUseTLS()
 		useTLS, _ := s.settingService.GetLdapUseTLS()
+		skipVerify, _ := s.settingService.GetLdapInsecureSkipVerify()
 		bindDN, _ := s.settingService.GetLdapBindDN()
 		bindDN, _ := s.settingService.GetLdapBindDN()
 		ldapPass, _ := s.settingService.GetLdapPassword()
 		ldapPass, _ := s.settingService.GetLdapPassword()
 		baseDN, _ := s.settingService.GetLdapBaseDN()
 		baseDN, _ := s.settingService.GetLdapBaseDN()
@@ -67,14 +68,15 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 		userAttr, _ := s.settingService.GetLdapUserAttr()
 		userAttr, _ := s.settingService.GetLdapUserAttr()
 
 
 		cfg := ldaputil.Config{
 		cfg := ldaputil.Config{
-			Host:       host,
-			Port:       port,
-			UseTLS:     useTLS,
-			BindDN:     bindDN,
-			Password:   ldapPass,
-			BaseDN:     baseDN,
-			UserFilter: userFilter,
-			UserAttr:   userAttr,
+			Host:               host,
+			Port:               port,
+			UseTLS:             useTLS,
+			InsecureSkipVerify: skipVerify,
+			BindDN:             bindDN,
+			Password:           ldapPass,
+			BaseDN:             baseDN,
+			UserFilter:         userFilter,
+			UserAttr:           userAttr,
 		}
 		}
 		ok, err := ldaputil.AuthenticateUser(cfg, username, password)
 		ok, err := ldaputil.AuthenticateUser(cfg, username, password)
 		if err != nil || !ok {
 		if err != nil || !ok {

+ 1 - 1
internal/web/service/server.go

@@ -825,7 +825,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 			continue
 			continue
 		}
 		}
 
 
-		if major > 26 || (major == 26 && minor > 4) || (major == 26 && minor == 4 && patch >= 25) {
+		if major > 26 || (major == 26 && minor > 6) || (major == 26 && minor == 6 && patch >= 27) {
 			versions = append(versions, release.TagName)
 			versions = append(versions, release.TagName)
 		}
 		}
 	}
 	}

+ 45 - 22
internal/web/service/setting.go

@@ -14,6 +14,8 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
+	"github.com/xlzd/gotp"
+	"gorm.io/gorm"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -25,8 +27,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
-
-	"gorm.io/gorm"
 )
 )
 
 
 //go:embed config.json
 //go:embed config.json
@@ -114,26 +114,27 @@ var defaultValueMap = map[string]string{
 	"devChannelEnable":            "false",
 	"devChannelEnable":            "false",
 
 
 	// LDAP defaults
 	// LDAP defaults
-	"ldapEnable":            "false",
-	"ldapHost":              "",
-	"ldapPort":              "389",
-	"ldapUseTLS":            "false",
-	"ldapBindDN":            "",
-	"ldapPassword":          "",
-	"ldapBaseDN":            "",
-	"ldapUserFilter":        "(objectClass=person)",
-	"ldapUserAttr":          "mail",
-	"ldapVlessField":        "vless_enabled",
-	"ldapSyncCron":          "@every 1m",
-	"ldapFlagField":         "",
-	"ldapTruthyValues":      "true,1,yes,on",
-	"ldapInvertFlag":        "false",
-	"ldapInboundTags":       "",
-	"ldapAutoCreate":        "false",
-	"ldapAutoDelete":        "false",
-	"ldapDefaultTotalGB":    "0",
-	"ldapDefaultExpiryDays": "0",
-	"ldapDefaultLimitIP":    "0",
+	"ldapEnable":             "false",
+	"ldapHost":               "",
+	"ldapPort":               "389",
+	"ldapUseTLS":             "false",
+	"ldapInsecureSkipVerify": "false",
+	"ldapBindDN":             "",
+	"ldapPassword":           "",
+	"ldapBaseDN":             "",
+	"ldapUserFilter":         "(objectClass=person)",
+	"ldapUserAttr":           "mail",
+	"ldapVlessField":         "vless_enabled",
+	"ldapSyncCron":           "@every 1m",
+	"ldapFlagField":          "",
+	"ldapTruthyValues":       "true,1,yes,on",
+	"ldapInvertFlag":         "false",
+	"ldapInboundTags":        "",
+	"ldapAutoCreate":         "false",
+	"ldapAutoDelete":         "false",
+	"ldapDefaultTotalGB":     "0",
+	"ldapDefaultExpiryDays":  "0",
+	"ldapDefaultLimitIP":     "0",
 
 
 	// Event bus — per-subscriber event filtering (empty = all disabled)
 	// Event bus — per-subscriber event filtering (empty = all disabled)
 	"tgEnabledEvents":   "login.attempt,cpu.high",
 	"tgEnabledEvents":   "login.attempt,cpu.high",
@@ -568,6 +569,24 @@ func (s *SettingService) SetTwoFactorToken(value string) error {
 	return s.setString("twoFactorToken", value)
 	return s.setString("twoFactorToken", value)
 }
 }
 
 
+func (s *SettingService) VerifyTwoFactorCode(code string) error {
+	enabled, err := s.GetTwoFactorEnable()
+	if err != nil {
+		return err
+	}
+	if !enabled {
+		return nil
+	}
+	token, err := s.GetTwoFactorToken()
+	if err != nil {
+		return err
+	}
+	if strings.TrimSpace(token) == "" || !gotp.NewDefaultTOTP(token).Verify(strings.TrimSpace(code), time.Now().Unix()) {
+		return common.NewError("invalid two factor code")
+	}
+	return nil
+}
+
 func (s *SettingService) GetPort() (int, error) {
 func (s *SettingService) GetPort() (int, error) {
 	return s.getInt("webPort")
 	return s.getInt("webPort")
 }
 }
@@ -904,6 +923,10 @@ func (s *SettingService) GetLdapUseTLS() (bool, error) {
 	return s.getBool("ldapUseTLS")
 	return s.getBool("ldapUseTLS")
 }
 }
 
 
+func (s *SettingService) GetLdapInsecureSkipVerify() (bool, error) {
+	return s.getBool("ldapInsecureSkipVerify")
+}
+
 func (s *SettingService) GetLdapBindDN() (string, error) {
 func (s *SettingService) GetLdapBindDN() (string, error) {
 	return s.getString("ldapBindDN")
 	return s.getString("ldapBindDN")
 }
 }

+ 21 - 0
internal/web/service/setting_security_test.go

@@ -4,6 +4,8 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
 
 
+	"github.com/xlzd/gotp"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
@@ -100,3 +102,22 @@ func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
 		t.Fatalf("allowPrivate result = %q, %v", got, err)
 		t.Fatalf("allowPrivate result = %q, %v", got, err)
 	}
 	}
 }
 }
+
+func TestVerifyTwoFactorCode(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
+		t.Fatal(err)
+	}
+	const token = "JBSWY3DPEHPK3PXP"
+	if err := s.saveSetting("twoFactorToken", token); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
+		t.Fatalf("valid code rejected: %v", err)
+	}
+	if err := s.VerifyTwoFactorCode("000000"); err == nil {
+		t.Fatal("invalid code accepted")
+	}
+}

+ 32 - 0
internal/web/translation/ar-EG.json

@@ -1220,6 +1220,8 @@
         "host": "مضيف LDAP",
         "host": "مضيف LDAP",
         "port": "منفذ LDAP",
         "port": "منفذ LDAP",
         "useTls": "استخدام TLS (LDAPS)",
         "useTls": "استخدام TLS (LDAPS)",
+        "skipTlsVerify": "تخطي التحقق من شهادة TLS",
+        "skipTlsVerifyDesc": "غير آمن — يعطل التحقق من شهادة الخادم. استخدم فقط مع CA الداخلية/غير الموثوقة.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "مهيأة؛ اترك فارغاً للاحتفاظ بكلمة المرور الحالية.",
         "passwordConfigured": "مهيأة؛ اترك فارغاً للاحتفاظ بكلمة المرور الحالية.",
         "passwordUnconfigured": "غير مهيأة.",
         "passwordUnconfigured": "غير مهيأة.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "تم الحذف",
         "toastDeleted": "تم الحذف",
         "toastDeleteFailed": "فشل الحذف"
         "toastDeleteFailed": "فشل الحذف"
       },
       },
+      "tabBalancerSettings": "إعدادات الموازن",
+      "tabObservatory": "المرصد",
+      "observatory": {
+        "title": "المرصد",
+        "burstTitle": "مرصد Burst",
+        "autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
+        "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى يتمكن الموازن من قياس الوجهات الصادرة واختيار الأفضل.",
+        "subjectSelector": "الوجهات المراقَبة",
+        "subjectSelectorDesc": "وسوم الوجهات الصادرة التي يفحصها هذا المرصد. تتم إدارتها تلقائيًا من الموازنات لديك.",
+        "probeURL": "رابط الفحص (URL)",
+        "probeURLDesc": "الرابط الذي يُطلب لقياس كل وجهة صادرة. يجب أن يُعيد HTTP 204.",
+        "probeInterval": "فترة الفحص",
+        "probeIntervalDesc": "كم مرة يتم فحص كل وجهة صادرة، مثل 30s أو 1m أو 2h45m.",
+        "enableConcurrency": "فحص متزامن",
+        "enableConcurrencyDesc": "افحص كل الوجهات المراقَبة دفعة واحدة بدلًا من واحدة تلو الأخرى. أسرع، لكنه أكثر وضوحًا على الشبكة.",
+        "destination": "وجهة الفحص",
+        "destinationDesc": "الرابط الذي يُطلب لقياس كل وجهة صادرة. يجب أن يُعيد HTTP 204.",
+        "connectivity": "فحص الاتصال",
+        "connectivityDesc": "رابط اختياري لفحص الشبكة المحلية، يُجرَّب فقط بعد فشل الوجهة. اتركه فارغًا للتخطي.",
+        "interval": "فترة الفحص",
+        "intervalDesc": "متوسط الوقت بين عمليات الفحص لكل وجهة صادرة، مثل 1m. الحد الأدنى 10s.",
+        "timeout": "مهلة الفحص",
+        "timeoutDesc": "كم من الوقت يُنتظر استجابة الفحص قبل اعتباره فاشلًا، مثل 5s.",
+        "sampling": "عدد العينات",
+        "samplingDesc": "عدد نتائج الفحص الأخيرة المحفوظة لتقييم كل وجهة صادرة.",
+        "httpMethod": "طريقة HTTP",
+        "httpMethodDesc": "طريقة HTTP المستخدمة في عمليات الفحص.",
+        "deleteAlsoObservatory": "هذا آخر موازن يستخدم Observatory، لذلك ستتم إزالته أيضًا.",
+        "deleteAlsoBurst": "هذا آخر موازن يستخدم Burst Observatory، لذلك ستتم إزالته أيضًا."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "أضف موازن تحميل",
         "addBalancer": "أضف موازن تحميل",
         "editBalancer": "عدل موازن التحميل",
         "editBalancer": "عدل موازن التحميل",

+ 32 - 0
internal/web/translation/en-US.json

@@ -1341,6 +1341,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "LDAP port",
         "port": "LDAP port",
         "useTls": "Use TLS (LDAPS)",
         "useTls": "Use TLS (LDAPS)",
+        "skipTlsVerify": "Skip TLS certificate verification",
+        "skipTlsVerifyDesc": "Insecure — disables server certificate validation. Use only with internal/untrusted CAs.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Configured; leave blank to keep current password.",
         "passwordConfigured": "Configured; leave blank to keep current password.",
         "passwordUnconfigured": "Not configured.",
         "passwordUnconfigured": "Not configured.",
@@ -1782,6 +1784,36 @@
         "toastDeleted": "Deleted",
         "toastDeleted": "Deleted",
         "toastDeleteFailed": "Delete failed"
         "toastDeleteFailed": "Delete failed"
       },
       },
+      "tabBalancerSettings": "Balancer Settings",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
+        "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so the balancer can measure your outbounds and pick the best one.",
+        "subjectSelector": "Watched Outbounds",
+        "subjectSelectorDesc": "Outbound tags this observer probes. Managed automatically from your balancers.",
+        "probeURL": "Probe URL",
+        "probeURLDesc": "URL fetched to measure each outbound. Should return HTTP 204.",
+        "probeInterval": "Probe Interval",
+        "probeIntervalDesc": "How often to probe each outbound, e.g. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Concurrent Probing",
+        "enableConcurrencyDesc": "Probe all watched outbounds at once instead of one-by-one. Faster, but more visible on the network.",
+        "destination": "Probe Destination",
+        "destinationDesc": "URL fetched to measure each outbound. Should return HTTP 204.",
+        "connectivity": "Connectivity Check",
+        "connectivityDesc": "Optional local-network check URL, tried only after the destination fails. Leave empty to skip.",
+        "interval": "Probe Interval",
+        "intervalDesc": "Average time between probes per outbound, e.g. 1m. Minimum 10s.",
+        "timeout": "Probe Timeout",
+        "timeoutDesc": "How long to wait for a probe before it counts as failed, e.g. 5s.",
+        "sampling": "Sampling Count",
+        "samplingDesc": "Number of recent probe results kept to score each outbound.",
+        "httpMethod": "HTTP Method",
+        "httpMethodDesc": "HTTP method used for probes.",
+        "deleteAlsoObservatory": "This is the last balancer using the Observatory, so it will be removed too.",
+        "deleteAlsoBurst": "This is the last balancer using the Burst Observatory, so it will be removed too."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Add Balancer",
         "addBalancer": "Add Balancer",
         "editBalancer": "Edit Balancer",
         "editBalancer": "Edit Balancer",

+ 32 - 0
internal/web/translation/es-ES.json

@@ -1220,6 +1220,8 @@
         "host": "Host LDAP",
         "host": "Host LDAP",
         "port": "Puerto LDAP",
         "port": "Puerto LDAP",
         "useTls": "Usar TLS (LDAPS)",
         "useTls": "Usar TLS (LDAPS)",
+        "skipTlsVerify": "Omitir verificación de certificado TLS",
+        "skipTlsVerifyDesc": "Inseguro — desactiva la validación del certificado del servidor. Usar solo con CA internos/no confiables.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Configurada; deja en blanco para mantener la contraseña actual.",
         "passwordConfigured": "Configurada; deja en blanco para mantener la contraseña actual.",
         "passwordUnconfigured": "No configurada.",
         "passwordUnconfigured": "No configurada.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Eliminada",
         "toastDeleted": "Eliminada",
         "toastDeleteFailed": "Error al eliminar"
         "toastDeleteFailed": "Error al eliminar"
       },
       },
+      "tabBalancerSettings": "Ajustes del balanceador",
+      "tabObservatory": "Observatorio",
+      "observatory": {
+        "title": "Observatorio",
+        "burstTitle": "Observatorio Burst",
+        "autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
+        "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que el balanceador pueda medir tus salidas y elegir la mejor.",
+        "subjectSelector": "Salidas vigiladas",
+        "subjectSelectorDesc": "Etiquetas de salida que sondea este observador. Se gestionan automáticamente a partir de tus balanceadores.",
+        "probeURL": "URL de sondeo",
+        "probeURLDesc": "URL solicitada para medir cada salida. Debe devolver HTTP 204.",
+        "probeInterval": "Intervalo de sondeo",
+        "probeIntervalDesc": "Con qué frecuencia se sondea cada salida, p. ej. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Sondeo concurrente",
+        "enableConcurrencyDesc": "Sondea todas las salidas vigiladas a la vez en lugar de una a una. Más rápido, pero más visible en la red.",
+        "destination": "Destino de sondeo",
+        "destinationDesc": "URL solicitada para medir cada salida. Debe devolver HTTP 204.",
+        "connectivity": "Comprobación de conectividad",
+        "connectivityDesc": "URL opcional de comprobación de red local, se prueba solo si falla el destino. Déjalo vacío para omitir.",
+        "interval": "Intervalo de sondeo",
+        "intervalDesc": "Tiempo medio entre sondeos por salida, p. ej. 1m. Mínimo 10s.",
+        "timeout": "Tiempo de espera del sondeo",
+        "timeoutDesc": "Cuánto esperar un sondeo antes de considerarlo fallido, p. ej. 5s.",
+        "sampling": "Número de muestras",
+        "samplingDesc": "Número de resultados de sondeo recientes que se conservan para puntuar cada salida.",
+        "httpMethod": "Método HTTP",
+        "httpMethodDesc": "Método HTTP usado para los sondeos.",
+        "deleteAlsoObservatory": "Este es el último balanceador que usa el Observatorio, por lo que también se eliminará.",
+        "deleteAlsoBurst": "Este es el último balanceador que usa el Observatorio Burst, por lo que también se eliminará."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Agregar equilibrador",
         "addBalancer": "Agregar equilibrador",
         "editBalancer": "Editar balanceador",
         "editBalancer": "Editar balanceador",

+ 32 - 0
internal/web/translation/fa-IR.json

@@ -1222,6 +1222,8 @@
         "host": "میزبان LDAP",
         "host": "میزبان LDAP",
         "port": "پورت LDAP",
         "port": "پورت LDAP",
         "useTls": "استفاده از TLS (LDAPS)",
         "useTls": "استفاده از TLS (LDAPS)",
+        "skipTlsVerify": "رد کردن تأیید گواهی TLS",
+        "skipTlsVerifyDesc": "ناامن — اعتبارسنجی گواهی سرور را غیرفعال می‌کند. فقط برای CAهای داخلی/غیرمعتبر استفاده کنید.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "تنظیم‌شده؛ برای حفظ رمز فعلی خالی بگذارید.",
         "passwordConfigured": "تنظیم‌شده؛ برای حفظ رمز فعلی خالی بگذارید.",
         "passwordUnconfigured": "تنظیم نشده.",
         "passwordUnconfigured": "تنظیم نشده.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "حذف شد",
         "toastDeleted": "حذف شد",
         "toastDeleteFailed": "حذف ناموفق بود"
         "toastDeleteFailed": "حذف ناموفق بود"
       },
       },
+      "tabBalancerSettings": "تنظیمات بالانسر",
+      "tabObservatory": "رصدخانه",
+      "observatory": {
+        "title": "رصدخانه",
+        "burstTitle": "رصدخانه Burst",
+        "autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.",
+        "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسر بتواند خروجی‌ها را اندازه‌گیری کند و بهترین را انتخاب کند.",
+        "subjectSelector": "خروجی‌های تحت نظر",
+        "subjectSelectorDesc": "تگ خروجی‌هایی که این رصدگر پروب می‌کند. به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شود.",
+        "probeURL": "آدرس پروب (URL)",
+        "probeURLDesc": "آدرسی که برای سنجش هر خروجی فراخوانی می‌شود. باید HTTP 204 برگرداند.",
+        "probeInterval": "بازهٔ پروب",
+        "probeIntervalDesc": "هر چند وقت یک‌بار هر خروجی پروب شود، مثلاً 30s یا 1m یا 2h45m.",
+        "enableConcurrency": "پروب هم‌زمان",
+        "enableConcurrencyDesc": "همهٔ خروجی‌های تحت نظر را به‌جای یکی‌یکی، هم‌زمان پروب کن. سریع‌تر است اما در شبکه نمایان‌تر.",
+        "destination": "مقصد پروب",
+        "destinationDesc": "آدرسی که برای سنجش هر خروجی فراخوانی می‌شود. باید HTTP 204 برگرداند.",
+        "connectivity": "بررسی اتصال",
+        "connectivityDesc": "آدرس اختیاری برای بررسی شبکهٔ محلی که فقط پس از شکست مقصد امتحان می‌شود. برای نادیده‌گرفتن خالی بگذارید.",
+        "interval": "بازهٔ پروب",
+        "intervalDesc": "میانگین فاصلهٔ زمانی بین پروب‌ها برای هر خروجی، مثلاً 1m. کمینه 10s.",
+        "timeout": "مهلت پروب",
+        "timeoutDesc": "چه مدت برای پاسخ یک پروب صبر شود تا ناموفق به‌حساب بیاید، مثلاً 5s.",
+        "sampling": "تعداد نمونه‌گیری",
+        "samplingDesc": "تعداد نتایج اخیر پروب که برای امتیازدهی به هر خروجی نگه داشته می‌شود.",
+        "httpMethod": "متد HTTP",
+        "httpMethodDesc": "متد HTTP که برای پروب‌ها استفاده می‌شود.",
+        "deleteAlsoObservatory": "این آخرین بالانسری است که از Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد.",
+        "deleteAlsoBurst": "این آخرین بالانسری است که از Burst Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "افزودن بالانسر",
         "addBalancer": "افزودن بالانسر",
         "editBalancer": "ویرایش بالانسر",
         "editBalancer": "ویرایش بالانسر",

+ 32 - 0
internal/web/translation/id-ID.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "Port LDAP",
         "port": "Port LDAP",
         "useTls": "Gunakan TLS (LDAPS)",
         "useTls": "Gunakan TLS (LDAPS)",
+        "skipTlsVerify": "Lewati verifikasi sertifikat TLS",
+        "skipTlsVerifyDesc": "Tidak aman — menonaktifkan validasi sertifikat server. Gunakan hanya dengan CA internal/tidak terpercaya.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Terkonfigurasi; biarkan kosong untuk mempertahankan kata sandi saat ini.",
         "passwordConfigured": "Terkonfigurasi; biarkan kosong untuk mempertahankan kata sandi saat ini.",
         "passwordUnconfigured": "Tidak terkonfigurasi.",
         "passwordUnconfigured": "Tidak terkonfigurasi.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Dihapus",
         "toastDeleted": "Dihapus",
         "toastDeleteFailed": "Gagal menghapus"
         "toastDeleteFailed": "Gagal menghapus"
       },
       },
+      "tabBalancerSettings": "Pengaturan Balancer",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
+        "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer dapat mengukur outbound Anda dan memilih yang terbaik.",
+        "subjectSelector": "Outbound yang Dipantau",
+        "subjectSelectorDesc": "Tag outbound yang di-probe observer ini. Dikelola otomatis dari balancer Anda.",
+        "probeURL": "URL Probe",
+        "probeURLDesc": "URL yang diminta untuk mengukur setiap outbound. Harus mengembalikan HTTP 204.",
+        "probeInterval": "Interval Probe",
+        "probeIntervalDesc": "Seberapa sering memprobe tiap outbound, mis. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Probe Bersamaan",
+        "enableConcurrencyDesc": "Probe semua outbound yang dipantau sekaligus, bukan satu per satu. Lebih cepat, tetapi lebih terlihat di jaringan.",
+        "destination": "Tujuan Probe",
+        "destinationDesc": "URL yang diminta untuk mengukur setiap outbound. Harus mengembalikan HTTP 204.",
+        "connectivity": "Pemeriksaan Konektivitas",
+        "connectivityDesc": "URL pemeriksaan jaringan lokal opsional, dicoba hanya setelah tujuan gagal. Kosongkan untuk melewati.",
+        "interval": "Interval Probe",
+        "intervalDesc": "Rata-rata waktu antar probe per outbound, mis. 1m. Minimal 10s.",
+        "timeout": "Batas Waktu Probe",
+        "timeoutDesc": "Berapa lama menunggu probe sebelum dianggap gagal, mis. 5s.",
+        "sampling": "Jumlah Sampling",
+        "samplingDesc": "Jumlah hasil probe terbaru yang disimpan untuk menilai tiap outbound.",
+        "httpMethod": "Metode HTTP",
+        "httpMethodDesc": "Metode HTTP yang digunakan untuk probe.",
+        "deleteAlsoObservatory": "Ini balancer terakhir yang memakai Observatory, jadi itu juga akan dihapus.",
+        "deleteAlsoBurst": "Ini balancer terakhir yang memakai Burst Observatory, jadi itu juga akan dihapus."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Tambahkan Penyeimbang",
         "addBalancer": "Tambahkan Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",

+ 32 - 0
internal/web/translation/ja-JP.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "LDAP ポート",
         "port": "LDAP ポート",
         "useTls": "TLS (LDAPS) を使用",
         "useTls": "TLS (LDAPS) を使用",
+        "skipTlsVerify": "TLS 証明書の検証をスキップ",
+        "skipTlsVerifyDesc": "安全ではありません — サーバー証明書の検証を無効化します。内部/信頼できない CA でのみ使用してください。",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "設定済み;現在のパスワードを保持するには空のままにします。",
         "passwordConfigured": "設定済み;現在のパスワードを保持するには空のままにします。",
         "passwordUnconfigured": "未設定。",
         "passwordUnconfigured": "未設定。",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "削除しました",
         "toastDeleted": "削除しました",
         "toastDeleteFailed": "削除に失敗しました"
         "toastDeleteFailed": "削除に失敗しました"
       },
       },
+      "tabBalancerSettings": "バランサー設定",
+      "tabObservatory": "オブザーバトリ",
+      "observatory": {
+        "title": "オブザーバトリ",
+        "burstTitle": "バースト オブザーバトリ",
+        "autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
+        "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、バランサーがアウトバウンドを測定して最適なものを選べるようになります。",
+        "subjectSelector": "監視対象のアウトバウンド",
+        "subjectSelectorDesc": "このオブザーバがプローブするアウトバウンドのタグ。バランサーから自動的に管理されます。",
+        "probeURL": "プローブ URL",
+        "probeURLDesc": "各アウトバウンドを測定するために取得する URL。HTTP 204 を返す必要があります。",
+        "probeInterval": "プローブ間隔",
+        "probeIntervalDesc": "各アウトバウンドをプローブする頻度。例: 30s、1m、2h45m。",
+        "enableConcurrency": "並行プローブ",
+        "enableConcurrencyDesc": "監視対象のアウトバウンドを1つずつではなく一度にプローブします。高速ですが、ネットワーク上で目立ちます。",
+        "destination": "プローブ先",
+        "destinationDesc": "各アウトバウンドを測定するために取得する URL。HTTP 204 を返す必要があります。",
+        "connectivity": "接続チェック",
+        "connectivityDesc": "任意のローカルネットワーク確認 URL。プローブ先が失敗した場合にのみ試行されます。空欄でスキップ。",
+        "interval": "プローブ間隔",
+        "intervalDesc": "アウトバウンドごとのプローブ間の平均時間。例: 1m。最小 10s。",
+        "timeout": "プローブ タイムアウト",
+        "timeoutDesc": "プローブを失敗とみなすまでの待機時間。例: 5s。",
+        "sampling": "サンプリング数",
+        "samplingDesc": "各アウトバウンドを評価するために保持する直近のプローブ結果の数。",
+        "httpMethod": "HTTP メソッド",
+        "httpMethodDesc": "プローブに使用する HTTP メソッド。",
+        "deleteAlsoObservatory": "これは Observatory を使用する最後のバランサーのため、こちらも削除されます。",
+        "deleteAlsoBurst": "これは Burst Observatory を使用する最後のバランサーのため、こちらも削除されます。"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "負荷分散追加",
         "addBalancer": "負荷分散追加",
         "editBalancer": "負荷分散編集",
         "editBalancer": "負荷分散編集",

+ 32 - 0
internal/web/translation/pt-BR.json

@@ -1220,6 +1220,8 @@
         "host": "Host LDAP",
         "host": "Host LDAP",
         "port": "Porta LDAP",
         "port": "Porta LDAP",
         "useTls": "Usar TLS (LDAPS)",
         "useTls": "Usar TLS (LDAPS)",
+        "skipTlsVerify": "Pular verificação de certificado TLS",
+        "skipTlsVerifyDesc": "Inseguro — desativa a validação do certificado do servidor. Use apenas com CAs internos/não confiáveis.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Configurada; deixe em branco para manter a senha atual.",
         "passwordConfigured": "Configurada; deixe em branco para manter a senha atual.",
         "passwordUnconfigured": "Não configurada.",
         "passwordUnconfigured": "Não configurada.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Excluído",
         "toastDeleted": "Excluído",
         "toastDeleteFailed": "Falha ao excluir"
         "toastDeleteFailed": "Falha ao excluir"
       },
       },
+      "tabBalancerSettings": "Configurações do balanceador",
+      "tabObservatory": "Observatório",
+      "observatory": {
+        "title": "Observatório",
+        "burstTitle": "Observatório Burst",
+        "autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
+        "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que o balanceador possa medir suas saídas e escolher a melhor.",
+        "subjectSelector": "Saídas monitoradas",
+        "subjectSelectorDesc": "Tags de saída que este observador sonda. Gerenciadas automaticamente a partir dos seus balanceadores.",
+        "probeURL": "URL de sondagem",
+        "probeURLDesc": "URL requisitada para medir cada saída. Deve retornar HTTP 204.",
+        "probeInterval": "Intervalo de sondagem",
+        "probeIntervalDesc": "Com que frequência sondar cada saída, ex.: 30s, 1m, 2h45m.",
+        "enableConcurrency": "Sondagem concorrente",
+        "enableConcurrencyDesc": "Sonda todas as saídas monitoradas de uma vez, em vez de uma a uma. Mais rápido, mas mais visível na rede.",
+        "destination": "Destino da sondagem",
+        "destinationDesc": "URL requisitada para medir cada saída. Deve retornar HTTP 204.",
+        "connectivity": "Verificação de conectividade",
+        "connectivityDesc": "URL opcional de verificação da rede local, testada apenas após o destino falhar. Deixe vazio para ignorar.",
+        "interval": "Intervalo de sondagem",
+        "intervalDesc": "Tempo médio entre sondagens por saída, ex.: 1m. Mínimo 10s.",
+        "timeout": "Tempo limite da sondagem",
+        "timeoutDesc": "Quanto esperar por uma sondagem antes de considerá-la falha, ex.: 5s.",
+        "sampling": "Número de amostras",
+        "samplingDesc": "Número de resultados de sondagem recentes mantidos para pontuar cada saída.",
+        "httpMethod": "Método HTTP",
+        "httpMethodDesc": "Método HTTP usado nas sondagens.",
+        "deleteAlsoObservatory": "Este é o último balanceador que usa o Observatório, então ele também será removido.",
+        "deleteAlsoBurst": "Este é o último balanceador que usa o Observatório Burst, então ele também será removido."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Adicionar Balanceador",
         "addBalancer": "Adicionar Balanceador",
         "editBalancer": "Editar Balanceador",
         "editBalancer": "Editar Balanceador",

+ 32 - 0
internal/web/translation/ru-RU.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP-хост",
         "host": "LDAP-хост",
         "port": "Порт LDAP",
         "port": "Порт LDAP",
         "useTls": "Использовать TLS (LDAPS)",
         "useTls": "Использовать TLS (LDAPS)",
+        "skipTlsVerify": "Пропустить проверку сертификата TLS",
+        "skipTlsVerifyDesc": "Небезопасно — отключает проверку сертификата сервера. Используйте только с внутренними/недоверенными CA.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Настроено; оставьте пустым, чтобы сохранить текущий пароль.",
         "passwordConfigured": "Настроено; оставьте пустым, чтобы сохранить текущий пароль.",
         "passwordUnconfigured": "Не настроено.",
         "passwordUnconfigured": "Не настроено.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Удалено",
         "toastDeleted": "Удалено",
         "toastDeleteFailed": "Не удалось удалить"
         "toastDeleteFailed": "Не удалось удалить"
       },
       },
+      "tabBalancerSettings": "Настройки балансировщика",
+      "tabObservatory": "Обсерватория",
+      "observatory": {
+        "title": "Обсерватория",
+        "burstTitle": "Burst-обсерватория",
+        "autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.",
+        "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщик мог измерять исходящие и выбирать лучший.",
+        "subjectSelector": "Отслеживаемые исходящие",
+        "subjectSelectorDesc": "Теги исходящих, которые опрашивает этот наблюдатель. Управляется автоматически на основе ваших балансировщиков.",
+        "probeURL": "URL пробы",
+        "probeURLDesc": "URL, запрашиваемый для измерения каждого исходящего. Должен возвращать HTTP 204.",
+        "probeInterval": "Интервал пробы",
+        "probeIntervalDesc": "Как часто опрашивать каждый исходящий, например 30s, 1m, 2h45m.",
+        "enableConcurrency": "Параллельные пробы",
+        "enableConcurrencyDesc": "Опрашивать все отслеживаемые исходящие сразу, а не по одному. Быстрее, но заметнее в сети.",
+        "destination": "Назначение пробы",
+        "destinationDesc": "URL, запрашиваемый для измерения каждого исходящего. Должен возвращать HTTP 204.",
+        "connectivity": "Проверка связи",
+        "connectivityDesc": "Необязательный URL проверки локальной сети, используется только после сбоя назначения. Оставьте пустым, чтобы пропустить.",
+        "interval": "Интервал пробы",
+        "intervalDesc": "Среднее время между пробами для каждого исходящего, например 1m. Минимум 10s.",
+        "timeout": "Тайм-аут пробы",
+        "timeoutDesc": "Сколько ждать ответа на пробу, прежде чем считать её неуспешной, например 5s.",
+        "sampling": "Размер выборки",
+        "samplingDesc": "Сколько последних результатов проб хранится для оценки каждого исходящего.",
+        "httpMethod": "Метод HTTP",
+        "httpMethodDesc": "Метод HTTP, используемый для проб.",
+        "deleteAlsoObservatory": "Это последний балансировщик, использующий Observatory, поэтому он тоже будет удалён.",
+        "deleteAlsoBurst": "Это последний балансировщик, использующий Burst Observatory, поэтому он тоже будет удалён."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Создать балансировщик",
         "addBalancer": "Создать балансировщик",
         "editBalancer": "Редактировать балансировщик",
         "editBalancer": "Редактировать балансировщик",

+ 32 - 0
internal/web/translation/tr-TR.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "LDAP port",
         "port": "LDAP port",
         "useTls": "TLS kullan (LDAPS)",
         "useTls": "TLS kullan (LDAPS)",
+        "skipTlsVerify": "TLS sertifika doğrulamasını atla",
+        "skipTlsVerifyDesc": "Güvenli değil — sunucu sertifika doğrulamasını devre dışı bırakır. Yalnızca dahili/güvenilmeyen CA'larla kullanın.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
         "passwordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
         "passwordUnconfigured": "Yapılandırılmadı.",
         "passwordUnconfigured": "Yapılandırılmadı.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Silindi",
         "toastDeleted": "Silindi",
         "toastDeleteFailed": "Silme işlemi başarısız"
         "toastDeleteFailed": "Silme işlemi başarısız"
       },
       },
+      "tabBalancerSettings": "Dengeleyici Ayarları",
+      "tabObservatory": "Gözlemci",
+      "observatory": {
+        "title": "Gözlemci",
+        "burstTitle": "Burst Gözlemci",
+        "autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.",
+        "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece dengeleyici çıkışlarınızı ölçüp en iyisini seçebilir.",
+        "subjectSelector": "İzlenen Çıkışlar",
+        "subjectSelectorDesc": "Bu gözlemcinin sınadığı çıkış etiketleri. Dengeleyicilerinize göre otomatik yönetilir.",
+        "probeURL": "Sınama URL'si",
+        "probeURLDesc": "Her çıkışı ölçmek için istenen URL. HTTP 204 döndürmelidir.",
+        "probeInterval": "Sınama Aralığı",
+        "probeIntervalDesc": "Her çıkışın ne sıklıkta sınanacağı, örn. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Eşzamanlı Sınama",
+        "enableConcurrencyDesc": "İzlenen tüm çıkışları tek tek yerine aynı anda sına. Daha hızlı ama ağda daha görünür.",
+        "destination": "Sınama Hedefi",
+        "destinationDesc": "Her çıkışı ölçmek için istenen URL. HTTP 204 döndürmelidir.",
+        "connectivity": "Bağlantı Denetimi",
+        "connectivityDesc": "İsteğe bağlı yerel ağ denetim URL'si; yalnızca hedef başarısız olduktan sonra denenir. Atlamak için boş bırakın.",
+        "interval": "Sınama Aralığı",
+        "intervalDesc": "Çıkış başına sınamalar arasındaki ortalama süre, örn. 1m. En az 10s.",
+        "timeout": "Sınama Zaman Aşımı",
+        "timeoutDesc": "Bir sınamanın başarısız sayılmadan önce ne kadar bekleneceği, örn. 5s.",
+        "sampling": "Örnekleme Sayısı",
+        "samplingDesc": "Her çıkışı puanlamak için tutulan son sınama sonucu sayısı.",
+        "httpMethod": "HTTP Yöntemi",
+        "httpMethodDesc": "Sınamalar için kullanılan HTTP yöntemi.",
+        "deleteAlsoObservatory": "Bu, Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak.",
+        "deleteAlsoBurst": "Bu, Burst Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Dengeleyici Ekle",
         "addBalancer": "Dengeleyici Ekle",
         "editBalancer": "Dengeleyiciyi Düzenle",
         "editBalancer": "Dengeleyiciyi Düzenle",

+ 32 - 0
internal/web/translation/uk-UA.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP-хост",
         "host": "LDAP-хост",
         "port": "Порт LDAP",
         "port": "Порт LDAP",
         "useTls": "Використовувати TLS (LDAPS)",
         "useTls": "Використовувати TLS (LDAPS)",
+        "skipTlsVerify": "Пропустити перевірку сертифіката TLS",
+        "skipTlsVerifyDesc": "Небезпечно — вимикає перевірку сертифіката сервера. Використовуйте лише з внутрішніми/ненадійними CA.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Налаштовано; залиште порожнім для збереження поточного паролю.",
         "passwordConfigured": "Налаштовано; залиште порожнім для збереження поточного паролю.",
         "passwordUnconfigured": "Не налаштовано.",
         "passwordUnconfigured": "Не налаштовано.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Видалено",
         "toastDeleted": "Видалено",
         "toastDeleteFailed": "Не вдалося видалити"
         "toastDeleteFailed": "Не вдалося видалити"
       },
       },
+      "tabBalancerSettings": "Налаштування балансувальника",
+      "tabObservatory": "Обсерваторія",
+      "observatory": {
+        "title": "Обсерваторія",
+        "burstTitle": "Burst-обсерваторія",
+        "autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.",
+        "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальник міг вимірювати вихідні й обирати найкращий.",
+        "subjectSelector": "Відстежувані вихідні",
+        "subjectSelectorDesc": "Теги вихідних, які опитує цей спостерігач. Керується автоматично на основі ваших балансувальників.",
+        "probeURL": "URL проби",
+        "probeURLDesc": "URL, що запитується для вимірювання кожного вихідного. Має повертати HTTP 204.",
+        "probeInterval": "Інтервал проби",
+        "probeIntervalDesc": "Як часто опитувати кожен вихідний, напр. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Паралельні проби",
+        "enableConcurrencyDesc": "Опитувати всі відстежувані вихідні одночасно, а не по одному. Швидше, але помітніше в мережі.",
+        "destination": "Призначення проби",
+        "destinationDesc": "URL, що запитується для вимірювання кожного вихідного. Має повертати HTTP 204.",
+        "connectivity": "Перевірка з’єднання",
+        "connectivityDesc": "Необов’язковий URL перевірки локальної мережі, використовується лише після збою призначення. Залиште порожнім, щоб пропустити.",
+        "interval": "Інтервал проби",
+        "intervalDesc": "Середній час між пробами для кожного вихідного, напр. 1m. Мінімум 10s.",
+        "timeout": "Тайм-аут проби",
+        "timeoutDesc": "Скільки чекати на пробу, перш ніж вважати її невдалою, напр. 5s.",
+        "sampling": "Розмір вибірки",
+        "samplingDesc": "Скільки останніх результатів проб зберігається для оцінювання кожного вихідного.",
+        "httpMethod": "Метод HTTP",
+        "httpMethodDesc": "Метод HTTP, що використовується для проб.",
+        "deleteAlsoObservatory": "Це останній балансувальник, що використовує Observatory, тож його теж буде видалено.",
+        "deleteAlsoBurst": "Це останній балансувальник, що використовує Burst Observatory, тож його теж буде видалено."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Додати балансир",
         "addBalancer": "Додати балансир",
         "editBalancer": "Редагувати балансир",
         "editBalancer": "Редагувати балансир",

+ 32 - 0
internal/web/translation/vi-VN.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "Cổng LDAP",
         "port": "Cổng LDAP",
         "useTls": "Dùng TLS (LDAPS)",
         "useTls": "Dùng TLS (LDAPS)",
+        "skipTlsVerify": "Bỏ qua xác minh chứng chỉ TLS",
+        "skipTlsVerifyDesc": "Không an toàn — tắt xác thực chứng chỉ máy chủ. Chỉ dùng với CA nội bộ/không đáng tin.",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
         "passwordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
         "passwordUnconfigured": "Chưa cấu hình.",
         "passwordUnconfigured": "Chưa cấu hình.",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "Đã xóa",
         "toastDeleted": "Đã xóa",
         "toastDeleteFailed": "Xóa thất bại"
         "toastDeleteFailed": "Xóa thất bại"
       },
       },
+      "tabBalancerSettings": "Cài đặt Balancer",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.",
+        "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để balancer có thể đo các outbound và chọn cái tốt nhất.",
+        "subjectSelector": "Outbound được theo dõi",
+        "subjectSelectorDesc": "Các thẻ outbound mà observer này dò. Được quản lý tự động từ các balancer của bạn.",
+        "probeURL": "URL dò",
+        "probeURLDesc": "URL được yêu cầu để đo mỗi outbound. Phải trả về HTTP 204.",
+        "probeInterval": "Khoảng thời gian dò",
+        "probeIntervalDesc": "Tần suất dò mỗi outbound, ví dụ 30s, 1m, 2h45m.",
+        "enableConcurrency": "Dò đồng thời",
+        "enableConcurrencyDesc": "Dò tất cả outbound được theo dõi cùng lúc thay vì lần lượt. Nhanh hơn nhưng dễ bị phát hiện trên mạng hơn.",
+        "destination": "Đích dò",
+        "destinationDesc": "URL được yêu cầu để đo mỗi outbound. Phải trả về HTTP 204.",
+        "connectivity": "Kiểm tra kết nối",
+        "connectivityDesc": "URL kiểm tra mạng cục bộ tùy chọn, chỉ thử sau khi đích thất bại. Để trống để bỏ qua.",
+        "interval": "Khoảng thời gian dò",
+        "intervalDesc": "Thời gian trung bình giữa các lần dò cho mỗi outbound, ví dụ 1m. Tối thiểu 10s.",
+        "timeout": "Thời gian chờ dò",
+        "timeoutDesc": "Thời gian chờ một lần dò trước khi coi là thất bại, ví dụ 5s.",
+        "sampling": "Số mẫu",
+        "samplingDesc": "Số kết quả dò gần đây được giữ để chấm điểm mỗi outbound.",
+        "httpMethod": "Phương thức HTTP",
+        "httpMethodDesc": "Phương thức HTTP dùng cho việc dò.",
+        "deleteAlsoObservatory": "Đây là balancer cuối cùng dùng Observatory, nên nó cũng sẽ bị xóa.",
+        "deleteAlsoBurst": "Đây là balancer cuối cùng dùng Burst Observatory, nên nó cũng sẽ bị xóa."
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Thêm cân bằng",
         "addBalancer": "Thêm cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",

+ 32 - 0
internal/web/translation/zh-CN.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "LDAP 端口",
         "port": "LDAP 端口",
         "useTls": "使用 TLS (LDAPS)",
         "useTls": "使用 TLS (LDAPS)",
+        "skipTlsVerify": "跳过 TLS 证书验证",
+        "skipTlsVerifyDesc": "不安全 — 禁用服务器证书验证。仅用于内部/不受信任的 CA。",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "已配置;留空以保留当前密码。",
         "passwordConfigured": "已配置;留空以保留当前密码。",
         "passwordUnconfigured": "未配置。",
         "passwordUnconfigured": "未配置。",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "已删除",
         "toastDeleted": "已删除",
         "toastDeleteFailed": "删除失败"
         "toastDeleteFailed": "删除失败"
       },
       },
+      "tabBalancerSettings": "负载均衡设置",
+      "tabObservatory": "观测器",
+      "observatory": {
+        "title": "观测器",
+        "burstTitle": "突发观测器",
+        "autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。",
+        "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便负载均衡器测量各出站并选择最优。",
+        "subjectSelector": "被观测的出站",
+        "subjectSelectorDesc": "该观测器探测的出站标签。根据你的负载均衡器自动管理。",
+        "probeURL": "探测 URL",
+        "probeURLDesc": "用于测量每个出站而请求的 URL,应返回 HTTP 204。",
+        "probeInterval": "探测间隔",
+        "probeIntervalDesc": "每个出站的探测频率,例如 30s、1m、2h45m。",
+        "enableConcurrency": "并发探测",
+        "enableConcurrencyDesc": "一次性探测所有被观测的出站,而不是逐个探测。更快,但在网络上更明显。",
+        "destination": "探测目标",
+        "destinationDesc": "用于测量每个出站而请求的 URL,应返回 HTTP 204。",
+        "connectivity": "连通性检查",
+        "connectivityDesc": "可选的本地网络检查 URL,仅在目标失败后才尝试。留空则跳过。",
+        "interval": "探测间隔",
+        "intervalDesc": "每个出站两次探测之间的平均时间,例如 1m。最小 10s。",
+        "timeout": "探测超时",
+        "timeoutDesc": "判定探测失败前的等待时长,例如 5s。",
+        "sampling": "采样数量",
+        "samplingDesc": "为每个出站评分而保留的最近探测结果数量。",
+        "httpMethod": "HTTP 方法",
+        "httpMethodDesc": "探测所用的 HTTP 方法。",
+        "deleteAlsoObservatory": "这是最后一个使用 Observatory 的负载均衡器,因此它也会被一并移除。",
+        "deleteAlsoBurst": "这是最后一个使用 Burst Observatory 的负载均衡器,因此它也会被一并移除。"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "添加负载均衡",
         "addBalancer": "添加负载均衡",
         "editBalancer": "编辑负载均衡",
         "editBalancer": "编辑负载均衡",

+ 32 - 0
internal/web/translation/zh-TW.json

@@ -1220,6 +1220,8 @@
         "host": "LDAP host",
         "host": "LDAP host",
         "port": "LDAP 連接埠",
         "port": "LDAP 連接埠",
         "useTls": "使用 TLS (LDAPS)",
         "useTls": "使用 TLS (LDAPS)",
+        "skipTlsVerify": "略過 TLS 憑證驗證",
+        "skipTlsVerifyDesc": "不安全 — 停用伺服器憑證驗證。僅用於內部/不受信任的 CA。",
         "bindDn": "Bind DN",
         "bindDn": "Bind DN",
         "passwordConfigured": "已設定;留空以保留目前密碼。",
         "passwordConfigured": "已設定;留空以保留目前密碼。",
         "passwordUnconfigured": "未設定。",
         "passwordUnconfigured": "未設定。",
@@ -1663,6 +1665,36 @@
         "toastDeleted": "已刪除",
         "toastDeleted": "已刪除",
         "toastDeleteFailed": "刪除失敗"
         "toastDeleteFailed": "刪除失敗"
       },
       },
+      "tabBalancerSettings": "負載平衡設定",
+      "tabObservatory": "觀測器",
+      "observatory": {
+        "title": "觀測器",
+        "burstTitle": "突發觀測器",
+        "autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。",
+        "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓負載平衡器能量測各出站並選出最佳者。",
+        "subjectSelector": "被觀測的出站",
+        "subjectSelectorDesc": "此觀測器探測的出站標籤。會根據你的負載平衡器自動管理。",
+        "probeURL": "探測 URL",
+        "probeURLDesc": "用於量測每個出站而請求的 URL,應回傳 HTTP 204。",
+        "probeInterval": "探測間隔",
+        "probeIntervalDesc": "每個出站的探測頻率,例如 30s、1m、2h45m。",
+        "enableConcurrency": "並行探測",
+        "enableConcurrencyDesc": "一次探測所有被觀測的出站,而非逐一探測。較快,但在網路上更明顯。",
+        "destination": "探測目標",
+        "destinationDesc": "用於量測每個出站而請求的 URL,應回傳 HTTP 204。",
+        "connectivity": "連線檢查",
+        "connectivityDesc": "選用的本機網路檢查 URL,僅在目標失敗後才嘗試。留空則略過。",
+        "interval": "探測間隔",
+        "intervalDesc": "每個出站兩次探測之間的平均時間,例如 1m。最小 10s。",
+        "timeout": "探測逾時",
+        "timeoutDesc": "判定探測失敗前的等待時間,例如 5s。",
+        "sampling": "取樣數量",
+        "samplingDesc": "為每個出站評分而保留的最近探測結果數量。",
+        "httpMethod": "HTTP 方法",
+        "httpMethodDesc": "探測所用的 HTTP 方法。",
+        "deleteAlsoObservatory": "這是最後一個使用 Observatory 的負載平衡器,因此它也會一併被移除。",
+        "deleteAlsoBurst": "這是最後一個使用 Burst Observatory 的負載平衡器,因此它也會一併被移除。"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "新增負載均衡",
         "addBalancer": "新增負載均衡",
         "editBalancer": "編輯負載均衡",
         "editBalancer": "編輯負載均衡",

+ 3 - 3
internal/web/web.go

@@ -168,9 +168,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	// Cap request bodies on state-changing requests so a stolen session/API
 	// Cap request bodies on state-changing requests so a stolen session/API
 	// token or a buggy client can't force large allocations or long DB
 	// token or a buggy client can't force large allocations or long DB
 	// transactions via bulk create/attach/import endpoints. GET/HEAD/OPTIONS
 	// transactions via bulk create/attach/import endpoints. GET/HEAD/OPTIONS
-	// carry no body and are left untouched. importDB restores a full SQLite
-	// backup that legitimately exceeds the cap, so it's exempt. Follow-up: make
-	// the limit a setting.
+	// carry no body and are left untouched. Database restore legitimately accepts
+	// large backups and streams them to disk, so only its exact route suffix is
+	// exempt. Follow-up: make the limit a setting.
 	const maxRequestBodyBytes = 10 << 20 // 10 MiB
 	const maxRequestBodyBytes = 10 << 20 // 10 MiB
 	engine.Use(middleware.MaxBodyBytes(maxRequestBodyBytes, "/panel/api/server/importDB"))
 	engine.Use(middleware.MaxBodyBytes(maxRequestBodyBytes, "/panel/api/server/importDB"))