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.
This commit is contained in:
MHSanaei
2026-06-10 23:52:20 +02:00
parent 6b16d8c37a
commit ca4f32e3da
29 changed files with 352 additions and 73 deletions
+73
View File
@@ -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.