update
update
update
update
update
update
update
update
update
update
update
This commit is contained in:
風雪 committed 2026-08-19 12:49:36 +08:00
1 parent 629363f152
commit 6b3a09a09c
22 files changed
+1287 -157

No files matched your search

+4 -2
View File
@@ -1,9 +1,11 @@
name: Release · Ant Design
on:
# Tag pushes are handled by release.yml (all editions in one Release).
# Use this workflow only to rebuild a single edition manually.
workflow_dispatch:
inputs:
tag:
description: Release tag
description: Release tag (e.g. v0.1.0)
required: true
type: string
permissions:
@@ -62,6 +64,6 @@ jobs:
path: artifacts
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.ref_name }}
generate_release_notes: true
files: artifacts/**/*.tar.gz
+4 -2
View File
@@ -1,9 +1,11 @@
name: Release · Mantine
on:
# Tag pushes are handled by release.yml (all editions in one Release).
# Use this workflow only to rebuild a single edition manually.
workflow_dispatch:
inputs:
tag:
description: Release tag
description: Release tag (e.g. v0.1.0-mantine)
required: true
type: string
permissions: {contents: write}
@@ -51,6 +53,6 @@ jobs:
with: {path: artifacts}
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.ref_name }}
generate_release_notes: true
files: artifacts/**/*.tar.gz
+4 -2
View File
@@ -1,9 +1,11 @@
name: Release · Material UI
on:
# Tag pushes are handled by release.yml (all editions in one Release).
# Use this workflow only to rebuild a single edition manually.
workflow_dispatch:
inputs:
tag:
description: Release tag
description: Release tag (e.g. v0.1.0-mui)
required: true
type: string
permissions: {contents: write}
@@ -51,6 +53,6 @@ jobs:
with: {path: artifacts}
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.ref_name }}
generate_release_notes: true
files: artifacts/**/*.tar.gz
+4 -2
View File
@@ -1,9 +1,11 @@
name: Release · shadcn
on:
# Tag pushes are handled by release.yml (all editions in one Release).
# Use this workflow only to rebuild a single edition manually.
workflow_dispatch:
inputs:
tag:
description: Release tag
description: Release tag (e.g. v0.1.0-shadcn)
required: true
type: string
permissions: {contents: write}
@@ -51,6 +53,6 @@ jobs:
with: {path: artifacts}
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
tag_name: ${{ inputs.tag || github.ref_name }}
generate_release_notes: true
files: artifacts/**/*.tar.gz
+140 -10
View File
@@ -1,17 +1,147 @@
name: Release (legacy)
# Deprecated: releases are now split by frontend edition.
# Use release-ant.yml, release-mui.yml, release-mantine.yml or release-shadcn.yml.
name: Release
# Builds all UI editions and publishes one GitHub Release with every artifact.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: Release tag (e.g. v0.1.0). Required for manual runs on a branch.
required: false
type: string
permissions:
contents: read
contents: write
jobs:
notice:
build:
strategy:
fail-fast: false
matrix:
include:
# Ant Design (primary)
- edition: antd
frontend_dir: frontend
goarch: amd64
artifact: linux-amd64-antd
- edition: antd
frontend_dir: frontend
goarch: arm64
artifact: linux-arm64-antd
- edition: antd
frontend_dir: frontend
goarch: arm
goarm: '7'
artifact: linux-armv7-antd
# Material UI
- edition: mui
frontend_dir: frontend-variants/mui
goarch: amd64
artifact: linux-amd64-mui
- edition: mui
frontend_dir: frontend-variants/mui
goarch: arm64
artifact: linux-arm64-mui
- edition: mui
frontend_dir: frontend-variants/mui
goarch: arm
goarm: '7'
artifact: linux-armv7-mui
# Mantine
- edition: mantine
frontend_dir: frontend-variants/mantine
goarch: amd64
artifact: linux-amd64-mantine
- edition: mantine
frontend_dir: frontend-variants/mantine
goarch: arm64
artifact: linux-arm64-mantine
- edition: mantine
frontend_dir: frontend-variants/mantine
goarch: arm
goarm: '7'
artifact: linux-armv7-mantine
# shadcn
- edition: shadcn
frontend_dir: frontend-variants/shadcn
goarch: amd64
artifact: linux-amd64-shadcn
- edition: shadcn
frontend_dir: frontend-variants/shadcn
goarch: arm64
artifact: linux-arm64-shadcn
- edition: shadcn
frontend_dir: frontend-variants/shadcn
goarch: arm
goarm: '7'
artifact: linux-armv7-shadcn
runs-on: ubuntu-latest
steps:
- run: |
echo 'This workflow is deprecated.'
echo 'Use one of the edition-specific release workflows instead.'
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-go@v5
with:
go-version: '1.25.x'
- name: Build frontend (${{ matrix.edition }})
working-directory: ${{ matrix.frontend_dir }}
run: npm install && npm run build
- name: Embed frontend
run: |
rm -rf backend/cmd/server/web/dist
cp -r "${{ matrix.frontend_dir }}/dist" backend/cmd/server/web/dist
- name: Build backend
working-directory: backend
env:
GOOS: linux
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
run: go build -trimpath -ldflags='-s -w' -o "../3m-ui-${{ matrix.artifact }}" ./cmd/server
- name: Archive
run: tar -czf "3m-ui-${{ matrix.artifact }}.tar.gz" "3m-ui-${{ matrix.artifact }}"
- uses: actions/upload-artifact@v4
with:
name: archive-${{ matrix.artifact }}
path: 3m-ui-${{ matrix.artifact }}.tar.gz
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List artifacts
run: find artifacts -type f | sort
- name: Resolve tag
id: meta
run: |
if [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
elif [ "${{ github.ref_type }}" = "tag" ]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag is required for workflow_dispatch on a branch" >&2
exit 1
fi
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: ${{ steps.meta.outputs.tag }}
generate_release_notes: true
body: |
## Packages
All UI editions are included in this release:
| File pattern | Frontend |
|---|---|
| `3m-ui-linux-*-antd.tar.gz` | Ant Design (primary) |
| `3m-ui-linux-*-mui.tar.gz` | Material UI |
| `3m-ui-linux-*-mantine.tar.gz` | Mantine |
| `3m-ui-linux-*-shadcn.tar.gz` | shadcn / Tailwind |
Architectures: `amd64`, `arm64`, `armv7`.
files: artifacts/**/*.tar.gz
+42
View File
@@ -42,6 +42,27 @@
每个版本独立打包 Linux `amd64`、`arm64` 和 `armv7`。选择的前端只在 CI 构建目录中嵌入 Go 二进制,不会把生成产物自动提交回 `main`。
### 发布(推送标签)
推送任意 `v*` 标签会触发统一 **Release** 工作流,一次构建全部 UI 版本,并挂到**同一个** GitHub Release 下:
| 产物后缀 | 前端 |
|---|---|
| `*-antd` | Ant Design(主版) |
| `*-mui` | Material UI |
| `*-mantine` | Mantine |
| `*-shadcn` | shadcn |
每个版本均提供 `linux-amd64` / `arm64` / `armv7`。
```bash
git tag v0.1.0
git push origin v0.1.0
# → 一个 Release,内含 12 个 tar.gz(4 前端 × 3 架构)
```
也可在 Actions 中手动运行 **Release**。单前端重建仍可用 `Release · Ant Design` 等独立 workflow(仅 `workflow_dispatch`)。
详细说明见 [`frontend-variants/README.md`](frontend-variants/README.md)。
## 架构
@@ -283,6 +304,27 @@ The 3M-UI backend, API contract and configuration model are decoupled from the U
Each edition produces separate Linux `amd64`, `arm64` and `armv7` packages. The selected frontend is embedded only in the CI workspace before the Go binary is built; generated assets are not automatically committed back to `main`.
### Release (push tags)
Pushing any `v*` tag runs the unified **Release** workflow: all UI editions are built and attached to **one** GitHub Release:
| Artifact suffix | Frontend |
|---|---|
| `*-antd` | Ant Design (primary) |
| `*-mui` | Material UI |
| `*-mantine` | Mantine |
| `*-shadcn` | shadcn |
Each edition ships `linux-amd64` / `arm64` / `armv7`.
```bash
git tag v0.1.0
git push origin v0.1.0
# → one Release with 12 tar.gz files (4 frontends × 3 archs)
```
You can also run **Release** manually in Actions. Single-edition rebuilds remain available via the `Release · …` workflows (`workflow_dispatch` only).
See [`frontend-variants/README.md`](frontend-variants/README.md) for details.
## Architecture
+2 -2
View File
@@ -25,9 +25,9 @@ func TestIsMihomoListenerProtocolRejectsInboundOnlyTypes(t *testing.T) {
func TestValidateRejectsDefaultSecrets(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: ServerConfig{Port: 8080},
Database: DatabaseConfig{Path: "test.db"},
JWT: JWTConfig{Secret: DefaultJWTSecret},
JWT: JWTConfig{Secret: DefaultJWTSecret},
Security: SecurityConfig{CredentialKey: "a-unique-credential-key-that-is-long-enough"},
}
if err := Validate(cfg); err == nil {
@@ -11,7 +11,7 @@ func TestListenerToProxiesDoesNotLeakServerTLSSecrets(t *testing.T) {
listener := models.Listener{
Name: "vless-reality",
Protocol: "vless",
Port: "443",
Port: "443",
Enabled: true,
Config: `{"certificate":"/etc/server.crt","private-key":"/etc/server.key","flow":"xtls-rprx-vision","ws-path":"/ws","reality-config":{"private-key":"server-secret","short-id":["0123456789abcdef"]}}`,
}
@@ -9,16 +9,16 @@ type ProxyUser struct {
Username string `gorm:"size:100;not null;uniqueIndex" json:"username"`
// PasswordEncrypted contains an AES-GCM encrypted password. It is never serialized to API responses.
PasswordEncrypted string `gorm:"type:text" json:"-"`
UUID string `gorm:"size:64;not null;uniqueIndex" json:"-"`
TrafficLimit int64 `gorm:"not null;default:0" json:"traffic_limit"`
TrafficUsed int64 `gorm:"not null;default:0" json:"traffic_used"`
UploadBytes int64 `gorm:"not null;default:0" json:"upload_bytes"`
DownloadBytes int64 `gorm:"not null;default:0" json:"download_bytes"`
PasswordEncrypted string `gorm:"type:text" json:"-"`
UUID string `gorm:"size:64;not null;uniqueIndex" json:"-"`
TrafficLimit int64 `gorm:"not null;default:0" json:"traffic_limit"`
TrafficUsed int64 `gorm:"not null;default:0" json:"traffic_used"`
UploadBytes int64 `gorm:"not null;default:0" json:"upload_bytes"`
DownloadBytes int64 `gorm:"not null;default:0" json:"download_bytes"`
LastSeen *time.Time `json:"last_seen"`
Online bool `gorm:"not null;default:false" json:"online"`
ExpireTime time.Time `json:"expire_time"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Online bool `gorm:"not null;default:false" json:"online"`
ExpireTime time.Time `json:"expire_time"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
// IPLimit is max concurrent client IPs (0 = unlimited). Parity with 3x-ui client limitIP.
IPLimit int `gorm:"not null;default:0" json:"ip_limit"`
// Remark is an admin-facing note (not used for auth).
+263 -22
View File
@@ -1,24 +1,265 @@
package listener
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)}
import (
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"net/http"
"strconv"
)
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)
}
+2 -2
View File
@@ -15,7 +15,7 @@ func TestGenerateConfigYAML(t *testing.T) {
Protocol: "shadowsocks",
Listen: "0.0.0.0",
BindAddress: "0.0.0.0",
Port: "1080",
Port: "1080",
Enabled: true,
UDP: true,
Config: `{"cipher":"aes-256-gcm","password":"pass"}`,
@@ -24,7 +24,7 @@ func TestGenerateConfigYAML(t *testing.T) {
Name: "disabled",
Type: "vless",
Listen: "127.0.0.1",
Port: "1081",
Port: "1081",
Enabled: false,
},
}
+263 -13
View File
@@ -10,16 +10,266 @@ import (
"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}
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
}
+12 -4
View File
@@ -8,12 +8,20 @@ import (
func TestValidateModel(t *testing.T) {
valid := &models.Listener{Name: "vless", Protocol: "VLESS", Port: "443", Config: `{"flow":"xtls-rprx-vision"}`}
if err := ValidateModel(valid); err != nil { t.Fatalf("valid listener rejected: %v", err) }
if valid.Protocol != "vless" { t.Fatalf("protocol was not normalized: %q", valid.Protocol) }
if err := ValidateModel(valid); err != nil {
t.Fatalf("valid listener rejected: %v", err)
}
if valid.Protocol != "vless" {
t.Fatalf("protocol was not normalized: %q", valid.Protocol)
}
invalid := &models.Listener{Name: "bad", Protocol: "vless", Type: "trojan", Port: "443"}
if err := ValidateModel(invalid); err == nil { t.Fatal("protocol/type mismatch was accepted") }
if err := ValidateModel(invalid); err == nil {
t.Fatal("protocol/type mismatch was accepted")
}
reserved := &models.Listener{Name: "bad-config", Protocol: "vless", Port: "443", Config: `{"port":8443}`}
if err := ValidateModel(reserved); err == nil { t.Fatal("reserved config field was accepted") }
if err := ValidateModel(reserved); err == nil {
t.Fatal("reserved config field was accepted")
}
}
@@ -55,7 +55,7 @@ func TestConfigEngineGeneration(t *testing.T) {
Name: "hk-gate",
Type: "mixed",
Listen: "127.0.0.1",
Port: "8081",
Port: "8081",
Enabled: true,
}
if err := db.Create(&listener).Error; err != nil {
+84 -23
View File
@@ -8,23 +8,62 @@ import (
"github.com/kazeyukiro/3m-ui/backend/internal/config"
)
type Service struct { pm *ProcessManager; cm *ConfigManager }
type Service struct {
pm *ProcessManager
cm *ConfigManager
}
func NewService(cfg *config.Config) *Service {
if cfg == nil { return &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() }
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 }
if s.pm == nil { return nil }
if err := s.pm.ValidateConfig(); err != nil { if readErr == nil { _ = s.cm.SaveConfig(old) }; return err }
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
}
@@ -32,17 +71,29 @@ func (s *Service) SaveConfig(content string) error {
// live file is changed, the current file is copied to <config>.bak. If
// validation or start/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 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
}
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) }
if readErr == nil {
_ = s.cm.SaveConfig(old)
}
return fmt.Errorf("validate Mihomo configuration: %w", err)
}
if !wasRunning {
@@ -58,7 +109,9 @@ func (s *Service) ApplyConfig(content string) error {
}
if err := s.pm.Restart(); err != nil {
if readErr == nil {
if restoreErr := s.cm.SaveConfig(old); restoreErr == nil { _ = s.pm.Restart() }
if restoreErr := s.cm.SaveConfig(old); restoreErr == nil {
_ = s.pm.Restart()
}
}
return fmt.Errorf("apply Mihomo configuration: %w", err)
}
@@ -66,16 +119,24 @@ func (s *Service) ApplyConfig(content string) error {
}
func (s *Service) RollbackConfig() 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")
}
backup, err := os.ReadFile(s.cm.configPath + ".bak")
if err != nil { return fmt.Errorf("read Mihomo config backup: %w", err) }
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
}
+33 -11
View File
@@ -11,28 +11,50 @@ func TestApplyConfigCreatesBackupAndRollbackRestoresPreviousConfig(t *testing.T)
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) }
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) }
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) }
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 != 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) }
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) }
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) }
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) }
if err := svc.ApplyConfig("mixed-port: 7892\n"); err != nil {
t.Fatalf("ApplyConfig: %v", err)
}
}
+1 -1
View File
@@ -12,9 +12,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/protocol"
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/protocol"
"github.com/kazeyukiro/3m-ui/backend/internal/user"
"gorm.io/gorm"
)
+4 -4
View File
@@ -13,7 +13,7 @@ func TestValidateNode(t *testing.T) {
n1 := models.Listener{
Name: "hk-ss",
Protocol: "shadowsocks",
Port: "8388",
Port: "8388",
BindAddress: "0.0.0.0",
Config: `{"password": "mypassword"}`,
}
@@ -26,7 +26,7 @@ func TestValidateNode(t *testing.T) {
n2 := models.Listener{
Name: "hk-ss",
Protocol: "shadowsocks",
Port: "8388",
Port: "8388",
BindAddress: "0.0.0.0",
Config: `{}`,
}
@@ -38,7 +38,7 @@ func TestValidateNode(t *testing.T) {
n3 := models.Listener{
Name: "hk-ss",
Protocol: "shadowsocks",
Port: "99999",
Port: "99999",
BindAddress: "0.0.0.0",
Config: `{"password": "pass"}`,
}
@@ -52,7 +52,7 @@ func TestGenerateConfigYAML(t *testing.T) {
{
Name: "hk-ss",
Protocol: "shadowsocks",
Port: "8388",
Port: "8388",
BindAddress: "0.0.0.0",
Enabled: true,
Config: `{"password": "secretpwd"}`,
+1 -1
View File
@@ -3,8 +3,8 @@ package protocol
import (
"strings"
"gorm.io/gorm"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"gorm.io/gorm"
)
// AccessProfile is the public-facing endpoint used when generating share links / client YAML.
+168 -9
View File
@@ -16,13 +16,172 @@ import (
"gorm.io/gorm"
)
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"` }
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)
}
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 { name := strings.TrimSpace(*in.Name); if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error":"name cannot be empty"}); return }; updates["name"] = name; t.Name = name }; if in.Enabled != nil { updates["enabled"] = *in.Enabled; t.Enabled = *in.Enabled }; if in.ExpireAt != nil { if !in.ExpireAt.After(time.Now()) { c.JSON(http.StatusBadRequest, gin.H{"error":"expire_at must be in the future"}); return }; updates["expire_at"] = in.ExpireAt; t.ExpireAt = 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"}) }
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"`
}
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 {
name := strings.TrimSpace(*in.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"})
return
}
updates["name"] = name
t.Name = name
}
if in.Enabled != nil {
updates["enabled"] = *in.Enabled
t.Enabled = *in.Enabled
}
if in.ExpireAt != nil {
if !in.ExpireAt.After(time.Now()) {
c.JSON(http.StatusBadRequest, gin.H{"error": "expire_at must be in the future"})
return
}
updates["expire_at"] = in.ExpireAt
t.ExpireAt = 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"})
}
+179 -13
View File
@@ -13,22 +13,188 @@ 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.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)) })
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
}
+66 -23
View File
@@ -8,50 +8,93 @@ import (
"gorm.io/gorm"
)
type Handler struct { db *gorm.DB }
type Handler struct{ db *gorm.DB }
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/settings", h.GetSettings); rg.PUT("/settings", h.PutSettings); rg.POST("/test", h.Test) }
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/settings", h.GetSettings)
rg.PUT("/settings", h.PutSettings)
rg.POST("/test", h.Test)
}
func (h *Handler) GetSettings(c *gin.Context) {
s, err := LoadSettings(h.db)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := s
if out.BotToken != "" { out.BotToken = maskToken(out.BotToken) }
if out.BotToken != "" {
out.BotToken = maskToken(out.BotToken)
}
c.JSON(http.StatusOK, out)
}
type putSettingsBody struct {
Enabled bool `json:"enabled"`
BotToken string `json:"bot_token"`
ChatIDs []string `json:"chat_ids"`
NotifyOnBlock bool `json:"notify_on_block"`
NotifyOnUnblock bool `json:"notify_on_unblock"`
NotifyOnExpiry bool `json:"notify_on_expiry"`
NotifyDailyDigest bool `json:"notify_daily_digest"`
KeepToken bool `json:"keep_token"`
Enabled bool `json:"enabled"`
BotToken string `json:"bot_token"`
ChatIDs []string `json:"chat_ids"`
NotifyOnBlock bool `json:"notify_on_block"`
NotifyOnUnblock bool `json:"notify_on_unblock"`
NotifyOnExpiry bool `json:"notify_on_expiry"`
NotifyDailyDigest bool `json:"notify_daily_digest"`
KeepToken bool `json:"keep_token"`
}
func (h *Handler) PutSettings(c *gin.Context) {
var body putSettingsBody
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
current, _ := LoadSettings(h.db)
s := Settings{Enabled: body.Enabled, BotToken: strings.TrimSpace(body.BotToken), ChatIDs: body.ChatIDs, NotifyOnBlock: body.NotifyOnBlock, NotifyOnUnblock: body.NotifyOnUnblock, NotifyOnExpiry: body.NotifyOnExpiry, NotifyDailyDigest: body.NotifyDailyDigest}
if body.KeepToken || s.BotToken == "" || strings.Contains(s.BotToken, "…") || strings.Contains(s.BotToken, "...") { s.BotToken = current.BotToken }
if s.Enabled && s.BotToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram bot token is required when notifications are enabled"}); return }
if s.Enabled && len(s.ChatIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "at least one Telegram Chat ID is required when notifications are enabled"}); return }
if err := SaveSettings(h.db, s); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
if body.KeepToken || s.BotToken == "" || strings.Contains(s.BotToken, "…") || strings.Contains(s.BotToken, "...") {
s.BotToken = current.BotToken
}
if s.Enabled && s.BotToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram bot token is required when notifications are enabled"})
return
}
if s.Enabled && len(s.ChatIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one Telegram Chat ID is required when notifications are enabled"})
return
}
if err := SaveSettings(h.db, s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := s
if out.BotToken != "" { out.BotToken = maskToken(out.BotToken) }
if out.BotToken != "" {
out.BotToken = maskToken(out.BotToken)
}
c.JSON(http.StatusOK, out)
}
func (h *Handler) Test(c *gin.Context) {
client, _, err := NewClientFromDB(h.db)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
if client == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram is disabled or incomplete (token + chat IDs required)"}); return }
if err := client.Validate(); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
if err := client.SendText("🔔 <b>3m-ui</b> Telegram 测试消息 / test message — 连接正常 / connection OK."); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if client == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram is disabled or incomplete (token + chat IDs required)"})
return
}
if err := client.Validate(); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if err := client.SendText("🔔 <b>3m-ui</b> Telegram 测试消息 / test message — 连接正常 / connection OK."); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func maskToken(token string) string { if len(token) <= 10 { return "********" }; return token[:6] + "…" + token[len(token)-4:] }
func maskToken(token string) string {
if len(token) <= 10 {
return "********"
}
return token[:6] + "…" + token[len(token)-4:]
}