diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 59a2dae..8a80499 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -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] diff --git a/backend/internal/docs/openapi.yaml b/backend/internal/docs/openapi.yaml index 941b3d5..eb7af9b 100644 --- a/backend/internal/docs/openapi.yaml +++ b/backend/internal/docs/openapi.yaml @@ -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] diff --git a/backend/internal/mihomo/api.go b/backend/internal/mihomo/api.go index ea34228..5676fb2 100644 --- a/backend/internal/mihomo/api.go +++ b/backend/internal/mihomo/api.go @@ -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 +} diff --git a/backend/internal/mihomo/config/server_routing.go b/backend/internal/mihomo/config/server_routing.go index 19c171d..193d90e 100644 --- a/backend/internal/mihomo/config/server_routing.go +++ b/backend/internal/mihomo/config/server_routing.go @@ -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{} { diff --git a/backend/internal/mihomo/config/server_routing_test.go b/backend/internal/mihomo/config/server_routing_test.go index 390ce0b..9055570 100644 --- a/backend/internal/mihomo/config/server_routing_test.go +++ b/backend/internal/mihomo/config/server_routing_test.go @@ -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) + } +} diff --git a/backend/internal/router/routing_routes.go b/backend/internal/router/routing_routes.go index 234c7b8..bbd6995 100644 --- a/backend/internal/router/routing_routes.go +++ b/backend/internal/router/routing_routes.go @@ -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}) + }) + } diff --git a/docs/routing.md b/docs/routing.md index b26f65b..29cc288 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -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,,` (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 diff --git a/frontend/src/api/routing.ts b/frontend/src/api/routing.ts index 0cba7f2..31bfce8 100644 --- a/frontend/src/api/routing.ts +++ b/frontend/src/api/routing.ts @@ -17,11 +17,26 @@ export const fetchRules = () => export const saveRules = (rules: string[]) => withNetworkRetry(() => client.put('/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 & { 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('/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), + ); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ace2688..2bb6e71 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 58b87de..866514c 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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 账户", diff --git a/frontend/src/pages/Routing.tsx b/frontend/src/pages/Routing.tsx index e8a91e7..e92b00e 100644 --- a/frontend/src/pages/Routing.tsx +++ b/frontend/src/pages/Routing.tsx @@ -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([]); const [warpDomains, setWarpDomains] = useState(''); + const [ruleProviders, setRuleProviders] = useState([]); + const [rpOpen, setRpOpen] = useState(false); + const [rpUpdating, setRpUpdating] = useState(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' && ( + + + + } + > + + {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'} + + {ruleProviders.length === 0 ? ( + {t('routing.rpEmpty') || 'No rule providers yet'} + ) : ( + + {ruleProviders.map((rp) => ( +
+
+ {rp.name} + + {rp.type}/{rp.behavior}/{rp.format || 'yaml'} + {rp.url ? ` · ${rp.url}` : ''} + +
+ + + + +
+ ))} +
+ )} + 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 + > +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ )} + {scope === 'client' && (