1
0

9 Коммитууд 1fd2c1333c ... 90a64a1b22

Эзэн SHA1 Мессеж Огноо
  MHSanaei 90a64a1b22 fix(ssl): prompt before setting IP cert path for panel 18 цаг өмнө
  MHSanaei 7ea88e3e37 fix(clients): store flow per-inbound for shared clients 19 цаг өмнө
  MHSanaei 8e301dbca9 fix(clients): preserve UUID when toggling enable from clients page 19 цаг өмнө
  MHSanaei 8a28373a01 fix(nodes): use GREATEST for last_online merge on PostgreSQL 19 цаг өмнө
  MHSanaei df777c12d3 fix(outbounds): preserve TLS/Reality security on save 19 цаг өмнө
  MHSanaei 169068d8fb fix(nodes): clean up orphaned client_inbounds on node inbound removal 19 цаг өмнө
  MHSanaei b395a1b951 fix(inbounds): restore xHTTP Headers editor in form 23 цаг өмнө
  MHSanaei cda7f2ac17 fix(sub): stop external-proxy dest from clobbering TLS SNI 1 өдөр өмнө
  MHSanaei 798e18b6ee feat(fallbacks): add per-rule dest override 1 өдөр өмнө

+ 15 - 0
database/db.go

@@ -83,6 +83,21 @@ func initModels() error {
 			return err
 		}
 	}
+	if err := pruneOrphanedClientInbounds(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func pruneOrphanedClientInbounds() error {
+	res := db.Exec("DELETE FROM client_inbounds WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
+	if res.Error != nil {
+		log.Printf("Error pruning orphaned client_inbounds rows: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Pruned %d orphaned client_inbounds row(s)", res.RowsAffected)
+	}
 	return nil
 }
 

+ 8 - 6
database/dialect.go

@@ -12,15 +12,17 @@ func JSONClientsFromInbound() string {
 	return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
 }
 
-// JSONFieldText returns a SQL expression that extracts the textual value of <key>
-// from a JSON expression. On both backends the result is the raw (unquoted) string,
-// so callers do NOT need to trim surrounding quotes.
 func JSONFieldText(expr, key string) string {
 	if IsPostgres() {
 		return fmt.Sprintf("(%s ->> '%s')", expr, key)
 	}
-	// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
-	// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
-	// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
+
 	return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
 }
+
+func GreatestExpr(a, b string) string {
+	if IsPostgres() {
+		return fmt.Sprintf("GREATEST(%s, %s)", a, b)
+	}
+	return fmt.Sprintf("MAX(%s, %s)", a, b)
+}

+ 11 - 15
database/model/model.go

@@ -41,17 +41,17 @@ type User struct {
 
 // Inbound represents an Xray inbound configuration with traffic statistics and settings.
 type Inbound struct {
-	Id                   int                  `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`                                                    // Unique identifier
-	UserId               int                  `json:"-"`                                                                                               // Associated user ID
-	Up                   int64                `json:"up" form:"up"`                                                                                    // Upload traffic in bytes
-	Down                 int64                `json:"down" form:"down"`                                                                                // Download traffic in bytes
-	Total                int64                `json:"total" form:"total"`                                                                              // Total traffic limit in bytes
-	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
-	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
-	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
+	Id                   int                  `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`                                                                                                                 // Unique identifier
+	UserId               int                  `json:"-"`                                                                                                                                                            // Associated user ID
+	Up                   int64                `json:"up" form:"up"`                                                                                                                                                 // Upload traffic in bytes
+	Down                 int64                `json:"down" form:"down"`                                                                                                                                             // Download traffic in bytes
+	Total                int64                `json:"total" form:"total"`                                                                                                                                           // Total traffic limit in bytes
+	Remark               string               `json:"remark" form:"remark"`                                                                                                                                         // Human-readable remark
+	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                                                                                        // Whether the inbound is enabled
+	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
-	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                               // Last traffic reset timestamp
-	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                        // Client traffic statistics
+	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                                                                                            // Last traffic reset timestamp
+	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                                                                                     // Client traffic statistics
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
@@ -513,11 +513,6 @@ type ClientInbound struct {
 
 func (ClientInbound) TableName() string { return "client_inbounds" }
 
-// InboundFallback is one routing rule on a master inbound's
-// settings.fallbacks array. The master is always a VLESS or Trojan
-// inbound on TCP transport with TLS or Reality. The child is any other
-// inbound — its listen+port becomes the fallback dest, with optional
-// SNI/ALPN/path match criteria pulled from the same row.
 type InboundFallback struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	MasterId  int    `json:"masterId" gorm:"index;not null;column:master_id"`
@@ -525,6 +520,7 @@ type InboundFallback struct {
 	Name      string `json:"name"`
 	Alpn      string `json:"alpn"`
 	Path      string `json:"path"`
+	Dest      string `json:"dest"`
 	Xver      int    `json:"xver"`
 	SortOrder int    `json:"sortOrder" gorm:"default:0;column:sort_order"`
 }

+ 0 - 1
database/model/model_test.go

@@ -188,4 +188,3 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
 		})
 	}
 }
-

+ 4 - 2
frontend/public/openapi.json

@@ -863,7 +863,7 @@
         "tags": [
           "Inbounds"
         ],
-        "summary": "List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.",
+        "summary": "List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/dest/xver match criteria. When dest is empty the child inbound's listen+port is used.",
         "operationId": "get_panel_api_inbounds_id_fallbacks",
         "parameters": [
           {
@@ -903,6 +903,7 @@
                       "name": "",
                       "alpn": "",
                       "path": "/vlws",
+                      "dest": "",
                       "xver": 2,
                       "sortOrder": 0
                     }
@@ -946,7 +947,8 @@
                   },
                   {
                     "childId": 12,
-                    "alpn": "h2"
+                    "alpn": "h2",
+                    "dest": "8443"
                   }
                 ]
               }

+ 22 - 11
frontend/src/hooks/useClients.ts

@@ -397,20 +397,31 @@ export function useClients() {
 
   const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
     if (!client?.email) return null;
-    const payload = {
-      email: client.email,
-      subId: client.subId,
-      id: client.uuid,
-      password: client.password,
-      auth: client.auth,
-      totalGB: client.totalGB || 0,
-      expiryTime: client.expiryTime || 0,
-      limitIp: client.limitIp || 0,
-      comment: client.comment || '',
+    const full = await hydrate(client.email);
+    const base = full?.client;
+    if (!base) return null;
+    const payload: Record<string, unknown> = {
+      email: base.email,
+      subId: base.subId,
+      id: base.uuid,
+      password: base.password,
+      auth: base.auth,
+      flow: base.flow || '',
+      security: base.security || 'auto',
+      totalGB: base.totalGB || 0,
+      expiryTime: base.expiryTime || 0,
+      limitIp: base.limitIp || 0,
+      tgId: Number(base.tgId) || 0,
+      reset: Number(base.reset) || 0,
+      group: base.group || '',
+      comment: base.comment || '',
       enable: !!enable,
     };
+    if (base.reverse?.tag) {
+      payload.reverse = { tag: base.reverse.tag };
+    }
     return update(client.email, payload);
-  }, [update]);
+  }, [hydrate, update]);
 
   // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
   // covers coarse 'invalidate' and 'inbounds' events centrally.

+ 4 - 4
frontend/src/pages/api-docs/endpoints.ts

@@ -199,12 +199,12 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/inbounds/:id/fallbacks',
-        summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
+        summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/dest/xver match criteria. When dest is empty the child inbound\'s listen+port is used.',
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "masterId": 10,\n      "childId": 11,\n      "name": "",\n      "alpn": "",\n      "path": "/vlws",\n      "xver": 2,\n      "sortOrder": 0\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "masterId": 10,\n      "childId": 11,\n      "name": "",\n      "alpn": "",\n      "path": "/vlws",\n      "dest": "",\n      "xver": 2,\n      "sortOrder": 0\n    }\n  ]\n}',
       },
       {
         method: 'POST',
@@ -212,9 +212,9 @@ export const sections: readonly Section[] = [
         summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
-          { name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
+          { name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, dest, xver, sortOrder} entries. Leave dest empty to auto-resolve from the child inbound\'s listen+port; set it (e.g. "8443", "127.0.0.1:8443", "/dev/shm/x.sock") to override.' },
         ],
-        body: '{\n  "fallbacks": [\n    { "childId": 11, "path": "/vlws", "xver": 2 },\n    { "childId": 12, "alpn": "h2" }\n  ]\n}',
+        body: '{\n  "fallbacks": [\n    { "childId": 11, "path": "/vlws", "xver": 2 },\n    { "childId": 12, "alpn": "h2", "dest": "8443" }\n  ]\n}',
         response: '{\n  "success": true,\n  "msg": "Inbound updated"\n}',
       },
     ],

+ 27 - 4
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -365,13 +365,21 @@ export default function InboundFormModal({
       return;
     }
     setFallbacks(
-      (msg.obj as { childId: number; name?: string; alpn?: string; path?: string; xver?: number }[])
+      (msg.obj as {
+        childId: number;
+        name?: string;
+        alpn?: string;
+        path?: string;
+        dest?: string;
+        xver?: number;
+      }[])
         .map((r) => ({
           rowKey: `fb-${++fallbackKeyRef.current}`,
           childId: r.childId,
           name: r.name || '',
           alpn: r.alpn || '',
           path: r.path || '',
+          dest: r.dest || '',
           xver: r.xver || 0,
         })),
     );
@@ -385,6 +393,7 @@ export default function InboundFormModal({
         name: c.name,
         alpn: c.alpn,
         path: c.path,
+        dest: c.dest,
         xver: Number(c.xver) || 0,
         sortOrder: i,
       })),
@@ -437,6 +446,7 @@ export default function InboundFormModal({
       name: '',
       alpn: '',
       path: '',
+      dest: '',
       xver: 0,
     }]);
   };
@@ -445,11 +455,11 @@ export default function InboundFormModal({
     setFallbacks((prev) => prev.map((r) => {
       if (r.rowKey !== rowKey) return r;
       // When the picker selects a new child inbound and the row hasn't
-      // been hand-edited yet (sni/alpn/path all blank, xver = 0), pull
-      // the SNI/ALPN/Path defaults off that child. Operators who
+      // been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
+      // pull the SNI/ALPN/Path defaults off that child. Operators who
       // intentionally typed values keep them — we only fill the empties.
       if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
-        const isPristine = !r.name && !r.alpn && !r.path && r.xver === 0;
+        const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
         if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
       }
       return { ...r, ...patch };
@@ -490,6 +500,7 @@ export default function InboundFormModal({
             name: derived.name ?? '',
             alpn: derived.alpn ?? '',
             path: derived.path ?? '',
+            dest: '',
             xver: derived.xver ?? 0,
           };
         });
@@ -1079,6 +1090,12 @@ export default function InboundFormModal({
               value={record.path}
               onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
             />
+            <InputAddon>Dest</InputAddon>
+            <Input
+              placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
+              value={record.dest}
+              onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
+            />
             <InputAddon>xver</InputAddon>
             <InputNumber
               min={0}
@@ -1965,6 +1982,12 @@ export default function InboundFormModal({
           >
             <Input />
           </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'headers']}
+            label={t('pages.inbounds.form.headers')}
+          >
+            <HeaderMapEditor mode="v1" />
+          </Form.Item>
           <Form.Item
             name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
             label={t('pages.inbounds.form.uplinkHttpMethod')}

+ 2 - 2
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -393,9 +393,8 @@ export default function OutboundFormModal({
 
   async function onOk() {
     if (activeKey === '2' && !applyJsonToForm()) return;
-    let values: OutboundFormValues;
     try {
-      values = await form.validateFields();
+      await form.validateFields();
     } catch {
       return;
     }
@@ -403,6 +402,7 @@ export default function OutboundFormModal({
       messageApi.error('Tag already used by another outbound');
       return;
     }
+    const values = form.getFieldsValue(true) as OutboundFormValues;
     onConfirm(formValuesToWirePayload(values));
   }
 

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -78,6 +78,7 @@ export const FallbackRowSchema = z.object({
   name: z.string().default(''),
   alpn: z.string().default(''),
   path: z.string().default(''),
+  dest: z.string().default(''),
   xver: z.number().int().min(0).max(2).default(0),
 });
 export type FallbackRow = z.infer<typeof FallbackRowSchema>;

+ 0 - 3
sub/subService.go

@@ -990,9 +990,6 @@ func externalProxySNI(ep map[string]any) (string, bool) {
 	if sni, ok := ep["sni"].(string); ok && sni != "" {
 		return sni, true
 	}
-	if dest, ok := ep["dest"].(string); ok && dest != "" {
-		return dest, true
-	}
 	return "", false
 }
 

+ 31 - 10
sub/subService_test.go

@@ -484,24 +484,42 @@ func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) {
 	}
 }
 
-func TestApplyExternalProxyTLSParams_FallsBackToDestSNI(t *testing.T) {
-	params := map[string]string{"security": "tls"}
+func TestApplyExternalProxyTLSParams_PreservesUpstreamSNI(t *testing.T) {
+	// External-proxy entry has no SNI of its own; its dest must not
+	// clobber the upstream tlsSettings.serverName already written into
+	// params. Regression: the dest fallback used to overwrite "222" with
+	// "111" whenever an operator set forceTls=same and left the proxy's
+	// SNI field blank.
+	params := map[string]string{"security": "tls", "sni": "real.example.com"}
 	ep := map[string]any{"dest": "proxy.example.com"}
 
 	applyExternalProxyTLSParams(ep, params, "tls")
 
-	if params["sni"] != "proxy.example.com" {
-		t.Fatalf("sni = %q, want proxy.example.com", params["sni"])
+	if params["sni"] != "real.example.com" {
+		t.Fatalf("sni = %q, want upstream sni preserved (real.example.com)", params["sni"])
+	}
+}
+
+func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T) {
+	params := map[string]string{"security": "tls", "sni": "real.example.com"}
+	ep := map[string]any{"dest": "proxy.example.com", "sni": "edge.example.com"}
+
+	applyExternalProxyTLSParams(ep, params, "tls")
+
+	if params["sni"] != "edge.example.com" {
+		t.Fatalf("sni = %q, want edge.example.com", params["sni"])
 	}
 }
 
 func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 	stream := map[string]any{
-		"security":    "tls",
-		"tlsSettings": map[string]any{},
+		"security": "tls",
+		"tlsSettings": map[string]any{
+			"serverName": "upstream.example.com",
+		},
 	}
 	proxies := []map[string]any{
-		{"dest": "a.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
+		{"dest": "a.example.com", "sni": "a-sni.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
 		{"dest": "b.example.com"},
 	}
 
@@ -518,11 +536,14 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 		results = append(results, snapshot)
 	}
 
-	if results[0]["serverName"] != "a.example.com" || results[0]["fingerprint"] != "chrome" {
+	if results[0]["serverName"] != "a-sni.example.com" || results[0]["fingerprint"] != "chrome" {
 		t.Fatalf("proxy A snapshot = %v", results[0])
 	}
-	if results[1]["serverName"] != "b.example.com" {
-		t.Fatalf("proxy B serverName = %v, want b.example.com", results[1]["serverName"])
+	// Proxy B has no SNI of its own — the upstream tlsSettings serverName
+	// must remain in place (no dest fallback) and no fingerprint/alpn
+	// must leak from proxy A.
+	if results[1]["serverName"] != "upstream.example.com" {
+		t.Fatalf("proxy B serverName = %v, want upstream.example.com preserved", results[1]["serverName"])
 	}
 	if results[1]["fingerprint"] != nil {
 		t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])

+ 24 - 24
web/entity/entity.go

@@ -21,34 +21,34 @@ type Msg struct {
 // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
 type AllSetting struct {
 	// Web server settings
-	WebListen         string `json:"webListen" form:"webListen"`                                       // Web server listen IP address
-	WebDomain         string `json:"webDomain" form:"webDomain"`                                       // Web server domain for domain validation
-	WebPort           int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`                // Web server port number
-	WebCertFile       string `json:"webCertFile" form:"webCertFile"`                                   // Path to SSL certificate file for web server
-	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`                                     // Path to SSL private key file for web server
-	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)
+	WebListen         string `json:"webListen" form:"webListen"`                                     // Web server listen IP address
+	WebDomain         string `json:"webDomain" form:"webDomain"`                                     // Web server domain for domain validation
+	WebPort           int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`              // Web server port number
+	WebCertFile       string `json:"webCertFile" form:"webCertFile"`                                 // Path to SSL certificate file for web server
+	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`                                   // Path to SSL private key file for web server
+	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
-	ExpireDiff  int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`          // Expiration warning threshold in days
-	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`// Traffic warning threshold percentage
-	RemarkModel string `json:"remarkModel" form:"remarkModel"`                         // Remark model pattern for inbounds
-	Datepicker  string `json:"datepicker" form:"datepicker"`                           // Date picker format
+	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"`      // Number of items per page in lists
+	ExpireDiff  int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`           // Expiration warning threshold in days
+	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
+	RemarkModel string `json:"remarkModel" form:"remarkModel"`                          // Remark model pattern for inbounds
+	Datepicker  string `json:"datepicker" form:"datepicker"`                            // Date picker format
 
 	// Telegram bot settings
-	TgBotEnable      bool   `json:"tgBotEnable" form:"tgBotEnable"`           // Enable Telegram bot notifications
-	TgBotToken       string `json:"tgBotToken" form:"tgBotToken"`             // Telegram bot token
-	TgBotProxy       string `json:"tgBotProxy" form:"tgBotProxy"`             // Proxy URL for Telegram bot
-	TgBotAPIServer   string `json:"tgBotAPIServer" form:"tgBotAPIServer"`     // Custom API server for Telegram bot
-	TgBotChatId      string `json:"tgBotChatId" form:"tgBotChatId"`           // Telegram chat ID for notifications
-	TgRunTime        string `json:"tgRunTime" form:"tgRunTime"`               // Cron schedule for Telegram notifications
-	TgBotBackup      bool   `json:"tgBotBackup" form:"tgBotBackup"`           // Enable database backup via Telegram
-	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`             // Send login notifications
-	TgCpu            int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`          // CPU usage threshold for alerts (percent)
-	TgLang           string `json:"tgLang" form:"tgLang"`                                 // Telegram bot language
+	TgBotEnable      bool   `json:"tgBotEnable" form:"tgBotEnable"`              // Enable Telegram bot notifications
+	TgBotToken       string `json:"tgBotToken" form:"tgBotToken"`                // Telegram bot token
+	TgBotProxy       string `json:"tgBotProxy" form:"tgBotProxy"`                // Proxy URL for Telegram bot
+	TgBotAPIServer   string `json:"tgBotAPIServer" form:"tgBotAPIServer"`        // Custom API server for Telegram bot
+	TgBotChatId      string `json:"tgBotChatId" form:"tgBotChatId"`              // Telegram chat ID for notifications
+	TgRunTime        string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
+	TgBotBackup      bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
+	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`    // Send login notifications
+	TgCpu            int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
+	TgLang           string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
 
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location

+ 20 - 28
web/service/client.go

@@ -299,9 +299,7 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
 	out := make([]model.Client, 0, len(rows))
 	for i := range rows {
 		c := rows[i].ToClient()
-		if rows[i].FlowOverride != "" {
-			c.Flow = rows[i].FlowOverride
-		}
+		c.Flow = rows[i].FlowOverride
 		out = append(out, *c)
 	}
 	return out, nil
@@ -455,7 +453,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		if err := s.fillProtocolDefaults(&client, inbound); err != nil {
 			return needRestart, err
 		}
-		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}})
 		if mErr != nil {
 			return needRestart, mErr
 		}
@@ -496,8 +494,13 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
 	return nil
 }
 
-// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
-// settings JSON. Returns "" when the field is missing or settings is invalid.
+func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
+	if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) {
+		c.Flow = ""
+	}
+	return c
+}
+
 func shadowsocksMethodFromSettings(settings string) string {
 	if settings == "" {
 		return ""
@@ -510,11 +513,6 @@ func shadowsocksMethodFromSettings(settings string) string {
 	return method
 }
 
-// randomShadowsocksClientKey returns a per-client key sized to the cipher.
-// The 2022-blake3 ciphers require a base64-encoded key of an exact byte
-// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and
-// chacha20-poly1305) — anything else fails with "bad key" on xray start.
-// Older ciphers accept arbitrary passwords, so we keep the uuid-style.
 func randomShadowsocksClientKey(method string) string {
 	if n := shadowsocksKeyBytes(method); n > 0 {
 		return random.Base64Bytes(n)
@@ -522,9 +520,6 @@ func randomShadowsocksClientKey(method string) string {
 	return strings.ReplaceAll(uuid.NewString(), "-", "")
 }
 
-// validShadowsocksClientKey reports whether key is acceptable for the cipher.
-// For 2022-blake3 it must decode to the exact byte length the cipher needs;
-// any other method accepts any non-empty string.
 func validShadowsocksClientKey(method, key string) bool {
 	n := shadowsocksKeyBytes(method)
 	if n == 0 {
@@ -547,13 +542,6 @@ func shadowsocksKeyBytes(method string) int {
 	return 0
 }
 
-// applyShadowsocksClientMethod normalises the per-client "method" field
-// when an inbound is created or updated:
-//   - Legacy ciphers: backfill `method` so xray's multi-user code is happy.
-//     "unsupported cipher method:" otherwise.
-//   - 2022-blake3-*: strip the per-client `method` because xray rejects
-//     it with "users must have empty method". This matters after an admin
-//     switches an existing inbound from a legacy cipher to a 2022 one.
 func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	method, _ := settings["method"].(string)
 	is2022 := strings.HasPrefix(method, "2022-blake3-")
@@ -604,10 +592,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		updated.CreatedAt = existing.CreatedAt
 	}
 
-	// Rename the ClientRecord row up front when the email changes. SyncInbound
-	// (invoked from UpdateInboundClient below) looks up by email — without
-	// renaming first it would treat the new email as a brand-new client,
-	// insert a duplicate ClientRecord, and leave the original orphaned.
 	if updated.Email != existing.Email {
 		var collisionCount int64
 		if err := database.GetDB().Model(&model.ClientRecord{}).
@@ -629,6 +613,14 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	for _, ibId := range inboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
 		if getErr != nil {
+			if errors.Is(getErr, gorm.ErrRecordNotFound) {
+				if err := database.GetDB().
+					Where("client_id = ? AND inbound_id = ?", id, ibId).
+					Delete(&model.ClientInbound{}).Error; err != nil {
+					return needRestart, err
+				}
+				continue
+			}
 			return needRestart, getErr
 		}
 		oldKey := clientKeyForProtocol(inbound.Protocol, existing)
@@ -638,7 +630,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
 			return needRestart, err
 		}
-		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}})
 		if mErr != nil {
 			return needRestart, mErr
 		}
@@ -744,7 +736,7 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
 		if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
 			return needRestart, err
 		}
-		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
 		if mErr != nil {
 			return needRestart, mErr
 		}
@@ -862,7 +854,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
 				recordErr("%s -> inbound %d: %v", rec.Email, ibId, err)
 				continue
 			}
-			clientsToAdd = append(clientsToAdd, client)
+			clientsToAdd = append(clientsToAdd, clientWithInboundFlow(client, inbound))
 		}
 
 		if len(clientsToAdd) == 0 {

+ 85 - 0
web/service/client_flow_isolation_test.go

@@ -0,0 +1,85 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
+	const vision = "xtls-rprx-vision"
+	cases := []struct {
+		name           string
+		protocol       model.Protocol
+		streamSettings string
+		wantFlow       string
+	}{
+		{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, vision},
+		{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, vision},
+		{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, ""},
+		{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, ""},
+		{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, ""},
+		{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, ""},
+		{"empty stream clears flow", model.VLESS, "", ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings}
+			got := clientWithInboundFlow(model.Client{Email: "[email protected]", Flow: vision}, ib)
+			if got.Flow != tc.wantFlow {
+				t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow)
+			}
+		})
+	}
+}
+
+func TestFlowIsolation_VisionDoesNotLeakToWsInbound(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	wsTls := &model.Inbound{Tag: "vless-ws", Enable: true, Port: 30001, Protocol: model.VLESS, StreamSettings: `{"network":"ws","security":"tls"}`}
+	if err := db.Create(wsTls).Error; err != nil {
+		t.Fatalf("create ws+tls inbound: %v", err)
+	}
+	reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 30002, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
+	if err := db.Create(reality).Error; err != nil {
+		t.Fatalf("create reality inbound: %v", err)
+	}
+
+	svc := ClientService{}
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
+	const vision = "xtls-rprx-vision"
+
+	source := model.Client{Email: email, ID: uid, Enable: true, Flow: vision}
+	for _, ib := range []*model.Inbound{wsTls, reality} {
+		gated := clientWithInboundFlow(source, ib)
+		if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil {
+			t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
+		}
+	}
+
+	realityList, err := svc.ListForInbound(nil, reality.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(reality): %v", err)
+	}
+	if len(realityList) != 1 || realityList[0].Flow != vision {
+		t.Errorf("Reality inbound should keep flow=%q, got %#v", vision, realityList)
+	}
+
+	wsList, err := svc.ListForInbound(nil, wsTls.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(ws): %v", err)
+	}
+	if len(wsList) != 1 || wsList[0].Flow != "" {
+		t.Errorf("WS+TLS inbound must not inherit Vision flow (#4628), got %#v", wsList)
+	}
+}

+ 10 - 7
web/service/fallback.go

@@ -18,6 +18,7 @@ type FallbackInput struct {
 	Name      string `json:"name"`
 	Alpn      string `json:"alpn"`
 	Path      string `json:"path"`
+	Dest      string `json:"dest"`
 	Xver      int    `json:"xver"`
 	SortOrder int    `json:"sortOrder"`
 }
@@ -71,6 +72,7 @@ func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error
 				Name:      c.Name,
 				Alpn:      c.Alpn,
 				Path:      c.Path,
+				Dest:      c.Dest,
 				Xver:      c.Xver,
 				SortOrder: c.SortOrder,
 			}
@@ -85,9 +87,6 @@ func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error
 	})
 }
 
-// BuildFallbacksJSON resolves the master's fallback rows into Xray's
-// expected settings.fallbacks shape, looking up each child's listen+port
-// to fill the dest field. Returns nil when the master has no rules.
 func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
 	if tx == nil {
 		tx = database.GetDB()
@@ -122,12 +121,16 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s
 		if !ok {
 			continue
 		}
-		listen := strings.TrimSpace(child.Listen)
-		if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
-			listen = "127.0.0.1"
+		dest := r.Dest
+		if dest == "" {
+			listen := strings.TrimSpace(child.Listen)
+			if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
+				listen = "127.0.0.1"
+			}
+			dest = fmt.Sprintf("%s:%d", listen, child.Port)
 		}
 		entry := map[string]any{
-			"dest": fmt.Sprintf("%s:%d", listen, child.Port),
+			"dest": dest,
 		}
 		if r.Name != "" {
 			entry["name"] = r.Name

+ 15 - 7
web/service/inbound.go

@@ -1074,7 +1074,7 @@ func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol)
 	}
 }
 
-func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
+func (s *InboundService) buildTargetClientFromSource(source model.Client, targetInbound *model.Inbound, email string, flow string) (model.Client, error) {
 	nowTs := time.Now().UnixMilli()
 	target := source
 	target.Email = email
@@ -1086,12 +1086,14 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
 	target.Auth = ""
 	target.Flow = ""
 
+	targetProtocol := targetInbound.Protocol
 	switch targetProtocol {
 	case model.VMESS:
 		target.ID = s.generateRandomCredential(targetProtocol)
 	case model.VLESS:
 		target.ID = s.generateRandomCredential(targetProtocol)
-		if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
+		if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
+			inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings) {
 			target.Flow = flow
 		}
 	case model.Trojan, model.Shadowsocks:
@@ -1192,7 +1194,7 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID
 		}
 
 		targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
-		targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
+		targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound, targetEmail, flow)
 		if buildErr != nil {
 			result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
 			continue
@@ -1432,6 +1434,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return false, err
 		}
+		if err := s.clientService.DetachInbound(tx, c.Id); err != nil {
+			return false, err
+		}
 		if err := tx.Where("id = ?", c.Id).
 			Delete(&model.Inbound{}).Error; err != nil {
 			return false, err
@@ -1500,10 +1505,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			}
 
 			if err := tx.Exec(
-				`UPDATE client_traffics
-				 SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
-				     last_online = MAX(last_online, ?)
-				 WHERE email = ?`,
+				fmt.Sprintf(
+					`UPDATE client_traffics
+					 SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
+					     last_online = %s
+					 WHERE email = ?`,
+					database.GreatestExpr("last_online", "?"),
+				),
 				cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
 				cs.LastOnline, cs.Email,
 			).Error; err != nil {

+ 0 - 1
web/service/xray.go

@@ -304,7 +304,6 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
-
 // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
 func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
 	if !s.IsXrayRunning() {

+ 1 - 0
web/translation/ar-EG.json

@@ -266,6 +266,7 @@
         "add": "إضافة fallback",
         "pickInbound": "اختر inbound",
         "matchAny": "أي",
+        "destPlaceholder": "تلقائي (listen:port للفرع)",
         "rederive": "إعادة الملء من الفرع",
         "rederived": "تم إعادة الملء من الفرع",
         "editAdvanced": "تحرير حقول التوجيه",

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

@@ -266,6 +266,7 @@
         "add": "Add fallback",
         "pickInbound": "Pick an inbound",
         "matchAny": "any",
+        "destPlaceholder": "auto (child listen:port)",
         "rederive": "Re-fill from child",
         "rederived": "Re-filled from child",
         "editAdvanced": "Edit routing fields",

+ 1 - 0
web/translation/es-ES.json

@@ -266,6 +266,7 @@
         "add": "Añadir fallback",
         "pickInbound": "Selecciona un inbound",
         "matchAny": "cualquiera",
+        "destPlaceholder": "automático (listen:puerto del hijo)",
         "rederive": "Rellenar desde el hijo",
         "rederived": "Rellenado desde el hijo",
         "editAdvanced": "Editar campos de enrutamiento",

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

@@ -266,6 +266,7 @@
         "add": "افزودن فال‌بک",
         "pickInbound": "یک اینباند انتخاب کنید",
         "matchAny": "همه",
+        "destPlaceholder": "خودکار (listen:port فرزند)",
         "rederive": "پر کردن مجدد از فرزند",
         "rederived": "از فرزند پر شد",
         "editAdvanced": "ویرایش فیلدهای مسیریابی",

+ 1 - 0
web/translation/id-ID.json

@@ -266,6 +266,7 @@
         "add": "Tambah fallback",
         "pickInbound": "Pilih inbound",
         "matchAny": "apa pun",
+        "destPlaceholder": "otomatis (listen:port child)",
         "rederive": "Isi ulang dari child",
         "rederived": "Diisi ulang dari child",
         "editAdvanced": "Edit field routing",

+ 1 - 0
web/translation/ja-JP.json

@@ -266,6 +266,7 @@
         "add": "フォールバックを追加",
         "pickInbound": "インバウンドを選択",
         "matchAny": "任意",
+        "destPlaceholder": "自動(子の listen:port)",
         "rederive": "子から再取得",
         "rederived": "子から再取得しました",
         "editAdvanced": "ルーティング項目を編集",

+ 1 - 0
web/translation/pt-BR.json

@@ -266,6 +266,7 @@
         "add": "Adicionar fallback",
         "pickInbound": "Escolha um inbound",
         "matchAny": "qualquer",
+        "destPlaceholder": "automático (listen:porta do filho)",
         "rederive": "Preencher a partir do filho",
         "rederived": "Preenchido a partir do filho",
         "editAdvanced": "Editar campos de roteamento",

+ 1 - 0
web/translation/ru-RU.json

@@ -266,6 +266,7 @@
         "add": "Добавить фолбэк",
         "pickInbound": "Выберите инбаунд",
         "matchAny": "любой",
+        "destPlaceholder": "авто (listen:порт дочернего)",
         "rederive": "Заполнить из дочернего",
         "rederived": "Заполнено из дочернего",
         "editAdvanced": "Изменить поля маршрутизации",

+ 1 - 0
web/translation/tr-TR.json

@@ -266,6 +266,7 @@
         "add": "Fallback ekle",
         "pickInbound": "Bir inbound seç",
         "matchAny": "herhangi",
+        "destPlaceholder": "otomatik (child listen:port)",
         "rederive": "Child'dan yeniden doldur",
         "rederived": "Child'dan yeniden dolduruldu",
         "editAdvanced": "Yönlendirme alanlarını düzenle",

+ 1 - 0
web/translation/uk-UA.json

@@ -266,6 +266,7 @@
         "add": "Додати фолбек",
         "pickInbound": "Оберіть інбаунд",
         "matchAny": "будь-який",
+        "destPlaceholder": "авто (listen:порт дочірнього)",
         "rederive": "Заповнити з дочірнього",
         "rederived": "Заповнено з дочірнього",
         "editAdvanced": "Редагувати поля маршрутизації",

+ 1 - 0
web/translation/vi-VN.json

@@ -266,6 +266,7 @@
         "add": "Thêm fallback",
         "pickInbound": "Chọn một inbound",
         "matchAny": "bất kỳ",
+        "destPlaceholder": "tự động (listen:port của child)",
         "rederive": "Điền lại từ child",
         "rederived": "Đã điền lại từ child",
         "editAdvanced": "Sửa trường định tuyến",

+ 1 - 0
web/translation/zh-CN.json

@@ -266,6 +266,7 @@
         "add": "添加回落",
         "pickInbound": "选择一个入站",
         "matchAny": "任意",
+        "destPlaceholder": "自动(子入站 listen:port)",
         "rederive": "从子入站重新填充",
         "rederived": "已从子入站重新填充",
         "editAdvanced": "编辑路由字段",

+ 1 - 0
web/translation/zh-TW.json

@@ -266,6 +266,7 @@
         "add": "新增回落",
         "pickInbound": "選擇一個入站",
         "matchAny": "任何",
+        "destPlaceholder": "自動(子入站 listen:port)",
         "rederive": "從子入站重新填入",
         "rederived": "已從子入站重新填入",
         "editAdvanced": "編輯路由欄位",

+ 19 - 13
x-ui.sh

@@ -1371,24 +1371,30 @@ ssl_cert_issue_for_ip() {
     chmod 600 $certPath/privkey.pem 2> /dev/null
     chmod 644 $certPath/fullchain.pem 2> /dev/null
 
-    # Set certificate paths for the panel
+    # Prompt user to set panel paths after successful certificate installation
     local webCertFile="${certPath}/fullchain.pem"
     local webKeyFile="${certPath}/privkey.pem"
 
-    if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
-        ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
-        LOGI "Certificate configured for panel"
-        LOGI "  - Certificate File: $webCertFile"
-        LOGI "  - Private Key File: $webKeyFile"
-        LOGI "  - Validity: ~6 days (auto-renews via acme.sh cron)"
-        echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
-        LOGI "Panel will restart to apply SSL certificate..."
-        restart
-        return 0
+    read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
+    if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
+        if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
+            ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
+            LOGI "Panel paths set for IP: $server_ip"
+            LOGI "  - Certificate File: $webCertFile"
+            LOGI "  - Private Key File: $webKeyFile"
+            LOGI "  - Validity: ~6 days (auto-renews via acme.sh cron)"
+            echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
+            LOGI "Panel will restart to apply SSL certificate..."
+            restart
+        else
+            LOGE "Error: Certificate or private key file not found for IP: $server_ip."
+            return 1
+        fi
     else
-        LOGE "Certificate files not found after installation"
-        return 1
+        LOGI "Skipping panel path setting."
     fi
+
+    return 0
 }
 
 ssl_cert_issue() {