Merge branch 'main' into fix/telegram-p0-p1-hardening

This commit is contained in:
風雪 authored and GitHub committed 2026-08-17 12:55:50 +08:00
commit d6545ede90
22 files changed
+831 -821

No files matched your search

+3 -2
View File
@@ -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:
+108 -15
View File
@@ -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 / 回滚配置 |
---
+4 -14
View File
@@ -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"`
}
+22 -108
View File
@@ -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)}
+40
View File
@@ -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)
}
}
}
+25
View File
@@ -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}
+21
View File
@@ -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") }
}
+40 -8
View File
@@ -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 }
+103 -5
View File
@@ -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
}
+81 -52
View File
@@ -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)
}
}
+41 -92
View File
@@ -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
}
+38
View File
@@ -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) }
}
+9 -128
View File
@@ -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"}) }
+18 -156
View File
@@ -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
}
+29
View File
@@ -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);
+1 -1
View File
@@ -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' },
+4 -10
View File
@@ -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
View File
@@ -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;