Merge branch 'main' into fix/telegram-p0-p1-hardening
This commit is contained in:
commit
d6545ede90
22 files changed
+831
-821
No files matched your search
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'agent/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
run: go test ./...
|
||||
- name: Telegram race/security tests
|
||||
run: go test -race ./internal/telegram -run 'Test(Concurrent|UpdateOffset|HTMLSplit|SplitTelegramHTML|ParseUpdates|SetCommands)'
|
||||
|
||||
- name: Go build
|
||||
run: go build ./...
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<strong>English</strong> · <a href="#中文">中文</a>
|
||||
</p>
|
||||
|
||||
3M-UI is a lightweight web management console for <a href="https://github.com/MetaCubeX/mihomo">Mihomo Core</a>. It focuses on VPS node management, listener configuration, proxy-user credentials, traffic usage, client subscriptions, and Telegram operations.
|
||||
3M-UI is a lightweight web management console for <a href="https://github.com/MetaCubeX/mihomo">Mihomo Core</a>. It focuses on VPS node management, listener configuration, proxy-user credentials, traffic usage, client subscriptions, configuration lifecycle management, and Telegram operations.
|
||||
|
||||
3M-UI 是一个轻量、现代的 Mihomo Core Web 管理面板,主要用于 VPS 节点管理、Listener 入站配置、代理用户凭据、流量统计、客户端订阅以及 Telegram 管理与通知。
|
||||
3M-UI 是一个轻量、现代的 Mihomo Core Web 管理面板,主要用于 VPS 节点管理、Listener 入站配置、代理用户凭据、流量统计、客户端订阅、配置生命周期管理以及 Telegram 管理与通知。
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
- JWT-based panel authentication
|
||||
- Client URI and subscription generation
|
||||
- Credential-aware Mihomo configuration generation
|
||||
- Configuration preview and validation before activation
|
||||
- Safe configuration apply with automatic `.bak` backup
|
||||
- Configuration rollback through the Mihomo service layer
|
||||
- Subscription token enable/disable, expiry and lifecycle management
|
||||
|
||||
### Telegram / Telegram 机器人
|
||||
|
||||
@@ -37,8 +41,11 @@ Telegram 不只是通知渠道,内置 Bot 同时支持通知、查询和交互
|
||||
- Telegram ID → proxy-user binding
|
||||
- User self-service usage and node links
|
||||
- Admin views for users, online users, listeners, traffic ranking, blocked users and low-quota users
|
||||
- Admin `/restart` for Mihomo
|
||||
- Admin `/restart` for Mihomo through the Mihomo service only
|
||||
- Admin client creation wizard with listener selection, generated password and binding
|
||||
- Callback permissions are re-checked before privileged actions
|
||||
- Wizard state prevents duplicate client-creation flows
|
||||
- Telegram update offset is persisted across bot restarts
|
||||
|
||||
### Bot Commands / Bot 命令
|
||||
|
||||
@@ -56,6 +63,11 @@ Telegram 不只是通知渠道,内置 Bot 同时支持通知、查询和交互
|
||||
| `/online` | Online clients | 在线客户端 | Admin |
|
||||
| `/listeners` | Listener list | 入站列表 | Admin |
|
||||
| `/traffic` | Traffic ranking | 流量排行 | Admin |
|
||||
| `/cancel` | Cancel current wizard | 取消当前向导 | All |
|
||||
|
||||
普通用户不能通过 `/usage <other-user>` 查询其他客户端;`/inbound` 和 `/restart` 严格要求管理员权限。
|
||||
|
||||
Regular users cannot use `/usage <other-user>` to inspect another client; `/inbound` and `/restart` require administrator privileges.
|
||||
|
||||
The Bot also exposes the same major functions through inline keyboards.
|
||||
|
||||
@@ -63,6 +75,22 @@ Bot 还提供 Inline Keyboard,可直接点击完成主要查询和管理操作
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ P0 Reliability / P0 可靠性
|
||||
|
||||
The P0 baseline is designed around one rule: **Web UI, Telegram and future API clients must all use the same service layer and never bypass Mihomo lifecycle controls.**
|
||||
|
||||
P0 基线遵循一个原则:**Web UI、Telegram 以及未来的 API/CLI 都必须经过统一 Service Layer,不能绕过 Mihomo 生命周期管理。**
|
||||
|
||||
- **Subscription / 订阅** — secure listener-bound tokens, expiry checks, Mihomo YAML and optional subconverter formats
|
||||
- **Client management / 客户端管理** — credentials, traffic limits, expiry, online state and Telegram binding
|
||||
- **Config preview / 配置预览** — validate a candidate without touching the live configuration
|
||||
- **Config apply / 配置应用** — validate, back up the current configuration, activate, and restore on failure
|
||||
- **Config rollback / 配置回滚** — restore the previous `.bak` configuration through `Mihomo Service`
|
||||
- **Mihomo Service / 核心服务** — start, stop, restart, status, logs and guarded configuration activation
|
||||
- **Telegram / Telegram** — bilingual notification/admin workflow with permission re-checks and persisted update offset
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture / 架构
|
||||
|
||||
```text
|
||||
@@ -72,18 +100,18 @@ Bot 还提供 Inline Keyboard,可直接点击完成主要查询和管理操作
|
||||
│ HTTP / REST
|
||||
┌──────────────▼───────────────┐
|
||||
│ Gin + GORM + JWT │
|
||||
└───────┬───────────┬─────────┘
|
||||
│ │
|
||||
│ └──────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌────────────────┐
|
||||
│ SQLite │ │ Telegram Bot │
|
||||
└──────────────┘ └────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Mihomo Core │
|
||||
└────────────────┘
|
||||
└──────────────┬───────────────┘
|
||||
│ Unified Service Layer
|
||||
┌───────┼──────────┬────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Clients Telegram Config Engine Mihomo Service
|
||||
│ │ │ │
|
||||
└───────┴──────────┴────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Mihomo Core │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -163,6 +191,29 @@ Available notification switches / 可用通知开关:
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Subscription / 订阅
|
||||
|
||||
Create a listener-bound access token from the panel. The token is the credential for the public subscription endpoint:
|
||||
|
||||
```text
|
||||
/api/v1/client/sub/<token>
|
||||
```
|
||||
|
||||
Optional targets include:
|
||||
|
||||
```text
|
||||
?target=mihomo
|
||||
?target=clash
|
||||
?target=sing-box
|
||||
?target=shadowrocket
|
||||
```
|
||||
|
||||
Subscription tokens can be disabled, expired or deleted without changing the underlying listener configuration.
|
||||
|
||||
订阅 Token 可以单独禁用、设置到期时间或删除,不会直接修改底层 Listener 配置。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Telegram Setup / Telegram 配置
|
||||
|
||||
1. Create a Bot with Telegram's BotFather and copy the Bot Token.
|
||||
@@ -185,6 +236,41 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Configuration Lifecycle / 配置生命周期
|
||||
|
||||
Recommended production workflow / 推荐生产流程:
|
||||
|
||||
```text
|
||||
Edit / 编辑
|
||||
↓
|
||||
Preview / 预览
|
||||
↓
|
||||
Validate / 校验
|
||||
↓
|
||||
Apply / 应用
|
||||
↓
|
||||
Backup current config / 备份旧配置
|
||||
↓
|
||||
Restart or start Mihomo / 重启或启动核心
|
||||
↓
|
||||
Health check / 健康检查
|
||||
↓
|
||||
Success → keep / 成功 → 保留
|
||||
Failure → rollback / 失败 → 回滚
|
||||
```
|
||||
|
||||
API endpoints / API:
|
||||
|
||||
| Method | Endpoint | Description / 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/config/preview` | Validate candidate without applying / 仅预览校验 |
|
||||
| POST | `/api/v1/config/validate` | Validate YAML / 校验 YAML |
|
||||
| POST | `/api/v1/config/apply` | Generate, validate and safely activate / 安全生成、校验并应用 |
|
||||
| POST | `/api/v1/config/rollback` | Restore previous `.bak` / 恢复上一份配置 |
|
||||
| GET | `/api/v1/config/download` | Download generated YAML / 下载配置 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security / 安全建议
|
||||
|
||||
1. The initial administrator credentials remain `admin` / `admin`. Do not change the initialization logic. Change the password after first login if your deployment requires it.
|
||||
@@ -194,6 +280,7 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
|
||||
5. Keep the Mihomo binary in an approved system path.
|
||||
6. Set `public_url` correctly when using a reverse proxy.
|
||||
7. Never publish your Telegram Bot Token in issues, screenshots or public repositories.
|
||||
8. Subscription tokens are bearer credentials; treat them like passwords and revoke them when exposed.
|
||||
|
||||
1. 初始管理员账号仍为 `admin` / `admin`,不会修改初始化密码逻辑。生产部署建议首次登录后修改密码。
|
||||
2. 生产环境使用 HTTPS,建议通过 Nginx 或 Caddy 反向代理。
|
||||
@@ -202,6 +289,7 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
|
||||
5. Mihomo 二进制文件放置在受信任的系统路径。
|
||||
6. 使用反向代理时正确设置 `public_url`。
|
||||
7. 不要在 Issue、截图或公开仓库中泄露 Telegram Bot Token。
|
||||
8. 订阅 Token 属于 Bearer 凭据,泄露后应立即禁用或删除。
|
||||
|
||||
---
|
||||
|
||||
@@ -217,8 +305,13 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
|
||||
| POST | `/api/v1/nodes` | Yes | Create listener / 创建入站 |
|
||||
| GET | `/api/v1/nodes/:id/uri` | Yes | Export node URI / 导出节点 URI |
|
||||
| GET | `/api/v1/client/sub/:token` | Token | Client subscription / 客户端订阅 |
|
||||
| GET | `/api/v1/telegram/settings` | Yes | Telegram settings / Telegram 设置 |
|
||||
| POST | `/api/v1/telegram/test` | Yes | Test Telegram / 测试 Telegram |
|
||||
| POST | `/api/v1/mihomo/start` | Yes | Start Mihomo / 启动核心 |
|
||||
| POST | `/api/v1/mihomo/stop` | Yes | Stop Mihomo / 停止核心 |
|
||||
| POST | `/api/v1/mihomo/restart` | Yes | Restart Mihomo / 重启核心 |
|
||||
| POST | `/api/v1/config/apply` | Yes | Safe config activation / 安全应用配置 |
|
||||
| POST | `/api/v1/config/rollback` | Yes | Restore previous config / 回滚配置 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,25 +18,15 @@ func InitDB(dbPath string) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.New(sqlite.Config{
|
||||
DriverName: sqliteDriverName,
|
||||
DSN: dbPath,
|
||||
}), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.New(sqlite.Config{DriverName: sqliteDriverName, DSN: dbPath}), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to sqlite database: %w", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Listener{},
|
||||
&models.ListenerUser{},
|
||||
&models.Subscription{},
|
||||
&models.AccessToken{},
|
||||
&models.Config{},
|
||||
&models.ProxyUser{},
|
||||
&models.TrafficRecord{},
|
||||
&models.PanelSetting{},
|
||||
&models.RemoteServer{},
|
||||
&models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
|
||||
&models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
|
||||
&models.ProxyUser{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run database auto-migration: %w", err)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ListenerTemplate stores reusable Listener configuration without runtime state.
|
||||
type ListenerTemplate struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||
Protocol string `gorm:"type:varchar(50);not null" json:"protocol"`
|
||||
Config string `gorm:"type:text;not null" json:"config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ListenerVersion stores an immutable snapshot of a Listener for diff/rollback.
|
||||
type ListenerVersion struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ListenerID uint `gorm:"not null;index" json:"listener_id"`
|
||||
Version int `gorm:"not null" json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Reason string `gorm:"type:varchar(100)" json:"reason,omitempty"`
|
||||
Snapshot string `gorm:"type:text;not null" json:"snapshot"`
|
||||
}
|
||||
@@ -1,110 +1,24 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
)
|
||||
|
||||
// Handler serves listener HTTP endpoints with an injected Service.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler constructs a listener HTTP handler.
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers listener CRUD routes under the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("", h.ListListeners)
|
||||
rg.POST("", h.CreateListener)
|
||||
rg.GET("/:id", h.GetListener)
|
||||
rg.PUT("/:id", h.UpdateListener)
|
||||
rg.DELETE("/:id", h.DeleteListener)
|
||||
rg.POST("/:id/reload", h.ReloadListener)
|
||||
}
|
||||
|
||||
func (h *Handler) ListListeners(c *gin.Context) {
|
||||
list, err := h.svc.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateListener(c *gin.Context) {
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Create(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (h *Handler) GetListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
l, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.ID = uint(id)
|
||||
if err := h.svc.Update(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) ReloadListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.TriggerReload(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener reloaded"})
|
||||
}
|
||||
import("net/http";"strconv";"github.com/gin-gonic/gin";"github.com/kazeyukiro/3m-ui/backend/internal/database/models")
|
||||
type Handler struct{svc *Service}
|
||||
func NewHandler(svc *Service)*Handler{return &Handler{svc:svc}}
|
||||
func(h *Handler)RegisterRoutes(rg *gin.RouterGroup){rg.GET("",h.ListListeners);rg.POST("",h.CreateListener);rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.GET("/templates/:id",h.GetTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate);rg.POST("/templates/:id/instantiate",h.InstantiateTemplate);rg.POST("/batch",h.BatchCreate);rg.POST("/batch/enabled",h.BatchEnabled);rg.GET("/:id",h.GetListener);rg.PUT("/:id",h.UpdateListener);rg.DELETE("/:id",h.DeleteListener);rg.POST("/:id/reload",h.ReloadListener);rg.POST("/:id/clone",h.CloneListener);rg.GET("/:id/versions",h.ListVersions);rg.GET("/:id/versions/:version/diff",h.DiffVersion);rg.POST("/:id/versions/:version/rollback",h.RollbackVersion)}
|
||||
func parseID(c *gin.Context,key string)(uint,bool){n,err:=strconv.ParseUint(c.Param(key),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return 0,false};return uint(n),true}
|
||||
func(h *Handler)ListListeners(c *gin.Context){list,err:=h.svc.GetAll();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,list)}
|
||||
func(h *Handler)CreateListener(c *gin.Context){var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.Create(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
|
||||
func(h *Handler)BatchCreate(c *gin.Context){var list []models.Listener;if err:=c.ShouldBindJSON(&list);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};out,err:=h.svc.BatchCreate(list);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,out)}
|
||||
func(h *Handler)GetListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};l,err:=h.svc.GetByID(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,l)}
|
||||
func(h *Handler)UpdateListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l.ID=id;if err:=h.svc.Update(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,l)}
|
||||
func(h *Handler)DeleteListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.Delete(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
|
||||
func(h *Handler)ReloadListener(c *gin.Context){if _,ok:=parseID(c,"id");!ok{return};if err:=h.svc.RegenerateConfig();err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
|
||||
func(h *Handler)CloneListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.Clone(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
|
||||
func(h *Handler)ListVersions(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=h.svc.ListVersions(id);if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,v)}
|
||||
func(h *Handler)DiffVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};d,err:=h.svc.DiffVersion(id,v);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.Data(200,"text/plain; charset=utf-8",[]byte(d))}
|
||||
func(h *Handler)RollbackVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};if err:=h.svc.RollbackVersion(id,v);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
|
||||
func(h *Handler)BatchEnabled(c *gin.Context){var req struct{IDs []uint `json:"ids"`;Enabled bool `json:"enabled"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.BatchSetEnabled(req.IDs,req.Enabled);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
|
||||
func(h *Handler)ListTemplates(c *gin.Context){t,err:=h.svc.ListTemplates();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,t)}
|
||||
func(h *Handler)GetTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};t,err:=h.svc.GetTemplate(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,t)}
|
||||
func(h *Handler)CreateTemplate(c *gin.Context){var t models.ListenerTemplate;if err:=c.ShouldBindJSON(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.CreateTemplate(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,t)}
|
||||
func(h *Handler)DeleteTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.DeleteTemplate(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
|
||||
func(h *Handler)InstantiateTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.InstantiateTemplate(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
|
||||
@@ -0,0 +1,40 @@
|
||||
package listener
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPortRangesOverlap(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"443", "443", true},
|
||||
{"443-445", "445-450", true},
|
||||
{"443-445", "446-450", false},
|
||||
{"443,8443", "8443", true},
|
||||
{"443,8443", "9443", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := PortRangesOverlap(tt.a, tt.b); got != tt.want {
|
||||
t.Fatalf("PortRangesOverlap(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressesConflict(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"0.0.0.0", "127.0.0.1", true},
|
||||
{"127.0.0.1", "127.0.0.1", true},
|
||||
{"127.0.0.1", "127.0.0.2", false},
|
||||
{"::", "::1", true},
|
||||
{"::1", "::1", true},
|
||||
{"127.0.0.1", "::1", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := AddressesConflict(tt.a, tt.b); got != tt.want {
|
||||
t.Fatalf("AddressesConflict(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) SaveVersion(listenerID uint, reason string) error { l,err:=s.GetByID(listenerID);if err!=nil{return err};var count int64;if err:=s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?",listenerID).Count(&count).Error;err!=nil{return err};data,err:=json.Marshal(l);if err!=nil{return fmt.Errorf("marshal listener snapshot: %w",err)};return s.db.Create(&models.ListenerVersion{ListenerID:listenerID,Version:int(count)+1,Reason:reason,Snapshot:string(data)}).Error }
|
||||
func (s *Service) ListVersions(listenerID uint)([]models.ListenerVersion,error){var versions []models.ListenerVersion;err:=s.db.Where("listener_id = ?",listenerID).Order("version desc").Find(&versions).Error;return versions,err}
|
||||
func (s *Service) RollbackVersion(listenerID uint,version int)error{s.mu.Lock();defer s.mu.Unlock();var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return fmt.Errorf("listener version not found: %w",err)};var target models.Listener;if err:=json.Unmarshal([]byte(v.Snapshot),&target);err!=nil{return fmt.Errorf("invalid listener snapshot: %w",err)};target.ID=listenerID;if err:=ValidateModel(&target);err!=nil{return fmt.Errorf("rollback validation failed: %w",err)};if err:=s.ensureEndpointAvailable(&target);err!=nil{return err};if err:=s.SaveVersion(listenerID,"before-rollback");err!=nil{return err};var previous models.Listener;if err:=s.db.First(&previous,listenerID).Error;err!=nil{return err};if err:=s.db.Save(&target).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Save(&previous);_=s.regenerateConfigLocked();return err};return nil}
|
||||
func (s *Service) Clone(id uint,name,port string)(*models.Listener,error){s.mu.Lock();defer s.mu.Unlock();var src models.Listener;if err:=s.db.First(&src,id).Error;err!=nil{return nil,err};name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")};src.ID=0;src.CreatedAt=time.Time{};src.UpdatedAt=time.Time{};src.DeletedAt=gorm.DeletedAt{};src.Name=name;if strings.TrimSpace(port)!=""{src.Port=strings.TrimSpace(port)};if err:=ValidateModel(&src);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(&src);err!=nil{return nil,err};if err:=s.db.Create(&src).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(&src);return nil,err};if err:=s.SaveVersion(src.ID,"clone");err!=nil{return nil,err};return &src,nil}
|
||||
func (s *Service) BatchCreate(list []models.Listener)([]models.Listener,error){s.mu.Lock();defer s.mu.Unlock();if len(list)==0{return nil,fmt.Errorf("no listeners supplied")};tx:=s.db.Begin();if tx.Error!=nil{return nil,tx.Error};created:=make([]models.Listener,0,len(list));for i:=range list{if err:=ValidateModel(&list[i]);err!=nil{tx.Rollback();return nil,err};var n int64;if err:=tx.Model(&models.Listener{}).Where("enabled = ?",true).Where("name = ?",list[i].Name).Count(&n).Error;err!=nil{tx.Rollback();return nil,err};if n>0{tx.Rollback();return nil,fmt.Errorf("listener name %q already exists",list[i].Name)};if err:=tx.Create(&list[i]).Error;err!=nil{tx.Rollback();return nil,err};created=append(created,list[i])};if err:=tx.Commit().Error;err!=nil{return nil,err};if err:=s.ensureBatchEndpointsAvailable(created);err!=nil{for _,l:=range created{_ = s.db.Delete(&l)};return nil,err};if err:=s.regenerateConfigLocked();err!=nil{for _,l:=range created{_ = s.db.Delete(&l)};_=s.regenerateConfigLocked();return nil,err};for _,l:=range created{_ = s.SaveVersion(l.ID,"batch-create")};return created,nil}
|
||||
func (s *Service) ensureBatchEndpointsAvailable(created []models.Listener)error{for i:=range created{if err:=s.ensureEndpointAvailable(&created[i]);err!=nil{return err};for j:=i+1;j<len(created);j++{if portsOverlap(created[i].Port,created[j].Port)&&listenerAddressesConflict(firstListenerAddress(created[i]),firstListenerAddress(created[j])){return fmt.Errorf("listeners %q and %q have conflicting endpoints",created[i].Name,created[j].Name)}}};return nil}
|
||||
func (s *Service) BatchSetEnabled(ids []uint,enabled bool)error{s.mu.Lock();defer s.mu.Unlock();if len(ids)==0{return fmt.Errorf("no listener ids supplied")};var previous []models.Listener;if err:=s.db.Where("id IN ?",ids).Find(&previous).Error;err!=nil{return err};if len(previous)!=len(ids){return fmt.Errorf("one or more listeners were not found")};if err:=s.db.Model(&models.Listener{}).Where("id IN ?",ids).Update("enabled",enabled).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{for i:=range previous{_ = s.db.Save(&previous[i])};_ = s.regenerateConfigLocked();return err};for _,l:=range previous{_ = s.SaveVersion(l.ID,"batch-enabled")};return nil}
|
||||
func (s *Service) DiffVersion(listenerID uint,version int)(string,error){var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return "",err};current,err:=s.GetByID(listenerID);if err!=nil{return "",err};cur,_:=json.MarshalIndent(current,""," ");var old any;if err:=json.Unmarshal([]byte(v.Snapshot),&old);err!=nil{return "",err};oldJSON,_:=json.MarshalIndent(old,""," ");return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s",version,strings.TrimSpace(string(oldJSON)),strings.TrimSpace(string(cur))),nil}
|
||||
func (s *Service) CreateTemplate(t *models.ListenerTemplate)error{if strings.TrimSpace(t.Name)==""{return fmt.Errorf("template name is required")};if strings.TrimSpace(t.Protocol)==""{return fmt.Errorf("template protocol is required")};probe:=&models.Listener{Name:t.Name,Protocol:t.Protocol,Port:"1",Config:t.Config};if err:=ValidateModel(probe);err!=nil{return err};return s.db.Create(t).Error}
|
||||
func (s *Service) ListTemplates()([]models.ListenerTemplate,error){var out []models.ListenerTemplate;err:=s.db.Order("name asc").Find(&out).Error;return out,err}
|
||||
func (s *Service) GetTemplate(id uint)(*models.ListenerTemplate,error){var t models.ListenerTemplate;if err:=s.db.First(&t,id).Error;err!=nil{return nil,err};return &t,nil}
|
||||
func (s *Service) DeleteTemplate(id uint)error{return s.db.Delete(&models.ListenerTemplate{},id).Error}
|
||||
func (s *Service) InstantiateTemplate(templateID uint,name,port string)(*models.Listener,error){s.mu.Lock();defer s.mu.Unlock();t,err:=s.GetTemplate(templateID);if err!=nil{return nil,err};name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")};l:=&models.Listener{Name:name,Protocol:t.Protocol,Port:strings.TrimSpace(port),BindAddress:"0.0.0.0",Enabled:true,Config:t.Config};if l.Port==""{l.Port="0"};if err:=ValidateModel(l);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(l);err!=nil{return nil,err};if err:=s.db.Create(l).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(l);return nil,err};if err:=s.SaveVersion(l.ID,"template");err!=nil{return nil,err};return l,nil}
|
||||
@@ -0,0 +1,21 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
)
|
||||
|
||||
func TestListenerTemplateModel(t *testing.T) {
|
||||
t := &models.ListenerTemplate{Name: "vless-default", Protocol: "vless", Config: `{"flow":"xtls-rprx-vision"}`}
|
||||
if t.Name == "" || t.Protocol == "" { t.Fatal("template fields unexpectedly empty") }
|
||||
}
|
||||
|
||||
func TestListenerVersionSnapshotIsJSON(t *testing.T) {
|
||||
l := &models.Listener{ID: 1, Name: "demo", Protocol: "vless", Port: "443", BindAddress: "0.0.0.0", Config: `{}`}
|
||||
if err := ValidateModel(l); err != nil { t.Fatalf("valid listener rejected: %v", err) }
|
||||
}
|
||||
|
||||
func TestBatchIDsRequireNonEmpty(t *testing.T) {
|
||||
if len([]uint{}) != 0 { t.Fatal("unexpected non-empty ids") }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
@@ -14,6 +15,7 @@ type Service struct {
|
||||
db *gorm.DB
|
||||
configPath string
|
||||
mihomoApply interface{ ApplyConfig(string) error }
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConfig(string) error }) *Service {
|
||||
@@ -21,9 +23,16 @@ func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConf
|
||||
}
|
||||
|
||||
func (s *Service) Create(l *models.Listener) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ValidateModel(l); err != nil { return err }
|
||||
if err := s.ensureEndpointAvailable(l); err != nil { return err }
|
||||
if err := s.db.Create(l).Error; err != nil { return fmt.Errorf("failed to create listener: %w", err) }
|
||||
if err := s.RegenerateConfig(); err != nil { _ = s.db.Delete(&models.Listener{}, l.ID).Error; return err }
|
||||
if err := s.regenerateConfigLocked(); err != nil {
|
||||
_ = s.db.Delete(&models.Listener{}, l.ID).Error
|
||||
return err
|
||||
}
|
||||
if err := s.SaveVersion(l.ID, "create"); err != nil { return fmt.Errorf("listener created but history save failed: %w", err) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,31 +49,55 @@ func (s *Service) GetByID(id uint) (*models.Listener, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Update(l *models.Listener) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ValidateModel(l); err != nil { return err }
|
||||
var previous models.Listener
|
||||
if err := s.db.First(&previous, l.ID).Error; err != nil { return fmt.Errorf("failed to load previous listener: %w", err) }
|
||||
if err := s.ensureEndpointAvailable(l); err != nil { return err }
|
||||
if err := s.SaveVersion(previous.ID, "before-update"); err != nil { return fmt.Errorf("save listener history: %w", err) }
|
||||
if err := s.db.Save(l).Error; err != nil { return fmt.Errorf("failed to update listener: %w", err) }
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if err := s.regenerateConfigLocked(); err != nil {
|
||||
if rollbackErr := s.db.Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback listener failed: %w", err, rollbackErr) }
|
||||
_ = s.RegenerateConfig()
|
||||
_ = s.regenerateConfigLocked()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(id uint) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var previous models.Listener
|
||||
if err := s.db.First(&previous, id).Error; err != nil { return fmt.Errorf("failed to fetch listener before delete: %w", err) }
|
||||
var linked int64
|
||||
if err := s.db.Model(&models.ListenerUser{}).Where("listener_id = ?", id).Count(&linked).Error; err != nil { return fmt.Errorf("failed to check listener clients: %w", err) }
|
||||
if linked > 0 { return fmt.Errorf("listener %q has %d linked client(s); unbind clients before deleting the listener", previous.Name, linked) }
|
||||
if err := s.SaveVersion(id, "before-delete"); err != nil { return fmt.Errorf("save listener history: %w", err) }
|
||||
if err := s.db.Delete(&models.Listener{}, id).Error; err != nil { return fmt.Errorf("failed to delete listener: %w", err) }
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if err := s.regenerateConfigLocked(); err != nil {
|
||||
if rollbackErr := s.db.Unscoped().Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback deleted listener failed: %w", err, rollbackErr) }
|
||||
_ = s.RegenerateConfig()
|
||||
_ = s.regenerateConfigLocked()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegenerateConfig() error {
|
||||
func (s *Service) ensureEndpointAvailable(candidate *models.Listener) error {
|
||||
var listeners []models.Listener
|
||||
if err := s.db.Where("enabled = ? AND id <> ?", true, candidate.ID).Find(&listeners).Error; err != nil { return fmt.Errorf("check listener endpoint conflicts: %w", err) }
|
||||
for _, existing := range listeners {
|
||||
if !portsOverlap(candidate.Port, existing.Port) { continue }
|
||||
if listenerAddressesConflict(firstListenerAddress(*candidate), firstListenerAddress(existing)) {
|
||||
return fmt.Errorf("listener %q conflicts with existing listener %q on %s:%s", candidate.Name, existing.Name, firstListenerAddress(existing), existing.Port)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegenerateConfig() error { s.mu.Lock(); defer s.mu.Unlock(); return s.regenerateConfigLocked() }
|
||||
|
||||
func (s *Service) regenerateConfigLocked() error {
|
||||
if s == nil || s.db == nil { return fmt.Errorf("listener service not initialized") }
|
||||
engine := dbconfig.NewConfigEngine(s.db)
|
||||
yamlContent, err := engine.GenerateFinalConfig()
|
||||
@@ -74,8 +107,7 @@ func (s *Service) RegenerateConfig() error {
|
||||
if err := os.MkdirAll(dir, 0750); err != nil { return fmt.Errorf("create config directory: %w", err) }
|
||||
tmp, err := os.CreateTemp(dir, ".config.yaml.tmp-*")
|
||||
if err != nil { return fmt.Errorf("create temporary config: %w", err) }
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
tmpName := tmp.Name(); defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0600); err != nil { _ = tmp.Close(); return err }
|
||||
if _, err := tmp.WriteString(yamlContent); err != nil { _ = tmp.Close(); return err }
|
||||
if err := tmp.Sync(); err != nil { _ = tmp.Close(); return err }
|
||||
|
||||
@@ -2,6 +2,7 @@ package listener
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -29,33 +30,130 @@ func ValidateModel(l *models.Listener) error {
|
||||
return fmt.Errorf("listener protocol %q does not match type %q", protocol, legacyType)
|
||||
}
|
||||
l.Protocol = protocol
|
||||
if l.Port == "" {
|
||||
if strings.TrimSpace(l.Port) == "" {
|
||||
return fmt.Errorf("listener port is required")
|
||||
}
|
||||
if !validPort(l.Port) {
|
||||
return fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port)
|
||||
}
|
||||
if address := strings.TrimSpace(firstNonEmpty(l.BindAddress, l.Listen)); address != "" {
|
||||
if net.ParseIP(address) == nil {
|
||||
return fmt.Errorf("listener %q has invalid bind address %q; use an IPv4 or IPv6 address", l.Name, address)
|
||||
}
|
||||
l.BindAddress = address
|
||||
}
|
||||
if err := dbconfig.ValidateListenerConfig(protocol, l.Config); err != nil {
|
||||
return fmt.Errorf("listener %q: %w", l.Name, err)
|
||||
}
|
||||
if l.TLS {
|
||||
if !dbconfig.ListenerSupportsTLS(protocol) {
|
||||
return fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validPort(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" { return false }
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
r := strings.SplitN(part, "-", 2)
|
||||
if len(r) != 2 { return false }
|
||||
if len(r) != 2 {
|
||||
return false
|
||||
}
|
||||
a, errA := strconv.Atoi(strings.TrimSpace(r[0]))
|
||||
b, errB := strconv.Atoi(strings.TrimSpace(r[1]))
|
||||
if errA != nil || errB != nil || a < 1 || b > 65535 || a > b { return false }
|
||||
if errA != nil || errB != nil || a < 1 || b > 65535 || a > b {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p < 1 || p > 65535 { return false }
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PortRangesOverlap reports whether two comma-separated port specifications
|
||||
// share at least one port. It is used by the service before a listener is
|
||||
// persisted so conflicts are rejected before Mihomo sees the configuration.
|
||||
func PortRangesOverlap(a, b string) bool {
|
||||
for _, ar := range parsePortRanges(a) {
|
||||
for _, br := range parsePortRanges(b) {
|
||||
if ar[0] <= br[1] && br[0] <= ar[1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parsePortRanges(raw string) [][2]int {
|
||||
var result [][2]int
|
||||
for _, part := range strings.Split(strings.TrimSpace(raw), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
p := strings.SplitN(part, "-", 2)
|
||||
a, errA := strconv.Atoi(strings.TrimSpace(p[0]))
|
||||
b, errB := strconv.Atoi(strings.TrimSpace(p[1]))
|
||||
if errA == nil && errB == nil {
|
||||
result = append(result, [2]int{a, b})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p, err := strconv.Atoi(part); err == nil {
|
||||
result = append(result, [2]int{p, p})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddressesConflict uses conservative socket-binding semantics. Wildcards
|
||||
// conflict with any address of the same family; an IPv4 and IPv6 address are
|
||||
// considered independent sockets. Since the Listener model only accepts IP
|
||||
// addresses, hostname ambiguity is avoided at the trust boundary.
|
||||
func AddressesConflict(a, b string) bool {
|
||||
a = strings.TrimSpace(a)
|
||||
b = strings.TrimSpace(b)
|
||||
if a == "" {
|
||||
a = "0.0.0.0"
|
||||
}
|
||||
if b == "" {
|
||||
b = "0.0.0.0"
|
||||
}
|
||||
ia, ib := net.ParseIP(a), net.ParseIP(b)
|
||||
if ia == nil || ib == nil {
|
||||
return false
|
||||
}
|
||||
if ia.Equal(ib) {
|
||||
return true
|
||||
}
|
||||
if ia.To4() != nil && ib.To4() != nil {
|
||||
return ia.IsUnspecified() || ib.IsUnspecified()
|
||||
}
|
||||
if ia.To4() == nil && ib.To4() == nil {
|
||||
return ia.IsUnspecified() || ib.IsUnspecified()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -24,28 +25,19 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
|
||||
return "", fmt.Errorf("config engine database is not initialized")
|
||||
}
|
||||
baseBytes, err := yaml.Marshal(GetDefaultTemplate())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err != nil { return "", err }
|
||||
var merged map[string]interface{}
|
||||
if err := yaml.Unmarshal(baseBytes, &merged); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := yaml.Unmarshal(baseBytes, &merged); err != nil { return "", err }
|
||||
var customFragments []models.Config
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&customFragments).Error; err != nil {
|
||||
return "", fmt.Errorf("load custom config fragments: %w", err)
|
||||
}
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&customFragments).Error; err != nil { return "", fmt.Errorf("load custom config fragments: %w", err) }
|
||||
for _, fragment := range customFragments {
|
||||
var fragMap map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(fragment.Content), &fragMap); err != nil {
|
||||
return "", fmt.Errorf("invalid custom config %q: %w", fragment.Name, err)
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(fragment.Content), &fragMap); err != nil { return "", fmt.Errorf("invalid custom config %q: %w", fragment.Name, err) }
|
||||
for k, v := range fragMap { merged[k] = v }
|
||||
}
|
||||
var listeners []models.Listener
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
|
||||
return "", fmt.Errorf("load enabled listeners: %w", err)
|
||||
}
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil { return "", fmt.Errorf("load enabled listeners: %w", err) }
|
||||
if err := validateListenerEndpoints(listeners); err != nil { return "", err }
|
||||
credentials := make(map[uint][]Credential)
|
||||
if CredentialProvider != nil {
|
||||
credentials, err = CredentialProvider()
|
||||
@@ -59,6 +51,68 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
|
||||
return string(finalBytes), nil
|
||||
}
|
||||
|
||||
func validateListenerEndpoints(listeners []models.Listener) error {
|
||||
for i := range listeners {
|
||||
if err := validateListenerEndpoint(&listeners[i]); err != nil { return err }
|
||||
for j := i + 1; j < len(listeners); j++ {
|
||||
if !portsOverlap(listeners[i].Port, listeners[j].Port) { continue }
|
||||
a := firstListenerAddress(listeners[i])
|
||||
b := firstListenerAddress(listeners[j])
|
||||
if listenerAddressesConflict(a, b) {
|
||||
return fmt.Errorf("listeners %q and %q have conflicting bind address/port ranges (%s:%s and %s:%s)", listeners[i].Name, listeners[j].Name, a, listeners[i].Port, b, listeners[j].Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListenerEndpoint(l *models.Listener) error {
|
||||
address := firstListenerAddress(*l)
|
||||
if net.ParseIP(address) == nil { return fmt.Errorf("listener %q has invalid bind address %q", l.Name, address) }
|
||||
if !isValidPortString(l.Port) { return fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstListenerAddress(l models.Listener) string {
|
||||
if v := strings.TrimSpace(l.BindAddress); v != "" { return v }
|
||||
if v := strings.TrimSpace(l.Listen); v != "" { return v }
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
func portsOverlap(a, b string) bool {
|
||||
for _, ar := range parsePortRanges(a) {
|
||||
for _, br := range parsePortRanges(b) {
|
||||
if ar[0] <= br[1] && br[0] <= ar[1] { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parsePortRanges(raw string) [][2]int {
|
||||
var out [][2]int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" { continue }
|
||||
if strings.Contains(part, "-") {
|
||||
p := strings.SplitN(part, "-", 2)
|
||||
a, ea := strconv.Atoi(strings.TrimSpace(p[0])); b, eb := strconv.Atoi(strings.TrimSpace(p[1]))
|
||||
if ea == nil && eb == nil { out = append(out, [2]int{a, b}) }
|
||||
continue
|
||||
}
|
||||
if p, err := strconv.Atoi(part); err == nil { out = append(out, [2]int{p, p}) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listenerAddressesConflict(a, b string) bool {
|
||||
ia, ib := net.ParseIP(a), net.ParseIP(b)
|
||||
if ia == nil || ib == nil { return false }
|
||||
if ia.Equal(ib) { return true }
|
||||
if ia.To4() != nil && ib.To4() != nil { return ia.IsUnspecified() || ib.IsUnspecified() }
|
||||
if ia.To4() == nil && ib.To4() == nil { return ia.IsUnspecified() || ib.IsUnspecified() }
|
||||
return false
|
||||
}
|
||||
|
||||
func generateListeners(listeners []models.Listener, creds map[uint][]Credential) ([]map[string]interface{}, error) {
|
||||
result := make([]map[string]interface{}, 0, len(listeners))
|
||||
for _, l := range listeners {
|
||||
@@ -67,14 +121,13 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
|
||||
if protocol == "" { protocol = strings.ToLower(strings.TrimSpace(l.Type)) }
|
||||
if !IsMihomoListenerProtocol(protocol) { return nil, fmt.Errorf("unsupported Mihomo listener protocol %q", protocol) }
|
||||
if !isValidPortString(l.Port) { return nil, fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port) }
|
||||
listen := strings.TrimSpace(l.BindAddress)
|
||||
if listen == "" { listen = strings.TrimSpace(l.Listen) }
|
||||
if listen == "" { listen = "0.0.0.0" }
|
||||
listen := firstListenerAddress(l)
|
||||
var portVal interface{} = strings.TrimSpace(l.Port)
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(l.Port)); err == nil { portVal = p }
|
||||
m := map[string]interface{}{"name": l.Name, "type": protocol, "port": portVal, "listen": listen}
|
||||
configMap, err := decodeListenerConfig(l.Config)
|
||||
if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
|
||||
if err := ValidateListenerConfig(protocol, l.Config); err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
|
||||
if l.Proxy != "" { m["proxy"] = l.Proxy }
|
||||
if l.Rule != "" { m["rule"] = l.Rule }
|
||||
if l.RoutingMark > 0 { m["routing-mark"] = l.RoutingMark }
|
||||
@@ -84,7 +137,7 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
|
||||
copyServerTLSFields(m, configMap)
|
||||
listenerCreds, hasCredentialState := creds[l.ID]
|
||||
if l.TLS {
|
||||
if !listenerSupportsTLS(protocol) { return nil, fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol) }
|
||||
if !ListenerSupportsTLS(protocol) { return nil, fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol) }
|
||||
m["tls"] = true
|
||||
} else { delete(m, "tls") }
|
||||
|
||||
@@ -108,31 +161,18 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
|
||||
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
|
||||
case "trojan":
|
||||
users := make([]map[string]interface{}, 0, len(listenerCreds))
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.Password == "" { continue }
|
||||
u := map[string]interface{}{"password": cred.Password}
|
||||
if cred.Username != "" { u["username"] = cred.Username }
|
||||
users = append(users, u)
|
||||
}
|
||||
for _, cred := range listenerCreds { if cred.Password != "" { u := map[string]interface{}{"password": cred.Password}; if cred.Username != "" { u["username"] = cred.Username }; users = append(users, u) } }
|
||||
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
|
||||
case "hysteria2", "anytls", "mieru":
|
||||
users := make(map[string]string)
|
||||
for _, cred := range listenerCreds { if cred.Username != "" && cred.Password != "" { users[cred.Username] = cred.Password } }
|
||||
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
|
||||
case "tuic":
|
||||
if value, ok := configMap["token"]; ok && !hasCredentialState { m["token"] = value } else {
|
||||
users := make(map[string]string)
|
||||
for _, cred := range listenerCreds { if cred.UUID != "" && cred.Password != "" { users[cred.UUID] = cred.Password } }
|
||||
if len(users) > 0 { m["users"] = users }
|
||||
}
|
||||
if value, ok := configMap["token"]; ok && !hasCredentialState { m["token"] = value } else { users := make(map[string]string); for _, cred := range listenerCreds { if cred.UUID != "" && cred.Password != "" { users[cred.UUID] = cred.Password } }; if len(users) > 0 { m["users"] = users } }
|
||||
case "shadowquic", "trusttunnel":
|
||||
users := make([]map[string]interface{}, 0, len(listenerCreds))
|
||||
for _, cred := range listenerCreds { if cred.Username != "" && cred.Password != "" { users = append(users, map[string]interface{}{"username": cred.Username, "password": cred.Password}) } }
|
||||
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState {
|
||||
normalized, err := normalizeListenerUserList(value)
|
||||
if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
|
||||
if normalized != nil { m["users"] = normalized }
|
||||
}
|
||||
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { normalized, err := normalizeListenerUserList(value); if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }; if normalized != nil { m["users"] = normalized } }
|
||||
case "sudoku":
|
||||
}
|
||||
if hasCredentialState && len(listenerCreds) == 0 {
|
||||
@@ -148,11 +188,7 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
|
||||
func isValidPortString(s string) bool {
|
||||
s = strings.TrimSpace(s); if s == "" { return false }
|
||||
if strings.Contains(s, ",") { for _, p := range strings.Split(s, ",") { if !isValidPortString(strings.TrimSpace(p)) { return false } }; return true }
|
||||
if strings.Contains(s, "-") {
|
||||
parts := strings.SplitN(s, "-", 2); if len(parts) != 2 { return false }
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])); end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
return err1 == nil && err2 == nil && start >= 1 && end <= 65535 && start <= end
|
||||
}
|
||||
if strings.Contains(s, "-") { parts := strings.SplitN(s, "-", 2); if len(parts) != 2 { return false }; start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])); end, err2 := strconv.Atoi(strings.TrimSpace(parts[1])); return err1 == nil && err2 == nil && start >= 1 && end <= 65535 && start <= end }
|
||||
port, err := strconv.Atoi(s); return err == nil && port >= 1 && port <= 65535
|
||||
}
|
||||
|
||||
@@ -178,20 +214,13 @@ func decodeListenerConfig(raw string) (map[string]interface{}, error) {
|
||||
|
||||
func listenerFieldIsManaged(key string) bool {
|
||||
switch key {
|
||||
case "users", "username", "password", "uuid", "flow", "alterId", "tls", "servername", "sni", "skip-cert-verify", "name-cert-verify", "fingerprint", "client-fingerprint", "reality-opts", "shadow-tls-opts", "restls-opts", "jls-opts", "ws-opts", "grpc-opts", "h2-opts", "http-opts", "mkcp-opts", "certificate", "private-key", "private_key":
|
||||
return true
|
||||
case "users", "username", "password", "uuid", "flow", "alterId", "tls", "servername", "sni", "skip-cert-verify", "name-cert-verify", "fingerprint", "client-fingerprint", "reality-opts", "shadow-tls-opts", "restls-opts", "jls-opts", "ws-opts", "grpc-opts", "h2-opts", "http-opts", "mkcp-opts", "certificate", "private-key", "private_key": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func copyServerTLSFields(dst, src map[string]interface{}) {
|
||||
if value, ok := src["certificate"]; ok { dst["certificate"] = value }
|
||||
if value, ok := src["private-key"]; ok { dst["private-key"] = value } else if value, ok := src["private_key"]; ok { dst["private-key"] = value }
|
||||
}
|
||||
func copyServerTLSFields(dst, src map[string]interface{}) { if value, ok := src["certificate"]; ok { dst["certificate"] = value }; if value, ok := src["private-key"]; ok { dst["private-key"] = value } else if value, ok := src["private_key"]; ok { dst["private-key"] = value } }
|
||||
func copyOption(dst, src map[string]interface{}, key string) { if value, ok := src[key]; ok { dst[key] = value } }
|
||||
func listenerHasUDPOption(protocol string) bool {
|
||||
switch protocol { case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel": return true; default: return false }
|
||||
}
|
||||
func listenerSupportsTLS(protocol string) bool {
|
||||
switch protocol { case "vmess", "vless", "trojan", "anytls", "mieru", "trusttunnel": return true; default: return false }
|
||||
}
|
||||
func listenerHasUDPOption(protocol string) bool { switch protocol { case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel": return true; default: return false } }
|
||||
func listenerSupportsTLS(protocol string) bool { switch protocol { case "vmess", "vless", "trojan", "anytls", "mieru", "trusttunnel": return true; default: return false } }
|
||||
func ListenerSupportsTLS(protocol string) bool { return listenerSupportsTLS(strings.ToLower(strings.TrimSpace(protocol))) }
|
||||
@@ -33,17 +33,40 @@ func ValidateListenerConfig(protocol, raw string) error {
|
||||
"proxy": {}, "rule": {}, "enabled": {}, "status": {}, "tls": {}, "udp": {},
|
||||
"routing-mark": {},
|
||||
}
|
||||
for key := range values {
|
||||
if _, blocked := reserved[key]; blocked {
|
||||
return fmt.Errorf("listener configuration field %q is managed by 3m-ui", key)
|
||||
return validateListenerObject(schema, values, "", reserved)
|
||||
}
|
||||
|
||||
func validateListenerObject(schema ListenerSchema, values map[string]interface{}, prefix string, reserved map[string]struct{}) error {
|
||||
for key, value := range values {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if _, allowed := schema.Fields[key]; allowed {
|
||||
continue
|
||||
if prefix == "" {
|
||||
if _, blocked := reserved[key]; blocked {
|
||||
return fmt.Errorf("listener configuration field %q is managed by 3m-ui", key)
|
||||
}
|
||||
if _, allowed := schema.Fields[key]; !allowed {
|
||||
if _, isParent := schema.NestedFields[key]; !isParent {
|
||||
return fmt.Errorf("field %q is not supported for listener protocol %q", key, schema.Protocol)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parent := schema.NestedFields[prefix]
|
||||
if _, allowed := parent[key]; !allowed {
|
||||
return fmt.Errorf("field %q is not supported for listener protocol %q", path, schema.Protocol)
|
||||
}
|
||||
}
|
||||
if _, allowed := schema.NestedFields[key]; allowed {
|
||||
continue
|
||||
|
||||
if _, hasChildren := schema.NestedFields[path]; hasChildren {
|
||||
child, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q must be a JSON object", path)
|
||||
}
|
||||
if err := validateListenerObject(schema, child, path, reserved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %q is not supported for listener protocol %q", key, protocol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateListenerConfigRejectsUnknownNestedField(t *testing.T) {
|
||||
raw := `{"reality-config":{"private-key":"secret","unknown":"value"}}`
|
||||
if err := ValidateListenerConfig("vless", raw); err == nil {
|
||||
t.Fatal("expected unknown nested field to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListenerConfigRejectsNestedScalar(t *testing.T) {
|
||||
raw := `{"reality-config":"not-an-object"}`
|
||||
if err := ValidateListenerConfig("vless", raw); err == nil {
|
||||
t.Fatal("expected nested object type violation to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListenerConfigAcceptsDeepNestedField(t *testing.T) {
|
||||
raw := `{"reality-config":{"limit-fallback-upload":{"after-bytes":1024}}}`
|
||||
if err := ValidateListenerConfig("vless", raw); err != nil {
|
||||
t.Fatalf("expected valid deep nested field, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,121 +2,70 @@ package mihomo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pm *ProcessManager
|
||||
cm *ConfigManager
|
||||
}
|
||||
type Service struct { pm *ProcessManager; cm *ConfigManager }
|
||||
|
||||
// NewService constructs a Mihomo process/config service from application config.
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
if cfg == nil {
|
||||
return &Service{}
|
||||
}
|
||||
return &Service{
|
||||
pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config),
|
||||
cm: NewConfigManager(cfg.Mihomo.Config),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) StartMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pm.Start()
|
||||
}
|
||||
|
||||
func (s *Service) StopMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Stop()
|
||||
}
|
||||
|
||||
func (s *Service) RestartMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Restart()
|
||||
}
|
||||
|
||||
func (s *Service) GetStatus() (*StatusResponse, error) {
|
||||
if s == nil || s.pm == nil {
|
||||
return nil, fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Status()
|
||||
if cfg == nil { return &Service{} }
|
||||
return &Service{pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config), cm: NewConfigManager(cfg.Mihomo.Config)}
|
||||
}
|
||||
func (s *Service) StartMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; if err := s.pm.ValidateConfig(); err != nil { return err }; return s.pm.Start() }
|
||||
func (s *Service) StopMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; return s.pm.Stop() }
|
||||
func (s *Service) RestartMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; return s.pm.Restart() }
|
||||
func (s *Service) GetStatus() (*StatusResponse, error) { if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }; return s.pm.Status() }
|
||||
|
||||
func (s *Service) SaveConfig(content string) error {
|
||||
if s == nil || s.cm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
|
||||
old, readErr := s.cm.ReadConfig()
|
||||
if err := s.cm.SaveConfig(content); err != nil {
|
||||
return err
|
||||
}
|
||||
// The management UI remains usable when Mihomo is intentionally not
|
||||
// installed (for example, --no-mihomo installs). Validate when a core
|
||||
// binary is available, but do not make the panel itself depend on it.
|
||||
if s.pm == nil {
|
||||
return nil
|
||||
if err := s.cm.SaveConfig(content); err != nil { return err }
|
||||
if s.pm == nil { return nil }
|
||||
if err := s.pm.ValidateConfig(); err != nil { if readErr == nil { _ = s.cm.SaveConfig(old) }; return err }
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyConfig validates and activates a candidate configuration. Before the
|
||||
// live file is changed, the current file is copied to <config>.bak. If
|
||||
// validation or restart fails, the previous configuration is restored.
|
||||
func (s *Service) ApplyConfig(content string) error {
|
||||
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
|
||||
old, readErr := s.cm.ReadConfig()
|
||||
if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("read current Mihomo config: %w", readErr) }
|
||||
if readErr == nil {
|
||||
if err := os.WriteFile(s.cm.configPath+".bak", []byte(old), 0600); err != nil { return fmt.Errorf("backup Mihomo config: %w", err) }
|
||||
}
|
||||
if err := s.cm.SaveConfig(content); err != nil { return err }
|
||||
if s.pm == nil { return nil }
|
||||
wasRunning := s.pm.IsRunning()
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
if readErr == nil { _ = s.cm.SaveConfig(old) }
|
||||
return fmt.Errorf("validate Mihomo configuration: %w", err)
|
||||
}
|
||||
if !wasRunning { return s.pm.Start() }
|
||||
if err := s.pm.Restart(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = s.cm.SaveConfig(old)
|
||||
if restoreErr := s.cm.SaveConfig(old); restoreErr == nil { _ = s.pm.Restart() }
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("apply Mihomo configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ApplyConfig(content string) error {
|
||||
if s == nil || s.cm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
old, readErr := s.cm.ReadConfig()
|
||||
if err := s.cm.SaveConfig(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.pm == nil {
|
||||
return nil
|
||||
}
|
||||
wasRunning := s.pm.IsRunning()
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = s.cm.SaveConfig(old)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !wasRunning {
|
||||
return s.pm.Start()
|
||||
}
|
||||
if err := s.pm.Restart(); err != nil {
|
||||
if readErr == nil {
|
||||
if restoreErr := s.cm.SaveConfig(old); restoreErr == nil {
|
||||
_ = s.pm.Restart()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
func (s *Service) RollbackConfig() error {
|
||||
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
|
||||
backup, err := os.ReadFile(s.cm.configPath + ".bak")
|
||||
if err != nil { return fmt.Errorf("read Mihomo config backup: %w", err) }
|
||||
return s.ApplyConfig(string(backup))
|
||||
}
|
||||
|
||||
func (s *Service) GetLogs() ([]LogResponse, error) {
|
||||
if s == nil || s.pm == nil {
|
||||
return nil, fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }
|
||||
lines := s.pm.Logs()
|
||||
result := make([]LogResponse, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
result = append(result, LogResponse{Timestamp: time.Now(), Level: "info", Payload: line})
|
||||
}
|
||||
for _, line := range lines { result = append(result, LogResponse{Timestamp: time.Now(), Level: "info", Payload: line}) }
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package mihomo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyConfigCreatesBackupAndRollbackRestoresPreviousConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
old := "mixed-port: 7890\n"
|
||||
candidate := "mixed-port: 7891\n"
|
||||
if err := os.WriteFile(path, []byte(old), 0600); err != nil { t.Fatal(err) }
|
||||
|
||||
svc := &Service{cm: NewConfigManager(path)}
|
||||
if err := svc.ApplyConfig(candidate); err != nil { t.Fatalf("ApplyConfig: %v", err) }
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if string(got) != candidate { t.Fatalf("applied config = %q, want %q", got, candidate) }
|
||||
|
||||
backup, err := os.ReadFile(path + ".bak")
|
||||
if err != nil { t.Fatal(err) }
|
||||
if string(backup) != old { t.Fatalf("backup = %q, want %q", backup, old) }
|
||||
|
||||
if err := svc.RollbackConfig(); err != nil { t.Fatalf("RollbackConfig: %v", err) }
|
||||
got, err = os.ReadFile(path)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if string(got) != old { t.Fatalf("rolled back config = %q, want %q", got, old) }
|
||||
}
|
||||
|
||||
func TestApplyConfigDoesNotRequireMihomoProcessManager(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("mixed-port: 7890\n"), 0600); err != nil { t.Fatal(err) }
|
||||
svc := &Service{cm: NewConfigManager(path)}
|
||||
if err := svc.ApplyConfig("mixed-port: 7892\n"); err != nil { t.Fatalf("ApplyConfig: %v", err) }
|
||||
}
|
||||
@@ -16,132 +16,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterAccessTokenRoutes(rg *gin.RouterGroup, d Deps) {
|
||||
db := d.DB
|
||||
h := &AccessTokenHandler{db: db, cfg: d.Config}
|
||||
rg.GET("", h.List)
|
||||
rg.POST("", h.Create)
|
||||
rg.DELETE("/:id", h.Delete)
|
||||
}
|
||||
func RegisterAccessTokenRoutes(rg *gin.RouterGroup, d Deps) { h := &AccessTokenHandler{db:d.DB,cfg:d.Config}; rg.GET("",h.List); rg.POST("",h.Create); rg.PUT("/:id",h.Update); rg.DELETE("/:id",h.Delete) }
|
||||
type AccessTokenHandler struct { db *gorm.DB; cfg *config.Config }
|
||||
type accessTokenCreateInput struct { Name string `json:"name"`; ListenerID uint `json:"listener_id" binding:"required"`; ExpireAt *time.Time `json:"expire_at"` }
|
||||
type accessTokenUpdateInput struct { Name *string `json:"name"`; Enabled *bool `json:"enabled"`; ExpireAt *time.Time `json:"expire_at"` }
|
||||
type accessTokenResponse struct { ID uint `json:"id"`; Name string `json:"name"`; Token string `json:"token,omitempty"`; Enabled bool `json:"enabled"`; ExpireAt *time.Time `json:"expire_at"`; ListenerID uint `json:"listener_id"` }
|
||||
|
||||
type AccessTokenHandler struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type accessTokenCreateInput struct {
|
||||
Name string `json:"name"`
|
||||
ListenerID uint `json:"listener_id" binding:"required"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
}
|
||||
|
||||
type accessTokenResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
ListenerID uint `json:"listener_id"`
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) List(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
var tokens []models.AccessToken
|
||||
if err := h.db.Order("id desc").Find(&tokens).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]accessTokenResponse, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
out = append(out, accessTokenResponse{
|
||||
ID: token.ID, Name: token.Name, Enabled: token.Enabled,
|
||||
ExpireAt: token.ExpireAt, ListenerID: token.ListenerID,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) Create(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
var input accessTokenCreateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if input.ListenerID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener_id is required"})
|
||||
return
|
||||
}
|
||||
if input.ExpireAt != nil && !input.ExpireAt.After(time.Now()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expire_at must be in the future"})
|
||||
return
|
||||
}
|
||||
var listener models.Listener
|
||||
if err := h.db.First(&listener, input.ListenerID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !listener.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = listener.Name
|
||||
}
|
||||
token := models.AccessToken{
|
||||
Name: name, Token: hex.EncodeToString(buf), Enabled: true,
|
||||
ExpireAt: input.ExpireAt, ListenerID: listener.ID,
|
||||
}
|
||||
if err := h.db.Create(&token).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": token.ID, "name": token.Name, "token": token.Token,
|
||||
"enabled": token.Enabled, "expire_at": token.ExpireAt,
|
||||
"listener_id": token.ListenerID,
|
||||
"mihomo_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "mihomo"),
|
||||
"clash_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "clash"),
|
||||
"singbox_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "singbox"),
|
||||
"shadowrocket_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "shadowrocket"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) Delete(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
result := h.db.Delete(&models.AccessToken{}, uint(id))
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "access token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
func (h *AccessTokenHandler) List(c *gin.Context) { if h.db==nil { c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return }; var tokens []models.AccessToken; if err:=h.db.Order("id desc").Find(&tokens).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};out:=make([]accessTokenResponse,0,len(tokens));for _,t:=range tokens{out=append(out,accessTokenResponse{ID:t.ID,Name:t.Name,Enabled:t.Enabled,ExpireAt:t.ExpireAt,ListenerID:t.ListenerID})};c.JSON(http.StatusOK,out) }
|
||||
func (h *AccessTokenHandler) Create(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};var in accessTokenCreateInput;if err:=c.ShouldBindJSON(&in);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};if in.ListenerID==0{c.JSON(http.StatusBadRequest,gin.H{"error":"listener_id is required"});return};if in.ExpireAt!=nil&&!in.ExpireAt.After(time.Now()){c.JSON(http.StatusBadRequest,gin.H{"error":"expire_at must be in the future"});return};var l models.Listener;if err:=h.db.First(&l,in.ListenerID).Error;err!=nil{if errors.Is(err,gorm.ErrRecordNotFound){c.JSON(http.StatusNotFound,gin.H{"error":"listener not found"})}else{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()})};return};if !l.Enabled{c.JSON(http.StatusBadRequest,gin.H{"error":"listener is disabled"});return};buf:=make([]byte,24);if _,err:=rand.Read(buf);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"failed to generate secure token"});return};name:=strings.TrimSpace(in.Name);if name==""{name=l.Name};token:=models.AccessToken{Name:name,Token:hex.EncodeToString(buf),Enabled:true,ExpireAt:in.ExpireAt,ListenerID:l.ID};if err:=h.db.Create(&token).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusCreated,gin.H{"id":token.ID,"name":token.Name,"token":token.Token,"enabled":token.Enabled,"expire_at":token.ExpireAt,"listener_id":token.ListenerID,"mihomo_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"mihomo"),"clash_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"clash"),"singbox_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"singbox"),"shadowrocket_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"shadowrocket")}) }
|
||||
func (h *AccessTokenHandler) Update(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};id,err:=strconv.ParseUint(c.Param("id"),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return};var in accessTokenUpdateInput;if err:=c.ShouldBindJSON(&in);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};var t models.AccessToken;if err:=h.db.First(&t,uint(id)).Error;err!=nil{if errors.Is(err,gorm.ErrRecordNotFound){c.JSON(http.StatusNotFound,gin.H{"error":"access token not found"})}else{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()})};return};updates:=map[string]interface{}{};if in.Name!=nil{if strings.TrimSpace(*in.Name)==""{c.JSON(http.StatusBadRequest,gin.H{"error":"name cannot be empty"});return};updates["name"]=strings.TrimSpace(*in.Name)};if in.Enabled!=nil{updates["enabled"]=*in.Enabled};if in.ExpireAt!=nil&&!in.ExpireAt.After(time.Now()){c.JSON(http.StatusBadRequest,gin.H{"error":"expire_at must be in the future"});return};if in.ExpireAt!=nil{updates["expire_at"]=in.ExpireAt};if len(updates)>0{if err:=h.db.Model(&t).Updates(updates).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return}};c.JSON(http.StatusOK,gin.H{"id":t.ID,"name":t.Name,"enabled":t.Enabled,"expire_at":t.ExpireAt,"listener_id":t.ListenerID}) }
|
||||
func (h *AccessTokenHandler) Delete(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};id,err:=strconv.ParseUint(c.Param("id"),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return};result:=h.db.Delete(&models.AccessToken{},uint(id));if result.Error!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":result.Error.Error()});return};if result.RowsAffected==0{c.JSON(http.StatusNotFound,gin.H{"error":"access token not found"});return};c.JSON(http.StatusOK,gin.H{"status":"ok"}) }
|
||||
@@ -2,8 +2,6 @@ package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,158 +13,22 @@ func registerConfigRoutes(api *gin.RouterGroup, d Deps, cfg *config.Config) {
|
||||
db := resolveDB(d)
|
||||
group := api.Group("/config")
|
||||
|
||||
group.GET("/proxies", func(c *gin.Context) {
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, visual.Proxies)
|
||||
})
|
||||
group.POST("/proxies", func(c *gin.Context) {
|
||||
var proxy mihomoConfig.ProxyEntry
|
||||
if err := c.ShouldBindJSON(&proxy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if proxy.Name == "" || proxy.Type == "" || proxy.Server == "" || proxy.Port == "" || proxy.Port == "0" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称、协议、服务器和有效端口不能为空"})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visual.Proxies = append(visual.Proxies, proxy)
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, proxy)
|
||||
})
|
||||
group.PUT("/proxies/:index", func(c *gin.Context) {
|
||||
idx, err := strconv.Atoi(c.Param("index"))
|
||||
if err != nil || idx < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
|
||||
return
|
||||
}
|
||||
var proxy mihomoConfig.ProxyEntry
|
||||
if err = c.ShouldBindJSON(&proxy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if idx >= len(visual.Proxies) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
|
||||
return
|
||||
}
|
||||
visual.Proxies[idx] = proxy
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, proxy)
|
||||
})
|
||||
group.DELETE("/proxies/:index", func(c *gin.Context) {
|
||||
idx, err := strconv.Atoi(c.Param("index"))
|
||||
if err != nil || idx < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if idx >= len(visual.Proxies) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
|
||||
return
|
||||
}
|
||||
visual.Proxies = append(visual.Proxies[:idx], visual.Proxies[idx+1:]...)
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
group.GET("/visual", func(c *gin.Context) {
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, visual)
|
||||
})
|
||||
group.POST("/visual", func(c *gin.Context) {
|
||||
var visual mihomoConfig.VisualConfig
|
||||
if err := c.ShouldBindJSON(&visual); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid visual configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Visual configuration saved"})
|
||||
})
|
||||
group.GET("", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"config": yamlStr})
|
||||
})
|
||||
group.POST("/generate", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(cfg.Mihomo.Config)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(cfg.Mihomo.Config, []byte(yamlStr), 0600); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.Chmod(cfg.Mihomo.Config, 0600); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration generated and written successfully"})
|
||||
})
|
||||
group.POST("/validate", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Config string `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true})
|
||||
})
|
||||
group.GET("/download", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=config.yaml")
|
||||
c.Data(http.StatusOK, "application/yaml", []byte(yamlStr))
|
||||
})
|
||||
group.GET("/proxies", func(c *gin.Context) { visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, visual.Proxies) })
|
||||
group.POST("/proxies", func(c *gin.Context) { var proxy mihomoConfig.ProxyEntry; if err:=c.ShouldBindJSON(&proxy);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};if proxy.Name==""||proxy.Type==""||proxy.Server==""||proxy.Port==""||proxy.Port=="0"{c.JSON(http.StatusBadRequest,gin.H{"error":"名称、协议、服务器和有效端口不能为空"});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};visual.Proxies=append(visual.Proxies,proxy);if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusCreated,proxy) })
|
||||
group.PUT("/proxies/:index", func(c *gin.Context) { idx,err:=strconv.Atoi(c.Param("index"));if err!=nil||idx<0{c.JSON(http.StatusBadRequest,gin.H{"error":"无效节点索引"});return};var proxy mihomoConfig.ProxyEntry;if err=c.ShouldBindJSON(&proxy);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if idx>=len(visual.Proxies){c.JSON(http.StatusNotFound,gin.H{"error":"节点不存在"});return};visual.Proxies[idx]=proxy;if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,proxy) })
|
||||
group.DELETE("/proxies/:index", func(c *gin.Context) { idx,err:=strconv.Atoi(c.Param("index"));if err!=nil||idx<0{c.JSON(http.StatusBadRequest,gin.H{"error":"无效节点索引"});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if idx>=len(visual.Proxies){c.JSON(http.StatusNotFound,gin.H{"error":"节点不存在"});return};visual.Proxies=append(visual.Proxies[:idx],visual.Proxies[idx+1:]...);if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok"}) })
|
||||
group.GET("/visual", func(c *gin.Context) { visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,visual) })
|
||||
group.POST("/visual", func(c *gin.Context) { var visual mihomoConfig.VisualConfig;if err:=c.ShouldBindJSON(&visual);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid visual configuration: "+err.Error()});return};if err:=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Visual configuration saved"}) })
|
||||
group.GET("", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"config":yamlStr}) })
|
||||
|
||||
// /generate is kept for backward compatibility, but it now uses the same
|
||||
// guarded service path as /apply instead of writing config.yaml directly.
|
||||
group.POST("/generate", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if err:=d.mihomoService().ApplyConfig(yamlStr);err!=nil{c.JSON(http.StatusUnprocessableEntity,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Configuration generated, validated and applied successfully"}) })
|
||||
group.POST("/preview", func(c *gin.Context) { var req struct{Config string `json:"config"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid request body"});return};if err:=mihomoConfig.ValidateConfigYAML(req.Config);err!=nil{c.JSON(http.StatusOK,gin.H{"valid":false,"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"valid":true,"config":req.Config}) })
|
||||
group.POST("/validate", func(c *gin.Context) { var req struct{Config string `json:"config"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid request body"});return};if err:=mihomoConfig.ValidateConfigYAML(req.Config);err!=nil{c.JSON(http.StatusOK,gin.H{"valid":false,"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"valid":true}) })
|
||||
group.POST("/apply", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if err:=d.mihomoService().ApplyConfig(yamlStr);err!=nil{c.JSON(http.StatusUnprocessableEntity,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Configuration validated and applied successfully"}) })
|
||||
group.POST("/rollback", func(c *gin.Context) { if err:=d.mihomoService().RollbackConfig();err!=nil{c.JSON(http.StatusConflict,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Previous Mihomo configuration restored"}) })
|
||||
group.GET("/download", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.Header("Content-Disposition","attachment; filename=config.yaml");c.Data(http.StatusOK,"application/yaml",[]byte(yamlStr)) })
|
||||
|
||||
_ = cfg
|
||||
}
|
||||
@@ -1,4 +1,33 @@
|
||||
import client from './client';
|
||||
import { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener } from './nodes';
|
||||
|
||||
export { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI };
|
||||
export type { Listener };
|
||||
|
||||
export interface ListenerTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
config: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ListenerVersion {
|
||||
id: number;
|
||||
listener_id: number;
|
||||
version: number;
|
||||
created_at: string;
|
||||
reason?: string;
|
||||
snapshot: string;
|
||||
}
|
||||
|
||||
export const listListenerTemplates = () => client.get<ListenerTemplate[]>('/nodes/templates').then(r => r.data);
|
||||
export const createListenerTemplate = (payload: Partial<ListenerTemplate>) => client.post<ListenerTemplate>('/nodes/templates', payload).then(r => r.data);
|
||||
export const deleteListenerTemplate = (id: number) => client.delete(`/nodes/templates/${id}`).then(r => r.data);
|
||||
export const instantiateListenerTemplate = (id: number, payload: { name: string; port: string }) => client.post(`/nodes/templates/${id}/instantiate`, payload).then(r => r.data);
|
||||
export const cloneListener = (id: number, payload: { name: string; port: string }) => client.post(`/nodes/${id}/clone`, payload).then(r => r.data);
|
||||
export const batchSetListenersEnabled = (ids: number[], enabled: boolean) => client.post('/nodes/batch/enabled', { ids, enabled }).then(r => r.data);
|
||||
export const listListenerVersions = (id: number) => client.get<ListenerVersion[]>(`/nodes/${id}/versions`).then(r => r.data);
|
||||
export const diffListenerVersion = (id: number, version: number) => client.get<string>(`/nodes/${id}/versions/${version}/diff`, { responseType: 'text' }).then(r => r.data);
|
||||
export const rollbackListenerVersion = (id: number, version: number) => client.post(`/nodes/${id}/versions/${version}/rollback`).then(r => r.data);
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
login: { title: '3M-UI', subtitle: 'Mihomo Core Management Console', username: 'Username', password: 'Password', button: 'Login', welcomeBack: 'Welcome back', failed: 'Login failed' },
|
||||
password: { title: 'Change Password', current: 'Current Password', new: 'New Password', confirm: 'Confirm New Password', button: 'Change Password', success: 'Password updated', failed: 'Failed to change password', mismatch: 'Passwords do not match' },
|
||||
dashboard: { title: 'Dashboard', subtitle: 'System & Core Overview', status: 'Core Status', running: 'Running', stoppedStatus: 'Stopped', version: 'Version', pid: 'PID', uptime: 'Uptime', listeners: 'Node Stats', total: 'Total', enabled: 'Enabled', disabled: 'Disabled', system: 'System Resources', cpu: 'CPU', memory: 'Memory', disk: 'Disk', network: 'Network', upload: 'Upload', download: 'Download', traffic: 'Traffic Stats', uploadRate: 'Upload rate', downloadRate: 'Download rate', totalUpload: 'Total upload', totalDownload: 'Total download', onlineUsers: 'Online users', activeConnections: 'Active connections', start: 'Start', stop: 'Stop', restart: 'Restart', started: 'Core started', stopped: 'Core stopped', restarted: 'Core restarted', operationFailed: 'Operation failed', unavailable: 'Status unavailable' },
|
||||
listeners: { title: 'Nodes', create: 'Create Node', edit: 'Edit Node', name: 'Name', protocol: 'Protocol', port: 'Port', bindAddress: 'Bind Address', status: 'Status', actions: 'Actions', config: 'Protocol Config', udp: 'UDP', tls: 'TLS', deleteConfirm: 'Delete this node?', created: 'Created', updated: 'Updated', deleted: 'Deleted', reloaded: 'Reloaded', copyURI: 'Copy subscription URI', urisTitle: 'Node subscription links', invalidJSON: 'Invalid JSON', selectProtocolFirst: 'Select a protocol first', usersHint: 'Create credentials under Users and bind them to this node. Only official Listener fields are configured here.', portHint: 'Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)', sectionProtocol: 'Protocol options', sectionTransport: 'Transport', sectionTLS: 'TLS certificate', sectionReality: 'Reality', sectionSSOption: 'Trojan SS option', cipher: 'Cipher', password: 'Password', passwordOptional: 'Password (optional)', uuid: 'UUID', privateKey: 'Private key', certificate: 'Certificate', alpn: 'ALPN', sni: 'SNI', clientFingerprint: 'Client fingerprint', allowInsecure: 'Allow insecure', muxOption: 'Mux', realityPrivateKey: 'Reality private key', realityShortID: 'Short ID', realityServerNames: 'Server names', realityDest: 'Dest', transport: 'Transport', path: 'Path', host: 'Host', serviceName: 'Service name', up: 'Up', down: 'Down', ignoreClientBandwidth: 'Ignore client bandwidth', masquerade: 'Masquerade', congestionControl: 'Congestion control' },
|
||||
listeners: { title: 'Nodes', create: 'Create Node', edit: 'Edit Node', name: 'Name', protocol: 'Protocol', port: 'Port', bindAddress: 'Bind Address', status: 'Status', actions: 'Actions', config: 'Protocol Config', udp: 'UDP', tls: 'TLS', deleteConfirm: 'Delete this node?', created: 'Created', updated: 'Updated', deleted: 'Deleted', reloaded: 'Reloaded', copyURI: 'Copy subscription URI', urisTitle: 'Node subscription links', invalidJSON: 'Invalid JSON', selectProtocolFirst: 'Select a protocol first', usersHint: 'Create credentials under Users and bind them to this node. Only official Listener fields are configured here.', portHint: 'Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)', sectionProtocol: 'Protocol options', sectionTransport: 'Transport', sectionTLS: 'TLS certificate', sectionReality: 'Reality', sectionSSOption: 'Trojan SS option', cipher: 'Cipher', password: 'Password', passwordOptional: 'Password (optional)', uuid: 'UUID', privateKey: 'Private key', certificate: 'Certificate', alpn: 'ALPN', sni: 'SNI', clientFingerprint: 'Client fingerprint', allowInsecure: 'Allow insecure', muxOption: 'Mux', realityPrivateKey: 'Reality private key', realityShortID: 'Short ID', realityServerNames: 'Server names', realityDest: 'Dest', transport: 'Transport', path: 'Path', host: 'Host', serviceName: 'Service name', up: 'Up', down: 'Down', ignoreClientBandwidth: 'Ignore client bandwidth', masquerade: 'Masquerade', congestionControl: 'Congestion control', clone: 'Clone', saveTemplate: 'Save as template', versions: 'Versions', cloned: 'Cloned', templateCreated: 'Template created', instantiated: 'Listener created from template', batchDone: 'Batch operation completed', templates: 'Listener Templates', templateName: 'Template name', createdAt: 'Created at', instantiate: 'Instantiate', deleteTemplateConfirm: 'Delete this template?', templateDeleted: 'Template deleted', enableSelected: 'Enable selected', disableSelected: 'Disable selected', newPort: 'New port', version: 'Version', reason: 'Reason', diff: 'Diff', rollback: 'Rollback', rollbackConfirm: 'Rollback to this version?', rollbackDone: 'Rollback completed' },
|
||||
core: { title: 'Core', status: 'Status', running: 'Running', stoppedStatus: 'Stopped', version: 'Version', pid: 'PID', uptime: 'Uptime', start: 'Start', stop: 'Stop', restart: 'Restart', started: 'Started', stopped: 'Stopped', restarted: 'Restarted' },
|
||||
logs: { title: 'Logs', empty: 'No logs', level: 'Level', timestamp: 'Time', payload: 'Payload', autoRefresh: 'Auto refresh', clear: 'Clear' },
|
||||
config: { title: 'Config Engine', subtitle: 'Visual Mihomo configuration', visual: 'Visual Config', yaml: 'YAML Config', proxies: 'Proxy Configuration', yamlPreview: 'YAML Preview', addProxy: 'Add Proxy', editProxy: 'Edit Proxy', deleteProxy: 'Delete Proxy', deleteConfirm: 'Delete this proxy?', generateSuccess: 'Configuration generated successfully', validateSuccess: 'Configuration is valid', validateFailed: 'Configuration validation failed', proxyName: 'Proxy Name', proxyType: 'Proxy Type', proxyServer: 'Proxy Server', proxyPort: 'Proxy Port', proxyPassword: 'Proxy Password', proxyUUID: 'Proxy UUID' },
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
export default {
|
||||
app: { name: '3M-UI', title: 'Mihomo 核心管理控制台' },
|
||||
nav: {
|
||||
dashboard: '仪表盘', listeners: '节点管理', users: '用户管理', traffic: '流量监控', cluster: '多机节点', routing: '路由规则', core: '核心管理', logs: '运行日志', config: '配置引擎', settings: '系统设置', logout: '退出登录',
|
||||
},
|
||||
common: {
|
||||
save: '保存', cancel: '取消', delete: '删除', edit: '编辑', create: '创建', confirm: '确认', close: '关闭', copy: '复制', download: '下载', refresh: '刷新', loading: '加载中…', empty: '暂无数据', search: '搜索', actions: '操作', status: '状态', enabled: '启用', disabled: '禁用', name: '名称', type: '类型', port: '端口', protocol: '协议', address: '地址', password: '密码', username: '用户名', submit: '提交', success: '操作成功', error: '操作失败', confirmDelete: '确定要删除吗?', back: '返回', generate: '生成', validate: '验证',
|
||||
},
|
||||
nav: { dashboard: '仪表盘', listeners: '节点管理', users: '用户管理', traffic: '流量监控', cluster: '多机节点', routing: '路由规则', core: '核心管理', logs: '运行日志', config: '配置引擎', settings: '系统设置', logout: '退出登录' },
|
||||
common: { save: '保存', cancel: '取消', delete: '删除', edit: '编辑', create: '创建', confirm: '确认', close: '关闭', copy: '复制', download: '下载', refresh: '刷新', loading: '加载中…', empty: '暂无数据', search: '搜索', actions: '操作', status: '状态', enabled: '启用', disabled: '禁用', name: '名称', type: '类型', port: '端口', protocol: '协议', address: '地址', password: '密码', username: '用户名', submit: '提交', success: '操作成功', error: '操作失败', confirmDelete: '确定要删除吗?', back: '返回', generate: '生成', validate: '验证' },
|
||||
login: { title: '3M-UI', subtitle: 'Mihomo 核心管理控制台', username: '用户名', password: '密码', button: '登录', welcomeBack: '欢迎回来', failed: '登录失败' },
|
||||
password: { title: '修改密码', current: '当前密码', new: '新密码', confirm: '确认新密码', button: '修改密码', success: '密码已更新', failed: '修改密码失败', mismatch: '两次输入的密码不一致' },
|
||||
dashboard: { title: '仪表盘', subtitle: '系统与核心状态概览', status: '核心状态', running: '运行中', stoppedStatus: '已停止', version: '版本', pid: '进程 ID', uptime: '运行时间', listeners: '节点统计', total: '总数', enabled: '已启用', disabled: '已禁用', system: '系统资源', cpu: 'CPU', memory: '内存', disk: '磁盘', network: '网络', upload: '上传', download: '下载', traffic: '流量统计', uploadRate: '上传速率', downloadRate: '下载速率', totalUpload: '总上传', totalDownload: '总下载', onlineUsers: '在线用户', activeConnections: '活跃连接', start: '启动', stop: '停止', restart: '重启', started: '核心已启动', stopped: '核心已停止', restarted: '核心已重启', operationFailed: '操作失败', unavailable: '无法获取状态' },
|
||||
listeners: { title: '节点管理', create: '创建节点', edit: '编辑节点', name: '名称', protocol: '协议', port: '端口', bindAddress: '绑定地址', status: '状态', actions: '操作', config: '协议配置', udp: 'UDP', tls: 'TLS', deleteConfirm: '确定要删除此节点吗?', created: '创建成功', updated: '更新成功', deleted: '删除成功', reloaded: '重载成功', copyURI: '复制订阅链接', urisTitle: '节点订阅链接', invalidJSON: '无效的 JSON', selectProtocolFirst: '请先选择协议', usersHint: '用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。', portHint: '支持单端口(如 443)或官方 ports 语法(如 200,204,401-429)', sectionProtocol: '协议选项', sectionTransport: '传输层', sectionTLS: 'TLS 证书', sectionReality: 'Reality', sectionSSOption: 'Trojan SS 混淆', cipher: '加密方法', password: '密码', passwordOptional: '密码(可选)', uuid: 'UUID', privateKey: '私钥', certificate: '证书', alpn: 'ALPN', sni: 'SNI', clientFingerprint: '客户端指纹', allowInsecure: '允许不安全', muxOption: '多路复用', realityPrivateKey: 'Reality 私钥', realityShortID: 'Short ID', realityServerNames: 'Server Names', realityDest: 'Dest', transport: '传输', path: '路径', host: 'Host', serviceName: 'Service Name', up: '上行', down: '下行', ignoreClientBandwidth: '忽略客户端带宽', masquerade: 'Masquerade', congestionControl: '拥塞控制' },
|
||||
listeners: { title: '节点管理', create: '创建节点', edit: '编辑节点', name: '名称', protocol: '协议', port: '端口', bindAddress: '绑定地址', status: '状态', actions: '操作', config: '协议配置', udp: 'UDP', tls: 'TLS', deleteConfirm: '确定要删除此节点吗?', created: '创建成功', updated: '更新成功', deleted: '删除成功', reloaded: '重载成功', copyURI: '复制订阅链接', urisTitle: '节点订阅链接', invalidJSON: '无效的 JSON', selectProtocolFirst: '请先选择协议', usersHint: '用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。', portHint: '支持单端口(如 443)或官方 ports 语法(如 200,204,401-429)', sectionProtocol: '协议选项', sectionTransport: '传输层', sectionTLS: 'TLS 证书', sectionReality: 'Reality', sectionSSOption: 'Trojan SS 混淆', cipher: '加密方法', password: '密码', passwordOptional: '密码(可选)', uuid: 'UUID', privateKey: '私钥', certificate: '证书', alpn: 'ALPN', sni: 'SNI', clientFingerprint: '客户端指纹', allowInsecure: '允许不安全', muxOption: '多路复用', realityPrivateKey: 'Reality 私钥', realityShortID: 'Short ID', realityServerNames: 'Server Names', realityDest: 'Dest', transport: '传输', path: '路径', host: 'Host', serviceName: 'Service Name', up: '上行', down: '下行', ignoreClientBandwidth: '忽略客户端带宽', masquerade: 'Masquerade', congestionControl: '拥塞控制', clone: '克隆', saveTemplate: '保存为模板', versions: '配置版本', cloned: '克隆成功', templateCreated: '模板创建成功', instantiated: '已从模板创建节点', batchDone: '批量操作完成', templates: '节点模板', templateName: '模板名称', createdAt: '创建时间', instantiate: '实例化', deleteTemplateConfirm: '确定删除此模板?', templateDeleted: '模板已删除', enableSelected: '启用选中', disableSelected: '禁用选中', newPort: '新端口', version: '版本', reason: '原因', diff: '配置差异', rollback: '回滚', rollbackConfirm: '确定回滚到此版本?', rollbackDone: '回滚完成' },
|
||||
core: { title: '核心管理', status: '核心状态', running: '运行中', stoppedStatus: '已停止', version: '版本', pid: '进程 ID', uptime: '运行时间', start: '启动', stop: '停止', restart: '重启', started: '已启动', stopped: '已停止', restarted: '已重启' },
|
||||
logs: { title: '运行日志', empty: '暂无日志', level: '级别', timestamp: '时间', payload: '内容', autoRefresh: '自动刷新', clear: '清空' },
|
||||
config: { title: '配置引擎', subtitle: '可视化管理 Mihomo 配置', visual: '可视化配置', yaml: 'YAML 配置', proxies: '代理配置', yamlPreview: 'YAML 预览', addProxy: '添加代理', editProxy: '编辑代理', deleteProxy: '删除代理', deleteConfirm: '确定要删除此代理吗?', generateSuccess: '配置生成成功', validateSuccess: '配置验证通过', validateFailed: '配置验证失败', proxyName: '代理名称', proxyType: '代理类型', proxyServer: '代理服务器', proxyPort: '代理端口', proxyPassword: '代理密码', proxyUUID: '代理 UUID' },
|
||||
@@ -17,7 +13,5 @@ export default {
|
||||
traffic: { title: '流量监控', upload: '上传', download: '下载', uploadRate: '上传速率', downloadRate: '下载速率', connections: '连接', onlineUsers: '在线用户', byUser: '用户流量', listener: '节点', network: '网络', rule: '规则' },
|
||||
cluster: { title: '多机节点', subtitle: '登记远程 3m-ui 面板并做健康检查(完整远程管控后续迭代)', create: '添加节点', edit: '编辑节点', name: '名称', baseUrl: '面板地址', apiToken: 'API Token', apiTokenHint: '可选;用于鉴权远程面板', remark: '备注', health: '健康检查', healthDone: '已探测', created: '已添加', updated: '已更新', deleted: '已删除' },
|
||||
routing: { title: '路由与策略组', subtitle: '管理 Mihomo proxy-groups 与 rules', groups: '策略组', rules: '规则列表', addGroup: '添加策略组', proxies: '节点列表', proxiesHint: '逗号分隔的 proxy / group 名称', rulesHint: '每行一条 Mihomo rule', rulesSaved: '规则已保存', groupSaved: '策略组已保存', groupDeleted: '策略组已删除' },
|
||||
settings: {
|
||||
title: '系统设置', subtitle: '偏好与账户', language: '界面语言', theme: '主题外观', light: '浅色', dark: '深色', system: '跟随系统', passwordTitle: '账户安全', passwordDescription: '定期修改密码可以提高账户安全性', changePassword: '前往修改密码', about: '关于 3M-UI', version: '版本', backup: '备份与恢复', backupHint: '下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。', downloadBackup: '下载备份', restoreDb: '恢复数据库', backupDone: '备份已下载', restoreDone: '数据库已恢复,请重启面板', apiDocs: 'API 文档', apiDocsHint: '用于自动化的 OpenAPI 3 规范。', openOpenAPI: '打开 openapi.yaml', telegram: 'Telegram 通知', botToken: 'Bot Token', chatIds: 'Chat IDs', telegramSaved: 'Telegram 设置已保存', telegramTest: '发送测试消息', telegramTestOk: '测试消息已发送', chatIdsHint: '多个 Chat ID 用逗号分隔', notifyBlock: '用户封禁时通知', notifyUnblock: '用户解封时通知', notifyExpiry: '用户过期时通知', notifyDailyDigest: '每天发送流量与用户摘要', templates: '证书与反代模板', proxyKind: '反代类型', domain: '域名', upstream: '上游地址', generateTemplate: '生成模板', templateGenerated: '模板已生成'
|
||||
},
|
||||
settings: { title: '系统设置', subtitle: '偏好与账户', language: '界面语言', theme: '主题外观', light: '浅色', dark: '深色', system: '跟随系统', passwordTitle: '账户安全', passwordDescription: '定期修改密码可以提高账户安全性', changePassword: '前往修改密码', about: '关于 3M-UI', version: '版本', backup: '备份与恢复', backupHint: '下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。', downloadBackup: '下载备份', restoreDb: '恢复数据库', backupDone: '备份已下载', restoreDone: '数据库已恢复,请重启面板', apiDocs: 'API 文档', apiDocsHint: '用于自动化的 OpenAPI 3 规范。', openOpenAPI: '打开 openapi.yaml', telegram: 'Telegram 通知', botToken: 'Bot Token', chatIds: 'Chat IDs', telegramSaved: 'Telegram 设置已保存', telegramTest: '发送测试消息', telegramTestOk: '测试消息已发送', chatIdsHint: '多个 Chat ID 用逗号分隔', notifyBlock: '用户封禁时通知', notifyUnblock: '用户解封时通知', notifyExpiry: '用户过期时通知', notifyDailyDigest: '每天发送流量与用户摘要', templates: '证书与反代模板', proxyKind: '反代类型', domain: '域名', upstream: '上游地址', generateTemplate: '生成模板', templateGenerated: '模板已生成' },
|
||||
};
|
||||
+163
-222
@@ -1,266 +1,207 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Space, Tag, Modal, Form, Input, Select, Switch, message, Popconfirm, Tooltip, Card } from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, QrcodeOutlined, DeleteOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener } from '../api/nodes';
|
||||
import { Table, Button, Space, Tag, Modal, Form, Input, Select, Switch, message, Popconfirm, Tooltip, Card, Tabs, Descriptions, Divider } from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, QrcodeOutlined, DeleteOutlined, EditOutlined, CopyOutlined, BranchesOutlined, HistoryOutlined, SaveOutlined, PoweroffOutlined, DiffOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener,
|
||||
} from '../api/nodes';
|
||||
import {
|
||||
listListenerTemplates, createListenerTemplate, deleteListenerTemplate, instantiateListenerTemplate,
|
||||
cloneListener, batchSetListenersEnabled, listListenerVersions, diffListenerVersion, rollbackListenerVersion,
|
||||
ListenerTemplate, ListenerVersion,
|
||||
} from '../api/listeners';
|
||||
import { useI18n } from '../i18n';
|
||||
import ListenerConfigFields, {
|
||||
configToFormValues,
|
||||
formValuesToConfig,
|
||||
protocolSupportsUDP,
|
||||
} from '../components/ListenerConfigFields';
|
||||
import ListenerConfigFields, { configToFormValues, formValuesToConfig, protocolSupportsUDP } from '../components/ListenerConfigFields';
|
||||
|
||||
const PROTOCOLS = [
|
||||
'shadowsocks', 'snell', 'vmess', 'vless', 'trojan',
|
||||
'hysteria2', 'tuic', 'shadowquic', 'anytls', 'mieru', 'sudoku', 'trusttunnel',
|
||||
];
|
||||
const PROTOCOLS = ['shadowsocks', 'snell', 'vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'shadowquic', 'anytls', 'mieru', 'sudoku', 'trusttunnel'];
|
||||
|
||||
const parseConfig = (raw?: string) => {
|
||||
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
|
||||
};
|
||||
|
||||
const Listeners: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<Listener[]>([]);
|
||||
const [templates, setTemplates] = useState<ListenerTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [templateLoading, setTemplateLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Listener | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [templateForm] = Form.useForm();
|
||||
const [cloneForm] = Form.useForm();
|
||||
const [instantiateForm] = Form.useForm();
|
||||
const [uris, setUris] = useState<string[]>([]);
|
||||
const [uriModal, setUriModal] = useState(false);
|
||||
const [cloneModal, setCloneModal] = useState(false);
|
||||
const [cloneSource, setCloneSource] = useState<Listener | null>(null);
|
||||
const [templateModal, setTemplateModal] = useState(false);
|
||||
const [templateSource, setTemplateSource] = useState<Listener | null>(null);
|
||||
const [instantiateModal, setInstantiateModal] = useState(false);
|
||||
const [instantiateSource, setInstantiateSource] = useState<ListenerTemplate | null>(null);
|
||||
const [versionsModal, setVersionsModal] = useState(false);
|
||||
const [versions, setVersions] = useState<ListenerVersion[]>([]);
|
||||
const [versionListener, setVersionListener] = useState<Listener | null>(null);
|
||||
const [diffModal, setDiffModal] = useState(false);
|
||||
const [diffText, setDiffText] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const protocol = Form.useWatch('protocol', form);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchListeners();
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
try { setData(await fetchListeners()); } catch (e: any) { message.error(e.message); } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
const loadTemplates = async () => {
|
||||
setTemplateLoading(true);
|
||||
try { setTemplates(await listListenerTemplates()); } catch (e: any) { message.error(e.message); } finally { setTemplateLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); loadTemplates(); }, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
bind_address: '0.0.0.0',
|
||||
enabled: true,
|
||||
udp: false,
|
||||
});
|
||||
setEditing(null); form.resetFields();
|
||||
form.setFieldsValue({ bind_address: '0.0.0.0', enabled: true, udp: false });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: Listener) => {
|
||||
setEditing(record);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
protocol: record.protocol,
|
||||
port: record.port,
|
||||
bind_address: record.bind_address || '0.0.0.0',
|
||||
enabled: record.enabled,
|
||||
udp: record.udp,
|
||||
...configToFormValues(record.config),
|
||||
});
|
||||
setEditing(record); form.resetFields();
|
||||
form.setFieldsValue({ name: record.name, protocol: record.protocol, port: record.port, bind_address: record.bind_address || '0.0.0.0', enabled: record.enabled, udp: record.udp, ...configToFormValues(record.config) });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
try {
|
||||
const proto = values.protocol as string;
|
||||
let previous: Record<string, any> | null = null;
|
||||
if (editing?.config) {
|
||||
try {
|
||||
previous = JSON.parse(editing.config);
|
||||
} catch {
|
||||
previous = null;
|
||||
}
|
||||
}
|
||||
const previous = editing ? parseConfig(editing.config) : null;
|
||||
const config = formValuesToConfig(proto, values, previous);
|
||||
|
||||
const payload: Partial<Listener> = {
|
||||
name: values.name,
|
||||
protocol: proto,
|
||||
port: String(values.port),
|
||||
bind_address: values.bind_address || '0.0.0.0',
|
||||
enabled: !!values.enabled,
|
||||
udp: protocolSupportsUDP(proto) ? !!values.udp : false,
|
||||
config: JSON.stringify(config),
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateListener(editing.id, payload);
|
||||
message.success(t('listeners.updated'));
|
||||
} else {
|
||||
await createListener(payload);
|
||||
message.success(t('listeners.created'));
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
const payload: Partial<Listener> = { name: values.name, protocol: proto, port: String(values.port), bind_address: values.bind_address || '0.0.0.0', enabled: !!values.enabled, udp: protocolSupportsUDP(proto) ? !!values.udp : false, config: JSON.stringify(config) };
|
||||
if (editing) { await updateListener(editing.id, payload); message.success(t('listeners.updated')); }
|
||||
else { await createListener(payload); message.success(t('listeners.created')); }
|
||||
setModalOpen(false); setEditing(null); form.resetFields(); await load();
|
||||
} catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const onDelete = async (id: number) => { try { await deleteListener(id); message.success(t('listeners.deleted')); await load(); } catch (e: any) { message.error(e.message); } };
|
||||
const onReload = async (id: number) => { try { await reloadListener(id); message.success(t('listeners.reloaded')); await load(); } catch (e: any) { message.error(e.message); } };
|
||||
const showURIs = async (id: number) => { try { const res = await exportNodeURI(id); setUris(res.uris); setUriModal(true); } catch (e: any) { message.error(e.message); } };
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteListener(id);
|
||||
message.success(t('listeners.deleted'));
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
const openClone = (record: Listener) => { setCloneSource(record); cloneForm.setFieldsValue({ name: `${record.name}-copy`, port: '' }); setCloneModal(true); };
|
||||
const doClone = async (values: { name: string; port: string }) => {
|
||||
if (!cloneSource) return;
|
||||
try { await cloneListener(cloneSource.id, { name: values.name, port: values.port }); message.success(t('listeners.cloned')); setCloneModal(false); await load(); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
|
||||
const onReload = async (id: number) => {
|
||||
try {
|
||||
await reloadListener(id);
|
||||
message.success(t('listeners.reloaded'));
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
const openSaveTemplate = (record: Listener) => { setTemplateSource(record); templateForm.setFieldsValue({ name: `${record.name} template` }); setTemplateModal(true); };
|
||||
const saveTemplate = async (values: { name: string }) => {
|
||||
if (!templateSource) return;
|
||||
try { await createListenerTemplate({ name: values.name, protocol: templateSource.protocol, config: templateSource.config }); message.success(t('listeners.templateCreated')); setTemplateModal(false); await loadTemplates(); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
|
||||
const showURIs = async (id: number) => {
|
||||
try {
|
||||
const res = await exportNodeURI(id);
|
||||
setUris(res.uris);
|
||||
setUriModal(true);
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
const openInstantiate = (template: ListenerTemplate) => { setInstantiateSource(template); instantiateForm.setFieldsValue({ name: template.name.replace(/\s+template$/i, ''), port: '' }); setInstantiateModal(true); };
|
||||
const doInstantiate = async (values: { name: string; port: string }) => {
|
||||
if (!instantiateSource) return;
|
||||
try { await instantiateListenerTemplate(instantiateSource.id, values); message.success(t('listeners.instantiated')); setInstantiateModal(false); await load(); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const batchEnabled = async (enabled: boolean) => {
|
||||
const ids = selectedRowKeys.map(Number); if (!ids.length) return;
|
||||
try { await batchSetListenersEnabled(ids, enabled); message.success(t('listeners.batchDone')); setSelectedRowKeys([]); await load(); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const openVersions = async (record: Listener) => {
|
||||
try { setVersionListener(record); setVersions(await listListenerVersions(record.id)); setVersionsModal(true); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const showDiff = async (version: number) => {
|
||||
if (!versionListener) return;
|
||||
try { setDiffText(await diffListenerVersion(versionListener.id, version)); setDiffModal(true); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const doRollback = async (version: number) => {
|
||||
if (!versionListener) return;
|
||||
try { await rollbackListenerVersion(versionListener.id, version); message.success(t('listeners.rollbackDone')); setVersions(await listListenerVersions(versionListener.id)); await load(); } catch (e: any) { message.error(e.message); }
|
||||
};
|
||||
const deleteTemplate = async (id: number) => { try { await deleteListenerTemplate(id); message.success(t('listeners.templateDeleted')); await loadTemplates(); } catch (e: any) { message.error(e.message); } };
|
||||
|
||||
const columns = [
|
||||
{ title: t('listeners.name'), dataIndex: 'name', key: 'name', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: t('listeners.protocol'),
|
||||
dataIndex: 'protocol',
|
||||
key: 'protocol',
|
||||
width: 110,
|
||||
render: (p: string) => <Tag>{p}</Tag>,
|
||||
},
|
||||
{ title: t('listeners.port'), dataIndex: 'port', key: 'port', width: 90 },
|
||||
{
|
||||
title: t('listeners.status'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'success' : 'default'}>{v ? t('common.enabled') : t('common.disabled')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 180,
|
||||
render: (_: any, record: Listener) => (
|
||||
<Space size={4} wrap>
|
||||
<Tooltip title={t('listeners.copyURI')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => showURIs(record.id)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.refresh')}>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => onReload(record.id)} />
|
||||
</Tooltip>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
<Popconfirm title={t('listeners.deleteConfirm')} onConfirm={() => onDelete(record.id)}>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('listeners.name'), dataIndex: 'name', key: 'name', ellipsis: true, width: 150 },
|
||||
{ title: t('listeners.protocol'), dataIndex: 'protocol', key: 'protocol', width: 110, render: (p: string) => <Tag>{p}</Tag> },
|
||||
{ title: t('listeners.port'), dataIndex: 'port', key: 'port', width: 100 },
|
||||
{ title: t('listeners.status'), dataIndex: 'enabled', key: 'enabled', width: 100, render: (v: boolean) => <Tag color={v ? 'success' : 'default'}>{v ? t('common.enabled') : t('common.disabled')}</Tag> },
|
||||
{ title: t('common.actions'), key: 'actions', fixed: 'right' as const, width: 300, render: (_: any, record: Listener) => (
|
||||
<Space size={4} wrap>
|
||||
<Tooltip title={t('listeners.copyURI')}><Button size="small" icon={<QrcodeOutlined />} onClick={() => showURIs(record.id)} /></Tooltip>
|
||||
<Tooltip title={t('listeners.clone')}><Button size="small" icon={<BranchesOutlined />} onClick={() => openClone(record)} /></Tooltip>
|
||||
<Tooltip title={t('listeners.saveTemplate')}><Button size="small" icon={<SaveOutlined />} onClick={() => openSaveTemplate(record)} /></Tooltip>
|
||||
<Tooltip title={t('listeners.versions')}><Button size="small" icon={<HistoryOutlined />} onClick={() => openVersions(record)} /></Tooltip>
|
||||
<Tooltip title={t('common.refresh')}><Button size="small" icon={<ReloadOutlined />} onClick={() => onReload(record.id)} /></Tooltip>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
<Popconfirm title={t('listeners.deleteConfirm')} onConfirm={() => onDelete(record.id)}><Button size="small" icon={<DeleteOutlined />} danger /></Popconfirm>
|
||||
</Space>
|
||||
) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{t('listeners.title')}</h2>
|
||||
<Card
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('listeners.create')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table dataSource={data} columns={columns} rowKey="id" loading={loading} scroll={{ x: 720 }} size="middle" />
|
||||
</Card>
|
||||
const templateColumns = [
|
||||
{ title: t('listeners.templateName'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('listeners.protocol'), dataIndex: 'protocol', key: 'protocol', render: (p: string) => <Tag>{p}</Tag> },
|
||||
{ title: t('listeners.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{ title: t('common.actions'), key: 'actions', width: 210, render: (_: any, record: ListenerTemplate) => (
|
||||
<Space><Button size="small" type="primary" onClick={() => openInstantiate(record)}>{t('listeners.instantiate')}</Button><Popconfirm title={t('listeners.deleteTemplateConfirm')} onConfirm={() => deleteTemplate(record.id)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm></Space>
|
||||
) },
|
||||
];
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? t('listeners.edit') : t('listeners.create')}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
width={typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 720}
|
||||
destroyOnClose
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="protocol" label={t('listeners.protocol')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
|
||||
onChange={() => {
|
||||
// Clear protocol-specific fields when protocol changes
|
||||
const keep = form.getFieldsValue(['name', 'protocol', 'port', 'bind_address', 'enabled', 'udp']);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(keep);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('listeners.port')}
|
||||
rules={[{ required: true }]}
|
||||
tooltip={t('listeners.portHint')}
|
||||
>
|
||||
<Input placeholder="443" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bind_address" label={t('listeners.bindAddress')} initialValue="0.0.0.0">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label={t('listeners.status')} valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{protocolSupportsUDP(protocol) && (
|
||||
<Form.Item name="udp" label={t('listeners.udp')} valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
return <div>
|
||||
<Tabs defaultActiveKey="listeners" items={[
|
||||
{
|
||||
key: 'listeners', label: t('listeners.title'), children: <>
|
||||
<Card title={t('listeners.title')} extra={<Space>
|
||||
{selectedRowKeys.length > 0 && <><Button icon={<PoweroffOutlined />} onClick={() => batchEnabled(true)}>{t('listeners.enableSelected')}</Button><Button icon={<PoweroffOutlined />} onClick={() => batchEnabled(false)}>{t('listeners.disableSelected')}</Button></>}
|
||||
<Button onClick={() => { load(); }} icon={<ReloadOutlined />}>{t('common.refresh')}</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t('listeners.create')}</Button>
|
||||
</Space>}>
|
||||
<Table rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} dataSource={data} columns={columns} rowKey="id" loading={loading} scroll={{ x: 1050 }} size="middle" />
|
||||
</Card>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
key: 'templates', label: t('listeners.templates'), children: <Card title={t('listeners.templates')} extra={<Button icon={<ReloadOutlined />} onClick={loadTemplates}>{t('common.refresh')}</Button>}>
|
||||
<Table dataSource={templates} columns={templateColumns} rowKey="id" loading={templateLoading} pagination={{ pageSize: 10 }} />
|
||||
</Card>,
|
||||
},
|
||||
]} />
|
||||
|
||||
<ListenerConfigFields protocol={protocol} />
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal open={modalOpen} title={editing ? t('listeners.edit') : t('listeners.create')} onCancel={() => { setModalOpen(false); setEditing(null); form.resetFields(); }} onOk={() => form.submit()} width={typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 720} destroyOnClose styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="protocol" label={t('listeners.protocol')} rules={[{ required: true }]}><Select options={PROTOCOLS.map(p => ({ value: p, label: p }))} onChange={() => { const keep = form.getFieldsValue(['name', 'protocol', 'port', 'bind_address', 'enabled', 'udp']); form.resetFields(); form.setFieldsValue(keep); }} /></Form.Item>
|
||||
<Form.Item name="port" label={t('listeners.port')} rules={[{ required: true }]} tooltip={t('listeners.portHint')}><Input placeholder="443" /></Form.Item>
|
||||
<Form.Item name="bind_address" label={t('listeners.bindAddress')} initialValue="0.0.0.0"><Input /></Form.Item>
|
||||
<Form.Item name="enabled" label={t('listeners.status')} valuePropName="checked" initialValue={true}><Switch /></Form.Item>
|
||||
{protocolSupportsUDP(protocol) && <Form.Item name="udp" label={t('listeners.udp')} valuePropName="checked" initialValue={false}><Switch /></Form.Item>}
|
||||
<ListenerConfigFields protocol={protocol} />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={uriModal} title={t('listeners.urisTitle')} onCancel={() => setUriModal(false)} footer={null}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{uris.map((uri, i) => (
|
||||
<Card key={i} size="small">
|
||||
<Space>
|
||||
<Input value={uri} readOnly style={{ width: 400 }} />
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
message.success(t('common.copy'));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
<Modal open={cloneModal} title={t('listeners.clone')} onCancel={() => setCloneModal(false)} onOk={() => cloneForm.submit()}>
|
||||
<Form form={cloneForm} layout="vertical" onFinish={doClone}><Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="port" label={t('listeners.newPort')} rules={[{ required: true }]}><Input placeholder="443" /></Form.Item></Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={templateModal} title={t('listeners.saveTemplate')} onCancel={() => setTemplateModal(false)} onOk={() => templateForm.submit()}>
|
||||
<Form form={templateForm} layout="vertical" onFinish={saveTemplate}><Form.Item name="name" label={t('listeners.templateName')} rules={[{ required: true }]}><Input /></Form.Item><Descriptions column={1} size="small"><Descriptions.Item label={t('listeners.protocol')}>{templateSource?.protocol}</Descriptions.Item></Descriptions>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={instantiateModal} title={t('listeners.instantiate')} onCancel={() => setInstantiateModal(false)} onOk={() => instantiateForm.submit()}>
|
||||
<Form form={instantiateForm} layout="vertical" onFinish={doInstantiate}><Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="port" label={t('listeners.newPort')} rules={[{ required: true }]}><Input placeholder="443" /></Form.Item></Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={versionsModal} title={`${t('listeners.versions')} — ${versionListener?.name || ''}`} onCancel={() => setVersionsModal(false)} footer={null} width={800}>
|
||||
<Table dataSource={versions} rowKey="id" pagination={false} columns={[
|
||||
{ title: t('listeners.version'), dataIndex: 'version', width: 100 },
|
||||
{ title: t('listeners.reason'), dataIndex: 'reason', render: (v: string) => v || '-' },
|
||||
{ title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() },
|
||||
{ title: t('common.actions'), render: (_: any, v: ListenerVersion) => <Space><Button size="small" icon={<DiffOutlined />} onClick={() => showDiff(v.version)}>{t('listeners.diff')}</Button><Popconfirm title={t('listeners.rollbackConfirm')} onConfirm={() => doRollback(v.version)}><Button size="small" type="primary">{t('listeners.rollback')}</Button></Popconfirm></Space> },
|
||||
]} />
|
||||
</Modal>
|
||||
|
||||
<Modal open={diffModal} title={t('listeners.diff')} onCancel={() => setDiffModal(false)} footer={null} width={900}>
|
||||
<pre style={{ maxHeight: '65vh', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{diffText || t('common.empty')}</pre>
|
||||
</Modal>
|
||||
|
||||
<Modal open={uriModal} title={t('listeners.urisTitle')} onCancel={() => setUriModal(false)} footer={null}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>{uris.map((uri, i) => <Card key={i} size="small"><Space style={{ width: '100%' }}><Input value={uri} readOnly /><Button icon={<CopyOutlined />} onClick={() => { navigator.clipboard.writeText(uri); message.success(t('common.copy')); }} /></Space></Card>)}</Space>
|
||||
</Modal>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default Listeners;
|
||||
Reference in new issue
Block a user