瀏覽代碼

feat: replace panel proxy URL with outbound-based egress bridge

Instead of requiring a manual SOCKS5/HTTP URL, the panel now lets the
admin pick an Xray outbound from a dropdown (same UX as Geodata
Auto-Update). At runtime, injectPanelEgress appends a loopback SOCKS
inbound (tag: panel-egress) and prepends a routing rule so the panel's
own HTTP traffic — version checks, Telegram, normal geo-file updates —
is routed through the chosen outbound. Xray-native Geodata Auto-Update
is unaffected (it uses its own geodata.outbound inside Xray). Blackhole
outbounds are excluded from both picker dropdowns since routing any
download through one just drops it. Translations updated for all 13
locales.
MHSanaei 10 小時之前
父節點
當前提交
ca4f32e3da

+ 6 - 6
frontend/public/openapi.json

@@ -120,8 +120,8 @@
             "minimum": 0,
             "type": "integer"
           },
-          "panelProxy": {
-            "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+          "panelOutbound": {
+            "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
           "remarkModel": {
@@ -383,7 +383,7 @@
           "ldapUserFilter",
           "ldapVlessField",
           "pageSize",
-          "panelProxy",
+          "panelOutbound",
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -554,8 +554,8 @@
             "minimum": 0,
             "type": "integer"
           },
-          "panelProxy": {
-            "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+          "panelOutbound": {
+            "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
           "remarkModel": {
@@ -823,7 +823,7 @@
           "ldapUserFilter",
           "ldapVlessField",
           "pageSize",
-          "panelProxy",
+          "panelOutbound",
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",

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

@@ -26,7 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserFilter": "",
     "ldapVlessField": "",
     "pageSize": 0,
-    "panelProxy": "",
+    "panelOutbound": "",
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
@@ -115,7 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserFilter": "",
     "ldapVlessField": "",
     "pageSize": 0,
-    "panelProxy": "",
+    "panelOutbound": "",
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,

+ 6 - 6
frontend/src/generated/schemas.ts

@@ -94,8 +94,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
-      "panelProxy": {
-        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+      "panelOutbound": {
+        "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
       "remarkModel": {
@@ -357,7 +357,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserFilter",
       "ldapVlessField",
       "pageSize",
-      "panelProxy",
+      "panelOutbound",
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
@@ -528,8 +528,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
-      "panelProxy": {
-        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+      "panelOutbound": {
+        "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
       "remarkModel": {
@@ -797,7 +797,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserFilter",
       "ldapVlessField",
       "pageSize",
-      "panelProxy",
+      "panelOutbound",
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",

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

@@ -30,7 +30,7 @@ export interface AllSetting {
   ldapUserFilter: string;
   ldapVlessField: string;
   pageSize: number;
-  panelProxy: string;
+  panelOutbound: string;
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
@@ -120,7 +120,7 @@ export interface AllSettingView {
   ldapUserFilter: string;
   ldapVlessField: string;
   pageSize: number;
-  panelProxy: string;
+  panelOutbound: string;
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;

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

@@ -38,7 +38,7 @@ export const AllSettingSchema = z.object({
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
-  panelProxy: z.string(),
+  panelOutbound: z.string(),
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
@@ -129,7 +129,7 @@ export const AllSettingViewSchema = z.object({
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
-  panelProxy: z.string(),
+  panelOutbound: z.string(),
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),

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

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

+ 5 - 1
frontend/src/pages/index/GeodataSection.tsx

@@ -65,10 +65,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       );
 
       // Download outbound candidates: template outbounds + subscription outbounds.
+      // Skip blackhole outbounds — routing a download through one just drops it.
       const tags = new Set<string>();
       const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
       for (const o of outbounds) {
-        const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
+        if (!o || typeof o !== 'object') continue;
+        const rec = o as Record<string, unknown>;
+        if (rec.protocol === 'blackhole') continue;
+        const tag = rec.tag;
         if (typeof tag === 'string' && tag) tags.add(tag);
       }
       const subTags = Array.isArray(payload.subscriptionOutboundTags)

+ 42 - 5
frontend/src/pages/settings/GeneralTab.tsx

@@ -43,6 +43,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
   const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
+  const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
 
   useEffect(() => {
     let cancelled = false;
@@ -65,6 +66,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
     return () => { cancelled = true; };
   }, []);
 
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      // Outbound tags for the panel egress picker: template outbounds plus
+      // subscription-derived outbounds, same candidate set as the geodata
+      // download picker.
+      const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
+      if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
+      try {
+        const payload = JSON.parse(msg.obj) as Record<string, unknown>;
+        const template = (payload.xraySetting || {}) as Record<string, unknown>;
+        const tags = new Set<string>();
+        const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
+        for (const o of outbounds) {
+          if (!o || typeof o !== 'object') continue;
+          const rec = o as Record<string, unknown>;
+          if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
+          const tag = rec.tag;
+          if (typeof tag === 'string' && tag) tags.add(tag);
+        }
+        const subTags = Array.isArray(payload.subscriptionOutboundTags) ? payload.subscriptionOutboundTags : [];
+        for (const tag of subTags) {
+          if (typeof tag === 'string' && tag) tags.add(tag);
+        }
+        setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
+      } catch {
+        setOutboundOptions([]);
+      }
+    })();
+    return () => { cancelled = true; };
+  }, []);
+
   const ldapInboundTagList = useMemo(() => {
     const csv = allSetting.ldapInboundTags || '';
     return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
@@ -133,11 +166,15 @@ 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 paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
+              <Select
+                style={{ width: '100%' }}
+                allowClear
+                showSearch
+                value={allSetting.panelOutbound || undefined}
+                placeholder={t('pages.settings.panelOutboundPh')}
+                options={outboundOptions}
+                onChange={(v) => updateSetting({ panelOutbound: (v as string | undefined) || '' })}
               />
             </SettingListItem>
 

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

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

+ 11 - 0
internal/web/controller/setting.go

@@ -5,6 +5,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
@@ -29,6 +30,7 @@ type SettingController struct {
 	userService     panel.UserService
 	panelService    panel.PanelService
 	apiTokenService panel.ApiTokenService
+	xrayService     service.XrayService
 }
 
 // NewSettingController creates a new SettingController and initializes its routes.
@@ -81,12 +83,21 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 		return
 	}
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
+	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
 	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr
 		}
 	}
+	if err == nil && allSetting.PanelOutbound != oldPanelOutbound {
+		// The egress bridge lives in the generated config; reconcile the
+		// running core. One SOCKS inbound plus one routing rule — both
+		// hot-appliable, so this normally does not restart Xray.
+		if applyErr := a.xrayService.RestartXray(false); applyErr != nil {
+			logger.Warning("apply panel outbound change failed:", applyErr)
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 }
 

+ 1 - 1
internal/web/entity/entity.go

@@ -29,7 +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=1,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)
+	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`                             // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
 
 	// UI settings
 	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`      // Number of items per page in lists (0 disables pagination)

+ 1 - 1
internal/web/service/integration/panel_proxy_test.go → internal/web/service/integration/panel_egress_test.go

@@ -28,7 +28,7 @@ func originServer(t *testing.T, hits *int64) *httptest.Server {
 	}))
 }
 
-func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
+func TestPanelEgress_NetproxyHelperRoutesThroughProxy(t *testing.T) {
 	var proxyHits, originHits int64
 	proxy := recordingProxy(t, &proxyHits)
 	defer proxy.Close()

+ 39 - 13
internal/web/service/setting.go

@@ -95,7 +95,7 @@ var defaultValueMap = map[string]string{
 	"externalTrafficInformURI":    "",
 	"restartXrayOnClientDisable":  "true",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
-	"panelProxy":                  "",
+	"panelOutbound":               "",
 
 	// LDAP defaults
 	"ldapEnable":            "false",
@@ -384,26 +384,52 @@ func (s *SettingService) SetTgBotProxy(token string) error {
 	return s.setString("tgBotProxy", token)
 }
 
-func (s *SettingService) GetPanelProxy() (string, error) {
-	return s.getString("panelProxy")
+// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
+// requests (version checks, Telegram, subscription fetches) are routed through.
+func (s *SettingService) GetPanelOutbound() (string, error) {
+	return s.getString("panelOutbound")
 }
 
-func (s *SettingService) SetPanelProxy(proxyUrl string) error {
-	return s.setString("panelProxy", proxyUrl)
+func (s *SettingService) SetPanelOutbound(tag string) error {
+	return s.setString("panelOutbound", tag)
+}
+
+// PanelEgressProxyURL resolves the loopback SOCKS bridge that the generated
+// config exposes when a panel outbound is configured (see injectPanelEgress).
+// It returns "" — meaning a direct connection — when the feature is off or
+// the bridge is not present in the running core yet.
+func (s *SettingService) PanelEgressProxyURL() string {
+	tag, err := s.GetPanelOutbound()
+	if err != nil || tag == "" {
+		return ""
+	}
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
+		return ""
+	}
+	cfg := proc.GetConfig()
+	if cfg == nil {
+		return ""
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
+			return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
+		}
+	}
+	logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
+	return ""
 }
 
 // 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.
+// outbound requests through the configured panel outbound (via the loopback
+// SOCKS bridge in the running Xray). When the feature is off or the bridge
+// is unavailable it falls back to a direct client.
 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 = ""
-	}
+	proxyUrl := s.PanelEgressProxyURL()
 	client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
 	if err != nil {
-		logger.Warningf("Invalid panel proxy %q, using direct connection: %v", proxyUrl, err)
+		logger.Warningf("Invalid panel egress proxy %q, using direct connection: %v", proxyUrl, err)
 		return &http.Client{Timeout: timeout}
 	}
 	return client

+ 5 - 6
internal/web/service/tgbot/tgbot.go

@@ -234,13 +234,12 @@ 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.
+	// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
+	// set. Resolved once at bot start: if Xray comes up later, the bot keeps
+	// its direct connection until it is restarted.
 	if tgBotProxy == "" {
-		panelProxy, perr := t.settingService.GetPanelProxy()
-		if perr != nil {
-			logger.Warning("Failed to get panel proxy URL:", perr)
-		} else if isSupportedBotProxyScheme(panelProxy) {
-			tgBotProxy = panelProxy
+		if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
+			tgBotProxy = egress
 		}
 	}
 

+ 73 - 0
internal/web/service/xray.go

@@ -273,9 +273,82 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
 	}
 
+	// Wire the panel's own HTTP traffic through the configured outbound, after
+	// the subscription merge so subscription outbound tags are valid targets.
+	if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
+		logger.Warning("read panelOutbound setting failed:", err)
+	} else if egressTag != "" {
+		injectPanelEgress(xrayConfig, egressTag)
+	}
+
 	return xrayConfig, nil
 }
 
+// PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
+// the generated config when a panel outbound is configured. The panel's own
+// HTTP clients dial through it to egress via the chosen outbound.
+const PanelEgressInboundTag = "panel-egress"
+
+// panelEgressBasePort is the first port tried for the egress bridge; ports
+// already taken by other inbounds in the generated config are skipped.
+const panelEgressBasePort = 62790
+
+// injectPanelEgress appends a loopback SOCKS inbound to the generated config
+// and prepends a routing rule sending it to outboundTag. Both live only in the
+// generated config — the stored template is never modified — and both are
+// hot-appliable, so changing the panel outbound never restarts the core.
+func injectPanelEgress(cfg *xray.Config, outboundTag string) {
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
+			logger.Warning("panel egress: inbound tag [", PanelEgressInboundTag, "] already exists, skipping injection")
+			return
+		}
+	}
+
+	// The rule must exist before the inbound takes traffic, otherwise the
+	// bridge would silently egress through the default outbound instead.
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+			logger.Warning("panel egress: routing section is unparsable, skipping injection:", err)
+			return
+		}
+	}
+	rules, _ := routing["rules"].([]any)
+	rule := map[string]any{
+		"type":        "field",
+		"inboundTag":  []any{PanelEgressInboundTag},
+		"outboundTag": outboundTag,
+	}
+	routing["rules"] = append([]any{rule}, rules...)
+	newRouting, err := json.Marshal(routing)
+	if err != nil {
+		logger.Warning("panel egress: failed to rebuild routing section, skipping injection:", err)
+		return
+	}
+	cfg.RouterConfig = json_util.RawMessage(newRouting)
+
+	used := make(map[int]struct{}, len(cfg.InboundConfigs))
+	for i := range cfg.InboundConfigs {
+		used[cfg.InboundConfigs[i].Port] = struct{}{}
+	}
+	port := panelEgressBasePort
+	for {
+		if _, taken := used[port]; !taken {
+			break
+		}
+		port++
+	}
+
+	cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     port,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+		Tag:      PanelEgressInboundTag,
+	})
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.

+ 116 - 0
internal/web/service/xray_config_inject_test.go

@@ -2,11 +2,23 @@ package service
 
 import (
 	"encoding/json"
+	"os"
 	"testing"
 
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/op/go-logging"
 )
 
+func TestMain(m *testing.M) {
+	// injectPanelEgress logs when it skips injection; the package logger must
+	// exist before any test exercises a skipped path.
+	xuilogger.InitLogger(logging.ERROR)
+	os.Exit(m.Run())
+}
+
 func TestEnsureAPIServices(t *testing.T) {
 	// legacy template without RoutingService gets it injected
 	out := ensureAPIServices(json_util.RawMessage(`{"services":["HandlerService","LoggerService","StatsService"],"tag":"api"}`))
@@ -41,3 +53,107 @@ func TestEnsureAPIServices(t *testing.T) {
 		t.Fatalf("nil api block must stay nil, got %s", got)
 	}
 }
+
+func egressTestConfig() *xray.Config {
+	return &xray.Config{
+		RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
+		InboundConfigs: []xray.InboundConfig{
+			{Port: 62789, Protocol: "tunnel", Tag: "api", Listen: json_util.RawMessage(`"127.0.0.1"`)},
+		},
+	}
+}
+
+type egressRouting struct {
+	DomainStrategy string `json:"domainStrategy"`
+	Rules          []struct {
+		InboundTag  []string `json:"inboundTag"`
+		OutboundTag string   `json:"outboundTag"`
+		Type        string   `json:"type"`
+	} `json:"rules"`
+}
+
+func TestInjectPanelEgress(t *testing.T) {
+	cfg := egressTestConfig()
+	injectPanelEgress(cfg, "warp")
+
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatalf("expected the egress inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
+	}
+	ib := cfg.InboundConfigs[1]
+	if ib.Tag != PanelEgressInboundTag || ib.Protocol != "socks" || ib.Port != panelEgressBasePort {
+		t.Fatalf("unexpected egress inbound: %+v", ib)
+	}
+	if string(ib.Listen) != `"127.0.0.1"` {
+		t.Fatalf("egress inbound must listen on loopback, got %s", ib.Listen)
+	}
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if routing.DomainStrategy != "AsIs" {
+		t.Fatalf("routing keys outside rules must be preserved, got %+v", routing)
+	}
+	if len(routing.Rules) != 2 {
+		t.Fatalf("expected egress rule + existing rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.Type != "field" || first.OutboundTag != "warp" ||
+		len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
+		t.Fatalf("egress rule must be prepended, got %+v", first)
+	}
+}
+
+func TestInjectPanelEgress_PortCollision(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: panelEgressBasePort, Protocol: "vless", Tag: "in-1"},
+		xray.InboundConfig{Port: panelEgressBasePort + 1, Protocol: "vless", Tag: "in-2"},
+	)
+	injectPanelEgress(cfg, "direct")
+	got := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
+	if got.Tag != PanelEgressInboundTag || got.Port != panelEgressBasePort+2 {
+		t.Fatalf("egress inbound must skip taken ports, got %+v", got)
+	}
+}
+
+func TestInjectPanelEgress_TagCollisionSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: PanelEgressInboundTag},
+	)
+	before := string(cfg.RouterConfig)
+	injectPanelEgress(cfg, "direct")
+	if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
+		t.Fatal("a user inbound owning the egress tag must make injection a no-op")
+	}
+}
+
+func TestInjectPanelEgress_NoRoutingSection(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = nil
+	injectPanelEgress(cfg, "direct")
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
+		t.Fatalf("a routing section must be created with the egress rule, got %+v", routing)
+	}
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatal("egress inbound must still be appended")
+	}
+}
+
+func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{not json`)
+	injectPanelEgress(cfg, "direct")
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatal("unparsable routing must skip the whole injection, inbound included")
+	}
+	if string(cfg.RouterConfig) != `{not json` {
+		t.Fatal("unparsable routing must be left untouched")
+	}
+}

+ 3 - 2
internal/web/translation/ar-EG.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "مسار URI للبانل. (يبدأ بـ '/' وبينتهي بـ '/')",
       "pageSize": "حجم الصفحة",
       "pageSizeDesc": "حدد حجم الصفحة لجدول الإدخالات. (0 = تعطيل)",
-      "panelProxy": "وكيل شبكة اللوحة",
-      "panelProxyDesc": "يوجه طلبات اللوحة الصادرة (تحديثات geo، فحص إصدارات Xray/اللوحة، تيليجرام) عبر هذا الوكيل لتجاوز فلترة GitHub/تيليجرام على الخادم. يقبل socks5:// أو http(s)://، مثل وارد SOCKS محلي لـ Xray. اتركه فارغاً للاتصال المباشر.",
+      "panelOutbound": "صادر ترافيك اللوحة",
+      "panelOutboundDesc": "بيوجه طلبات اللوحة نفسها — فحص إصدارات وتنزيلات اللوحة/Xray، تيليجرام، وتحديث ملفات geo العادي — عبر صادر Xray ده لتجاوز فلترة GitHub/تيليجرام على الخادم. وارد جسر محلي بيتضاف تلقائياً للإعداد الشغال وبيتطبق مباشرة. تحديث Geodata التلقائي الأصلي في Xray مش متأثر؛ ليه صادر تنزيل خاص بيه. اتركه فارغاً للاتصال المباشر.",
+      "panelOutboundPh": "اتصال مباشر",
       "remarkModel": "نموذج الملاحظة وحرف الفصل",
       "datepicker": "نوع التقويم",
       "datepickerPlaceholder": "اختار التاريخ",

+ 3 - 2
internal/web/translation/en-US.json

@@ -939,8 +939,9 @@
       "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.",
+      "panelOutbound": "Panel Traffic Outbound",
+      "panelOutboundDesc": "Routes the panel's own requests — panel/Xray version checks and downloads, Telegram, and the normal geo-file update — through this Xray outbound to bypass server-side filtering of GitHub/Telegram. A loopback bridge inbound is added to the running config automatically and applied live. The Xray-native Geodata Auto-Update is not affected; it has its own download outbound. Leave empty for a direct connection.",
+      "panelOutboundPh": "Direct connection",
       "remarkModel": "Remark Model & Separation Character",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",

+ 3 - 2
internal/web/translation/es-ES.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Debe empezar con '/' y terminar con.",
       "pageSize": "Tamaño de paginación",
       "pageSizeDesc": "Defina el tamaño de página para la tabla de entradas. Establezca 0 para desactivar",
-      "panelProxy": "Proxy de red del panel",
-      "panelProxyDesc": "Enruta las peticiones salientes del propio panel (actualizaciones de geo, comprobaciones de versión de Xray/panel, Telegram) a través de este proxy para sortear el filtrado de GitHub/Telegram en el servidor. Acepta socks5:// o http(s)://, p. ej. una entrada SOCKS local de Xray. Deja vacío para conexión directa.",
+      "panelOutbound": "Salida del tráfico del panel",
+      "panelOutboundDesc": "Enruta las peticiones del propio panel — comprobaciones de versión y descargas de panel/Xray, Telegram y la actualización normal de archivos geo — a través de esta salida de Xray para sortear el filtrado de GitHub/Telegram en el servidor. Una entrada puente local se añade automáticamente a la configuración en ejecución y se aplica en vivo. La Autoactualización de Geodata nativa de Xray no se ve afectada; tiene su propia salida de descarga. Deja vacío para conexión directa.",
+      "panelOutboundPh": "Conexión directa",
       "remarkModel": "Modelo de observación y carácter de separación",
       "datepicker": "selector de fechas",
       "datepickerPlaceholder": "Seleccionar fecha",

+ 3 - 2
internal/web/translation/fa-IR.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "برای وب پنل. با '/' شروع‌ و با '/' خاتمه‌ می‌یابد URI مسیر",
       "pageSize": "اندازه صفحه بندی جدول",
       "pageSizeDesc": "(اندازه صفحه برای جدول ورودی‌ها.(0 = غیرفعال",
-      "panelProxy": "پراکسی شبکه پنل",
-      "panelProxyDesc": "درخواست‌های خروجی خود پنل (به‌روزرسانی geo، بررسی نسخه Xray/پنل، تلگرام) را از این پراکسی عبور می‌دهد تا فیلترینگ GitHub/تلگرام در سرور دور زده شود. socks5:// یا http(s):// قابل قبول است، مثل ورودی SOCKS محلی Xray. برای اتصال مستقیم خالی بگذارید.",
+      "panelOutbound": "اوتباند ترافیک پنل",
+      "panelOutboundDesc": "درخواست‌های خود پنل — بررسی نسخه و دانلود پنل/Xray، تلگرام، و به‌روزرسانی معمولی فایل‌های geo — را از این اوتباند Xray عبور می‌دهد تا فیلترینگ GitHub/تلگرام در سمت سرور دور زده شود. یک ورودی پل لوکال به‌صورت خودکار به کانفیگ در حال اجرا اضافه و زنده اعمال می‌شود. روی Geodata Auto-Update نِیتیو Xray اثری ندارد؛ آن اوتباند دانلود مخصوص خودش را دارد. برای اتصال مستقیم خالی بگذارید.",
+      "panelOutboundPh": "اتصال مستقیم",
       "remarkModel": "نام‌کانفیگ و جداکننده",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",

+ 3 - 2
internal/web/translation/id-ID.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "URI path untuk panel web. (dimulai dengan ‘/‘ dan diakhiri dengan ‘/‘)",
       "pageSize": "Ukuran Halaman",
       "pageSizeDesc": "Tentukan ukuran halaman untuk tabel masuk. (0 = nonaktif)",
-      "panelProxy": "Proxy jaringan panel",
-      "panelProxyDesc": "Mengarahkan permintaan keluar panel sendiri (pembaruan geo, pemeriksaan versi Xray/panel, Telegram) melalui proxy ini untuk melewati pemfilteran GitHub/Telegram di sisi server. Menerima socks5:// atau http(s)://, mis. inbound SOCKS lokal Xray. Kosongkan untuk koneksi langsung.",
+      "panelOutbound": "Outbound lalu lintas panel",
+      "panelOutboundDesc": "Mengarahkan permintaan panel sendiri — pemeriksaan versi dan unduhan panel/Xray, Telegram, dan pembaruan file geo biasa — melalui outbound Xray ini untuk melewati pemfilteran GitHub/Telegram di sisi server. Inbound jembatan lokal ditambahkan secara otomatis ke konfigurasi yang berjalan dan diterapkan langsung. Pembaruan Otomatis Geodata bawaan Xray tidak terpengaruh; ia memiliki outbound unduhan sendiri. Kosongkan untuk koneksi langsung.",
+      "panelOutboundPh": "Koneksi langsung",
       "remarkModel": "Model Catatan & Karakter Pemisah",
       "datepicker": "Jenis Kalender",
       "datepickerPlaceholder": "Pilih tanggal",

+ 3 - 2
internal/web/translation/ja-JP.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "'/'で始まり、'/'で終わる必要があります",
       "pageSize": "ページサイズ",
       "pageSizeDesc": "インバウンドテーブルのページサイズを定義します。0を設定すると無効化されます",
-      "panelProxy": "パネルネットワークプロキシ",
-      "panelProxyDesc": "パネル自体のアウトバウンドリクエスト (geo 更新、Xray/パネルバージョンチェック、Telegram) をこのプロキシ経由でルーティングし、サーバー側の GitHub/Telegram フィルタリングを回避します。socks5:// または http(s):// を受け付けます。例: ローカルの Xray SOCKS インバウンド。直接接続するには空のままにします。",
+      "panelOutbound": "パネルトラフィックのアウトバウンド",
+      "panelOutboundDesc": "パネル自体のリクエスト (パネル/Xray のバージョンチェックとダウンロード、Telegram、通常の geo ファイル更新) をこの Xray アウトバウンド経由でルーティングし、サーバー側の GitHub/Telegram フィルタリングを回避します。ローカルのブリッジインバウンドが実行中の設定に自動的に追加され、ライブで適用されます。Xray ネイティブの Geodata 自動更新は影響を受けません。専用のダウンロードアウトバウンドを持ちます。直接接続するには空のままにします。",
+      "panelOutboundPh": "直接接続",
       "remarkModel": "備考モデルと区切り記号",
       "datepicker": "日付ピッカー",
       "datepickerPlaceholder": "日付を選択",

+ 3 - 2
internal/web/translation/pt-BR.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "O caminho URI para o painel web. (começa com ‘/‘ e termina com ‘/‘)",
       "pageSize": "Tamanho da Paginação",
       "pageSizeDesc": "Definir o tamanho da página para a tabela de entradas. (0 = desativado)",
-      "panelProxy": "Proxy de rede do painel",
-      "panelProxyDesc": "Encaminha as requisições de saída do próprio painel (atualizações de geo, verificações de versão do Xray/painel, Telegram) por este proxy para contornar a filtragem de GitHub/Telegram no servidor. Aceita socks5:// ou http(s)://, ex. uma entrada SOCKS local do Xray. Deixe vazio para conexão direta.",
+      "panelOutbound": "Saída do tráfego do painel",
+      "panelOutboundDesc": "Encaminha as requisições do próprio painel — verificações de versão e downloads do painel/Xray, Telegram e a atualização normal de arquivos geo — por esta saída do Xray para contornar a filtragem de GitHub/Telegram no servidor. Uma entrada ponte local é adicionada automaticamente à configuração em execução e aplicada ao vivo. A Atualização Automática de Geodata nativa do Xray não é afetada; ela tem sua própria saída de download. Deixe vazio para conexão direta.",
+      "panelOutboundPh": "Conexão direta",
       "remarkModel": "Modelo de Observação & Caractere de Separação",
       "datepicker": "Tipo de Calendário",
       "datepickerPlaceholder": "Selecionar data",

+ 3 - 2
internal/web/translation/ru-RU.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Должен начинаться с '/' и заканчиваться '/'",
       "pageSize": "Размер нумерации страниц",
       "pageSizeDesc": "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить",
-      "panelProxy": "Сетевой прокси панели",
-      "panelProxyDesc": "Маршрутизирует исходящие запросы самой панели (обновления geo, проверки версий Xray/панели, Telegram) через этот прокси для обхода серверной фильтрации GitHub/Telegram. Принимает socks5:// или http(s)://, напр. локальный SOCKS-входящий Xray. Оставьте пустым для прямого подключения.",
+      "panelOutbound": "Исходящий для трафика панели",
+      "panelOutboundDesc": "Маршрутизирует собственные запросы панели — проверки версий и загрузки панели/Xray, Telegram и обычное обновление geo-файлов — через этот исходящий Xray для обхода серверной фильтрации GitHub/Telegram. Локальный мост-входящий добавляется в работающую конфигурацию автоматически и применяется на лету. Встроенное в Xray автообновление Geodata не затрагивается; у него свой исходящий для загрузки. Оставьте пустым для прямого подключения.",
+      "panelOutboundPh": "Прямое подключение",
       "remarkModel": "Модель примечания и символ разделения",
       "datepicker": "Тип календаря",
       "datepickerPlaceholder": "Выберите дату",

+ 3 - 2
internal/web/translation/tr-TR.json

@@ -937,8 +937,9 @@
       "panelUrlPathDesc": "Web paneli için URI yolu. ('/' ile başlar ve '/' ile biter)",
       "pageSize": "Sayfa Boyutu",
       "pageSizeDesc": "Gelen Bağlantılar tablosu için sayfa boyutunu belirler. (0 = devre dışı)",
-      "panelProxy": "Panel Ağ Proxy'si",
-      "panelProxyDesc": "Panelin kendi giden isteklerini (geo güncellemeleri, Xray/panel sürüm kontrolleri, Telegram) bu proxy üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini atlatmak için. socks5:// veya http(s):// kabul eder, örn. yerel bir Xray SOCKS gelen bağlantı. Doğrudan bağlantı için boş bırakın.",
+      "panelOutbound": "Panel Trafiği Gideni",
+      "panelOutboundDesc": "Panelin kendi isteklerini — panel/Xray sürüm kontrolleri ve indirmeleri, Telegram ve normal geo dosyası güncellemesi — bu Xray gideni üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini aşmak için. Yerel bir köprü gelen bağlantısı çalışan yapılandırmaya otomatik eklenir ve canlı uygulanır. Xray'in yerel Geodata Otomatik Güncellemesi etkilenmez; kendi indirme gidenine sahiptir. Doğrudan bağlantı için boş bırakın.",
+      "panelOutboundPh": "Doğrudan bağlantı",
       "remarkModel": "Açıklama Modeli ve Ayırma Karakteri",
       "datepicker": "Takvim Türü",
       "datepickerPlaceholder": "Tarih Seçin",

+ 3 - 2
internal/web/translation/uk-UA.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Шлях URL для веб-панелі. (починається з ‘/‘ і закінчується ‘/‘)",
       "pageSize": "Розмір сторінки",
       "pageSizeDesc": "Визначити розмір сторінки для вхідної таблиці. (0 = вимкнено)",
-      "panelProxy": "Мережевий проксі панелі",
-      "panelProxyDesc": "Маршрутизує власні вихідні запити панелі (оновлення geo, перевірки версій Xray/панелі, Telegram) через цей проксі для обходу фільтрації GitHub/Telegram на стороні сервера. Приймає socks5:// або http(s)://, напр. локальний SOCKS-вхідний Xray. Залиште порожнім для прямого підключення.",
+      "panelOutbound": "Вихідний для трафіку панелі",
+      "panelOutboundDesc": "Маршрутизує власні запити панелі — перевірки версій і завантаження панелі/Xray, Telegram та звичайне оновлення geo-файлів — через цей вихідний Xray для обходу фільтрації GitHub/Telegram на стороні сервера. Локальний міст-вхідний додається до робочої конфігурації автоматично і застосовується наживо. Вбудоване в Xray автооновлення Geodata не зачіпається; воно має власний вихідний для завантаження. Залиште порожнім для прямого підключення.",
+      "panelOutboundPh": "Пряме підключення",
       "remarkModel": "Модель зауваження та роздільний символ",
       "datepicker": "Тип календаря",
       "datepickerPlaceholder": "Виберіть дату",

+ 3 - 2
internal/web/translation/vi-VN.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Phải bắt đầu và kết thúc bằng '/'",
       "pageSize": "Kích thước phân trang",
       "pageSizeDesc": "Xác định kích thước trang cho bảng gửi đến. Đặt 0 để tắt",
-      "panelProxy": "Proxy mạng của bảng điều khiển",
-      "panelProxyDesc": "Định tuyến các yêu cầu đi của chính bảng điều khiển (cập nhật geo, kiểm tra phiên bản Xray/panel, Telegram) qua proxy này để vượt qua lọc GitHub/Telegram phía máy chủ. Chấp nhận socks5:// hoặc http(s)://, ví dụ inbound SOCKS cục bộ của Xray. Để trống để kết nối trực tiếp.",
+      "panelOutbound": "Outbound cho lưu lượng panel",
+      "panelOutboundDesc": "Định tuyến các yêu cầu của chính bảng điều khiển — kiểm tra phiên bản và tải xuống panel/Xray, Telegram, và cập nhật tệp geo thông thường — qua outbound Xray này để vượt qua lọc GitHub/Telegram phía máy chủ. Một inbound cầu nối cục bộ được tự động thêm vào cấu hình đang chạy và áp dụng trực tiếp. Tính năng Tự động cập nhật Geodata gốc của Xray không bị ảnh hưởng; nó có outbound tải xuống riêng. Để trống để kết nối trực tiếp.",
+      "panelOutboundPh": "Kết nối trực tiếp",
       "remarkModel": "Ghi chú mô hình và ký tự phân tách",
       "datepicker": "Kiểu lịch",
       "datepickerPlaceholder": "Chọn ngày",

+ 3 - 2
internal/web/translation/zh-CN.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "必须以 '/' 开头,以 '/' 结尾",
       "pageSize": "分页大小",
       "pageSizeDesc": "定义入站表的页面大小。设置 0 表示禁用",
-      "panelProxy": "面板网络代理",
-      "panelProxyDesc": "通过此代理路由面板自身的出站请求(geo 更新、Xray/面板版本检查、Telegram),以绕过服务端对 GitHub/Telegram 的过滤。接受 socks5:// 或 http(s)://,如本地 Xray SOCKS 入站。留空表示直连。",
+      "panelOutbound": "面板流量出站",
+      "panelOutboundDesc": "通过此 Xray 出站路由面板自身的请求(面板/Xray 版本检查与下载、Telegram、普通 geo 文件更新),以绕过服务端对 GitHub/Telegram 的过滤。本地桥接入站会自动添加到运行中的配置并实时生效。Xray 原生的 Geodata 自动更新不受影响,它有自己的下载出站。留空表示直连。",
+      "panelOutboundPh": "直连",
       "remarkModel": "备注模型和分隔符",
       "datepicker": "日期选择器",
       "datepickerPlaceholder": "选择日期",

+ 3 - 2
internal/web/translation/zh-TW.json

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "必須以 '/' 開頭,以 '/' 結尾",
       "pageSize": "分頁大小",
       "pageSizeDesc": "定義入站表的頁面大小。設定 0 表示禁用",
-      "panelProxy": "面板網路代理",
-      "panelProxyDesc": "透過此代理路由面板自身的出站請求(geo 更新、Xray/面板版本檢查、Telegram),以繞過伺服器端對 GitHub/Telegram 的過濾。接受 socks5:// 或 http(s)://,如本地 Xray SOCKS 入站。留空表示直連。",
+      "panelOutbound": "面板流量出站",
+      "panelOutboundDesc": "透過此 Xray 出站路由面板自身的請求(面板/Xray 版本檢查與下載、Telegram、一般 geo 檔案更新),以繞過伺服器端對 GitHub/Telegram 的過濾。本地橋接入站會自動加入執行中的設定並即時生效。Xray 原生的 Geodata 自動更新不受影響,它有自己的下載出站。留空表示直連。",
+      "panelOutboundPh": "直連",
       "remarkModel": "備註模型和分隔符",
       "datepicker": "日期選擇器",
       "datepickerPlaceholder": "選擇日期",