6 Комити 3046d96145 ... a07b68894c

Аутор SHA1 Порука Датум
  MHSanaei a07b68894c docs(api): document clients bulkAttach endpoint пре 5 часа
  MHSanaei 9e005ffcf9 feat(inbounds): restore "Set Cert from Panel" / Clear buttons in TLS certs пре 5 часа
  MHSanaei 486ac9c28d feat(inbounds): expose Vision testseed field with sensible default пре 5 часа
  MHSanaei 1a096d72f1 feat(inbounds): bulk-attach & assign-group client actions + form defaults пре 6 часа
  MHSanaei 9d9737f470 feat(settings): panel network proxy for the panel's own outbound requests пре 7 часа
  Sanaei 272854df91 Client/inbound resilience + Postgres pool tuning + schema fixes (#4607) пре 9 часа
37 измењених фајлова са 935 додато и 68 уклоњено
  1. 24 2
      database/db.go
  2. 63 1
      database/model/model.go
  3. 63 0
      frontend/public/openapi.json
  4. 5 2
      frontend/src/lib/xray/inbound-defaults.ts
  5. 1 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  6. 1 0
      frontend/src/models/setting.ts
  7. 11 0
      frontend/src/pages/api-docs/endpoints.ts
  8. 30 0
      frontend/src/pages/clients/ClientFormModal.tsx
  9. 61 0
      frontend/src/pages/inbounds/AssignClientsGroupModal.tsx
  10. 108 0
      frontend/src/pages/inbounds/AttachClientsModal.tsx
  11. 67 3
      frontend/src/pages/inbounds/InboundFormModal.tsx
  12. 4 4
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  13. 5 1
      frontend/src/pages/inbounds/InboundList.tsx
  14. 36 1
      frontend/src/pages/inbounds/InboundsPage.tsx
  15. 8 0
      frontend/src/pages/settings/GeneralTab.tsx
  16. 5 16
      frontend/src/pages/settings/TelegramTab.tsx
  17. 1 0
      frontend/src/schemas/client.ts
  18. 4 1
      frontend/src/schemas/protocols/stream/external-proxy.ts
  19. 1 3
      frontend/src/schemas/protocols/stream/tcp.ts
  20. 1 0
      frontend/src/schemas/setting.ts
  21. 0 16
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  22. 6 1
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  23. 3 1
      frontend/src/test/__snapshots__/stream.test.ts.snap
  24. 12 2
      frontend/src/test/inbound-defaults.test.ts
  25. 1 1
      go.mod
  26. 72 0
      util/netproxy/netproxy.go
  27. 54 0
      util/netproxy/netproxy_test.go
  28. 24 0
      web/controller/client.go
  29. 33 3
      web/controller/util.go
  30. 1 0
      web/entity/entity.go
  31. 162 2
      web/service/client.go
  32. 2 2
      web/service/panel.go
  33. 4 5
      web/service/server.go
  34. 28 0
      web/service/setting.go
  35. 11 0
      web/service/tgbot.go
  36. 12 0
      web/translation/en-US.json
  37. 11 0
      web/translation/fa-IR.json

+ 24 - 2
database/db.go

@@ -409,9 +409,19 @@ func InitDB(dbPath string) error {
 	if err != nil {
 		return err
 	}
-	sqlDB.SetMaxOpenConns(8)
-	sqlDB.SetMaxIdleConns(4)
+	var maxOpen, maxIdle int
+	switch config.GetDBKind() {
+	case "postgres":
+		maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 25)
+		maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 25)
+	default:
+		maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 8)
+		maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 4)
+	}
+	sqlDB.SetMaxOpenConns(maxOpen)
+	sqlDB.SetMaxIdleConns(maxIdle)
 	sqlDB.SetConnMaxLifetime(time.Hour)
+	sqlDB.SetConnMaxIdleTime(30 * time.Minute)
 
 	if err := initModels(); err != nil {
 		return err
@@ -428,6 +438,18 @@ func InitDB(dbPath string) error {
 	return runSeeders(isUsersEmpty)
 }
 
+func envInt(key string, def int) int {
+	v := strings.TrimSpace(os.Getenv(key))
+	if v == "" {
+		return def
+	}
+	n, err := strconv.Atoi(v)
+	if err != nil || n <= 0 {
+		return def
+	}
+	return n
+}
+
 // CloseDB closes the database connection if it exists.
 func CloseDB() error {
 	if db != nil {

+ 63 - 1
database/model/model.go

@@ -220,10 +220,19 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen = fmt.Sprintf("\"%v\"", listen)
 	protocol := string(i.Protocol)
 	settings := i.Settings
-	if i.Protocol == Shadowsocks {
+	switch i.Protocol {
+	case Shadowsocks:
 		if healed, ok := HealShadowsocksClientMethods(settings); ok {
 			settings = healed
 		}
+	case VMESS:
+		if stripped, ok := StripVmessClientSecurity(settings); ok {
+			settings = stripped
+		}
+	case VLESS:
+		if stripped, ok := StripVlessInboundEncryption(settings); ok {
+			settings = stripped
+		}
 	}
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
@@ -236,6 +245,59 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	}
 }
 
+func StripVmessClientSecurity(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if _, has := cm["security"]; has {
+			delete(cm, "security")
+			clients[i] = cm
+			changed = true
+		}
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
+func StripVlessInboundEncryption(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	if _, has := parsed["encryption"]; !has {
+		return settings, false
+	}
+	delete(parsed, "encryption")
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // HealShadowsocksClientMethods normalises the per-client `method` field
 // on a shadowsocks inbound's settings JSON before it leaves for xray-core:
 //   - Legacy ciphers (aes-*, chacha20-*): every client must carry a

+ 63 - 0
frontend/public/openapi.json

@@ -2905,6 +2905,69 @@
         }
       }
     },
+    "/panel/api/clients/bulkAttach": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Attach many existing clients to many inbounds in one call. Each client keeps its identity (email/UUID/password/subId) and a shared traffic row; all clients are added to a target inbound in a single AddInboundClient call. Clients already present on a target are reported under skipped. Returns per-email attached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.",
+        "operationId": "post_panel_api_clients_bulkAttach",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ],
+                "inboundIds": [
+                  7,
+                  9
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "attached": [
+                      "alice",
+                      "bob"
+                    ],
+                    "skipped": [
+                      "bob"
+                    ],
+                    "errors": []
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkResetTraffic": {
       "post": {
         "tags": [

+ 5 - 2
frontend/src/lib/xray/inbound-defaults.ts

@@ -185,13 +185,16 @@ export function createDefaultHysteriaInboundSettings(
 }
 
 export function createDefaultHttpInboundSettings(): HttpInboundSettings {
-  return { accounts: [], allowTransparent: false };
+  return {
+    accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
+    allowTransparent: false,
+  };
 }
 
 export function createDefaultMixedInboundSettings(): MixedInboundSettings {
   return {
     auth: 'password',
-    accounts: [],
+    accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
     udp: false,
     ip: '127.0.0.1',
   };

+ 1 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -116,7 +116,7 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
   const testseed = savedSeed.length === 4
     && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
     ? (savedSeed as number[])
-    : [];
+    : [900, 500, 900, 256];
   return {
     address,
     port,

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

@@ -9,6 +9,7 @@ export class AllSetting {
   webBasePath = '/';
   sessionMaxAge = 360;
   trustedProxyCIDRs = '127.0.0.1/32,::1/128';
+  panelProxy = '';
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;

+ 11 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -551,6 +551,17 @@ export const sections: readonly Section[] = [
         body: '{\n  "emails": ["alice", "bob"],\n  "group": "customer-a"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "affected": 2\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkAttach',
+        summary: 'Attach many existing clients to many inbounds in one call. Each client keeps its identity (email/UUID/password/subId) and a shared traffic row; all clients are added to a target inbound in a single AddInboundClient call. Clients already present on a target are reported under skipped. Returns per-email attached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.',
+        params: [
+          { name: 'emails', in: 'body (json)', type: 'array', desc: 'Emails of existing clients to attach.' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Target inbound IDs to attach every client to.' },
+        ],
+        body: '{\n  "emails": ["alice", "bob"],\n  "inboundIds": [7, 9]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "attached": ["alice", "bob"],\n    "skipped": ["bob"],\n    "errors": []\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/bulkResetTraffic',

+ 30 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -26,6 +26,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
@@ -77,6 +78,7 @@ interface FormState {
   password: string;
   auth: string;
   flow: string;
+  security: string;
   reverseTag: string;
   totalGB: number;
   expiryDate: Dayjs | null;
@@ -99,6 +101,7 @@ function emptyForm(): FormState {
     password: '',
     auth: '',
     flow: '',
+    security: 'auto',
     reverseTag: '',
     totalGB: 0,
     expiryDate: null,
@@ -163,6 +166,7 @@ export default function ClientFormModal({
         password: client.password || '',
         auth: client.auth || '',
         flow: client.flow || '',
+        security: client.security || 'auto',
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
@@ -214,6 +218,14 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
+  const vmessIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'vmess') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
   const showFlow = useMemo(
     () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
     [form.inboundIds, flowCapableIds],
@@ -224,6 +236,11 @@ export default function ClientFormModal({
     [form.inboundIds, vlessLikeIds],
   );
 
+  const showSecurity = useMemo(
+    () => (form.inboundIds || []).some((id) => vmessIds.has(id)),
+    [form.inboundIds, vmessIds],
+  );
+
   useEffect(() => {
     if (!showFlow && form.flow) {
 
@@ -286,6 +303,7 @@ export default function ClientFormModal({
       password: form.password,
       auth: form.auth,
       flow: form.flow,
+      security: form.security,
       reverseTag: form.reverseTag,
       totalGB: form.totalGB,
       delayedStart: form.delayedStart,
@@ -313,6 +331,7 @@ export default function ClientFormModal({
       password: form.password,
       auth: form.auth,
       flow: showFlow ? (form.flow || '') : '',
+      security: showSecurity ? (form.security || 'auto') : 'auto',
       totalGB: gbToBytes(form.totalGB),
       expiryTime,
       reset: Number(form.reset) || 0,
@@ -497,6 +516,17 @@ export default function ClientFormModal({
                 </Form.Item>
               </Col>
             )}
+            {showSecurity && (
+              <Col xs={24} md={12}>
+                <Form.Item label={t('pages.clients.vmessSecurity')}>
+                  <Select
+                    value={form.security}
+                    onChange={(v) => update('security', v)}
+                    options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                  />
+                </Form.Item>
+              </Col>
+            )}
           </Row>
 
           <Row gutter={16}>

+ 61 - 0
frontend/src/pages/inbounds/AssignClientsGroupModal.tsx

@@ -0,0 +1,61 @@
+import { lazy, useEffect, useMemo, useState } from 'react';
+
+import { HttpUtil } from '@/utils';
+import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
+
+const BulkAssignGroupModal = lazy(() => import('@/pages/clients/BulkAssignGroupModal'));
+
+interface AssignClientsGroupModalProps {
+  open: boolean;
+  source: DBInbound | null;
+  onClose: () => void;
+  onAssigned?: () => void;
+}
+
+function readClientEmails(settings: unknown): string[] {
+  const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
+  const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
+  return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
+}
+
+export default function AssignClientsGroupModal({
+  open,
+  source,
+  onClose,
+  onAssigned,
+}: AssignClientsGroupModalProps) {
+  const [groups, setGroups] = useState<string[]>([]);
+
+  const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
+
+  useEffect(() => {
+    if (!open) return;
+    let cancelled = false;
+    (async () => {
+      const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
+      if (cancelled) return;
+      const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
+      setGroups(list.map((g) => g?.name || '').filter(Boolean));
+    })();
+    return () => { cancelled = true; };
+  }, [open]);
+
+  return (
+    <BulkAssignGroupModal
+      open={open}
+      count={emails.length}
+      groups={groups}
+      onOpenChange={(o) => { if (!o) onClose(); }}
+      onSubmit={async (group) => {
+        const msg = await HttpUtil.post(
+          '/panel/api/clients/bulkAssignGroup',
+          { emails, group },
+          { headers: { 'Content-Type': 'application/json' } },
+        );
+        if (!msg?.success) return null;
+        onAssigned?.();
+        return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
+      }}
+    />
+  );
+}

+ 108 - 0
frontend/src/pages/inbounds/AttachClientsModal.tsx

@@ -0,0 +1,108 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Modal, Select, Typography, message } from 'antd';
+
+import { HttpUtil } from '@/utils';
+import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
+import { isInboundMultiUser } from './InboundList';
+
+interface AttachClientsModalProps {
+  open: boolean;
+  source: DBInbound | null;
+  dbInbounds: DBInbound[];
+  onClose: () => void;
+  onAttached?: () => void;
+}
+
+interface BulkAttachResult {
+  attached?: string[];
+  skipped?: string[];
+  errors?: string[];
+}
+
+function readClientEmails(settings: unknown): string[] {
+  const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
+  const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
+  return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
+}
+
+export default function AttachClientsModal({
+  open,
+  source,
+  dbInbounds,
+  onClose,
+  onAttached,
+}: AttachClientsModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [targetIds, setTargetIds] = useState<number[]>([]);
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (open) setTargetIds([]);
+  }, [open]);
+
+  const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
+
+  const targetOptions = useMemo(() => {
+    if (!source) return [];
+    return (dbInbounds || [])
+      .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
+      .map((ib) => ({ value: ib.id, label: `${ib.remark} (${ib.protocol}@${ib.port})` }));
+  }, [dbInbounds, source]);
+
+  async function submit() {
+    if (!source || targetIds.length === 0 || emails.length === 0) return;
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/clients/bulkAttach', { emails, inboundIds: targetIds }, { headers: { 'Content-Type': 'application/json' } });
+      if (!msg?.success) {
+        messageApi.error(msg?.msg || t('somethingWentWrong'));
+        return;
+      }
+      const result = (msg.obj || {}) as BulkAttachResult;
+      const attached = result.attached?.length ?? 0;
+      const skipped = result.skipped?.length ?? 0;
+      const errors = result.errors?.length ?? 0;
+      if (errors > 0) {
+        messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
+      } else {
+        messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
+      }
+      onAttached?.();
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <Modal
+      open={open}
+      onCancel={onClose}
+      onOk={submit}
+      okButtonProps={{ disabled: targetIds.length === 0 || emails.length === 0, loading: saving }}
+      okText={t('pages.inbounds.attachClients')}
+      cancelText={t('cancel')}
+      title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })}
+    >
+      {messageContextHolder}
+      <Typography.Paragraph type="secondary">
+        {t('pages.inbounds.attachClientsDesc', { count: emails.length })}
+      </Typography.Paragraph>
+      {targetOptions.length === 0 ? (
+        <Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
+      ) : (
+        <Select
+          mode="multiple"
+          style={{ width: '100%' }}
+          value={targetIds}
+          onChange={setTargetIds}
+          options={targetOptions}
+          placeholder={t('pages.inbounds.attachClientsTargets')}
+          optionFilterProp="label"
+        />
+      )}
+    </Modal>
+  );
+}

+ 67 - 3
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -572,6 +572,41 @@ export default function InboundFormModal({
     form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
   };
 
+  const setCertFromPanel = async (certName: number) => {
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
+      if (msg?.success) {
+        const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
+        if (!obj.webCertFile && !obj.webKeyFile) {
+          messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
+          return;
+        }
+        form.setFieldValue(
+          ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+          obj.webCertFile ?? '',
+        );
+        form.setFieldValue(
+          ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+          obj.webKeyFile ?? '',
+        );
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearCertFiles = (certName: number) => {
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+      '',
+    );
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+      '',
+    );
+  };
+
   const onSecurityChange = async (next: string) => {
     const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
     const cleaned: Record<string, unknown> = { ...current, security: next };
@@ -1428,6 +1463,21 @@ export default function InboundFormModal({
               {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })}
             </Text>
           </Form.Item>
+          {network === 'tcp' && (security === 'tls' || security === 'reality') && (
+            <Form.Item
+              label="Vision testseed"
+              name={['settings', 'testseed']}
+              initialValue={[900, 500, 900, 256]}
+              normalize={(v: unknown) =>
+                Array.isArray(v)
+                  ? v.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0)
+                  : []
+              }
+              extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
+            >
+              <Select mode="tags" tokenSeparators={[',', ' ']} placeholder="four positive integers" />
+            </Form.Item>
+          )}
         </>
       )}
 
@@ -2473,9 +2523,9 @@ export default function InboundFormModal({
                 disabled={!tlsOk}
                 onChange={(e) => onSecurityChange(e.target.value)}
               >
-                {!tlsOnly && <Radio.Button value="none">none</Radio.Button>}
-                <Radio.Button value="tls">tls</Radio.Button>
-                {realityOk && <Radio.Button value="reality">reality</Radio.Button>}
+                {!tlsOnly && <Radio.Button value="none">None</Radio.Button>}
+                <Radio.Button value="tls">TLS</Radio.Button>
+                {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
               </Radio.Group>
             );
           }}
@@ -2634,6 +2684,20 @@ export default function InboundFormModal({
                                 >
                                   <Input />
                                 </Form.Item>
+                                <Form.Item label=" ">
+                                  <Space>
+                                    <Button
+                                      type="primary"
+                                      loading={saving}
+                                      onClick={() => setCertFromPanel(certField.name)}
+                                    >
+                                      {t('pages.inbounds.setDefaultCert')}
+                                    </Button>
+                                    <Button danger onClick={() => clearCertFiles(certField.name)}>
+                                      Clear
+                                    </Button>
+                                  </Space>
+                                </Form.Item>
                               </>
                             ) : (
                               <>

+ 4 - 4
frontend/src/pages/inbounds/InboundInfoModal.tsx

@@ -911,11 +911,11 @@ export default function InboundInfoModal({
                       <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
                     </Tooltip>
                     <Space size={4} wrap className="share-buttons">
-                      <Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
-                        <Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>SOCKS5</Button>
+                      <Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
+                        <Button size="small" onClick={() => copyText(`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`, t)}>SOCKS5</Button>
                       </Tooltip>
-                      <Tooltip title={`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
-                        <Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>HTTP</Button>
+                      <Tooltip title={`http://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
+                        <Button size="small" onClick={() => copyText(`http://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`, t)}>HTTP</Button>
                       </Tooltip>
                       <Tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
                         <Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`, t)}>Telegram</Button>

+ 5 - 1
frontend/src/pages/inbounds/InboundList.tsx

@@ -28,6 +28,8 @@ import {
   BlockOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
+  TagsOutlined,
+  UsergroupAddOutlined,
   UsergroupDeleteOutlined,
 } from '@ant-design/icons';
 
@@ -108,7 +110,7 @@ function readSettings(settings: unknown): { method?: string; network?: string; a
   return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
 }
 
-function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
+export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
   switch (record.protocol) {
     case 'vmess':
     case 'vless':
@@ -259,6 +261,8 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
   items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
   items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
   if (isInboundMultiUser(record) && hasClients) {
+    items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
+    items.push({ key: 'assignGroup', icon: <TagsOutlined />, label: t('pages.inbounds.assignClientsGroup') });
     items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
   }
   items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });

+ 36 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -38,6 +38,8 @@ import LazyMount from '@/components/LazyMount';
 const InboundFormModal = lazy(() => import('./InboundFormModal'));
 const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./QrCodeModal'));
+const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
+const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
 
 type RowAction =
   | 'edit'
@@ -49,6 +51,8 @@ type RowAction =
   | 'delete'
   | 'resetTraffic'
   | 'delAllClients'
+  | 'attachClients'
+  | 'assignGroup'
   | 'clone';
 
 type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -121,6 +125,12 @@ export default function InboundsPage() {
   const [qrOpen, setQrOpen] = useState(false);
   const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
 
+  const [attachOpen, setAttachOpen] = useState(false);
+  const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
+
+  const [groupOpen, setGroupOpen] = useState(false);
+  const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
+
   const [textOpen, setTextOpen] = useState(false);
   const [textTitle, setTextTitle] = useState('');
   const [textContent, setTextContent] = useState('');
@@ -438,7 +448,7 @@ export default function InboundsPage() {
     // Actions that touch per-client secrets (uuid, password, flow, ...) need
     // the full payload that the slim list view does not ship. Hydrate first
     // and then operate on the rehydrated record.
-    const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone'];
+    const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'assignGroup'];
     let target = dbInbound;
     if (hydratingKeys.includes(key)) {
       const hydrated = await hydrateInbound(dbInbound.id);
@@ -475,6 +485,14 @@ export default function InboundsPage() {
       case 'delAllClients':
         confirmDelAllClients(target);
         break;
+      case 'attachClients':
+        setAttachSource(target);
+        setAttachOpen(true);
+        break;
+      case 'assignGroup':
+        setGroupSource(target);
+        setGroupOpen(true);
+        break;
       case 'clone':
         confirmClone(target);
         break;
@@ -587,6 +605,23 @@ export default function InboundsPage() {
             subSettings={subSettings}
           />
         </LazyMount>
+        <LazyMount when={attachOpen}>
+          <AttachClientsModal
+            open={attachOpen}
+            onClose={() => setAttachOpen(false)}
+            onAttached={refresh}
+            source={attachSource}
+            dbInbounds={dbInbounds}
+          />
+        </LazyMount>
+        <LazyMount when={groupOpen}>
+          <AssignClientsGroupModal
+            open={groupOpen}
+            onClose={() => setGroupOpen(false)}
+            onAssigned={refresh}
+            source={groupSource}
+          />
+        </LazyMount>
 
         <LazyMount when={textOpen}>
           <TextModal

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

@@ -170,6 +170,14 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.panelProxy')} description={t('pages.settings.panelProxyDesc')}>
+              <Input
+                value={allSetting.panelProxy}
+                placeholder="socks5:// or http://user:pass@host:port"
+                onChange={(e) => updateSetting({ panelProxy: e.target.value })}
+              />
+            </SettingListItem>
+
             <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
               <InputNumber value={allSetting.pageSize} min={0} step={5} style={{ width: '100%' }}
                 onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />

+ 5 - 16
frontend/src/pages/settings/TelegramTab.tsx

@@ -61,6 +61,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
                 options={langOptions}
               />
             </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
+              <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
+                onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
+            </SettingListItem>
           </>
         ),
       },
@@ -85,22 +90,6 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
           </>
         ),
       },
-      {
-        key: '3',
-        label: t('pages.settings.proxyAndServer'),
-        children: (
-          <>
-            <SettingListItem paddings="small" title={t('pages.settings.telegramProxy')} description={t('pages.settings.telegramProxyDesc')}>
-              <Input value={allSetting.tgBotProxy} placeholder="socks5://user:pass@host:port"
-                onChange={(e) => updateSetting({ tgBotProxy: e.target.value })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.telegramAPIServer')} description={t('pages.settings.telegramAPIServerDesc')}>
-              <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
-                onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
-            </SettingListItem>
-          </>
-        ),
-      },
     ]} />
   );
 }

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

@@ -113,6 +113,7 @@ export const ClientFormSchema = z.object({
   password: z.string(),
   auth: z.string(),
   flow: z.string(),
+  security: z.string(),
   reverseTag: z.string(),
   totalGB: z.number().min(0),
   delayedStart: z.boolean(),

+ 4 - 1
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -17,7 +17,10 @@ export const ExternalProxyEntrySchema = z.object({
   port: PortSchema.default(443),
   remark: z.string().default(''),
   sni: z.string().optional(),
-  fingerprint: UtlsFingerprintSchema.optional(),
+  fingerprint: z.preprocess(
+    (val) => (val === '' ? undefined : val),
+    UtlsFingerprintSchema.optional(),
+  ),
   alpn: z.array(AlpnSchema).optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 1 - 3
frontend/src/schemas/protocols/stream/tcp.ts

@@ -38,10 +38,8 @@ export const TcpHeaderSchema = z.discriminatedUnion('type', [
 ]);
 export type TcpHeader = z.infer<typeof TcpHeaderSchema>;
 
-// Top-level TCP stream payload. `acceptProxyProtocol` only appears on the
-// wire when true (panel omits it when false), so we treat it as optional.
 export const TcpStreamSettingsSchema = z.object({
-  acceptProxyProtocol: z.literal(true).optional(),
+  acceptProxyProtocol: z.boolean().default(false),
   header: TcpHeaderSchema.optional(),
 });
 export type TcpStreamSettings = z.infer<typeof TcpStreamSettingsSchema>;

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

@@ -13,6 +13,7 @@ export const AllSettingSchema = z.object({
   webBasePath: absolutePath.optional(),
   sessionMaxAge: z.number().int().min(1).optional(),
   trustedProxyCIDRs: z.string().optional(),
+  panelProxy: z.string().optional(),
   pageSize: z.number().int().min(1).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.optional(),

+ 0 - 16
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -1,12 +1,5 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`createDefault*InboundSettings factories > http 1`] = `
-{
-  "accounts": [],
-  "allowTransparent": false,
-}
-`;
-
 exports[`createDefault*InboundSettings factories > hysteria (v1, defaults to v2 wire version) 1`] = `
 {
   "clients": [],
@@ -14,15 +7,6 @@ exports[`createDefault*InboundSettings factories > hysteria (v1, defaults to v2
 }
 `;
 
-exports[`createDefault*InboundSettings factories > mixed 1`] = `
-{
-  "accounts": [],
-  "auth": "password",
-  "ip": "127.0.0.1",
-  "udp": false,
-}
-`;
-
 exports[`createDefault*InboundSettings factories > shadowsocks 1`] = `
 {
   "clients": [],

+ 6 - 1
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -43,7 +43,9 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
   "streamSettings": {
     "network": "tcp",
     "security": "tls",
-    "tcpSettings": {},
+    "tcpSettings": {
+      "acceptProxyProtocol": false,
+    },
     "tlsSettings": {
       "alpn": [
         "h3",
@@ -125,6 +127,7 @@ exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably
     "network": "tcp",
     "security": "none",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },
@@ -292,6 +295,7 @@ exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`
     },
     "security": "reality",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },
@@ -434,6 +438,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
     "network": "tcp",
     "security": "tls",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },

+ 3 - 1
frontend/src/test/__snapshots__/stream.test.ts.snap

@@ -14,7 +14,9 @@ exports[`NetworkSettingsSchema fixtures > parses grpc-basic byte-stably 1`] = `
 exports[`NetworkSettingsSchema fixtures > parses tcp-none byte-stably 1`] = `
 {
   "network": "tcp",
-  "tcpSettings": {},
+  "tcpSettings": {
+    "acceptProxyProtocol": false,
+  },
 }
 `;
 

+ 12 - 2
frontend/src/test/inbound-defaults.test.ts

@@ -112,13 +112,23 @@ describe('createDefault*InboundSettings factories', () => {
 
   it('http', () => {
     const s = createDefaultHttpInboundSettings();
-    expect(s).toMatchSnapshot();
+    expect(s.allowTransparent).toBe(false);
+    const accounts = s.accounts ?? [];
+    expect(accounts).toHaveLength(1);
+    expect(accounts[0].user.length).toBe(8);
+    expect(accounts[0].pass.length).toBe(12);
     expect(HttpInboundSettingsSchema.parse(s)).toEqual(s);
   });
 
   it('mixed', () => {
     const s = createDefaultMixedInboundSettings();
-    expect(s).toMatchSnapshot();
+    expect(s.auth).toBe('password');
+    expect(s.udp).toBe(false);
+    expect(s.ip).toBe('127.0.0.1');
+    const accounts = s.accounts ?? [];
+    expect(accounts).toHaveLength(1);
+    expect(accounts[0].user.length).toBe(8);
+    expect(accounts[0].pass.length).toBe(12);
     expect(MixedInboundSettingsSchema.parse(s)).toEqual(s);
   });
 

+ 1 - 1
go.mod

@@ -95,7 +95,7 @@ require (
 	golang.org/x/arch v0.27.0 // indirect
 	golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
 	golang.org/x/mod v0.36.0 // indirect
-	golang.org/x/net v0.55.0 // indirect
+	golang.org/x/net v0.55.0
 	golang.org/x/sync v0.20.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.45.0 // indirect

+ 72 - 0
util/netproxy/netproxy.go

@@ -0,0 +1,72 @@
+// Package netproxy builds HTTP clients that route the panel's own outbound
+// requests through an admin-configured proxy, used to reach GitHub and Telegram
+// from servers where those services are filtered.
+package netproxy
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"golang.org/x/net/proxy"
+)
+
+// NewHTTPClient returns an *http.Client whose transport honors proxyURL.
+//
+// An empty proxyURL yields a plain client (unchanged behavior). socks5/socks5h
+// URLs are dialed through golang.org/x/net/proxy; http/https URLs use the
+// standard library proxy support. Any other scheme returns an error so callers
+// can log it and fall back to a direct connection.
+//
+// The proxy address is intentionally not subjected to SSRF filtering: it is
+// admin-configured and is commonly a loopback/private address (for example a
+// local Xray SOCKS inbound).
+func NewHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
+	if proxyURL == "" {
+		return &http.Client{Timeout: timeout}, nil
+	}
+
+	parsed, err := url.Parse(proxyURL)
+	if err != nil {
+		return nil, fmt.Errorf("parse proxy url: %w", err)
+	}
+
+	transport := baseTransport()
+
+	switch strings.ToLower(parsed.Scheme) {
+	case "socks5", "socks5h":
+		var auth *proxy.Auth
+		if parsed.User != nil {
+			password, _ := parsed.User.Password()
+			auth = &proxy.Auth{User: parsed.User.Username(), Password: password}
+		}
+		dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct)
+		if err != nil {
+			return nil, fmt.Errorf("create socks5 dialer: %w", err)
+		}
+		if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
+			transport.DialContext = contextDialer.DialContext
+		} else {
+			transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
+				return dialer.Dial(network, addr)
+			}
+		}
+	case "http", "https":
+		transport.Proxy = http.ProxyURL(parsed)
+	default:
+		return nil, fmt.Errorf("unsupported proxy scheme %q", parsed.Scheme)
+	}
+
+	return &http.Client{Timeout: timeout, Transport: transport}, nil
+}
+
+func baseTransport() *http.Transport {
+	if base, ok := http.DefaultTransport.(*http.Transport); ok {
+		return base.Clone()
+	}
+	return &http.Transport{}
+}

+ 54 - 0
util/netproxy/netproxy_test.go

@@ -0,0 +1,54 @@
+package netproxy
+
+import (
+	"net/http"
+	"testing"
+	"time"
+)
+
+func TestNewHTTPClient(t *testing.T) {
+	tests := []struct {
+		name      string
+		proxyURL  string
+		wantErr   bool
+		wantProxy bool
+		wantDial  bool
+	}{
+		{name: "empty returns direct client", proxyURL: ""},
+		{name: "socks5 sets custom dialer", proxyURL: "socks5://127.0.0.1:1080", wantDial: true},
+		{name: "socks5 with auth", proxyURL: "socks5://user:[email protected]:1080", wantDial: true},
+		{name: "http sets transport proxy", proxyURL: "http://127.0.0.1:8080", wantProxy: true},
+		{name: "https sets transport proxy", proxyURL: "https://127.0.0.1:8080", wantProxy: true},
+		{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
+			if tc.wantErr {
+				if err == nil {
+					t.Fatalf("expected error for %q, got nil", tc.proxyURL)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error for %q: %v", tc.proxyURL, err)
+			}
+			if client.Timeout != 5*time.Second {
+				t.Errorf("timeout = %v, want 5s", client.Timeout)
+			}
+			if tc.wantProxy {
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok || transport.Proxy == nil {
+					t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
+				}
+			}
+			if tc.wantDial {
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok || transport.DialContext == nil {
+					t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
+				}
+			}
+		})
+	}
+}

+ 24 - 0
web/controller/client.go

@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
 	g.POST("/bulkAssignGroup", a.bulkAssignGroup)
+	g.POST("/bulkAttach", a.bulkAttach)
 	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
@@ -239,6 +240,29 @@ func (a *ClientController) bulkAssignGroup(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type bulkAttachRequest struct {
+	Emails     []string `json:"emails"`
+	InboundIds []int    `json:"inboundIds"`
+}
+
+func (a *ClientController) bulkAttach(c *gin.Context) {
+	var req bulkAttachRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.BulkAttach(&a.inboundService, req.Emails, req.InboundIds)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
 func (a *ClientController) bulkDelete(c *gin.Context) {
 	var req bulkDeleteRequest
 	if err := c.ShouldBindJSON(&req); err != nil {

+ 33 - 3
web/controller/util.go

@@ -5,6 +5,8 @@ import (
 	"net"
 	"net/http"
 	"net/netip"
+	"path/filepath"
+	"runtime"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/logger"
@@ -125,6 +127,32 @@ func jsonObj(c *gin.Context, obj any, err error) {
 	jsonMsgObj(c, "", obj, err)
 }
 
+func requestErrorContext(c *gin.Context) string {
+	handler, loc := callerOutsideUtil()
+	return fmt.Sprintf("[%s %s handler=%s %s]", c.Request.Method, c.Request.URL.Path, handler, loc)
+}
+
+func callerOutsideUtil() (string, string) {
+	var pcs [12]uintptr
+	n := runtime.Callers(2, pcs[:])
+	frames := runtime.CallersFrames(pcs[:n])
+	for {
+		frame, more := frames.Next()
+		base := filepath.Base(frame.File)
+		if base != "util.go" {
+			name := frame.Function
+			if idx := strings.LastIndex(name, "/"); idx >= 0 {
+				name = name[idx+1:]
+			}
+			return name, fmt.Sprintf("%s:%d", base, frame.Line)
+		}
+		if !more {
+			break
+		}
+	}
+	return "unknown", "unknown"
+}
+
 // jsonMsgObj sends a JSON response with a message, object, and error status.
 func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 	m := entity.Msg{
@@ -137,16 +165,18 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 		}
 	} else {
 		m.Success = false
+		ctx := requestErrorContext(c)
+		fail := I18nWeb(c, "fail")
 		errStr := err.Error()
 		if errStr != "" {
 			m.Msg = msg + " (" + errStr + ")"
-			logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+			logger.Warningf("%s %s %s: %v", ctx, msg, fail, err)
 		} else if msg != "" {
 			m.Msg = msg
-			logger.Warning(msg + " " + I18nWeb(c, "fail"))
+			logger.Warningf("%s %s %s", ctx, msg, fail)
 		} else {
 			m.Msg = I18nWeb(c, "somethingWentWrong")
-			logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
+			logger.Warningf("%s %s %s", ctx, m.Msg, fail)
 		}
 	}
 	c.JSON(http.StatusOK, m)

+ 1 - 0
web/entity/entity.go

@@ -29,6 +29,7 @@ type AllSetting struct {
 	WebBasePath       string `json:"webBasePath" form:"webBasePath"`                                   // Base path for web panel URLs
 	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=0,lte=525600"`   // Session maximum age in minutes (cap at one year)
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`                       // Trusted reverse proxy IPs/CIDRs for forwarded headers
+	PanelProxy        string `json:"panelProxy" form:"panelProxy"`                                     // Proxy URL for the panel's own outbound requests (GitHub/Telegram)
 
 	// UI settings
 	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"`     // Number of items per page in lists

+ 162 - 2
web/service/client.go

@@ -678,6 +678,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	for _, ibId := range inboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
 		if getErr != nil {
+			if errors.Is(getErr, gorm.ErrRecordNotFound) {
+				continue
+			}
 			return needRestart, getErr
 		}
 		key := clientKeyForProtocol(inbound.Protocol, existing)
@@ -788,6 +791,99 @@ func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string,
 	return s.Attach(inboundSvc, rec.Id, inboundIds)
 }
 
+// BulkAttachResult reports the outcome of a bulk attach across target inbounds.
+type BulkAttachResult struct {
+	Attached []string `json:"attached"`
+	Skipped  []string `json:"skipped"`
+	Errors   []string `json:"errors"`
+}
+
+// BulkAttach attaches the given existing clients (by email) to each target inbound,
+// reusing their identity (email/UUID/password/subId) and a shared traffic row. It adds
+// all clients to a target in a single AddInboundClient call, and reports clients already
+// present on a target as skipped.
+func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkAttachResult, bool, error) {
+	result := &BulkAttachResult{}
+	if len(emails) == 0 || len(inboundIds) == 0 {
+		return result, false, nil
+	}
+
+	records := make([]*model.ClientRecord, 0, len(emails))
+	seenEmail := make(map[string]struct{}, len(emails))
+	for _, email := range emails {
+		if email == "" {
+			continue
+		}
+		key := strings.ToLower(email)
+		if _, ok := seenEmail[key]; ok {
+			continue
+		}
+		seenEmail[key] = struct{}{}
+		rec, err := s.GetRecordByEmail(nil, email)
+		if err != nil {
+			result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
+			continue
+		}
+		records = append(records, rec)
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		inbound, err := inboundSvc.GetInbound(ibId)
+		if err != nil {
+			result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
+			continue
+		}
+		existingClients, err := inboundSvc.GetClients(inbound)
+		if err != nil {
+			result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
+			continue
+		}
+		have := make(map[string]struct{}, len(existingClients))
+		for _, c := range existingClients {
+			have[strings.ToLower(c.Email)] = struct{}{}
+		}
+
+		clientsToAdd := make([]model.Client, 0, len(records))
+		for _, rec := range records {
+			if _, attached := have[strings.ToLower(rec.Email)]; attached {
+				result.Skipped = append(result.Skipped, rec.Email)
+				continue
+			}
+			client := *rec.ToClient()
+			client.UpdatedAt = time.Now().UnixMilli()
+			if err := s.fillProtocolDefaults(&client, inbound); err != nil {
+				result.Errors = append(result.Errors, fmt.Sprintf("%s -> inbound %d: %v", rec.Email, ibId, err))
+				continue
+			}
+			clientsToAdd = append(clientsToAdd, client)
+		}
+
+		if len(clientsToAdd) == 0 {
+			continue
+		}
+
+		payload, err := json.Marshal(map[string][]model.Client{"clients": clientsToAdd})
+		if err != nil {
+			result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
+			continue
+		}
+		nr, err := s.AddInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)})
+		if err != nil {
+			result.Errors = append(result.Errors, fmt.Sprintf("inbound %d: %v", ibId, err))
+			continue
+		}
+		if nr {
+			needRestart = true
+		}
+		for _, c := range clientsToAdd {
+			result.Attached = append(result.Attached, c.Email)
+		}
+	}
+
+	return result, needRestart, nil
+}
+
 func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
@@ -804,10 +900,74 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 		return false, common.NewError("client email is required")
 	}
 	rec, err := s.GetRecordByEmail(nil, email)
-	if err != nil {
+	if err == nil {
+		return s.Delete(inboundSvc, rec.Id, keepTraffic)
+	}
+	if !errors.Is(err, gorm.ErrRecordNotFound) {
 		return false, err
 	}
-	return s.Delete(inboundSvc, rec.Id, keepTraffic)
+	inboundIds, idsErr := s.findInboundIdsByClientEmail(email)
+	if idsErr != nil {
+		return false, idsErr
+	}
+	if len(inboundIds) == 0 {
+		return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
+	}
+	needRestart := false
+	for _, ibId := range inboundIds {
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	if !keepTraffic {
+		db := database.GetDB()
+		if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+			return needRestart, err
+		}
+		if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
+			return needRestart, err
+		}
+	}
+	return needRestart, nil
+}
+
+// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
+// JSON contains an entry with the given email. Driver-portable (no JSON
+// operators) by parsing in Go — fine for the rare fallback path.
+func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
+	var inbounds []model.Inbound
+	if err := database.GetDB().
+		Select("id, settings").
+		Where("settings LIKE ?", "%"+email+"%").
+		Find(&inbounds).Error; err != nil {
+		return nil, err
+	}
+	out := make([]int, 0, len(inbounds))
+	for _, ib := range inbounds {
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		for _, c := range clients {
+			cm, ok := c.(map[string]any)
+			if !ok {
+				continue
+			}
+			if cEmail, _ := cm["email"].(string); cEmail == email {
+				out = append(out, ib.Id)
+				break
+			}
+		}
+	}
+	return out, nil
 }
 
 func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {

+ 2 - 2
web/service/panel.go

@@ -131,7 +131,7 @@ func (s *PanelService) StartUpdate() error {
 }
 
 func downloadPanelUpdater() (string, error) {
-	client := &http.Client{Timeout: 15 * time.Second}
+	client := (&SettingService{}).NewProxiedHTTPClient(15 * time.Second)
 	resp, err := client.Get(panelUpdaterURL)
 	if err != nil {
 		return "", fmt.Errorf("download panel updater: %w", err)
@@ -169,7 +169,7 @@ func downloadPanelUpdater() (string, error) {
 }
 
 func fetchLatestPanelVersion() (string, error) {
-	client := &http.Client{Timeout: 10 * time.Second}
+	client := (&SettingService{}).NewProxiedHTTPClient(10 * time.Second)
 	resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
 	if err != nil {
 		return "", err

+ 4 - 5
web/service/server.go

@@ -617,8 +617,6 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
 	return s.emaCPU, nil
 }
 
-var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
-
 const (
 	maxXrayArchiveBytes = 200 << 20
 	maxXrayBinaryBytes  = 200 << 20
@@ -630,7 +628,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 		bufferSize = 8192
 	)
 
-	resp, err := xrayVersionsClient.Get(XrayURL)
+	resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Get(XrayURL)
 	if err != nil {
 		return nil, err
 	}
@@ -729,7 +727,7 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
 
 	fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
 	url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
-	client := &http.Client{Timeout: 60 * time.Second}
+	client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
 	resp, err := client.Get(url)
 	if err != nil {
 		return "", err
@@ -1273,6 +1271,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 		}
 	}
 
+	client := s.settingService.NewProxiedHTTPClient(0)
+
 	downloadFile := func(url, destPath string) error {
 		var req *http.Request
 		req, err := http.NewRequest("GET", url, nil)
@@ -1288,7 +1288,6 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 			}
 		}
 
-		client := &http.Client{}
 		resp, err := client.Do(req)
 		if err != nil {
 			return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)

+ 28 - 0
web/service/setting.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"net/http"
 	"reflect"
 	"strconv"
 	"strings"
@@ -15,6 +16,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/util/random"
 	"github.com/mhsanaei/3x-ui/v3/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/web/entity"
@@ -88,6 +90,7 @@ var defaultValueMap = map[string]string{
 	"externalTrafficInformURI":    "",
 	"restartXrayOnClientDisable":  "true",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
+	"panelProxy":                  "",
 
 	// LDAP defaults
 	"ldapEnable":            "false",
@@ -351,6 +354,31 @@ func (s *SettingService) SetTgBotProxy(token string) error {
 	return s.setString("tgBotProxy", token)
 }
 
+func (s *SettingService) GetPanelProxy() (string, error) {
+	return s.getString("panelProxy")
+}
+
+func (s *SettingService) SetPanelProxy(proxyUrl string) error {
+	return s.setString("panelProxy", proxyUrl)
+}
+
+// NewProxiedHTTPClient returns an HTTP client that routes the panel's own
+// outbound requests through the configured panelProxy setting. An invalid or
+// missing proxy falls back to a direct client so existing behavior is preserved.
+func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Client {
+	proxyUrl, err := s.GetPanelProxy()
+	if err != nil {
+		logger.Warning("Failed to read panel proxy setting:", err)
+		proxyUrl = ""
+	}
+	client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
+	if err != nil {
+		logger.Warningf("Invalid panel proxy %q, using direct connection: %v", proxyUrl, err)
+		return &http.Client{Timeout: timeout}
+	}
+	return client
+}
+
 func (s *SettingService) GetTgBotAPIServer() (string, error) {
 	return s.getString("tgBotAPIServer")
 }

+ 11 - 0
web/service/tgbot.go

@@ -246,6 +246,17 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 		logger.Warning("Failed to get Telegram bot proxy URL:", err)
 	}
 
+	// Fall back to the panel-wide proxy when no dedicated bot proxy is set.
+	// The bot's fasthttp dialer only supports SOCKS5, so other schemes are ignored.
+	if tgBotProxy == "" {
+		panelProxy, perr := t.settingService.GetPanelProxy()
+		if perr != nil {
+			logger.Warning("Failed to get panel proxy URL:", perr)
+		} else if strings.HasPrefix(panelProxy, "socks5://") {
+			tgBotProxy = panelProxy
+		}
+	}
+
 	// Get Telegram bot API server URL
 	tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
 	if err != nil {

+ 12 - 0
web/translation/en-US.json

@@ -298,6 +298,14 @@
       "delAllClients": "Delete All Clients",
       "delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
       "delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
+      "attachClients": "Attach Clients To…",
+      "assignClientsGroup": "Assign Clients To Group…",
+      "attachClientsTitle": "Attach clients from \"{remark}\"",
+      "attachClientsDesc": "Attaches the same {count} clients (same UUID/password and shared traffic) to the selected inbound(s). They stay on this inbound too.",
+      "attachClientsTargets": "Target inbounds",
+      "attachClientsNoTargets": "No other compatible inbounds available to attach to.",
+      "attachClientsResult": "Attached {attached}, skipped {skipped}.",
+      "attachClientsResultMixed": "Attached {attached}, skipped {skipped}, errors {errors}.",
       "exportLinksTitle": "Export inbound links",
       "exportSubsTitle": "Export subscription links",
       "exportAllLinksTitle": "Export all inbound links",
@@ -347,6 +355,7 @@
       "IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
       "IPLimitlogclear": "Clear the Log",
       "setDefaultCert": "Set Cert from Panel",
+      "setDefaultCertEmpty": "No certificate is configured for the panel. Set one under Settings first.",
       "streamTab": "Stream",
       "securityTab": "Security",
       "sniffingTab": "Sniffing",
@@ -545,6 +554,7 @@
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "flow": "Flow",
+      "vmessSecurity": "VMess Security",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Optional reverse tag",
       "telegramId": "Telegram user ID",
@@ -685,6 +695,8 @@
       "panelUrlPathDesc": "The URI path for the web panel. (begins with ‘/‘ and concludes with ‘/‘)",
       "pageSize": "Pagination Size",
       "pageSizeDesc": "Define page size for inbounds table. (0 = disable)",
+      "panelProxy": "Panel Network Proxy",
+      "panelProxyDesc": "Routes the panel's own outbound requests (geo updates, Xray/panel version checks, Telegram) through this proxy to bypass server-side filtering of GitHub/Telegram. Accepts socks5:// or http(s)://, e.g. a local Xray SOCKS inbound. Leave empty for a direct connection.",
       "remarkModel": "Remark Model & Separation Character",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",

+ 11 - 0
web/translation/fa-IR.json

@@ -293,6 +293,14 @@
       "delAllClients": "حذف همه کلاینت‌ها",
       "delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
       "delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.",
+      "attachClients": "اتصال کلاینت‌ها به…",
+      "assignClientsGroup": "افزودن کلاینت‌ها به گروه…",
+      "attachClientsTitle": "اتصال کلاینت‌های «{remark}»",
+      "attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخاب‌شده هم متصل می‌کند. روی این اینباند هم باقی می‌مانند.",
+      "attachClientsTargets": "اینباندهای مقصد",
+      "attachClientsNoTargets": "اینباند سازگار دیگری برای اتصال وجود ندارد.",
+      "attachClientsResult": "{attached} متصل شد، {skipped} رد شد.",
+      "attachClientsResultMixed": "{attached} متصل شد، {skipped} رد شد، {errors} خطا.",
       "exportLinksTitle": "خروجی لینک‌های اینباند",
       "exportSubsTitle": "خروجی لینک‌های ساب",
       "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها",
@@ -509,6 +517,7 @@
       "hysteriaAuth": "Auth (هیستریا)",
       "uuid": "UUID",
       "flow": "Flow",
+      "vmessSecurity": "امنیت VMess",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",
@@ -620,6 +629,8 @@
       "panelUrlPathDesc": "برای وب پنل. با '/' شروع‌ و با '/' خاتمه‌ می‌یابد URI مسیر",
       "pageSize": "اندازه صفحه بندی جدول",
       "pageSizeDesc": "(اندازه صفحه برای جدول ورودی‌ها.(0 = غیرفعال",
+      "panelProxy": "پراکسی شبکه‌ی پنل",
+      "panelProxyDesc": "درخواست‌های خروجیِ خودِ پنل (آپدیت geo، چک نسخه‌ی Xray و پنل، تلگرام) را از این پراکسی عبور می‌دهد تا فیلترینگ سروری گیت‌هاب/تلگرام دور زده شود. پشتیبانی از socks5:// و http(s)://، برای نمونه یک اینباند SOCKS لوکالِ Xray. برای اتصال مستقیم خالی بگذارید.",
       "remarkModel": "نام‌کانفیگ و جداکننده",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",