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.
This commit is contained in:
kazeyukiro committed 2026-09-29 15:14:39 +00:00
1 parent addf8aee32
commit 4d51a5704f
8 files changed
+336 -23

No files matched your search

+7 -3
View File
@@ -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)
@@ -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
}
@@ -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)
}
}
+27
View File
@@ -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)
})
}
+15
View File
@@ -16,3 +16,18 @@ export const fetchRules = () =>
withNetworkRetry(() => client.get<string[]>('/config/rules').then((r) => r.data));
export const saveRules = (rules: string[]) =>
withNetworkRetry(() => client.put<string[]>('/config/rules', rules).then((r) => r.data));
export interface ServerRoutingConfig {
proxies: Array<Record<string, unknown> & { name: string; type: string; server?: string; port?: number | string }>;
proxyGroups: GroupEntry[];
rules: string[];
}
export const fetchServerRouting = () =>
withNetworkRetry(() =>
client.get<ServerRoutingConfig>('/config/server-routing').then((r) => r.data),
);
export const saveServerRouting = (cfg: ServerRoutingConfig) =>
withNetworkRetry(() =>
client.put<ServerRoutingConfig>('/config/server-routing', cfg).then((r) => r.data),
);
+6 -2
View File
@@ -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": {
+6 -2
View File
@@ -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": {
+62 -16
View File
@@ -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<RuleRow[]>([]);
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<RuleRow>) => {
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 (
<div>
<PageHeader title={t('routing.title')} subtitle={t('routing.subtitle')} />
<Typography.Paragraph type="secondary" style={{ marginTop: -4, marginBottom: isMobile ? 8 : 12, fontSize: isMobile ? 12 : undefined }}>
{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.'}
</Typography.Paragraph>
<Tabs
activeKey={scope}
onChange={(k) => 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' },
]}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: isMobile ? 8 : 12 }}
message={
scope === 'server'
? (t('routing.serverHint') ||
'Server egress: rules applied to the panel Mihomo process (like 3x-ui Xray routing). Affects traffic after it hits your listeners. Save then Apply.')
: (t('routing.pageHint') ||
'Client subscription: proxy-groups and rules go into Mihomo/Clash subscription YAML only. Save, then refresh the subscription in the client.')
}
/>
{scope === 'client' && (
<Card
title={t('routing.groups')}
extra={
@@ -341,11 +386,12 @@ const RoutingPage: React.FC = () => {
]}
/>
</Card>
)}
<Card title={t('routing.rules')} extra={isMobile ? undefined : ruleCardExtra}>
{isMobile ? <div style={{ marginBottom: 12 }}>{ruleCardExtra}</div> : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{rules.map((row, index) => (
{activeRules.map((row, index) => (
<div
key={row.key || newRuleKey()}
style={{
@@ -427,7 +473,7 @@ const RoutingPage: React.FC = () => {
onClick={() => moveRule(index, 1)}
/>
</Tooltip>
<Button size="small" danger icon={<IconDelete />} disabled={rules.length <= 1} onClick={() => removeRule(index)} />
<Button size="small" danger icon={<IconDelete />} disabled={activeRules.length <= 1} onClick={() => removeRule(index)} />
</Space>
</div>
</div>