feat(routing): Mihomo rule-providers with hot update

Store rule-providers on server-routing, emit official rule-providers
block into serving config, and expose PUT hot-update via core
/providers/rules/{name}. Panel UI on Routing → Server egress.
This commit is contained in:
kazeyukiro committed 2026-09-29 16:26:38 +00:00
1 parent 95961c6cc2
commit 96d4a8a432
11 files changed
+493 -4

No files matched your search

+23
View File
@@ -1432,6 +1432,29 @@ security:
responses:
"200":
description: Saved ServerRoutingConfig
/config/rule-providers/status:
get:
tags: [config]
summary: Live rule-provider status from Mihomo (GET /providers/rules)
responses:
"200":
description: providers object from core
"502":
description: core API unreachable
/config/rule-providers/{name}/update:
put:
tags: [config]
summary: Hot-update one rule-set (Mihomo PUT /providers/rules/{name})
parameters:
- in: path
name: name
required: true
schema: { type: string }
responses:
"200":
description: ok
"502":
description: update failed
/system/warp:
get:
tags: [system]
+23
View File
@@ -1522,6 +1522,29 @@ security:
responses:
"200":
description: Saved ServerRoutingConfig
/config/rule-providers/status:
get:
tags: [config]
summary: Live rule-provider status from Mihomo (GET /providers/rules)
responses:
"200":
description: providers object from core
"502":
description: core API unreachable
/config/rule-providers/{name}/update:
put:
tags: [config]
summary: Hot-update one rule-set (Mihomo PUT /providers/rules/{name})
parameters:
- in: path
name: name
required: true
schema: { type: string }
responses:
"200":
description: ok
"502":
description: update failed
/system/warp:
get:
tags: [system]
+51
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
@@ -84,3 +85,53 @@ func (api *ExternalControllerAPI) ReloadConfig(payload map[string]interface{}) e
return nil
}
// ListRuleProviders GET /providers/rules
func (api *ExternalControllerAPI) ListRuleProviders() (map[string]interface{}, error) {
req, err := http.NewRequest(http.MethodGet, api.BaseURL+"/providers/rules", nil)
if err != nil {
return nil, err
}
if api.Secret != "" {
req.Header.Set("Authorization", "Bearer "+api.Secret)
}
resp, err := api.client.Do(req)
if err != nil {
return nil, fmt.Errorf("mihomo core API offline: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list rule providers: status %s", resp.Status)
}
var data map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return data, nil
}
// UpdateRuleProvider PUT /providers/rules/{name} — hot-reload one rule-set (official Clash API).
func (api *ExternalControllerAPI) UpdateRuleProvider(name string) error {
if name == "" {
return fmt.Errorf("rule provider name is empty")
}
req, err := http.NewRequest(http.MethodPut, api.BaseURL+"/providers/rules/"+url.PathEscape(name), nil)
if err != nil {
return err
}
if api.Secret != "" {
req.Header.Set("Authorization", "Bearer "+api.Secret)
}
// Rule-set download can be slow
client := *api.client
client.Timeout = 120 * time.Second
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("mihomo core API offline: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("update rule provider %q: status %s", name, resp.Status)
}
return nil
}
@@ -18,11 +18,27 @@ const serverRoutingName = "server-routing"
// ServerRoutingConfig controls how traffic leaves the VPS after hitting a
// listener — server-side Mihomo proxies, groups, and rules after traffic hits listeners.
// RuleProvider is one Mihomo rule-providers entry
// (https://wiki.metacubex.one/config/rule-providers/).
type RuleProvider struct {
Name string `json:"name"`
Type string `json:"type"` // http | file | inline
Behavior string `json:"behavior"` // domain | ipcidr | classical
Format string `json:"format,omitempty"` // yaml | text | mrs (default yaml)
URL string `json:"url,omitempty"` // required when type=http
Path string `json:"path,omitempty"` // under Mihomo -d home; auto if empty
Interval int `json:"interval,omitempty"` // seconds; http update interval
Proxy string `json:"proxy,omitempty"` // download via this outbound (e.g. DIRECT)
Payload []string `json:"payload,omitempty"` // inline only
SizeLimit int `json:"sizeLimit,omitempty"` // bytes; 0 = unlimited
}
type ServerRoutingConfig struct {
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
Rules []string `json:"rules" yaml:"rules"`
WarpDomains []string `json:"warpDomains" yaml:"warp-domains,omitempty"`
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
Rules []string `json:"rules" yaml:"rules"`
WarpDomains []string `json:"warpDomains" yaml:"warp-domains,omitempty"`
RuleProviders []RuleProvider `json:"ruleProviders" yaml:"rule-providers,omitempty"`
}
// DefaultServerRouting is pure direct egress (historical 3m-ui behaviour).
@@ -185,6 +201,87 @@ func applyServerRouting(merged map[string]interface{}, sr ServerRoutingConfig, w
rules = []interface{}{"MATCH,DIRECT"}
}
merged["rules"] = rules
applyRuleProviders(merged, sr.RuleProviders)
}
// applyRuleProviders writes top-level rule-providers per MetaCubeX wiki.
func applyRuleProviders(merged map[string]interface{}, providers []RuleProvider) {
if len(providers) == 0 {
delete(merged, "rule-providers")
return
}
out := make(map[string]interface{}, len(providers))
for _, p := range providers {
name := strings.TrimSpace(p.Name)
if name == "" {
continue
}
typ := strings.ToLower(strings.TrimSpace(p.Type))
if typ == "" {
typ = "http"
}
behavior := strings.ToLower(strings.TrimSpace(p.Behavior))
if behavior == "" {
behavior = "classical"
}
format := strings.ToLower(strings.TrimSpace(p.Format))
if format == "" {
format = "yaml"
}
entry := map[string]interface{}{
"type": typ,
"behavior": behavior,
}
if format != "yaml" || typ == "http" || typ == "file" {
entry["format"] = format
}
switch typ {
case "http":
if u := strings.TrimSpace(p.URL); u != "" {
entry["url"] = u
}
path := strings.TrimSpace(p.Path)
if path == "" {
// Relative to Mihomo -d home (SAFE_PATHS). Official docs allow omitting path
// (url MD5 filename); we pin a stable path for operators.
ext := format
if ext == "" {
ext = "yaml"
}
path = "./rule-providers/" + name + "." + ext
}
entry["path"] = path
if p.Interval > 0 {
entry["interval"] = p.Interval
} else {
entry["interval"] = 86400
}
if px := strings.TrimSpace(p.Proxy); px != "" {
entry["proxy"] = px
} else {
entry["proxy"] = "DIRECT"
}
if p.SizeLimit > 0 {
entry["size-limit"] = p.SizeLimit
}
case "file":
path := strings.TrimSpace(p.Path)
if path == "" {
path = "./rule-providers/" + name + ".yaml"
}
entry["path"] = path
case "inline":
if len(p.Payload) > 0 {
entry["payload"] = p.Payload
}
}
out[name] = entry
}
if len(out) == 0 {
delete(merged, "rule-providers")
return
}
merged["rule-providers"] = out
}
func proxyEntriesToYAML(entries []ProxyEntry) []interface{} {
@@ -73,3 +73,25 @@ func TestApplyServerRoutingWarpDomains(t *testing.T) {
t.Fatalf("rule2: %v", rules[2])
}
}
func TestApplyRuleProviders(t *testing.T) {
merged := map[string]interface{}{}
sr := ServerRoutingConfig{
Rules: []string{"RULE-SET,gfw,DIRECT", "MATCH,DIRECT"},
RuleProviders: []RuleProvider{
{Name: "gfw", Type: "http", Behavior: "domain", Format: "mrs", URL: "https://example.com/gfw.mrs"},
},
}
applyServerRouting(merged, sr, nil)
rp, ok := merged["rule-providers"].(map[string]interface{})
if !ok || rp["gfw"] == nil {
t.Fatalf("rule-providers: %#v", merged["rule-providers"])
}
entry := rp["gfw"].(map[string]interface{})
if entry["type"] != "http" || entry["behavior"] != "domain" {
t.Fatalf("entry: %#v", entry)
}
if entry["path"] == nil || entry["url"] != "https://example.com/gfw.mrs" {
t.Fatalf("path/url: %#v", entry)
}
}
+38
View File
@@ -2,8 +2,11 @@ package router
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
mihomo "github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
"gorm.io/gorm"
)
@@ -89,4 +92,39 @@ func registerRoutingRoutes(api *gin.RouterGroup, db *gorm.DB) {
}
c.JSON(http.StatusOK, saved)
})
// Rule providers (rule-set) — stored in server-routing; hot-update via Mihomo API.
group.GET("/rule-providers/status", func(c *gin.Context) {
controller := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER"))
if controller == "" {
controller = "127.0.0.1:9090"
}
base := "http://" + controller
api := mihomo.NewExternalControllerAPI(base, mihomoConfig.ControllerSecret())
data, err := api.ListRuleProviders()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error(), "providers": map[string]interface{}{}})
return
}
c.JSON(http.StatusOK, data)
})
group.PUT("/rule-providers/:name/update", func(c *gin.Context) {
name := strings.TrimSpace(c.Param("name"))
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
controller := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER"))
if controller == "" {
controller = "127.0.0.1:9090"
}
base := "http://" + controller
api := mihomo.NewExternalControllerAPI(base, mihomoConfig.ControllerSecret())
if err := api.UpdateRuleProvider(name); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "name": name})
})
}
+16
View File
@@ -42,3 +42,19 @@ WARP 在 **设置** 里一键注册,把 YAML 作为出站合并进服务端规
See embedded OpenAPI (`GET /api/v1/openapi.yaml`) for `/config/server-routing` and `/system/warp`.
## Rule providers (rule-set)
Server egress can declare Mihomo **`rule-providers`** ([official docs](https://wiki.metacubex.one/config/rule-providers/)).
1. **Routing → Server egress → Rule providers → Add** (`http` / `file` / `inline`, behavior `domain` | `ipcidr` | `classical`, format `yaml` | `text` | `mrs`)
2. **Save → Generate & apply** so `rule-providers:` is written into the serving config (path under Mihomo `-d` home, default `./rule-providers/{name}.{format}`)
3. Add a rule: `RULE-SET,<name>,<TARGET>` (e.g. `RULE-SET,gfw,WARP`)
4. **Hot update**: button calls Mihomo `PUT /providers/rules/{name}` (Clash API) to refresh that set without a full process restart for that provider payload
API:
- Stored in `GET/PUT /api/v1/config/server-routing` field `ruleProviders`
- `PUT /api/v1/config/rule-providers/{name}/update` — hot reload
- `GET /api/v1/config/rule-providers/status` — live status from core
+24
View File
@@ -17,11 +17,26 @@ export const fetchRules = () =>
export const saveRules = (rules: string[]) =>
withNetworkRetry(() => client.put<string[]>('/config/rules', rules).then((r) => r.data));
/** Mihomo rule-providers entry (wiki.metacubex.one/config/rule-providers). */
export interface RuleProvider {
name: string;
type: 'http' | 'file' | 'inline' | string;
behavior: 'domain' | 'ipcidr' | 'classical' | string;
format?: 'yaml' | 'text' | 'mrs' | string;
url?: string;
path?: string;
interval?: number;
proxy?: string;
payload?: string[];
sizeLimit?: number;
}
export interface ServerRoutingConfig {
proxies: Array<Record<string, unknown> & { name: string; type: string; server?: string; port?: number | string }>;
proxyGroups: GroupEntry[];
rules: string[];
warpDomains?: string[];
ruleProviders?: RuleProvider[];
}
export const fetchServerRouting = () =>
@@ -32,3 +47,12 @@ export const saveServerRouting = (cfg: ServerRoutingConfig) =>
withNetworkRetry(() =>
client.put<ServerRoutingConfig>('/config/server-routing', cfg).then((r) => r.data),
);
export const fetchRuleProviderStatus = () =>
withNetworkRetry(() => client.get('/config/rule-providers/status').then((r) => r.data));
/** Hot-reload one rule-set via Mihomo PUT /providers/rules/{name}. */
export const updateRuleProvider = (name: string) =>
withNetworkRetry(() =>
client.put(`/config/rule-providers/${encodeURIComponent(name)}/update`).then((r) => r.data),
);
+8
View File
@@ -829,6 +829,14 @@ export default {
"tplWarpAi": "AI domains → WARP (needs outbound named WARP)",
"warpAiHint": "Register WARP in Settings (name the proxy WARP), then Generate & apply",
"applyPromptBodyServer": "Generate & apply writes server egress (rules + WARP domains + WARP outbound) into the panel Mihomo config and reloads the core.",
"ruleProviders": "Rule providers (rule-set)",
"rpHint": "Official Mihomo rule-providers. Save + Apply writes rule-providers into config. Hot update calls PUT /providers/rules/{name}. Use rules: RULE-SET,name,TARGET",
"rpEmpty": "No rule providers yet",
"rpAdd": "Add rule provider",
"rpHotUpdate": "Hot update",
"rpUpdated": "Rule provider updated",
"rpDup": "Name already exists",
"rpAddedHint": "Added — Save then Generate & apply so Mihomo loads rule-providers",
"noServerTemplates": "Server egress has no templates — set WARP domains below or edit rules.",
"warpDomains": "WARP domains",
"warpDomainsHintShort": "Requires WARP account in Settings",
+8
View File
@@ -829,6 +829,14 @@ export default {
"tplWarpAi": "AI 域名 → WARP(需名为 WARP 的出站)",
"warpAiHint": "请先在设置注册 WARP,并将出站名称设为 WARP,再点生成并应用",
"applyPromptBodyServer": "生成并应用会把服务端出站(规则 + WARP 域名 + WARP 出站)写入面板 Mihomo 配置并重载核心。",
"ruleProviders": "规则集 (rule-providers)",
"rpHint": "官方 Mihomo rule-providers。保存并应用后写入配置;热更新调用 PUT /providers/rules/{name}。规则写法:RULE-SET,名称,出站",
"rpEmpty": "暂无规则集",
"rpAdd": "添加规则集",
"rpHotUpdate": "热更新",
"rpUpdated": "规则集已更新",
"rpDup": "名称已存在",
"rpAddedHint": "已添加 — 请保存并「生成并应用」以便 Mihomo 加载 rule-providers",
"noServerTemplates": "服务端出站没有模板 — 请在下方填写 WARP 域名或编辑规则。",
"warpDomains": "WARP 域名",
"warpDomainsHintShort": "需先在设置中注册 WARP 账户",
+179
View File
@@ -35,7 +35,9 @@ import {
saveRules,
fetchServerRouting,
saveServerRouting,
updateRuleProvider,
type GroupEntry,
type RuleProvider,
} from '../api/routing';
import { generateConfig, applyConfigYAML, fetchProxies } from '../api/config';
import PageHeader from '../components/PageHeader';
@@ -67,6 +69,10 @@ const RoutingPage: React.FC = () => {
const [scope, setScope] = useState<'client' | 'server'>('client');
const [serverRules, setServerRules] = useState<RuleRow[]>([]);
const [warpDomains, setWarpDomains] = useState<string>('');
const [ruleProviders, setRuleProviders] = useState<import('../api/routing').RuleProvider[]>([]);
const [rpOpen, setRpOpen] = useState(false);
const [rpUpdating, setRpUpdating] = useState<string | null>(null);
const [rpForm] = Form.useForm();
const [form] = Form.useForm();
const targetOptions = useMemo(() => {
@@ -89,6 +95,7 @@ const RoutingPage: React.FC = () => {
setRules(parseRulesText((Array.isArray(r) ? r : []).join('\n')));
setServerRules(parseRulesText((Array.isArray(sr?.rules) ? sr.rules : ['MATCH,DIRECT']).join('\n')));
setWarpDomains(Array.isArray(sr?.warpDomains) ? sr.warpDomains.join('\n') : '');
setRuleProviders(Array.isArray(sr?.ruleProviders) ? sr.ruleProviders : []);
setProxyNames(
(Array.isArray(px) ? px : [])
.map((p: any) => String(p?.name || '').trim())
@@ -167,6 +174,7 @@ const RoutingPage: React.FC = () => {
proxyGroups: [],
rules: serializeRules(current),
warpDomains: domains,
ruleProviders,
});
setServerRules(parseRulesText((Array.isArray(saved?.rules) ? saved.rules : serializeRules(current)).join('\n')));
if (Array.isArray(saved?.warpDomains)) {
@@ -397,6 +405,177 @@ const RoutingPage: React.FC = () => {
)}
{scope === 'server' && (
<Card
title={t('routing.ruleProviders') || 'Rule providers (rule-set)'}
style={{ marginBottom: 16 }}
extra={
<Space wrap size="small">
<Button
size="small"
type="primary"
onClick={() => {
rpForm.resetFields();
rpForm.setFieldsValue({ type: 'http', behavior: 'domain', format: 'mrs', interval: 86400, proxy: 'DIRECT' });
setRpOpen(true);
}}
>
{t('routing.rpAdd') || 'Add'}
</Button>
</Space>
}
>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
{t('routing.rpHint') ||
'Official Mihomo rule-providers. After Save + Apply, use Update to hot-reload a set (PUT /providers/rules/{name}) without full restart. Rules: RULE-SET,name,TARGET'}
</Typography.Paragraph>
{ruleProviders.length === 0 ? (
<Typography.Text type="secondary">{t('routing.rpEmpty') || 'No rule providers yet'}</Typography.Text>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{ruleProviders.map((rp) => (
<div
key={rp.name}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<Typography.Text strong>{rp.name}</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{rp.type}/{rp.behavior}/{rp.format || 'yaml'}
{rp.url ? ` · ${rp.url}` : ''}
</Typography.Text>
</div>
<Space size="small" wrap>
<Button
size="small"
loading={rpUpdating === rp.name}
onClick={async () => {
setRpUpdating(rp.name);
try {
await updateRuleProvider(rp.name);
message.success(t('routing.rpUpdated') || `Updated ${rp.name}`);
} catch (e: any) {
message.error(errMsg(e));
} finally {
setRpUpdating(null);
}
}}
>
{t('routing.rpHotUpdate') || 'Hot update'}
</Button>
<Button
size="small"
danger
onClick={() => {
setRuleProviders((prev) => prev.filter((x) => x.name !== rp.name));
}}
>
{t('common.delete') || 'Delete'}
</Button>
</Space>
</div>
))}
</Space>
)}
<Modal
title={t('routing.rpAdd') || 'Add rule provider'}
open={rpOpen}
onCancel={() => setRpOpen(false)}
onOk={async () => {
try {
const v = await rpForm.validateFields();
const name = String(v.name || '').trim();
if (!name) return;
if (ruleProviders.some((x) => x.name === name)) {
message.error(t('routing.rpDup') || 'Name already exists');
return;
}
const entry: RuleProvider = {
name,
type: v.type || 'http',
behavior: v.behavior || 'domain',
format: v.format || 'yaml',
url: v.url,
path: v.path,
interval: v.interval != null ? Number(v.interval) : 86400,
proxy: v.proxy || 'DIRECT',
payload:
typeof v.payload === 'string'
? String(v.payload)
.split(/\n/)
.map((s: string) => s.trim())
.filter(Boolean)
: undefined,
};
setRuleProviders((prev) => [...prev, entry]);
setRpOpen(false);
message.info(t('routing.rpAddedHint') || 'Added — Save then Generate & apply so Mihomo loads rule-providers');
} catch {
/* validate */
}
}}
destroyOnHidden
>
<Form form={rpForm} layout="vertical" size="small">
<Form.Item name="name" label="name" rules={[{ required: true }]}>
<Input placeholder="gfw" />
</Form.Item>
<Form.Item name="type" label="type" initialValue="http">
<Select
options={[
{ value: 'http', label: 'http' },
{ value: 'file', label: 'file' },
{ value: 'inline', label: 'inline' },
]}
/>
</Form.Item>
<Form.Item name="behavior" label="behavior" initialValue="domain">
<Select
options={[
{ value: 'domain', label: 'domain' },
{ value: 'ipcidr', label: 'ipcidr' },
{ value: 'classical', label: 'classical' },
]}
/>
</Form.Item>
<Form.Item name="format" label="format" initialValue="mrs">
<Select
options={[
{ value: 'mrs', label: 'mrs' },
{ value: 'yaml', label: 'yaml' },
{ value: 'text', label: 'text' },
]}
/>
</Form.Item>
<Form.Item name="url" label="url" extra="Required for type=http">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="path" label="path" extra="Optional; default ./rule-providers/{name}.{format}">
<Input placeholder="./rule-providers/gfw.mrs" />
</Form.Item>
<Form.Item name="interval" label="interval (s)" initialValue={86400}>
<InputNumber min={60} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="proxy" label="proxy (download)" initialValue="DIRECT">
<Input placeholder="DIRECT" />
</Form.Item>
<Form.Item name="payload" label="payload (inline, one per line)">
<Input.TextArea rows={3} placeholder="DOMAIN-SUFFIX,example.com" />
</Form.Item>
</Form>
</Modal>
</Card>
)}
{scope === 'client' && (
<Card
title={t('routing.groups')}