From 4d51a5704f8867d6ceff8399d097672eefb2cebf Mon Sep 17 00:00:00 2001 From: kazeyukiro Date: Tue, 29 Sep 2026 15:14:39 +0000 Subject: [PATCH] feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. --- backend/internal/mihomo/config/generator.go | 10 +- .../internal/mihomo/config/server_routing.go | 168 ++++++++++++++++++ .../mihomo/config/server_routing_test.go | 45 +++++ backend/internal/router/routing_routes.go | 27 +++ frontend/src/api/routing.ts | 15 ++ frontend/src/i18n/locales/en.ts | 8 +- frontend/src/i18n/locales/zh-CN.ts | 8 +- frontend/src/pages/Routing.tsx | 78 ++++++-- 8 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 backend/internal/mihomo/config/server_routing.go create mode 100644 backend/internal/mihomo/config/server_routing_test.go diff --git a/backend/internal/mihomo/config/generator.go b/backend/internal/mihomo/config/generator.go index 0a5f0c6..18521b4 100644 --- a/backend/internal/mihomo/config/generator.go +++ b/backend/internal/mihomo/config/generator.go @@ -67,9 +67,13 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) { if CoreLowMemory() { merged["geodata-loader"] = geodataLoaderMemConservative } - // Inbound panel: always DIRECT exit (no server-side split). - merged["rules"] = []interface{}{"MATCH,DIRECT"} - merged["proxy-groups"] = []interface{}{} + // Server-side egress routing (3x-ui-style): proxies / groups / rules for the + // serving Mihomo process. visual-config remains client-subscription only. + sr, srErr := GetServerRouting(ce.db) + if srErr != nil { + return "", fmt.Errorf("load server-routing: %w", srErr) + } + applyServerRouting(merged, sr) var listeners []models.Listener if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil { return "", fmt.Errorf("load enabled listeners: %w", err) diff --git a/backend/internal/mihomo/config/server_routing.go b/backend/internal/mihomo/config/server_routing.go new file mode 100644 index 0000000..5ae3423 --- /dev/null +++ b/backend/internal/mihomo/config/server_routing.go @@ -0,0 +1,168 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "gopkg.in/yaml.v3" + "gorm.io/gorm" +) + +// serverRoutingName is the panel_settings / config fragment key for *serving* +// Mihomo rules (post-inbound egress). Distinct from visual-config, which only +// shapes client subscription YAML. +const serverRoutingName = "server-routing" + +// ServerRoutingConfig controls how traffic leaves the VPS after hitting a +// listener — the Mihomo equivalent of 3x-ui's Xray outbounds + routing rules. +type ServerRoutingConfig struct { + Proxies []ProxyEntry `json:"proxies" yaml:"proxies"` + Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"` + Rules []string `json:"rules" yaml:"rules"` +} + +// DefaultServerRouting is pure direct egress (historical 3m-ui behaviour). +func DefaultServerRouting() ServerRoutingConfig { + return ServerRoutingConfig{ + Proxies: nil, + Groups: nil, + Rules: []string{"MATCH,DIRECT"}, + } +} + +func GetServerRouting(db *gorm.DB) (ServerRoutingConfig, error) { + cfg := DefaultServerRouting() + if db == nil { + return cfg, nil + } + var row models.PanelSetting + err := db.Where("key = ?", serverRoutingName).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + // Legacy: some installs may only have rules in a Config fragment. + var frag models.Config + if e2 := db.Where("name = ?", serverRoutingName).First(&frag).Error; e2 == nil && strings.TrimSpace(frag.Content) != "" { + if e3 := yaml.Unmarshal([]byte(frag.Content), &cfg); e3 == nil { + normalizeServerRouting(&cfg) + return cfg, nil + } + } + return cfg, nil + } + if err != nil { + return cfg, err + } + if strings.TrimSpace(row.Value) == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil { + return cfg, fmt.Errorf("parse server-routing: %w", err) + } + normalizeServerRouting(&cfg) + return cfg, nil +} + +func SaveServerRouting(db *gorm.DB, cfg ServerRoutingConfig) error { + if db == nil { + return fmt.Errorf("database is not initialized") + } + normalizeServerRouting(&cfg) + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + var row models.PanelSetting + err = db.Where("key = ?", serverRoutingName).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return db.Create(&models.PanelSetting{Key: serverRoutingName, Value: string(raw)}).Error + } + if err != nil { + return err + } + row.Value = string(raw) + return db.Save(&row).Error +} + +func normalizeServerRouting(cfg *ServerRoutingConfig) { + if cfg == nil { + return + } + if len(cfg.Rules) == 0 { + cfg.Rules = []string{"MATCH,DIRECT"} + return + } + hasMatch := false + for _, r := range cfg.Rules { + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(r)), "MATCH,") { + hasMatch = true + break + } + } + if !hasMatch { + cfg.Rules = append(cfg.Rules, "MATCH,DIRECT") + } +} + +// applyServerRouting writes proxies / groups / rules into the serving config map. +func applyServerRouting(merged map[string]interface{}, sr ServerRoutingConfig) { + normalizeServerRouting(&sr) + if len(sr.Proxies) > 0 { + merged["proxies"] = proxyEntriesToYAML(sr.Proxies) + } + if len(sr.Groups) > 0 { + merged["proxy-groups"] = groupEntriesToYAML(sr.Groups) + } else { + merged["proxy-groups"] = []interface{}{} + } + rules := make([]interface{}, 0, len(sr.Rules)) + for _, r := range sr.Rules { + r = strings.TrimSpace(r) + if r != "" { + rules = append(rules, r) + } + } + if len(rules) == 0 { + rules = []interface{}{"MATCH,DIRECT"} + } + merged["rules"] = rules +} + +func proxyEntriesToYAML(entries []ProxyEntry) []interface{} { + out := make([]interface{}, 0, len(entries)) + for _, p := range entries { + m := map[string]interface{}{ + "name": p.Name, + "type": p.Type, + "server": p.Server, + "port": p.Port, + } + for k, v := range p.Options { + m[k] = v + } + out = append(out, m) + } + return out +} + +func groupEntriesToYAML(entries []GroupEntry) []interface{} { + out := make([]interface{}, 0, len(entries)) + for _, g := range entries { + m := map[string]interface{}{ + "name": g.Name, + "type": g.Type, + } + if len(g.Proxies) > 0 { + m["proxies"] = g.Proxies + } + if g.URL != "" { + m["url"] = g.URL + } + if g.Interval > 0 { + m["interval"] = g.Interval + } + out = append(out, m) + } + return out +} diff --git a/backend/internal/mihomo/config/server_routing_test.go b/backend/internal/mihomo/config/server_routing_test.go new file mode 100644 index 0000000..2b0009d --- /dev/null +++ b/backend/internal/mihomo/config/server_routing_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "testing" +) + +func TestApplyServerRoutingDefault(t *testing.T) { + merged := map[string]interface{}{} + applyServerRouting(merged, DefaultServerRouting()) + rules, _ := merged["rules"].([]interface{}) + if len(rules) != 1 || rules[0] != "MATCH,DIRECT" { + t.Fatalf("rules: %#v", rules) + } +} + +func TestApplyServerRoutingCustom(t *testing.T) { + merged := map[string]interface{}{} + sr := ServerRoutingConfig{ + Rules: []string{"GEOIP,private,DIRECT", "DOMAIN-SUFFIX,google.com,WARP"}, + Proxies: []ProxyEntry{ + {Name: "WARP", Type: "wireguard", Server: "engage.cloudflareclient.com", Port: 2408}, + }, + } + applyServerRouting(merged, sr) + rules, _ := merged["rules"].([]interface{}) + if len(rules) != 3 { // + MATCH,DIRECT auto + t.Fatalf("expected MATCH appended, got %#v", rules) + } + last := rules[len(rules)-1].(string) + if last != "MATCH,DIRECT" { + t.Fatalf("last rule: %s", last) + } + proxies, _ := merged["proxies"].([]interface{}) + if len(proxies) != 1 { + t.Fatalf("proxies: %#v", proxies) + } +} + +func TestNormalizeServerRoutingEmpty(t *testing.T) { + sr := ServerRoutingConfig{} + normalizeServerRouting(&sr) + if len(sr.Rules) != 1 || sr.Rules[0] != "MATCH,DIRECT" { + t.Fatalf("%#v", sr.Rules) + } +} diff --git a/backend/internal/router/routing_routes.go b/backend/internal/router/routing_routes.go index 0e21677..234c7b8 100644 --- a/backend/internal/router/routing_routes.go +++ b/backend/internal/router/routing_routes.go @@ -62,4 +62,31 @@ func registerRoutingRoutes(api *gin.RouterGroup, db *gorm.DB) { } c.JSON(http.StatusOK, visual.Rules) }) + + // Server-side egress (serving Mihomo), distinct from client subscription visual-config. + group.GET("/server-routing", func(c *gin.Context) { + sr, err := mihomoConfig.GetServerRouting(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, sr) + }) + group.PUT("/server-routing", func(c *gin.Context) { + var sr mihomoConfig.ServerRoutingConfig + if err := c.ShouldBindJSON(&sr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := mihomoConfig.SaveServerRouting(db, sr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + saved, err := mihomoConfig.GetServerRouting(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, saved) + }) } diff --git a/frontend/src/api/routing.ts b/frontend/src/api/routing.ts index 152e5fa..5e0ac18 100644 --- a/frontend/src/api/routing.ts +++ b/frontend/src/api/routing.ts @@ -16,3 +16,18 @@ export const fetchRules = () => withNetworkRetry(() => client.get('/config/rules').then((r) => r.data)); export const saveRules = (rules: string[]) => withNetworkRetry(() => client.put('/config/rules', rules).then((r) => r.data)); + +export interface ServerRoutingConfig { + proxies: Array & { name: string; type: string; server?: string; port?: number | string }>; + proxyGroups: GroupEntry[]; + rules: string[]; +} + +export const fetchServerRouting = () => + withNetworkRetry(() => + client.get('/config/server-routing').then((r) => r.data), + ); +export const saveServerRouting = (cfg: ServerRoutingConfig) => + withNetworkRetry(() => + client.put('/config/server-routing', cfg).then((r) => r.data), + ); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e62827e..1316931 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -814,13 +814,17 @@ export default { "ruleInvalid": "Invalid rules", "applyDone": "Configuration applied", "applyNow": "Generate & apply", - "applyPromptBody": "Saved for client subscriptions. Generate & apply refreshes the panel core (server stays MATCH,DIRECT). Update the subscription in your client for new groups.", + "applyPromptBody": "Saved. Generate & apply refreshes panel Mihomo using Server egress rules; update the client subscription separately.", "applyPromptTitle": "Apply configuration?", "groupAdded": "Group added", "addRule": "Add rule", - "pageHint": "Proxy-groups and rules are for client Mihomo/Clash subscriptions only. Save, then update the subscription in the client. Panel server always uses MATCH,DIRECT.", + "pageHint": "Client subscription: proxy-groups and rules go into Mihomo/Clash subscription YAML only. Save, then refresh the subscription in the client.", "rulesSaved": "Rules saved", "groupSaved": "Group saved", + "tabClient": "Client subscription", + "tabServer": "Server egress", + "serverHint": "Server egress: rules apply to panel Mihomo (like 3x-ui Xray routing) after traffic hits listeners. Save then Apply.", + "serverRulesSaved": "Server routing saved", "groupDeleted": "Group deleted" }, "settings": { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b0f1e9d..334e326 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -814,13 +814,17 @@ export default { "ruleInvalid": "规则无效", "applyDone": "配置已应用", "applyNow": "生成并应用", - "applyPromptBody": "已保存供客户端订阅使用。生成并应用会刷新面板核心(服务端仍为 MATCH,DIRECT)。请在客户端更新订阅以加载新策略组。", + "applyPromptBody": "已保存。生成并应用会按「服务端出站」规则刷新面板 Mihomo;客户端订阅需在客户端更新。", "applyPromptTitle": "应用配置?", "groupAdded": "策略组已添加", "addRule": "添加规则", - "pageHint": "策略组与规则仅用于客户端 Mihomo/Clash 订阅。保存后请在客户端更新订阅。面板服务端固定 MATCH,DIRECT。", + "pageHint": "客户端订阅:策略组与规则只进入 Mihomo/Clash 订阅 YAML。保存后请在客户端更新订阅。", "rulesSaved": "规则已保存", "groupSaved": "策略组已保存", + "tabClient": "客户端订阅", + "tabServer": "服务端出站", + "serverHint": "服务端出站:规则写入面板 Mihomo 进程(类似 3x-ui 的 Xray 路由),影响用户流量到达节点之后的出口。保存后请点应用。", + "serverRulesSaved": "服务端路由已保存", "groupDeleted": "策略组已删除" }, "settings": { diff --git a/frontend/src/pages/Routing.tsx b/frontend/src/pages/Routing.tsx index 8c53e09..e676885 100644 --- a/frontend/src/pages/Routing.tsx +++ b/frontend/src/pages/Routing.tsx @@ -16,6 +16,8 @@ import { Dropdown, Tooltip, Divider, + Tabs, + Alert, } from 'antd'; import { IconAddRule, @@ -31,6 +33,8 @@ import { saveGroups, fetchRules, saveRules, + fetchServerRouting, + saveServerRouting, type GroupEntry, } from '../api/routing'; import { generateConfig, applyConfigYAML, fetchProxies } from '../api/config'; @@ -60,6 +64,8 @@ const RoutingPage: React.FC = () => { const [saving, setSaving] = useState(false); const [applying, setApplying] = useState(false); const [groupOpen, setGroupOpen] = useState(false); + const [scope, setScope] = useState<'client' | 'server'>('client'); + const [serverRules, setServerRules] = useState([]); const [form] = Form.useForm(); const targetOptions = useMemo(() => { @@ -72,13 +78,15 @@ const RoutingPage: React.FC = () => { const load = useCallback(async () => { setLoading(true); try { - const [g, r, px] = await Promise.all([ + const [g, r, px, sr] = await Promise.all([ fetchGroups(), fetchRules(), fetchProxies().catch(() => []), + fetchServerRouting().catch(() => ({ rules: ['MATCH,DIRECT'], proxyGroups: [], proxies: [] })), ]); setGroups(Array.isArray(g) ? g : []); setRules(parseRulesText((Array.isArray(r) ? r : []).join('\n'))); + setServerRules(parseRulesText((Array.isArray(sr?.rules) ? sr.rules : ['MATCH,DIRECT']).join('\n'))); setProxyNames( (Array.isArray(px) ? px : []) .map((p: any) => String(p?.name || '').trim()) @@ -121,8 +129,12 @@ const RoutingPage: React.FC = () => { }); }; + const activeRules = scope === 'server' ? serverRules : rules; + const setActiveRules = scope === 'server' ? setServerRules : setRules; + const onSaveRules = async () => { - const issues = validateRules(rules); + const current = scope === 'server' ? serverRules : rules; + const issues = validateRules(current); const hard = issues.filter((i) => !i.message.includes('recommended')); if (hard.length) { message.error( @@ -139,10 +151,21 @@ const RoutingPage: React.FC = () => { } setSaving(true); try { - const saved = await saveRules(serializeRules(rules)); - setRules(parseRulesText((Array.isArray(saved) ? saved : serializeRules(rules)).join('\n'))); - message.success(t('routing.rulesSaved') || 'Rules saved'); - offerApply(); + if (scope === 'server') { + const saved = await saveServerRouting({ + proxies: [], + proxyGroups: [], + rules: serializeRules(current), + }); + setServerRules(parseRulesText((Array.isArray(saved?.rules) ? saved.rules : serializeRules(current)).join('\n'))); + message.success(t('routing.serverRulesSaved') || 'Server routing saved'); + offerApply(); + } else { + const saved = await saveRules(serializeRules(current)); + setRules(parseRulesText((Array.isArray(saved) ? saved : serializeRules(current)).join('\n'))); + message.success(t('routing.rulesSaved') || 'Rules saved'); + offerApply(); + } } catch (e: any) { message.error(errMsg(e)); } finally { @@ -190,11 +213,13 @@ const RoutingPage: React.FC = () => { }; const updateRule = (index: number, patch: Partial) => { - setRules((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch, raw: undefined } : r))); + const setter = scope === 'server' ? setServerRules : setRules; + setter((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch, raw: undefined } : r))); }; const moveRule = (index: number, dir: -1 | 1) => { - setRules((prev) => { + const setter = scope === 'server' ? setServerRules : setRules; + setter((prev) => { const j = index + dir; if (j < 0 || j >= prev.length) return prev; const next = [...prev]; @@ -206,11 +231,13 @@ const RoutingPage: React.FC = () => { }; const removeRule = (index: number) => { - setRules((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))); + const setter = scope === 'server' ? setServerRules : setRules; + setter((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))); }; const addRule = () => { - setRules((prev) => [...prev, emptyRule({ type: 'DOMAIN-SUFFIX', target: 'DIRECT' })]); + const setter = scope === 'server' ? setServerRules : setRules; + setter((prev) => [...prev, emptyRule({ type: 'DOMAIN-SUFFIX', target: 'DIRECT' })]); }; const onTemplate = async (id: string) => { @@ -298,11 +325,29 @@ const RoutingPage: React.FC = () => { return (
- - {t('routing.pageHint') || - 'Proxy-groups and rules are for **client** Mihomo/Clash subscriptions only (not server-side split). Save, then update the subscription in the client. Panel core always uses MATCH,DIRECT.'} - + setScope(k as 'client' | 'server')} + style={{ marginBottom: 8 }} + items={[ + { key: 'client', label: t('routing.tabClient') || 'Client subscription' }, + { key: 'server', label: t('routing.tabServer') || 'Server egress' }, + ]} + /> + + {scope === 'client' && ( { ]} /> + )} {isMobile ?
{ruleCardExtra}
: null}
- {rules.map((row, index) => ( + {activeRules.map((row, index) => (
{ onClick={() => moveRule(index, 1)} /> -