diff --git a/.github/workflows/release-ant.yml b/.github/workflows/release-ant.yml index 99a4606..b9f0d7d 100644 --- a/.github/workflows/release-ant.yml +++ b/.github/workflows/release-ant.yml @@ -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 diff --git a/.github/workflows/release-mantine.yml b/.github/workflows/release-mantine.yml index da97684..dc2a9ce 100644 --- a/.github/workflows/release-mantine.yml +++ b/.github/workflows/release-mantine.yml @@ -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 diff --git a/.github/workflows/release-mui.yml b/.github/workflows/release-mui.yml index 5eae8a7..32f2e28 100644 --- a/.github/workflows/release-mui.yml +++ b/.github/workflows/release-mui.yml @@ -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 diff --git a/.github/workflows/release-shadcn.yml b/.github/workflows/release-shadcn.yml index 4c500ce..3827afc 100644 --- a/.github/workflows/release-shadcn.yml +++ b/.github/workflows/release-shadcn.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15f0b7a..5f15a4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index f0166bf..fb3e4a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 528e96d..fabec01 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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 { diff --git a/backend/internal/converter/listener_schema_test.go b/backend/internal/converter/listener_schema_test.go index ed75ee7..4d8422a 100644 --- a/backend/internal/converter/listener_schema_test.go +++ b/backend/internal/converter/listener_schema_test.go @@ -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"]}}`, } diff --git a/backend/internal/database/models/proxy_user.go b/backend/internal/database/models/proxy_user.go index 03fa8f1..71866e9 100644 --- a/backend/internal/database/models/proxy_user.go +++ b/backend/internal/database/models/proxy_user.go @@ -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). diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go index 56026b1..5dec149 100644 --- a/backend/internal/listener/api.go +++ b/backend/internal/listener/api.go @@ -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) +} diff --git a/backend/internal/listener/listener_test.go b/backend/internal/listener/listener_test.go index 0be993e..d0a5e41 100644 --- a/backend/internal/listener/listener_test.go +++ b/backend/internal/listener/listener_test.go @@ -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, }, } diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go index 46437a8..cdbb2f2 100644 --- a/backend/internal/listener/p2.go +++ b/backend/internal/listener/p2.go @@ -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 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 +} diff --git a/backend/internal/listener/validation_test.go b/backend/internal/listener/validation_test.go index f9e24bb..91f1f50 100644 --- a/backend/internal/listener/validation_test.go +++ b/backend/internal/listener/validation_test.go @@ -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") + } } diff --git a/backend/internal/mihomo/config/config_test.go b/backend/internal/mihomo/config/config_test.go index eb5a3fd..bd70a22 100644 --- a/backend/internal/mihomo/config/config_test.go +++ b/backend/internal/mihomo/config/config_test.go @@ -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 { diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go index fed00df..9d905e4 100644 --- a/backend/internal/mihomo/service.go +++ b/backend/internal/mihomo/service.go @@ -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 .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 } diff --git a/backend/internal/mihomo/service_test.go b/backend/internal/mihomo/service_test.go index 02373fc..3b0aad0 100644 --- a/backend/internal/mihomo/service_test.go +++ b/backend/internal/mihomo/service_test.go @@ -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) + } } diff --git a/backend/internal/node/api.go b/backend/internal/node/api.go index 1ab49d7..6c32248 100644 --- a/backend/internal/node/api.go +++ b/backend/internal/node/api.go @@ -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" ) diff --git a/backend/internal/node/node_test.go b/backend/internal/node/node_test.go index 92bcb36..9d9c8aa 100644 --- a/backend/internal/node/node_test.go +++ b/backend/internal/node/node_test.go @@ -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"}`, diff --git a/backend/internal/protocol/access_profile.go b/backend/internal/protocol/access_profile.go index 0f33478..ac20988 100644 --- a/backend/internal/protocol/access_profile.go +++ b/backend/internal/protocol/access_profile.go @@ -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. diff --git a/backend/internal/router/access_token.go b/backend/internal/router/access_token.go index cb61951..02adf8b 100644 --- a/backend/internal/router/access_token.go +++ b/backend/internal/router/access_token.go @@ -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"}) +} diff --git a/backend/internal/router/config_routes.go b/backend/internal/router/config_routes.go index 05e5574..542841f 100644 --- a/backend/internal/router/config_routes.go +++ b/backend/internal/router/config_routes.go @@ -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 } diff --git a/backend/internal/telegram/api.go b/backend/internal/telegram/api.go index 2859789..fe64b59 100644 --- a/backend/internal/telegram/api.go +++ b/backend/internal/telegram/api.go @@ -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("🔔 3m-ui 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("🔔 3m-ui 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:] +}