chore: remove Cloudflare WARP feature (panel, API, docs)

Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
chore: remove Cloudflare WARP feature (panel, API, docs)
Drop register/inject endpoints, Settings/Routing UI, server-exit WARP
path, and documentation. Client routing and inbound panel unchanged;
server remains MATCH,DIRECT.
This commit is contained in:
kazeyukiro committed 2026-09-23 17:05:13 +00:00
1 parent ca548e29c9
commit 52bb97c083
39 files changed
+25 -1743

No files matched your search

-4
View File
@@ -6,7 +6,6 @@
> Languages / 语言: [README](./README.md) · **English** · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · [日本語](./README.ja.md) · [한국어](./README.ko.md) · [Español](./README.es.md) · [Français](./README.fr.md) · [Deutsch](./README.de.md) · [Русский](./README.ru.md) · [Português (Brasil)](./README.pt-BR.md) · [Tiếng Việt](./README.vi.md) · [Bahasa Indonesia](./README.id.md) · [ไทย](./README.th.md) · [Türkçe](./README.tr.md) · [العربية](./README.ar.md) · [हिन्दी](./README.hi.md) · [Polski](./README.pl.md) · [Українська](./README.uk.md)
**Mihomo server web management panel**
> **Stable first:** install and update use formal releases by default. Pre-releases require an explicit choice. Full bundles and Docker images ship a pinned Mihomo core — see [installation docs](https://3m-ui.top/docs/install.html).
@@ -28,8 +27,6 @@ Lightweight and self-hosted. Manage [Mihomo](https://github.com/MetaCubeX/mihomo
| **Users** | Bind nodes, traffic/expiry, **per-node usage** (billed = raw × node multiplier), IP/sub-pull limits, start-on-first-use, cycle renew/reset, groups/tags, external sub merge, batch ops, subscription tokens |
| **Subscriptions** | UA routing for Clash/Mihomo YAML, v2ray Base64 (`?target=v2ray` always Base64), sing-box JSON; optional `?target=`; HTML info page; TUIC/HY2 share TLS params |
| **Config** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Routing** | Structured Mihomo rules, proxy-groups, templates, WARP inject; save then generate & apply |
| **Ops** | Core start/stop/update, logs, dashboard, online connections, Geo, panel SSL/ACME, WARP one-click, backup/restore |
| **Telegram** | Alerts and bot commands (token + chat IDs) |
| **Cluster** | Register remote panels, health checks, node mirror sync, merged subscriptions; **push local nodes by name** (created disabled on remote) |
@@ -52,7 +49,6 @@ Default panel: `http://SERVER_IP:8080/` — user `admin`, one-time random passwo
---
### Install with an AI assistant prompt
Copy the prompt into ChatGPT / Claude / Cursor (and state your OS, e.g. Ubuntu 22.04, and whether you have root SSH). The assistant should only use the official installer — not rewrite URLs or invent third-party commands. The full prompt and usage notes live in [the AI install prompt doc](docs/ai-install-prompt.md).
-8
View File
@@ -6,10 +6,8 @@
> Languages / 语言: **README** · [English](./README.en.md) · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · [日本語](./README.ja.md) · [한국어](./README.ko.md) · [Español](./README.es.md) · [Français](./README.fr.md) · [Deutsch](./README.de.md) · [Русский](./README.ru.md) · [Português (Brasil)](./README.pt-BR.md) · [Tiếng Việt](./README.vi.md) · [Bahasa Indonesia](./README.id.md) · [ไทย](./README.th.md) · [Türkçe](./README.tr.md) · [العربية](./README.ar.md) · [हिन्दी](./README.hi.md) · [Polski](./README.pl.md) · [Українська](./README.uk.md)
**Mihomo 服务端 Web 管理面板**
> **稳定版优先:** 默认安装和更新只使用正式 Release。预发布需要主动选择。完整安装包及 Docker 镜像包含固定版本的 Mihomo;详见 [安装、升级与恢复](docs/installation.md)。
轻量、自托管,用于在 Linux 上管理 [Mihomo](https://github.com/MetaCubeX/mihomo) Listener、用户、订阅与运行状态。
@@ -31,13 +29,10 @@
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **订阅** | UA 自动识别 Clash/Mihomo YAML、v2ray Base64(`?target=v2ray` 始终 Base64)、sing-box JSON;可选 `?target=`;HTML 订阅页;TUIC/HY2 分享链含 SNI/`allow_insecure` 等客户端参数 |
| **配置** | 生成 → 校验 → 应用 分离;失败回滚上一份 `config.yaml` |
| **路由** | 结构化规则(类型/目标/排序)、策略组、模板、WARP 注入出站;保存后需生成并应用 |
| **运维** | 核心启停/更新、日志、仪表盘、在线连接、Geo、面板 SSL/ACME、WARP 一键、备份恢复 |
| **Telegram** | 告警与管理命令(需 Token + Chat ID) |
| **多机** | 登记远程面板、健康检查、同步节点镜像、合并订阅;**按名称选择本机节点推送**到远程(默认禁用,可改名/端口) |
| **安全** | JWT、首登改密、TOTP 两步验证、web_path 路径前缀、凭据加密、CORS |
### 补充说明(用户限制 / 证书 / 订阅)
- [用户限制:IP · 订阅拉取](docs/users-limits.md)
@@ -155,7 +150,6 @@ JWT / 凭据密钥请使用独立随机值(≥ 32 字节),不要使用文
自定义面板端口:`3m-ui config port <port>` 或编辑配置后重启服务。
新建节点默认随机填写 10000~60000 的监听端口(轻量抽样并避开已加载节点端口;支持倒序范围),可手动修改或点击「随机端口」。
---
## 架构示意
@@ -188,7 +182,6 @@ JWT / 凭据密钥请使用独立随机值(≥ 32 字节),不要使用文
---
## 致谢
特别感谢以下开源项目和社区:
@@ -230,7 +223,6 @@ JWT / 凭据密钥请使用独立随机值(≥ 32 字节),不要使用文
---
---
## English & other languages
-4
View File
@@ -6,7 +6,6 @@
> Languages / 语言: [README](./README.md) · [English](./README.en.md) · **简体中文** · [繁體中文](./README.zh-TW.md) · [日本語](./README.ja.md) · [한국어](./README.ko.md) · [Español](./README.es.md) · [Français](./README.fr.md) · [Deutsch](./README.de.md) · [Русский](./README.ru.md) · [Português (Brasil)](./README.pt-BR.md) · [Tiếng Việt](./README.vi.md) · [Bahasa Indonesia](./README.id.md) · [ไทย](./README.th.md) · [Türkçe](./README.tr.md) · [العربية](./README.ar.md) · [हिन्दी](./README.hi.md) · [Polski](./README.pl.md) · [Українська](./README.uk.md)
**Mihomo 服务端 Web 管理面板**
> **稳定版优先:** 默认安装和更新只使用正式 Release。预发布需主动选择。完整安装包及 Docker 镜像包含固定版本 Mihomo;详见 [安装文档](https://3m-ui.top/docs/install.html)。
@@ -27,9 +26,7 @@
| **节点** | 协议注册表驱动的 Listener:VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC 等;REALITY、TLS 自签、一键批量证书、**流量倍率**、传输层字段校验 |
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **订阅** | UA 识别 Clash/Mihomo YAML、v2ray Base64(`?target=v2ray` 始终 Base64)、sing-box JSON;可选 `?target=`;HTML 订阅页;TUIC/HY2 分享链含客户端 TLS 参数 |
| **路由** | 结构化规则、策略组、模板、WARP 注入;保存后生成并应用 |
| **配置** | 生成 → 校验 → 应用;失败回滚上一份 `config.yaml` |
| **运维** | 核心启停/更新、日志、仪表盘、在线连接、Geo、面板 SSL/ACME、WARP 一键、备份恢复 |
| **Telegram** | 告警与管理命令(需 Token + Chat ID) |
| **多机** | 登记远程面板、健康检查、节点镜像同步、合并订阅;按名称选择本机节点推送到远程(默认禁用) |
@@ -78,7 +75,6 @@ curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/docs/ai-insta
curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | sudo sh
```
---
## 源码编译
+4 -5
View File
@@ -24,9 +24,8 @@ func clientSubscriptionDocument(proxies []map[string]interface{}, names []string
var groups []interface{}
var rules []string
// Merge panel visual outbounds (e.g. WARP WireGuard) into the client document
// so inject-warp + MATCH,WARP-OUT works on phones/PCs. Server config still
// strips these (inbound-only).
// Merge panel visual outbounds (e.g. visual outbounds) into the client document
// so optional visual outbounds for client YAML. Server strips visual proxies.
if visual != nil && len(visual.Proxies) > 0 {
proxies, names = mergeVisualProxiesForClient(proxies, names, visual.Proxies)
}
@@ -171,7 +170,7 @@ func adaptVisualGroupsForClient(src []mihomocfg.GroupEntry, names []string) []in
members = append(members, m)
continue
}
// Drop server-only outbounds (e.g. WARP-OUT) not present in this sub.
// Drop outbounds not present in this subscription.
}
if shouldInjectSubscriptionNodes(name, members, groupNameSet) && len(names) > 0 {
injected := make([]string, 0, len(names)+len(members))
@@ -258,7 +257,7 @@ func filterClientRules(rules []string, groupNames map[string]struct{}, proxyName
target = strings.TrimSpace(parts[targetIdx])
}
if _, ok := valid[target]; !ok {
// Server-only outbound (e.g. WARP) — fall back to PROXY group or DIRECT.
// Unknown outbound — fall back to PROXY group or DIRECT.
if _, has := valid["PROXY"]; has {
parts[targetIdx] = "PROXY"
} else if _, has := valid["代理"]; has {
@@ -73,10 +73,10 @@ func TestClientSubscriptionUsesVisualGroups(t *testing.T) {
func TestClientSubscriptionIncludesVisualProxy(t *testing.T) {
v := &mihomocfg.VisualConfig{
Proxies: []mihomocfg.ProxyEntry{
{Name: "WARP-OUT", Type: "wireguard", Server: "engage.cloudflareclient.com", Port: 2408,
{Name: "EXTRA-OUT", Type: "ss", Server: "example.com", Port: 2408,
Options: map[string]interface{}{"private-key": "x", "public-key": "y"}},
},
Rules: []string{"MATCH,WARP-OUT"},
Rules: []string{"MATCH,EXTRA-OUT"},
}
doc := clientSubscriptionDocument(
[]map[string]interface{}{{"name": "node-a", "type": "vless"}},
@@ -88,7 +88,7 @@ func TestClientSubscriptionIncludesVisualProxy(t *testing.T) {
t.Fatal(err)
}
s := string(raw)
for _, want := range []string{"WARP-OUT", "wireguard", "MATCH,WARP-OUT", "node-a"} {
for _, want := range []string{"EXTRA-OUT", "ss", "MATCH,EXTRA-OUT", "node-a"} {
if !strings.Contains(s, want) {
t.Fatalf("missing %q in:\n%s", want, s)
}
+4 -8
View File
@@ -49,9 +49,8 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
// Listeners are owned by the panel DB; fragments must not inject them
// or Mihomo will report duplicate listener names after merge.
delete(fragMap, "listeners")
// visual-config primarily holds **client subscription** routing.
// Community rules/groups stay off the server; WARP-style exit proxies
// are re-applied below via applyServerExitFromVisual.
// visual-config is client-subscription routing only; never merge
// rules / proxy-groups / proxies into the serving Mihomo process.
if fragment.Name == visualConfigName || fragment.Type == "visual" {
delete(fragMap, "rules")
delete(fragMap, "proxy-groups")
@@ -61,12 +60,9 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
merged[k] = v
}
}
// Default: inbound panel exits DIRECT. If visual-config has a WARP (or
// similar) outbound, use it so user traffic via listeners exits with that IP.
// Inbound panel: always DIRECT exit (no server-side split).
merged["rules"] = []interface{}{"MATCH,DIRECT"}
merged["proxy-groups"] = []interface{}{}
if err := applyServerExitFromVisual(ce.db, merged); err != nil {
return "", err
}
var listeners []models.Listener
if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
return "", fmt.Errorf("load enabled listeners: %w", err)
@@ -9,9 +9,9 @@ import (
func TestProxyEntryYAMLRoundTripKeepsPrivateKey(t *testing.T) {
in := ProxyEntry{
Name: "WARP-OUT",
Name: "WG-OUT",
Type: "wireguard",
Server: "engage.cloudflareclient.com",
Server: "example.com",
Port: 2408,
Options: map[string]interface{}{
"private-key": "eCtXsJZ27+4PbhDkHnB923tkUn2Gj59wZw5wFA75MnU=",
@@ -1,208 +0,0 @@
package config
import (
"encoding/base64"
"fmt"
"strings"
"gorm.io/gorm"
)
// applyServerExitFromVisual optionally sets the serving Mihomo process to exit
// via a WARP-style outbound stored in visual-config (user → node → WARP IP).
// Client community rules/groups are never applied here.
func applyServerExitFromVisual(db *gorm.DB, merged map[string]interface{}) error {
if db == nil || merged == nil {
if merged != nil {
merged["rules"] = []interface{}{"MATCH,DIRECT"}
}
return nil
}
cfg, err := GetVisualConfig(db)
if err != nil {
merged["rules"] = []interface{}{"MATCH,DIRECT"}
return nil
}
exits := serverExitProxies(cfg.Proxies)
if len(exits) == 0 {
merged["rules"] = []interface{}{"MATCH,DIRECT"}
return nil
}
existing := proxiesAsMaps(merged["proxies"])
nameSet := map[string]struct{}{}
for _, m := range existing {
if n, _ := m["name"].(string); n != "" {
nameSet[n] = struct{}{}
}
}
var primary string
for _, p := range exits {
m, err := normalizeServerExitProxy(p)
if err != nil {
return fmt.Errorf("server exit proxy %q: %w", p.Name, err)
}
n, _ := m["name"].(string)
if n == "" {
continue
}
if _, ok := nameSet[n]; ok {
for i, em := range existing {
if en, _ := em["name"].(string); en == n {
existing[i] = m
break
}
}
} else {
existing = append(existing, m)
nameSet[n] = struct{}{}
}
if primary == "" {
primary = n
}
}
if primary == "" {
merged["rules"] = []interface{}{"MATCH,DIRECT"}
return nil
}
merged["proxies"] = existing
merged["rules"] = serverExitRules(cfg.Rules, primary)
return nil
}
func serverExitProxies(list []ProxyEntry) []ProxyEntry {
var out []ProxyEntry
for _, p := range list {
if isServerExitProxy(p) {
out = append(out, p)
}
}
return out
}
func isServerExitProxy(p ProxyEntry) bool {
typ := strings.ToLower(strings.TrimSpace(p.Type))
name := strings.ToUpper(strings.TrimSpace(p.Name))
if typ == "wireguard" {
return true
}
// MASQUE uses different crypto; only promote wireguard to server exit for now.
if strings.Contains(name, "WARP") && typ == "wireguard" {
return true
}
if strings.Contains(name, "WARP") && typ == "" {
// type lost on bad round-trip — still try if private-key looks like WG
return true
}
return false
}
func normalizeServerExitProxy(p ProxyEntry) (map[string]interface{}, error) {
m := proxyEntryToMap(p)
typ := strings.ToLower(strings.TrimSpace(fmt.Sprint(m["type"])))
if typ == "" || typ == "masque" {
// Force wireguard for Cloudflare WARP server exit (MASQUE schema differs).
m["type"] = "wireguard"
typ = "wireguard"
}
if typ != "wireguard" {
return nil, fmt.Errorf("unsupported exit type %q (want wireguard)", typ)
}
pk := strings.TrimSpace(fmt.Sprint(m["private-key"]))
if pk == "" || pk == "<nil>" {
return nil, fmt.Errorf("missing private-key (re-inject WARP)")
}
if err := validateWireGuardPrivateKey(pk); err != nil {
return nil, err
}
m["private-key"] = pk
// Ensure peer public key (Cloudflare WARP account key is well-known).
if strings.TrimSpace(fmt.Sprint(m["public-key"])) == "" || fmt.Sprint(m["public-key"]) == "<nil>" {
m["public-key"] = "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo="
}
if _, ok := m["udp"]; !ok {
m["udp"] = true
}
if _, ok := m["allowed-ips"]; !ok {
m["allowed-ips"] = []string{"0.0.0.0/0", "::/0"}
}
if srv := strings.TrimSpace(fmt.Sprint(m["server"])); srv == "" || srv == "<nil>" {
m["server"] = "engage.cloudflareclient.com"
}
if m["port"] == nil || fmt.Sprint(m["port"]) == "0" {
m["port"] = 2408
}
return m, nil
}
func validateWireGuardPrivateKey(pk string) error {
// Accept standard base64 (with padding) of 32 raw bytes — WireGuard/Curve25519.
raw, err := base64.StdEncoding.DecodeString(pk)
if err != nil {
// try raw std without padding
raw, err = base64.RawStdEncoding.DecodeString(pk)
}
if err != nil {
return fmt.Errorf("private-key is not valid base64 (got %q): %w", truncateKey(pk), err)
}
if len(raw) != 32 {
return fmt.Errorf("private-key must decode to 32 bytes (WireGuard), got %d — re-inject WARP", len(raw))
}
return nil
}
func truncateKey(s string) string {
if len(s) <= 16 {
return s
}
return s[:8] + "…" + s[len(s)-4:]
}
func proxyEntryToMap(p ProxyEntry) map[string]interface{} {
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
}
return m
}
func proxiesAsMaps(v interface{}) []map[string]interface{} {
out := []map[string]interface{}{}
switch t := v.(type) {
case []map[string]interface{}:
return append(out, t...)
case []interface{}:
for _, item := range t {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
}
return out
}
func serverExitRules(visualRules []string, exitName string) []interface{} {
if exitName == "" {
return []interface{}{"MATCH,DIRECT"}
}
cnDirect := false
for _, line := range visualRules {
u := strings.ToUpper(strings.TrimSpace(line))
if strings.Contains(u, "GEOIP,CN,DIRECT") {
cnDirect = true
break
}
}
if cnDirect {
return []interface{}{
"GEOIP,CN,DIRECT,no-resolve",
"MATCH," + exitName,
}
}
return []interface{}{"MATCH," + exitName}
}
@@ -1,23 +0,0 @@
package config
import "testing"
func TestServerExitRules(t *testing.T) {
r := serverExitRules(nil, "WARP-OUT")
if len(r) != 1 || r[0] != "MATCH,WARP-OUT" {
t.Fatalf("got %#v", r)
}
r = serverExitRules([]string{"GEOIP,CN,DIRECT", "MATCH,WARP-OUT"}, "WARP-OUT")
if len(r) != 2 || r[1] != "MATCH,WARP-OUT" {
t.Fatalf("cn mode %#v", r)
}
}
func TestIsServerExitProxy(t *testing.T) {
if !isServerExitProxy(ProxyEntry{Name: "WARP-OUT", Type: "wireguard"}) {
t.Fatal("expected wireguard")
}
if isServerExitProxy(ProxyEntry{Name: "ss1", Type: "ss"}) {
t.Fatal("ss should not be server exit")
}
}
-108
View File
@@ -194,111 +194,3 @@ func SaveVisualConfig(db *gorm.DB, cfg VisualConfig) error {
return db.Create(&fragment).Error
}
// InjectWARPProxy merges a WARP outbound (from register YAML) into visual proxies.
// ruleMode: ""|"none" — no rule change; "match" — append MATCH,<name> (before last MATCH if any);
// "cn_direct" — ensure GEOIP,CN,DIRECT then MATCH,<name>.
func InjectWARPProxy(db *gorm.DB, proxyYAML string, proxyName string, ruleMode string) (VisualConfig, error) {
cfg, err := GetVisualConfig(db)
if err != nil {
return cfg, err
}
var frag map[string]interface{}
if err := yaml.Unmarshal([]byte(proxyYAML), &frag); err != nil {
return cfg, fmt.Errorf("parse WARP yaml: %w", err)
}
// Accept either {proxies:[{...}]} or a single proxy map, or full config snippet.
var proxyMap map[string]interface{}
if list, ok := frag["proxies"].([]interface{}); ok && len(list) > 0 {
if m, ok := list[0].(map[string]interface{}); ok {
proxyMap = m
}
} else if frag["type"] != nil && frag["name"] != nil {
proxyMap = frag
}
if proxyMap == nil {
return cfg, fmt.Errorf("WARP yaml has no proxies entry")
}
name := proxyName
if name == "" {
if n, ok := proxyMap["name"].(string); ok && n != "" {
name = n
} else {
name = "WARP-OUT"
}
}
proxyMap["name"] = name
typ, _ := proxyMap["type"].(string)
typ = strings.ToLower(strings.TrimSpace(typ))
if typ == "" || typ == "masque" {
// Server exit + client sub both use WireGuard schema for Cloudflare WARP.
// MASQUE private-key is not interchangeable with WG; force wireguard.
typ = "wireguard"
proxyMap["type"] = "wireguard"
}
server, _ := proxyMap["server"].(string)
port := proxyMap["port"]
opts := map[string]interface{}{}
for k, v := range proxyMap {
if k == "name" || k == "type" || k == "server" || k == "port" {
continue
}
opts[k] = v
}
if _, ok := opts["private-key"]; !ok {
return cfg, fmt.Errorf("WARP yaml missing private-key")
}
if _, ok := opts["allowed-ips"]; !ok {
opts["allowed-ips"] = []string{"0.0.0.0/0", "::/0"}
}
entry := ProxyEntry{Name: name, Type: typ, Server: server, Port: port, Options: opts}
// Replace existing same name
replaced := false
for i, p := range cfg.Proxies {
if p.Name == name {
cfg.Proxies[i] = entry
replaced = true
break
}
}
if !replaced {
cfg.Proxies = append(cfg.Proxies, entry)
}
ruleMode = strings.ToLower(strings.TrimSpace(ruleMode))
switch ruleMode {
case "", "none":
// leave rules
case "match":
cfg.Rules = appendRulePreferLastMatch(cfg.Rules, "MATCH,"+name)
case "cn_direct":
cfg.Rules = []string{"GEOIP,CN,DIRECT", "MATCH," + name}
default:
return cfg, fmt.Errorf("invalid rule_mode %q", ruleMode)
}
if err := SaveVisualConfig(db, cfg); err != nil {
return cfg, err
}
return cfg, nil
}
func appendRulePreferLastMatch(rules []string, matchRule string) []string {
out := make([]string, 0, len(rules)+1)
inserted := false
for _, r := range rules {
tr := strings.TrimSpace(r)
if strings.HasPrefix(strings.ToUpper(tr), "MATCH,") {
if !inserted {
out = append(out, matchRule)
inserted = true
}
continue // drop old MATCH lines; single final MATCH
}
if tr != "" {
out = append(out, tr)
}
}
if !inserted {
out = append(out, matchRule)
}
return out
}
@@ -1,14 +0,0 @@
package config
import "testing"
func TestAppendRulePreferLastMatch(t *testing.T) {
got := appendRulePreferLastMatch([]string{"GEOIP,CN,DIRECT", "MATCH,DIRECT"}, "MATCH,WARP-OUT")
if len(got) != 2 || got[1] != "MATCH,WARP-OUT" {
t.Fatalf("got %#v", got)
}
got = appendRulePreferLastMatch([]string{}, "MATCH,WARP-OUT")
if len(got) != 1 || got[0] != "MATCH,WARP-OUT" {
t.Fatalf("empty %#v", got)
}
}
-44
View File
@@ -5,7 +5,6 @@ import (
"github.com/gin-gonic/gin"
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
"github.com/kazeyukiro/3m-ui/backend/internal/system"
"gorm.io/gorm"
)
@@ -63,48 +62,5 @@ func registerRoutingRoutes(api *gin.RouterGroup, db *gorm.DB) {
}
c.JSON(http.StatusOK, visual.Rules)
})
group.POST("/routing/inject-warp", func(c *gin.Context) {
var body struct {
Mode string `json:"mode"`
RuleMode string `json:"rule_mode"`
Name string `json:"name"`
}
_ = c.ShouldBindJSON(&body)
if body.Mode == "" {
body.Mode = "wireguard"
}
res, err := system.RegisterWARP()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
yamlStr := res.YAML
name := body.Name
if name == "" {
name = "WARP-OUT"
}
if body.Mode == "masque" {
if res.MasqueYAML == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "MASQUE YAML empty"})
return
}
yamlStr = res.MasqueYAML
if body.Name == "" {
name = "WARP-Masque-OUT"
}
}
cfg, err := mihomoConfig.InjectWARPProxy(db, yamlStr, name, body.RuleMode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"name": name,
"rules": cfg.Rules,
"proxies": cfg.Proxies,
"groups": cfg.Groups,
})
})
}
-75
View File
@@ -39,8 +39,6 @@ func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/templates/reverse-proxy", h.ReverseProxy)
rg.POST("/templates/acme", h.ACME)
rg.POST("/geofiles/update", h.UpdateGeoFiles)
rg.POST("/templates/warp", h.WARP)
rg.POST("/templates/warp/register", h.WARPRegister)
}
func (h *Handler) GetSystemStatus(c *gin.Context) {
@@ -154,79 +152,6 @@ func (h *Handler) UpdateGeoFiles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"dir": dir, "files": result})
}
// WARP builds a Mihomo WireGuard fragment from operator-supplied fields
// (m-ui compatible schema). Prefer POST /templates/warp/register for one-click.
func (h *Handler) WARP(c *gin.Context) {
var body struct {
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"` // peer public key
Server string `json:"server"`
Port int `json:"port"`
Address string `json:"address"`
IPv6 string `json:"ipv6"`
Reserved []int `json:"reserved"`
}
_ = c.ShouldBindJSON(&body)
ipv4, ipv6 := body.Address, body.IPv6
if i := strings.IndexByte(ipv4, ','); i >= 0 {
if ipv6 == "" {
ipv6 = strings.TrimSpace(ipv4[i+1:])
}
ipv4 = strings.TrimSpace(ipv4[:i])
}
pub := body.PublicKey
if pub == "" {
pub = "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo="
}
server := body.Server
if server == "" {
server = "engage.cloudflareclient.com"
}
port := body.Port
if port == 0 {
port = 2408
}
yaml, err := WARPTemplate(body.PrivateKey, pub, server, port, ipv4, ipv6, body.Reserved)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"yaml": yaml})
}
func (h *Handler) WARPRegister(c *gin.Context) {
res, err := RegisterWARP()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// Optional `mode` query param: "masque" returns only the MASQUE YAML,
// "wireguard" (default) returns only the WireGuard YAML, "both" returns
// the full result (including both YAMLs). This keeps the response shape
// backward-compatible (the default `yaml` field stays WireGuard).
mode := strings.TrimSpace(c.Query("mode"))
switch mode {
case "", "wireguard":
c.JSON(http.StatusOK, gin.H{
"yaml": res.YAML,
"masque_yaml": res.MasqueYAML, // bonus, for clients that support it
"address": res.Address,
"ipv6": res.IPv6,
"reserved": res.Reserved,
})
case "masque":
c.JSON(http.StatusOK, gin.H{
"yaml": res.MasqueYAML,
"address": res.Address,
"ipv6": res.IPv6,
})
case "both":
c.JSON(http.StatusOK, res)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid mode %q: must be wireguard, masque, or both", mode)})
}
}
func (h *Handler) backupDir() string {
return backupsDirFromDB(h.dbPath)
}
-212
View File
@@ -1,212 +0,0 @@
package system
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// warpWireGuardSpec is the structured shape of the Mihomo WireGuard outbound
// we render for Cloudflare WARP. Keeping it as a struct lets yaml.v3 handle
// escaping / indentation, which prevents a malicious operator-supplied
// private_key or address from breaking out of the YAML scalar and injecting
// sibling keys.
// Field names align with mihomo's wireguard outbound schema documented at
// https://wiki.metacubex.one/en/config/proxies/wg/ — in particular, IPv4 and
// IPv6 are separate fields (ip + ipv6), NOT a comma-joined string.
type warpWireGuardSpec struct {
PrivateKey string `yaml:"private-key,omitempty"`
Server string `yaml:"server"`
Port int `yaml:"port"`
IP string `yaml:"ip"` // IPv4 address (no CIDR)
IPv6 string `yaml:"ipv6,omitempty"` // IPv6 address (no CIDR)
PublicKey string `yaml:"public-key"`
UDP bool `yaml:"udp"`
Reserved []int `yaml:"reserved,omitempty"`
MTU int `yaml:"mtu"`
}
type warpProxyGroup struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Proxies []string `yaml:"proxies"`
}
type warpConfig struct {
Proxies []map[string]interface{} `yaml:"proxies"`
Groups []warpProxyGroup `yaml:"proxy-groups,omitempty"`
}
// validateWARPField rejects shell-significant / YAML-significant characters in
// a WARP WireGuard field. WireGuard private keys and addresses are restricted
// to base64 (key) and dotted-quad/CIDR (address); anything else is suspicious.
func validateWARPField(s string) bool {
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '+' || r == '/' || r == '=': // base64 alphabet
case r == ':' || r == '.' || r == '-' || r == '_':
case r == ',': // reserved list separator (kept simple)
default:
return false
}
}
return true
}
// WARPMasqueTemplate returns a Mihomo YAML fragment for Cloudflare WARP using
// the MASQUE protocol (RFC 9298 / HTTP/3 CONNECT-UDP). Cloudflare WARP now
// defaults to MASQUE per the registration API's `policy.tunnel_protocol`
// field — this template emits the schema documented at
// https://wiki.metacubex.one/config/proxies/masque/
//
// Key differences vs WireGuard WARP:
// - `ip` / `ipv6` carry a CIDR (/32 and /128) per mihomo's masque schema
// - No `reserved` field (that's wireguard-only)
// - Same X25519 keypair (Curve25519 private/public key from WARP register)
// - Optional `network` field: "" (default UDP) | "h2" | "h3-l4proxy"
// - Optional `congestion-controller`: "bbr" recommended for WARP
func WARPMasqueTemplate(privateKey, ipv4, ipv6, network string) (string, error) {
privateKey = strings.TrimSpace(privateKey)
if privateKey != "" {
if !validateWARPField(privateKey) {
return "", fmt.Errorf("invalid private_key: must be base64 / wireguard-safe")
}
}
ipv4 = strings.TrimSpace(ipv4)
ipv6 = strings.TrimSpace(ipv6)
if ipv4 == "" && ipv6 == "" {
ipv4 = "172.16.0.2"
}
// masque requires CIDR form. Append /32 / /128 if caller passed bare IP.
ipv4 = ensureCIDR(ipv4, "/32")
ipv6 = ensureCIDR(ipv6, "/128")
if !validateWARPField(ipv4) {
return "", fmt.Errorf("invalid IPv4 address: %q", ipv4)
}
if !validateWARPField(ipv6) {
return "", fmt.Errorf("invalid IPv6 address: %q", ipv6)
}
key := privateKey
if key == "" {
key = "YOUR_WARP_PRIVATE_KEY"
}
network = strings.TrimSpace(network)
if network != "" && network != "h2" && network != "h3-l4proxy" {
return "", fmt.Errorf("invalid network %q: must be '', 'h2', or 'h3-l4proxy'", network)
}
proxy := map[string]interface{}{
"name": "WARP-Masque",
"type": "masque",
"server": "engage.cloudflareclient.com",
"port": 2408,
"ip": ipv4,
"private-key": key,
"public-key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"udp": true,
"mtu": 1280,
}
if ipv6 != "" {
proxy["ipv6"] = ipv6
}
if network != "" {
proxy["network"] = network
}
cfg := warpConfig{
Proxies: []map[string]interface{}{proxy},
Groups: []warpProxyGroup{
{Name: "WARP-Masque-OUT", Type: "select", Proxies: []string{"WARP-Masque", "DIRECT"}},
},
}
out, err := yaml.Marshal(cfg)
if err != nil {
return "", err
}
header := "# Cloudflare WARP (MASQUE) outbound for Mihomo\n"
footer := "\n# rules:\n# - MATCH,WARP-Masque-OUT\n"
return header + string(out) + footer, nil
}
// ensureCIDR appends suffix if s does not already contain a '/'.
func ensureCIDR(s, suffix string) string {
if s == "" {
return s
}
if strings.IndexByte(s, '/') >= 0 {
return s
}
return s + suffix
}
// WARPTemplate returns a Mihomo WireGuard outbound for Cloudflare WARP
// (aligned with RomanovCaesar/m-ui warpOutbound).
func WARPTemplate(privateKey, peerPublicKey, server string, port int, ipv4, ipv6 string, reserved []int) (string, error) {
privateKey = strings.TrimSpace(privateKey)
peerPublicKey = strings.TrimSpace(peerPublicKey)
server = strings.TrimSpace(server)
if privateKey == "" {
return "", fmt.Errorf("private_key required")
}
if !validateWARPField(privateKey) {
return "", fmt.Errorf("invalid private_key")
}
if peerPublicKey == "" {
return "", fmt.Errorf("peer public_key required")
}
if !validateWARPField(peerPublicKey) {
return "", fmt.Errorf("invalid peer public_key")
}
if server == "" {
server = "engage.cloudflareclient.com"
}
if port < 1 || port > 65535 {
port = 2408
}
ipv4 = strings.TrimSpace(ipv4)
ipv6 = strings.TrimSpace(ipv6)
if ipv4 == "" && ipv6 == "" {
return "", fmt.Errorf("at least one of ipv4/ipv6 required")
}
if ipv4 != "" && !validateWARPField(ipv4) {
return "", fmt.Errorf("invalid IPv4 %q", ipv4)
}
if ipv6 != "" && !validateWARPField(ipv6) {
return "", fmt.Errorf("invalid IPv6 %q", ipv6)
}
proxy := map[string]interface{}{
"name": "WARP",
"type": "wireguard",
"server": server,
"port": port,
"private-key": privateKey,
"public-key": peerPublicKey,
"udp": true,
"mtu": 1420,
"allowed-ips": []string{"0.0.0.0/0", "::/0"},
}
if ipv4 != "" {
proxy["ip"] = ipv4
}
if ipv6 != "" {
proxy["ipv6"] = ipv6
}
if len(reserved) == 3 {
proxy["reserved"] = reserved
}
cfg := warpConfig{
Proxies: []map[string]interface{}{proxy},
Groups: []warpProxyGroup{
{Name: "WARP-OUT", Type: "select", Proxies: []string{"WARP", "DIRECT"}},
},
}
out, err := yaml.Marshal(cfg)
if err != nil {
return "", err
}
header := "# Cloudflare WARP (WireGuard) — m-ui compatible outbound for Mihomo\n"
footer := "\n# rules:\n# - MATCH,WARP-OUT\n"
return header + string(out) + footer, nil
}
-268
View File
@@ -1,268 +0,0 @@
package system
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
)
// Cloudflare consumer registration API (same endpoint family as m-ui / 3x-ui / wgcf).
const warpAPIBase = "https://api.cloudflareclient.com/v0a2158"
// WARPRegisterResult is the material needed to build a Mihomo WARP outbound.
type WARPRegisterResult struct {
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"` // peer (server) public key
Address string `json:"address"`
IPv6 string `json:"ipv6,omitempty"`
Reserved []int `json:"reserved,omitempty"`
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
DeviceID string `json:"device_id,omitempty"`
YAML string `json:"yaml"`
MasqueYAML string `json:"masque_yaml,omitempty"`
}
type warpDevice struct {
ID string `json:"id"`
Token string `json:"token,omitempty"`
Name string `json:"name"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
Account warpAccountInfo `json:"account"`
Config warpTunnelConfig `json:"config"`
}
type warpAccountInfo struct {
License string `json:"license"`
AccountType string `json:"account_type"`
Role string `json:"role"`
PremiumData uint64 `json:"premium_data"`
Quota uint64 `json:"quota"`
Usage uint64 `json:"usage"`
}
type warpTunnelConfig struct {
ClientID string `json:"client_id"`
Interface struct {
Addresses struct {
V4 string `json:"v4"`
V6 string `json:"v6"`
} `json:"addresses"`
} `json:"interface"`
Peers []struct {
PublicKey string `json:"public_key"`
Endpoint struct {
Host string `json:"host"`
V4 string `json:"v4"`
V6 string `json:"v6"`
} `json:"endpoint"`
} `json:"peers"`
}
type warpHTTPClient struct {
baseURL string
httpClient *http.Client
}
func defaultWarpClient() *warpHTTPClient {
return &warpHTTPClient{
baseURL: warpAPIBase,
httpClient: &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
func (c *warpHTTPClient) request(ctx context.Context, method, path string, input, output any) error {
var body io.Reader
if input != nil {
data, err := json.Marshal(input)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("warp: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("CF-Client-Version", "a-7.21-0721")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("warp: connect Cloudflare failed (check outbound network): %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("warp: Cloudflare HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil || len(data) > 1<<20 {
return fmt.Errorf("warp: response read failed or too large")
}
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || trimmed[0] != '{' {
return fmt.Errorf("warp: invalid response object")
}
var envelope struct {
Success *bool `json:"success"`
Errors []json.RawMessage `json:"errors"`
Result json.RawMessage `json:"result"`
}
if err = json.Unmarshal(data, &envelope); err != nil {
return fmt.Errorf("warp: invalid JSON")
}
if envelope.Success != nil && !*envelope.Success || len(envelope.Errors) > 0 {
return fmt.Errorf("warp: Cloudflare rejected the request")
}
if len(envelope.Result) > 0 && string(envelope.Result) != "null" {
data = envelope.Result
}
if output != nil {
if err = json.Unmarshal(data, output); err != nil {
return fmt.Errorf("warp: decode device: %w", err)
}
}
return nil
}
func decodeWarpKey(value string) ([]byte, error) {
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil || len(data) != 32 {
return nil, fmt.Errorf("WireGuard key must be 32-byte base64")
}
return data, nil
}
func warpAddress(value string, ipv4 bool) (string, error) {
if value == "" {
return "", nil
}
address, err := netip.ParseAddr(value)
if err != nil {
if prefix, prefixErr := netip.ParsePrefix(value); prefixErr == nil {
address = prefix.Addr()
err = nil
}
}
if err != nil || address.Is4() != ipv4 {
return "", fmt.Errorf("invalid tunnel address %q", value)
}
return address.String(), nil
}
func valueOr(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// RegisterWARP registers a Cloudflare WARP device (m-ui / 3x-ui style) and
// returns a Mihomo WireGuard outbound YAML fragment.
func RegisterWARP() (*WARPRegisterResult, error) {
return registerWARPWithClient(context.Background(), defaultWarpClient())
}
func registerWARPWithClient(ctx context.Context, client *warpHTTPClient) (*WARPRegisterResult, error) {
if client == nil {
client = defaultWarpClient()
}
key, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("warp: generate key: %w", err)
}
privB64 := base64.StdEncoding.EncodeToString(key.Bytes())
pubB64 := base64.StdEncoding.EncodeToString(key.PublicKey().Bytes())
input := map[string]any{
"key": pubB64,
"tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
"type": "PC",
"model": "3m-ui",
"name": "3m-ui",
}
var device warpDevice
if err = client.request(ctx, http.MethodPost, "/reg", input, &device); err != nil {
return nil, err
}
if device.ID == "" {
return nil, fmt.Errorf("warp: empty device id")
}
if _, err = decodeWarpKey(privB64); err != nil {
return nil, err
}
cfg := device.Config
if len(cfg.Peers) == 0 {
return nil, fmt.Errorf("warp: no WireGuard peer yet — retry registration")
}
peer := cfg.Peers[0]
if _, err = decodeWarpKey(peer.PublicKey); err != nil {
return nil, fmt.Errorf("warp: peer public-key invalid")
}
endpoint := valueOr(peer.Endpoint.Host, valueOr(peer.Endpoint.V4, peer.Endpoint.V6))
host, portText, err := net.SplitHostPort(endpoint)
port, portErr := strconv.Atoi(portText)
if err != nil || portErr != nil || port < 1 || port > 65535 || host == "" || strings.ContainsAny(host, "/\r\n \t") {
// Fallback: host-only endpoint + default WARP port
host = strings.TrimSpace(endpoint)
if host == "" || strings.ContainsAny(host, "/\r\n \t") {
return nil, fmt.Errorf("warp: invalid peer endpoint %q", endpoint)
}
port = 2408
}
reservedRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.ClientID))
if err != nil || len(reservedRaw) != 3 {
return nil, fmt.Errorf("warp: client_id must be 3-byte base64 (got %q)", cfg.ClientID)
}
reserved := []int{int(reservedRaw[0]), int(reservedRaw[1]), int(reservedRaw[2])}
v4, err := warpAddress(cfg.Interface.Addresses.V4, true)
if err != nil {
return nil, err
}
v6, err := warpAddress(cfg.Interface.Addresses.V6, false)
if err != nil {
return nil, err
}
if v4 == "" && v6 == "" {
return nil, fmt.Errorf("warp: empty interface addresses")
}
yamlStr, err := WARPTemplate(privB64, peer.PublicKey, host, port, v4, v6, reserved)
if err != nil {
return nil, err
}
masqueYAML, _ := WARPMasqueTemplate(privB64, v4, v6, "")
return &WARPRegisterResult{
PrivateKey: privB64,
PublicKey: peer.PublicKey,
Address: v4,
IPv6: v6,
Reserved: reserved,
Server: host,
Port: port,
DeviceID: device.ID,
YAML: yamlStr,
MasqueYAML: masqueYAML,
}, nil
}
@@ -1,121 +0,0 @@
package system
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWARPTemplate_MUIShape(t *testing.T) {
yaml, err := WARPTemplate(
"CJiuBUMZWavAdfelvnUUnee+sQqHwU5ObGkFxjb6zWo=",
"bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"engage.cloudflareclient.com",
2408,
"172.16.0.2",
"2606:4700:110:8216:dac5:4a83:49de:6997",
[]int{171, 91, 109},
)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"type: wireguard",
"ip: 172.16.0.2",
"private-key: CJiuBUMZWavAdfelvnUUnee+sQqHwU5ObGkFxjb6zWo=",
"public-key: bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"server: engage.cloudflareclient.com",
"port: 2408",
"mtu: 1420",
"reserved:",
} {
if !strings.Contains(yaml, want) {
t.Fatalf("missing %q in:\n%s", want, yaml)
}
}
}
func TestRegisterWARP_WithMockServer(t *testing.T) {
device := map[string]any{
"id": "f0844485-ee81-4c51-a022-a86549ebb070",
"token": "test-token",
"name": "3m-ui",
"model": "3m-ui",
"account": map[string]any{
"license": "aaaaaaaa-bbbbbbbb-cccccccc",
"account_type": "free",
},
"config": map[string]any{
"client_id": "qVtt",
"interface": map[string]any{
"addresses": map[string]any{
"v4": "172.16.0.2",
"v6": "2606:4700:110::1",
},
},
"peers": []map[string]any{
{
"public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"endpoint": map[string]any{
"host": "engage.cloudflareclient.com:2408",
"v4": "162.159.192.1:2408",
},
},
},
},
}
body, _ := json.Marshal(device)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/reg" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer srv.Close()
client := &warpHTTPClient{baseURL: srv.URL, httpClient: srv.Client()}
res, err := registerWARPWithClient(context.Background(), client)
if err != nil {
t.Fatal(err)
}
if res.Address != "172.16.0.2" {
t.Fatalf("address=%q", res.Address)
}
if len(res.Reserved) != 3 {
t.Fatalf("reserved=%v", res.Reserved)
}
if !strings.Contains(res.YAML, "type: wireguard") {
t.Fatalf("yaml:\n%s", res.YAML)
}
if !strings.Contains(res.YAML, "public-key: bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=") {
t.Fatalf("peer key missing:\n%s", res.YAML)
}
if res.Server != "engage.cloudflareclient.com" || res.Port != 2408 {
t.Fatalf("endpoint %s:%d", res.Server, res.Port)
}
}
func TestRegisterWARP_RejectsBadEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"success":false,"errors":[{"message":"nope"}]}`))
}))
defer srv.Close()
client := &warpHTTPClient{baseURL: srv.URL, httpClient: srv.Client()}
if _, err := registerWARPWithClient(context.Background(), client); err == nil {
t.Fatal("expected error")
}
}
func TestDecodeWarpKey(t *testing.T) {
if _, err := decodeWarpKey("CJiuBUMZWavAdfelvnUUnee+sQqHwU5ObGkFxjb6zWo="); err != nil {
t.Fatal(err)
}
if _, err := decodeWarpKey("bad"); err == nil {
t.Fatal("expected error")
}
}
+7 -101
View File
@@ -1,105 +1,11 @@
<!-- server split removed: visual routing is client-subscription only -->
# Routing & proxy-groups
# Routing (client subscription)
Panel page **Routing** edits the **client subscription** layout only (`proxy-groups` + `rules` in `visual-config`). Mihomo/Clash YAML subscriptions export these groups with your nodes injected into leaf selects.
The **Routing** page edits **client subscription** layout only: Mihomo/Clash `proxy-groups` and `rules` stored in the `visual-config` fragment.
**Server-side split is disabled:** the panel Mihomo process always uses `MATCH,DIRECT` (inbound panel). Visual rules/groups/proxies are **not** merged into the serving `config.yaml`.
- Community templates (YiXuanZX / echs-top / AIsouler) fill groups + rules for the **client** YAML.
- After **Save**, update the subscription in the client (Mihomo/Clash YAML target).
- The **panel Mihomo process** always uses `MATCH,DIRECT` (inbound panel). Visual rules/groups/proxies are **not** merged into the serving `config.yaml`.
## Defaults
## Config engine
Fresh install uses:
```text
MATCH,DIRECT
```
That is intentional for a **server inbound** panel: client traffic that reaches this host leaves via DIRECT unless you add rules.
## Structured rules (UI)
Each rule has:
| Field | Meaning |
|-------|---------|
| Type | e.g. `DOMAIN`, `DOMAIN-SUFFIX`, `GEOIP`, `GEOSITE`, `IP-CIDR`, `MATCH`, … |
| Payload | Match value (empty for `MATCH`) |
| Target | `DIRECT`, `REJECT`, a **proxy-group** name, or an outbound **proxy** name |
| no-resolve | Optional on `IP-CIDR` / `IP-CIDR6` / `GEOIP` |
Rules can be reordered. Advanced lines that the UI cannot parse are kept as **raw** and saved unchanged.
**Save** writes the database. Rules take effect only after **Generate & apply** (same path as node config).
### Validation
- Type, target required; payload required except `MATCH`
- Warning if the last rule is not `MATCH`
### Templates
| Template | Result |
|----------|--------|
| DIRECT only | `MATCH,DIRECT` |
| CN direct | `GEOIP,CN,DIRECT` + `MATCH,<first group or PROXY>` |
| Sample ads | Several `DOMAIN-*` → `REJECT` + `MATCH,DIRECT` |
| Via group | `MATCH,<first group or PROXY>` |
### WARP inject
**Routing → WARP** calls `POST /api/v1/config/routing/inject-warp`:
1. Registers Cloudflare WARP
2. Merges the outbound into `visual-config` proxies
3. Optional rule update: none / final `MATCH,<name>` / `GEOIP,CN,DIRECT` + `MATCH,<name>`
Then generate & apply. Requires the panel host to reach Cloudflare.
## Proxy-groups
Simple groups: `select` / `url-test` / `fallback` / `load-balance`, member list, optional URL/interval for url-test.
## APIs
```http
GET/PUT /api/v1/config/rules
GET/PUT /api/v1/config/groups
POST /api/v1/config/routing/inject-warp
```
```json
{ "mode": "wireguard", "rule_mode": "match", "name": "WARP-OUT" }
```
`rule_mode`: `none` | `match` | `cn_direct`.
Also related: `GET/POST /api/v1/config/proxies`, visual config under **Config**.
## Related
- [Installation](installation.md) — frontend stack
- [Cluster](cluster.md) — multi-panel (separate from Mihomo rules)
## Community rule templates
Routing → **Templates** includes adaptations of public Mihomo rule projects (for server visual-config; not a full client overwrite script):
| Template | Upstream | Intent |
|----------|----------|--------|
| YiXuanZX/rules | [YiXuanZX/rules](https://github.com/YiXuanZX/rules) | Groups: 香港/新加坡/日本/美国/其他 + **代理 / AI / TG**. Rules: CN direct, OpenAI→AI, Telegram→TG, GFW→代理 |
| echs-top/proxy | [echs-top/proxy](https://github.com/echs-top/proxy) | Groups: **代理连接 / TELEGRAM / 国外AI / GOOGLE / 海外媒体 / …**. Ads REJECT + CN direct + category rules |
| AIsouler/MyClash | [AIsouler/MyClash](https://github.com/AIsouler/MyClash) | Groups: **直连 / AdBlock / Google / AI / Telegram / Steam / 默认代理 / 漏网之鱼**. Lite GEOSITE map |
Applying a community template **writes matching proxy-groups** (merged by name) and rules. Leaf members use existing visual outbounds if any, else `DIRECT` — put real nodes/WARP into the region or policy groups afterward.
These use **GEOSITE/GEOIP** against MetaCubeX geodata (Settings → Geo). Full upstream `rule-providers` / region filters remain optional via custom YAML fragments.
## Server WARP exit (user → node → WARP IP)
1. Routing page → **Inject WARP** (optional rule: all MATCH WARP, or CN DIRECT + MATCH WARP).
2. **Save** then **Generate & apply**.
3. Panel Mihomo loads the WireGuard outbound and sets `MATCH,<WARP name>` (community templates stay client-only).
4. User traffic through listeners exits with a Cloudflare WARP IP.
Remove WARP proxies from visual-config (or clear routing proxies) and re-apply to return to `MATCH,DIRECT`.
**Generate & apply** rebuilds listener config for the server core. It does not apply client community rules to the host process.
-17
View File
@@ -16,20 +16,3 @@ 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 const injectWarpRouting = (payload: {
mode?: 'wireguard' | 'masque';
rule_mode?: 'none' | 'match' | 'cn_direct';
name?: string;
}) =>
withNetworkRetry(() =>
client
.post<{
status: string;
name: string;
rules: string[];
proxies: unknown[];
groups: GroupEntry[];
}>('/config/routing/inject-warp', payload)
.then((r) => r.data),
);
-4
View File
@@ -62,10 +62,6 @@ export const restoreDatabase = (file: File) => {
export const openApiUrl = '/api/v1/openapi.yaml';
export const registerWarp = () =>
client.post<{ private_key: string; public_key: string; address: string; reserved?: string; yaml: string }>(
'/system/templates/warp/register',
).then((r) => r.data);
export interface LocalBackupItem {
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "أعد التشغيل للتطبيق",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "الأمان",
"warp": "Cloudflare WARP",
"warpHint": "تسجيل WARP WireGuard بنقرة واحدة (مقطع YAML لـ outbound Mihomo)",
"warpRegister": "تسجيل WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "تم تسجيل WARP — تم نسخ YAML",
"totp": "المصادقة الثنائية (TOTP)",
"totpOn": "مفعّل",
"totpSetup": "امسح بتطبيق المصادقة",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) امسح QR 2) أو أدخل السر 3) أكّد برمز من 6 أرقام",
"totpSecret": "المفتاح السري",
"totpEnabledOk": "تم تفعيل المصادقة الثنائية",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Neu starten zum Anwenden",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Sicherheit",
"warp": "Cloudflare WARP",
"warpHint": "Ein-Klick WARP-WireGuard-Config (YAML-Fragment für Mihomo-Outbound)",
"warpRegister": "WARP registrieren",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP registriert — YAML in Zwischenablage",
"totp": "Zwei-Faktor (TOTP)",
"totpOn": "Aktiv",
"totpSetup": "Mit Authenticator scannen",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) QR scannen 2) Oder Geheimnis manuell 3) Mit 6-stelligem Code bestätigen",
"totpSecret": "Geheimschlüssel",
"totpEnabledOk": "Zwei-Faktor-Authentifizierung aktiviert",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
+2 -21
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Register WARP into visual-config. After Generate & apply, listener traffic exits via WARP (server). The same outbound is also exported in client subscriptions.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -749,11 +738,11 @@ export default {
"ruleInvalid": "Invalid rules",
"applyDone": "Configuration applied",
"applyNow": "Generate & apply",
"applyPromptBody": "Saved. Generate & apply reloads the panel core: WARP exit applies on the server if injected; client groups need a subscription update.",
"applyPromptBody": "Saved for client subscriptions. Generate & apply refreshes the panel core (server stays MATCH,DIRECT). Update the subscription in your client for new groups.",
"applyPromptTitle": "Apply configuration?",
"groupAdded": "Group added",
"addRule": "Add rule",
"pageHint": "Client groups/rules go into subscriptions. WARP inject also becomes the server exit (user → node → WARP IP) after Generate & apply. Server does not use community rule templates.",
"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.",
"rulesSaved": "Rules saved",
"groupSaved": "Group saved",
"groupDeleted": "Group deleted"
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Restart to apply",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Security",
"warp": "Cloudflare WARP",
"warpHint": "One-click register a WARP WireGuard config (YAML fragment for Mihomo outbound). Requires outbound HTTPS to Cloudflare.",
"warpRegister": "Register WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP registered — YAML copied to clipboard",
"totp": "Two-factor (TOTP)",
"totpOn": "Enabled",
"totpSetup": "Scan with authenticator",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Scan the QR code 2) Or enter the secret manually 3) Confirm with a 6-digit code",
"totpSecret": "Secret key",
"totpEnabledOk": "Two-factor authentication enabled",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Reiniciar para aplicar",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Seguridad",
"warp": "Cloudflare WARP",
"warpHint": "Registro WARP WireGuard en un clic (fragmento YAML para outbound Mihomo)",
"warpRegister": "Registrar WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP registrado — YAML copiado",
"totp": "Dos factores (TOTP)",
"totpOn": "Activado",
"totpSetup": "Escanear con el autenticador",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Escanea el QR 2) O introduce el secreto 3) Confirma con un código de 6 dígitos",
"totpSecret": "Clave secreta",
"totpEnabledOk": "Autenticación en dos factores activada",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Redémarrer pour appliquer",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Sécurité",
"warp": "Cloudflare WARP",
"warpHint": "Enregistrement WARP WireGuard en un clic (fragment YAML pour outbound Mihomo)",
"warpRegister": "Enregistrer WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP enregistré — YAML copié",
"totp": "Double authentification (TOTP)",
"totpOn": "Activé",
"totpSetup": "Scanner avec l’authenticator",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Scanner le QR 2) Ou saisir le secret 3) Confirmer avec un code à 6 chiffres",
"totpSecret": "Clé secrète",
"totpEnabledOk": "Double authentification activée",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "लागू करने के लिए पुनः आरंभ",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "सुरक्षा",
"warp": "Cloudflare WARP",
"warpHint": "एक-क्लिक WARP WireGuard पंजीकरण (Mihomo outbound के लिए YAML अंश)",
"warpRegister": "WARP पंजीकृत करें",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP पंजीकृत — YAML कॉपी हुआ",
"totp": "दो-चरण (TOTP)",
"totpOn": "सक्रिय",
"totpSetup": "प्रमाणक से स्कैन करें",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) QR स्कैन 2) या सीक्रेट दर्ज करें 3) 6 अंकों के कोड से पुष्टि",
"totpSecret": "गुप्त कुंजी",
"totpEnabledOk": "दो-चरण प्रमाणीकरण सक्रिय",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Restart untuk menerapkan",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Keamanan",
"warp": "Cloudflare WARP",
"warpHint": "Daftar WARP WireGuard sekali klik (fragmen YAML untuk outbound Mihomo)",
"warpRegister": "Daftarkan WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP terdaftar — YAML disalin",
"totp": "Dua faktor (TOTP)",
"totpOn": "Aktif",
"totpSetup": "Pindai dengan authenticator",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Pindai QR 2) Atau masukkan secret 3) Konfirmasi dengan kode 6 digit",
"totpSecret": "Kunci rahasia",
"totpEnabledOk": "Autentikasi dua faktor diaktifkan",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "適用するには再起動",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "セキュリティ",
"warp": "Cloudflare WARP",
"warpHint": "ワンクリックで WARP WireGuard 設定(Mihomo outbound 用 YAML)",
"warpRegister": "WARP を登録",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP 登録済み — YAML をコピーしました",
"totp": "二要素認証 (TOTP)",
"totpOn": "有効",
"totpSetup": "認証アプリでスキャン",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) QRをスキャン 2) またはシークレットを手入力 3) 6桁コードで確認",
"totpSecret": "シークレット",
"totpEnabledOk": "二要素認証が有効になりました",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "적용하려면 재시작",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "보안",
"warp": "Cloudflare WARP",
"warpHint": "원클릭 WARP WireGuard 설정(Mihomo outbound용 YAML)",
"warpRegister": "WARP 등록",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP 등록됨 — YAML 복사됨",
"totp": "2단계 인증 (TOTP)",
"totpOn": "사용 중",
"totpSetup": "인증 앱으로 스캔",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) QR 스캔 2) 또는 시크릿 수동 입력 3) 6자리 코드 확인",
"totpSecret": "시크릿 키",
"totpEnabledOk": "2단계 인증이 켜졌습니다",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Zrestartuj, aby zastosować",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Bezpieczeństwo",
"warp": "Cloudflare WARP",
"warpHint": "Rejestracja WARP WireGuard jednym kliknięciem (fragment YAML dla outbound Mihomo)",
"warpRegister": "Zarejestruj WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP zarejestrowany — YAML skopiowany",
"totp": "Dwuskładnikowe (TOTP)",
"totpOn": "Włączone",
"totpSetup": "Zeskanuj authenticatorem",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Zeskanuj QR 2) Lub wpisz sekret 3) Potwierdź 6-cyfrowym kodem",
"totpSecret": "Klucz tajny",
"totpEnabledOk": "Włączono uwierzytelnianie dwuskładnikowe",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Reiniciar para aplicar",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Segurança",
"warp": "Cloudflare WARP",
"warpHint": "Registro WARP WireGuard com um clique (fragmento YAML para outbound Mihomo)",
"warpRegister": "Registrar WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP registrado — YAML copiado",
"totp": "Dois fatores (TOTP)",
"totpOn": "Ativado",
"totpSetup": "Escanear com o autenticador",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Escaneie o QR 2) Ou digite o segredo 3) Confirme com código de 6 dígitos",
"totpSecret": "Chave secreta",
"totpEnabledOk": "Autenticação em dois fatores ativada",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Перезапустить для применения",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Безопасность",
"warp": "Cloudflare WARP",
"warpHint": "Однокнопочная регистрация WARP WireGuard (фрагмент YAML для outbound Mihomo)",
"warpRegister": "Зарегистрировать WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP зарегистрирован — YAML скопирован",
"totp": "Двухфакторная (TOTP)",
"totpOn": "Включено",
"totpSetup": "Сканируйте аутентификатором",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Сканируйте QR 2) Или введите секрет 3) Подтвердите 6-значным кодом",
"totpSecret": "Секретный ключ",
"totpEnabledOk": "Двухфакторная аутентификация включена",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "รีสตาร์ทเพื่อใช้",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "ความปลอดภัย",
"warp": "Cloudflare WARP",
"warpHint": "ลงทะเบียน WARP WireGuard คลิกเดียว (ชิ้นส่วน YAML สำหรับ outbound Mihomo)",
"warpRegister": "ลงทะเบียน WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "ลงทะเบียน WARP แล้ว — คัดลอก YAML แล้ว",
"totp": "สองขั้นตอน (TOTP)",
"totpOn": "เปิดใช้",
"totpSetup": "สแกนด้วยแอปยืนยันตัวตน",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) สแกน QR 2) หรือใส่รหัสลับ 3) ยืนยันด้วยรหัส 6 หลัก",
"totpSecret": "รหัสลับ",
"totpEnabledOk": "เปิดการยืนยันตัวตนสองขั้นตอนแล้ว",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Uygulamak için yeniden başlat",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Güvenlik",
"warp": "Cloudflare WARP",
"warpHint": "Tek tıkla WARP WireGuard kaydı (Mihomo outbound için YAML parçası)",
"warpRegister": "WARP kaydet",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP kaydedildi — YAML kopyalandı",
"totp": "İki adımlı (TOTP)",
"totpOn": "Açık",
"totpSetup": "Doğrulayıcı ile tarayın",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) QR tarayın 2) Veya gizli anahtarı girin 3) 6 haneli kodla onaylayın",
"totpSecret": "Gizli anahtar",
"totpEnabledOk": "İki adımlı doğrulama etkin",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Перезапустити для застосування",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Безпека",
"warp": "Cloudflare WARP",
"warpHint": "Реєстрація WARP WireGuard в один клік (фрагмент YAML для outbound Mihomo)",
"warpRegister": "Зареєструвати WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP зареєстровано — YAML скопійовано",
"totp": "Двофакторна (TOTP)",
"totpOn": "Увімкнено",
"totpSetup": "Скануйте автентифікатором",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Скануйте QR 2) Або введіть секрет 3) Підтвердіть 6-значним кодом",
"totpSecret": "Секретний ключ",
"totpEnabledOk": "Двофакторну автентифікацію увімкнено",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "Registers Cloudflare WARP into visual-config. The outbound is exported in Mihomo/Clash client subscriptions (not applied as server-side split). Then update the subscription in the client.",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "Update GeoIP/GeoSite under Settings if GEOSITE rules fail",
"tplAisouler": "Community: AIsouler/MyClash lite",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "Khởi động lại để áp dụng",
"restoreRestartHint": "Restart the 3m-ui service after restoring the database",
"security": "Bảo mật",
"warp": "Cloudflare WARP",
"warpHint": "Đăng ký WARP WireGuard một cú nhấp (mảnh YAML cho outbound Mihomo)",
"warpRegister": "Đăng ký WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "Đã đăng ký WARP — YAML đã sao chép",
"totp": "Xác thực 2 bước (TOTP)",
"totpOn": "Đã bật",
"totpSetup": "Quét bằng ứng dụng xác thực",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1) Quét QR 2) Hoặc nhập secret 3) Xác nhận bằng mã 6 số",
"totpSecret": "Khóa bí mật",
"totpEnabledOk": "Đã bật xác thực 2 bước",
"warpDoneNoCopy": "WARP registered. Copy the YAML manually if clipboard is unavailable.",
"warpEmpty": "No WARP configuration returned.",
"localBackups": "On-disk backups",
"backupKeep": "Keep last",
"backupOlderDays": "Older than (days)",
+2 -21
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "高级/未解析规则(原样保存)",
"moveDown": "下移",
"moveUp": "上移",
"warpNote": "面板需能访问 Cloudflare。",
"warpInjected": "已添加 WARP 出站",
"warpRuleCn": "GEOIP CN 直连 + MATCH 出站",
"warpRuleMatch": "最终 MATCH → 该出站",
"warpRuleNone": "不改规则",
"warpRuleMode": "规则处理",
"warpName": "出站名称",
"warpMode": "模式",
"warpInjectHint": "将 WARP 写入 visual-config。生成并应用后,入站用户流量经 WARP 出站(服务端)。同一出站也会进入客户端订阅。",
"warpInjectTitle": "注入 Cloudflare WARP 出站",
"warpInject": "注入 WARP",
"tplViaGroup": "MATCH → 第一个策略组 / PROXY",
"tplCommunityHint": "若 GEOSITE 无效,请在设置中更新 Geo 数据",
"tplAisouler": "社区:AIsouler/MyClash 精简思路",
@@ -749,11 +738,11 @@ export default {
"ruleInvalid": "规则无效",
"applyDone": "配置已应用",
"applyNow": "生成并应用",
"applyPromptBody": "已保存。生成并应用会重载面板核心:若已注入 WARP,服务端出口为 WARP;客户端策略组需更新订阅。",
"applyPromptBody": "已保存供客户端订阅使用。生成并应用会刷新面板核心(服务端仍为 MATCH,DIRECT)。请在客户端更新订阅以加载新策略组。",
"applyPromptTitle": "应用配置?",
"groupAdded": "策略组已添加",
"addRule": "添加规则",
"pageHint": "客户端策略组/规则写入订阅。注入 WARP 并「生成并应用」后,用户经节点出去的流量会走 WARP IP。社区分流模板不会加载到服务端。",
"pageHint": "策略组与规则仅用于客户端 Mihomo/Clash 订阅。保存后请在客户端更新订阅。面板服务端固定 MATCH,DIRECT。",
"rulesSaved": "规则已保存",
"groupSaved": "策略组已保存",
"groupDeleted": "策略组已删除"
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "重启以应用",
"restoreRestartHint": "恢复数据库后请重启 3m-ui 服务",
"security": "安全",
"warp": "Cloudflare WARP",
"warpHint": "一键向 Cloudflare 注册 WARP,生成 Mihomo WireGuard 出站 YAML。需要面板能访问 Cloudflare。",
"warpRegister": "一键注册 WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP 已注册,YAML 已复制到剪贴板",
"totp": "两步验证 (TOTP)",
"totpOn": "已启用",
"totpSetup": "请用验证器扫描二维码",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1)扫描二维码 2)或手动录入密钥 3)输入 6 位验证码确认",
"totpSecret": "密钥",
"totpEnabledOk": "两步验证已启用",
"warpDoneNoCopy": "WARP 已注册。若无法写入剪贴板,请手动复制 YAML。",
"warpEmpty": "未返回 WARP 配置。",
"localBackups": "磁盘备份",
"backupKeep": "保留最近",
"backupOlderDays": "早于(天)",
-19
View File
@@ -724,17 +724,6 @@ export default {
"rawRule": "Advanced / unparsed rule (saved as-is)",
"moveDown": "Move down",
"moveUp": "Move up",
"warpNote": "Panel needs outbound access to Cloudflare.",
"warpInjected": "WARP outbound added",
"warpRuleCn": "GEOIP CN DIRECT + MATCH outbound",
"warpRuleMatch": "Final MATCH → this outbound",
"warpRuleNone": "Do not change rules",
"warpRuleMode": "Rule update",
"warpName": "Outbound name",
"warpMode": "Mode",
"warpInjectHint": "將 Cloudflare WARP 寫入 visual-config。該出站會出現在 Mihomo/Clash 客戶端訂閱中(不會作為服務端分流)。請在客戶端更新訂閱。",
"warpInjectTitle": "Inject Cloudflare WARP",
"warpInject": "WARP",
"tplViaGroup": "MATCH → first group / PROXY",
"tplCommunityHint": "若 GEOSITE 無效,請在設定中更新 Geo 資料",
"tplAisouler": "社群:AIsouler/MyClash 精簡思路",
@@ -870,12 +859,6 @@ export default {
"restoreRestart": "重新啟動以应用",
"restoreRestartHint": "還原数据库后請重新啟動 3m-ui 服务",
"security": "安全",
"warp": "Cloudflare WARP",
"warpHint": "一键向 Cloudflare 注册 WARP,產生 Mihomo WireGuard 出站 YAML。需要面板能访问 Cloudflare。",
"warpRegister": "一键注册 WARP",
"warpWireguard": "WireGuard",
"warpMasque": "MASQUE",
"warpDone": "WARP 已注册,YAML 已複製到剪贴板",
"totp": "兩步驟驗證 (TOTP)",
"totpOn": "已啟用",
"totpSetup": "請用驗證器掃描 QR 碼",
@@ -889,8 +872,6 @@ export default {
"totpSetupSteps": "1)掃描 QR 碼 2)或手動錄入金鑰 3)輸入 6 位驗證碼確認",
"totpSecret": "金鑰",
"totpEnabledOk": "兩步驟驗證已啟用",
"warpDoneNoCopy": "WARP 已註冊。若無法寫入剪貼簿,請手動複製 YAML。",
"warpEmpty": "未回傳 WARP 設定。",
"localBackups": "磁碟備份",
"backupKeep": "保留最近",
"backupOlderDays": "早於(天)",
+1 -82
View File
@@ -33,7 +33,6 @@ import {
saveGroups,
fetchRules,
saveRules,
injectWarpRouting,
type GroupEntry,
} from '../api/routing';
import { generateConfig, applyConfigYAML, fetchProxies } from '../api/config';
@@ -63,10 +62,7 @@ const RoutingPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [applying, setApplying] = useState(false);
const [groupOpen, setGroupOpen] = useState(false);
const [warpOpen, setWarpOpen] = useState(false);
const [warpBusy, setWarpBusy] = useState(false);
const [form] = Form.useForm();
const [warpForm] = Form.useForm();
const targetOptions = useMemo(() => {
const set = new Set<string>(['DIRECT', 'REJECT', 'COMPATIBLE']);
@@ -256,33 +252,6 @@ const RoutingPage: React.FC = () => {
);
};
const onInjectWarp = async () => {
const values = await warpForm.validateFields().catch(() => null);
if (!values) return;
setWarpBusy(true);
try {
const res = await injectWarpRouting({
mode: values.mode || 'wireguard',
rule_mode: values.rule_mode || 'match',
name: values.name || undefined,
});
setRules(parseRulesText((res.rules || []).join('\n')));
setProxyNames((prev) => {
const n = res.name || 'WARP-OUT';
return prev.includes(n) ? prev : [...prev, n];
});
message.success(
(t('routing.warpInjected') || 'WARP outbound added') + (res.name ? `: ${res.name}` : ''),
);
setWarpOpen(false);
offerApply();
} catch (e: any) {
message.error(errMsg(e));
} finally {
setWarpBusy(false);
}
};
const templateMenu = {
items: [
{ key: 'direct_only', label: t('routing.tplDirect') || 'MATCH → DIRECT only' },
@@ -309,13 +278,6 @@ const RoutingPage: React.FC = () => {
const ruleCardExtra = (
<Space wrap size="small">
<Dropdown menu={templateMenu}>
<Button size="small" icon={<Layers size={16} />}>
{t('routing.templates') || 'Templates'}
</Button>
</Dropdown>
<Button size="small" icon={<Cloud size={16} />} onClick={() => { warpForm.resetFields(); setWarpOpen(true); }}>
{t('routing.warpInject') || 'WARP'}
</Button>
<Button size="small" icon={<ListPlus size={16} />} onClick={addRule}>
{t('routing.addRule') || 'Add rule'}
</Button>
@@ -501,7 +463,7 @@ const RoutingPage: React.FC = () => {
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="DIRECT, WARP-OUT, …"
placeholder="DIRECT, PROXY, …"
options={targetOptions}
/>
</Form.Item>
@@ -513,49 +475,6 @@ const RoutingPage: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
open={warpOpen}
title={t('routing.warpInjectTitle') || 'Inject Cloudflare WARP outbound'}
onCancel={() => setWarpOpen(false)}
onOk={onInjectWarp}
confirmLoading={warpBusy}
okText={t('routing.warpInject') || 'Register & inject'}
width={isMobile ? '100%' : 480}
style={isMobile ? { top: 8 } : undefined}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{t('routing.warpInjectHint') ||
'Registers a WARP account, adds the outbound into visual-config proxies, and optionally adjusts rules. Then generate & apply to load into Mihomo.'}
</Typography.Paragraph>
<Form form={warpForm} layout="vertical" initialValues={{ mode: 'wireguard', rule_mode: 'match', name: 'WARP-OUT' }}>
<Form.Item name="mode" label={t('routing.warpMode') || 'Mode'}>
<Select
options={[
{ value: 'wireguard', label: 'WireGuard' },
{ value: 'masque', label: 'MASQUE' },
]}
/>
</Form.Item>
<Form.Item name="name" label={t('routing.warpName') || 'Outbound name'}>
<Input placeholder="WARP-OUT" />
</Form.Item>
<Form.Item name="rule_mode" label={t('routing.warpRuleMode') || 'Rule update'}>
<Select
options={[
{ value: 'none', label: t('routing.warpRuleNone') || 'Do not change rules' },
{ value: 'match', label: t('routing.warpRuleMatch') || 'Final MATCH → this outbound' },
{ value: 'cn_direct', label: t('routing.warpRuleCn') || 'GEOIP CN DIRECT + MATCH outbound' },
]}
/>
</Form.Item>
</Form>
<Divider style={{ margin: '8px 0' }} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('routing.warpNote') || 'Requires outbound connectivity from the panel to Cloudflare.'}
</Typography.Text>
</Modal>
</div>
);
};
-86
View File
@@ -76,10 +76,6 @@ type SectionKey =
const Settings: React.FC = () => {
const [section, setSection] = useState<SectionKey>('panel');
const [warpMode, setWarpMode] = useState<'wireguard' | 'masque'>('wireguard');
const [warpYamlOpen, setWarpYamlOpen] = useState(false);
const [warpYaml, setWarpYaml] = useState('');
const [warpYamlTitle, setWarpYamlTitle] = useState('');
const [panelServer, setPanelServer] = useState<{
port?: number;
listen?: string;
@@ -1253,88 +1249,6 @@ const Settings: React.FC = () => {
{t('settings.updateGeofiles') || 'Update geo files'}
</Button>
</Card>
<Card title={t('settings.warp', 'Cloudflare WARP')} style={{ marginTop: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Text type="secondary">{t('settings.warpHint', 'One-click register a WARP WireGuard config (YAML for Mihomo outbound).')}</Text>
<Segmented
value={warpMode}
onChange={(v) => setWarpMode(v as 'wireguard' | 'masque')}
options={[
{ label: t('settings.warpWireguard', 'WireGuard'), value: 'wireguard' },
{ label: t('settings.warpMasque', 'MASQUE'), value: 'masque' },
]}
/>
<Button
onClick={async () => {
try {
const res = await client.post(`/system/templates/warp/register?mode=${warpMode}`);
const yaml =
(typeof res.data?.yaml === 'string' && res.data.yaml) ||
(typeof res.data?.masque_yaml === 'string' && res.data.masque_yaml) ||
'';
if (!yaml.trim()) {
message.error(t('settings.warpEmpty', 'WARP registration returned empty YAML'));
return;
}
try {
await copyText(yaml);
message.success(t('settings.warpDone', 'WARP registered — YAML copied'));
} catch {
message.success(t('settings.warpDoneNoCopy', 'WARP registered (copy failed — select text in the dialog)'));
}
setWarpYamlTitle(
warpMode === 'masque' ? 'WARP MASQUE YAML' : 'WARP WireGuard YAML',
);
setWarpYaml(yaml);
setWarpYamlOpen(true);
} catch (e: any) {
message.error(e?.response?.data?.error || e.message || t('common.error'));
}
}}
>
{t('settings.warpRegister', 'Register WARP')} ({warpMode === 'masque' ? 'MASQUE' : 'WireGuard'})
</Button>
</Space>
</Card>
<Modal
open={warpYamlOpen}
title={warpYamlTitle}
width={Math.min(720, typeof window !== 'undefined' ? window.innerWidth - 32 : 720)}
onCancel={() => setWarpYamlOpen(false)}
onOk={() => setWarpYamlOpen(false)}
okText={t('common.close', 'Close')}
cancelButtonProps={{ style: { display: 'none' } }}
destroyOnHidden
styles={{
// antd v6: use container (not content)
container: { background: token.colorBgElevated },
header: { background: token.colorBgElevated, color: token.colorText },
body: { background: token.colorBgElevated },
footer: { background: token.colorBgElevated },
}}
>
<pre
style={{
margin: 0,
padding: 12,
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
lineHeight: 1.45,
background: token.colorBgContainer,
color: token.colorText,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG ?? token.borderRadius,
}}
>
{warpYaml}
</pre>
</Modal>
</Space>
)}