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:
1 parent
ca548e29c9
commit
52bb97c083
39 files changed
+25
-1743
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 源码编译
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "早于(天)",
|
||||
|
||||
@@ -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": "早於(天)",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user