updete
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled

This commit is contained in:
2026-01-02 01:59:44 +08:00
parent 82c1a6a266
commit f46741a84b
44 changed files with 10502 additions and 1486 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(grep:*)",
"Bash(go list:*)",
"Bash(go get:*)"
]
}
}

View File

@@ -1,5 +1,15 @@
package main
// @title GoTunnel API
// @version 1.0
// @description GoTunnel 内网穿透服务器 API
// @host localhost:7500
// @BasePath /
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description JWT Bearer token
import (
"flag"
"fmt"
@@ -9,6 +19,8 @@ import (
"syscall"
"time"
_ "github.com/gotunnel/docs" // Swagger docs
"github.com/gotunnel/internal/server/app"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"

2398
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

2374
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1500
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

62
go.mod
View File

@@ -10,19 +10,77 @@ require (
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.2.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.1 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

195
go.sum
View File

@@ -1,40 +1,233 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 h1:3uSSOd6mVlwcX3k5OYOpiDqFgRmaE2dBfLvVIFWWHrw=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
@@ -63,3 +256,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -3,10 +3,14 @@ package tunnel
import (
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
@@ -231,6 +235,8 @@ func (c *Client) handleStream(stream net.Conn) {
c.handleClientRestart(stream, msg)
case protocol.MsgTypePluginConfigUpdate:
c.handlePluginConfigUpdate(stream, msg)
case protocol.MsgTypeUpdateDownload:
c.handleUpdateDownload(stream, msg)
}
}
@@ -738,3 +744,181 @@ func (c *Client) sendPluginConfigUpdateResult(stream net.Conn, pluginName, ruleN
msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, result)
protocol.WriteMessage(stream, msg)
}
// handleUpdateDownload 处理更新下载请求
func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) {
defer stream.Close()
var req protocol.UpdateDownloadRequest
if err := msg.ParsePayload(&req); err != nil {
log.Printf("[Client] Parse update request error: %v", err)
c.sendUpdateResult(stream, false, "invalid request")
return
}
log.Printf("[Client] Update download requested: %s", req.DownloadURL)
// 异步执行更新
go func() {
if err := c.performSelfUpdate(req.DownloadURL); err != nil {
log.Printf("[Client] Update failed: %v", err)
}
}()
c.sendUpdateResult(stream, true, "update started")
}
// sendUpdateResult 发送更新结果
func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string) {
result := protocol.UpdateResultResponse{
Success: success,
Message: message,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateResult, result)
protocol.WriteMessage(stream, msg)
}
// performSelfUpdate 执行自更新
func (c *Client) performSelfUpdate(downloadURL string) error {
log.Printf("[Client] Starting self-update from: %s", downloadURL)
// 创建临时文件
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, "gotunnel_client_update")
if runtime.GOOS == "windows" {
tempFile += ".exe"
}
// 下载新版本
if err := downloadUpdateFile(downloadURL, tempFile); err != nil {
return fmt.Errorf("download update: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(tempFile, 0755); err != nil {
os.Remove(tempFile)
return fmt.Errorf("chmod: %w", err)
}
}
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
os.Remove(tempFile)
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理
if runtime.GOOS == "windows" {
return performWindowsClientUpdate(tempFile, currentPath, c.ServerAddr, c.Token, c.ID)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 停止所有插件
c.stopAllPlugins()
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backup current: %w", err)
}
// 移动新文件
if err := os.Rename(tempFile, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
log.Printf("[Client] Update completed, restarting...")
// 重启进程
restartClientProcess(currentPath, c.ServerAddr, c.Token, c.ID)
return nil
}
// stopAllPlugins 停止所有运行中的插件
func (c *Client) stopAllPlugins() {
c.pluginMu.Lock()
for key, handler := range c.runningPlugins {
log.Printf("[Client] Stopping plugin %s for update", key)
handler.Stop()
}
c.runningPlugins = make(map[string]plugin.ClientPlugin)
c.pluginMu.Unlock()
}
// downloadUpdateFile 下载更新文件
func downloadUpdateFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// performWindowsClientUpdate Windows 平台更新
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token, id string) error {
// 创建批处理脚本
args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token)
if id != "" {
args += fmt.Sprintf(` -id "%s"`, id)
}
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
start "" "%s" %s
del "%%~f0"
`, currentPath, newFile, currentPath, currentPath, args)
batchPath := filepath.Join(os.TempDir(), "gotunnel_client_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
// 退出当前进程
os.Exit(0)
return nil
}
// restartClientProcess 重启客户端进程
func restartClientProcess(path, serverAddr, token, id string) {
args := []string{"-s", serverAddr, "-t", token}
if id != "" {
args = append(args, "-id", id)
}
cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}

View File

@@ -2,10 +2,8 @@ package app
import (
"embed"
"io"
"io/fs"
"log"
"net/http"
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
@@ -16,33 +14,6 @@ import (
//go:embed dist/*
var staticFiles embed.FS
// spaHandler SPA路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
f, err := h.fs.Open(path)
if err != nil {
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, _ := f.Stat()
if stat.IsDir() {
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
}
http.ServeContent(w, r, path, stat.ModTime(), f.(io.ReadSeeker))
}
// WebServer Web控制台服务
type WebServer struct {
ClientStore db.ClientStore
@@ -63,36 +34,29 @@ func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.Ser
}
}
// Run 启动Web服务
// Run 启动Web服务 (无认证,仅用于开发)
func (w *WebServer) Run(addr string) error {
r := router.New()
router.RegisterRoutes(r, w)
// 使用默认凭据和 JWT
jwtAuth := auth.NewJWTAuth("dev-secret", 24)
r.SetupRoutes(w, jwtAuth, "admin", "admin")
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s", addr)
return http.ListenAndServe(addr, r.Handler())
return r.Engine.Run(addr)
}
// RunWithAuth 启动带认证的Web服务
// RunWithAuth 启动带 Basic Auth 的 Web 服务 (已废弃,使用 RunWithJWT)
func (w *WebServer) RunWithAuth(addr, username, password string) error {
r := router.New()
router.RegisterRoutes(r, w)
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
auth := &router.AuthConfig{Username: username, Password: password}
handler := router.BasicAuthMiddleware(auth, r.Handler())
log.Printf("[Web] Console listening on %s (auth enabled)", addr)
return http.ListenAndServe(addr, handler)
// 转发到 JWT 认证
return w.RunWithJWT(addr, username, password, "auto-generated-secret")
}
// RunWithJWT 启动带 JWT 认证的 Web 服务
@@ -102,26 +66,18 @@ func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error
// JWT 认证器
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
// 注册认证路由(不需要认证)
authHandler := router.NewAuthHandler(username, password, jwtAuth)
router.RegisterAuthRoutes(r, authHandler)
// 注册业务路由
router.RegisterRoutes(r, w)
// 设置所有路由
r.SetupRoutes(w, jwtAuth, username, password)
// 静态文件
staticFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return err
}
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/
skipPaths := []string{"/api/auth/"}
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
r.SetupStaticFiles(staticFS)
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
return http.ListenAndServe(addr, handler)
return r.Engine.Run(addr)
}
// GetClientStore 获取客户端存储

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package router
import (
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// RegisterAuthRoutes 注册认证路由
func RegisterAuthRoutes(r *Router, h *AuthHandler) {
r.HandleFunc("/api/auth/login", h.handleLogin)
r.HandleFunc("/api/auth/check", h.handleCheck)
}
// handleLogin 处理登录请求
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
// 验证用户名密码
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(r.RemoteAddr, req.Username, false)
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return
}
security.LogWebLogin(r.RemoteAddr, req.Username, true)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
}
// handleCheck 检查 token 是否有效
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
// 从 Authorization header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
// 解析 Bearer token
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
tokenStr := authHeader[len(prefix):]
// 验证 token
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": claims.Username,
})
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"github.com/gotunnel/internal/server/db"
"github.com/gotunnel/pkg/protocol"
)
// CreateClientRequest 创建客户端请求
// @Description 创建新客户端的请求体
type CreateClientRequest struct {
ID string `json:"id" binding:"required,min=1,max=64" example:"client-001"`
Rules []protocol.ProxyRule `json:"rules"`
}
// UpdateClientRequest 更新客户端请求
// @Description 更新客户端配置的请求体
type UpdateClientRequest struct {
Nickname string `json:"nickname" binding:"max=128" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins"`
}
// ClientResponse 客户端详情响应
// @Description 客户端详细信息
type ClientResponse struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins,omitempty"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
}
// ClientListItem 客户端列表项
// @Description 客户端列表中的单个项目
type ClientListItem struct {
ID string `json:"id" example:"client-001"`
Nickname string `json:"nickname,omitempty" example:"My Client"`
Online bool `json:"online" example:"true"`
LastPing string `json:"last_ping,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
RuleCount int `json:"rule_count" example:"3"`
}
// InstallPluginsRequest 安装插件到客户端请求
// @Description 安装插件到指定客户端
type InstallPluginsRequest struct {
Plugins []string `json:"plugins" binding:"required,min=1,dive,required" example:"socks5,http-proxy"`
}
// ClientPluginActionRequest 客户端插件操作请求
// @Description 对客户端插件执行操作
type ClientPluginActionRequest struct {
RuleName string `json:"rule_name"`
Config map[string]string `json:"config,omitempty"`
Restart bool `json:"restart"`
}

View File

@@ -0,0 +1,53 @@
package dto
// UpdateServerConfigRequest 更新服务器配置请求
// @Description 更新服务器配置
type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
}
// ServerConfigPart 服务器配置部分
// @Description 隧道服务器配置
type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Token string `json:"token" binding:"omitempty,min=8"`
HeartbeatSec int `json:"heartbeat_sec" binding:"omitempty,min=1,max=300"`
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
}
// WebConfigPart Web 配置部分
// @Description Web 控制台配置
type WebConfigPart struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username" binding:"omitempty,min=3,max=32"`
Password string `json:"password" binding:"omitempty,min=6,max=64"`
}
// ServerConfigResponse 服务器配置响应
// @Description 服务器配置信息
type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
}
// ServerConfigInfo 服务器配置信息
type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token
HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
}
// WebConfigInfo Web 配置信息
type WebConfigInfo struct {
Enabled bool `json:"enabled"`
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Username string `json:"username"`
Password string `json:"password"` // 显示为 ****
}

View File

@@ -0,0 +1,105 @@
package dto
// PluginConfigRequest 更新插件配置请求
// @Description 更新客户端插件配置
type PluginConfigRequest struct {
Config map[string]string `json:"config" binding:"required"`
}
// PluginConfigResponse 插件配置响应
// @Description 插件配置详情
type PluginConfigResponse struct {
PluginName string `json:"plugin_name"`
Schema []ConfigField `json:"schema"`
Config map[string]string `json:"config"`
}
// ConfigField 配置字段定义
// @Description 配置表单字段
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
// @Description 代理规则的配置模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
// @Description 服务端插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginCreateRequest 创建 JS 插件请求
// @Description 创建新的 JS 插件
type JSPluginCreateRequest struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// JSPluginUpdateRequest 更新 JS 插件请求
// @Description 更新 JS 插件
type JSPluginUpdateRequest struct {
Source string `json:"source"`
Signature string `json:"signature"`
Description string `json:"description" binding:"max=500"`
Author string `json:"author" binding:"max=64"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
Enabled bool `json:"enabled"`
}
// JSPluginInstallRequest JS 插件安装请求
// @Description 安装 JS 插件到客户端
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
Source string `json:"source" binding:"required"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}
// StorePluginInfo 扩展商店插件信息
// @Description 插件商店中的插件信息
type StorePluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Author string `json:"author"`
Icon string `json:"icon,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
SignatureURL string `json:"signature_url,omitempty"`
}
// StoreInstallRequest 从商店安装插件请求
// @Description 从插件商店安装插件到客户端
type StoreInstallRequest struct {
PluginName string `json:"plugin_name" binding:"required"`
DownloadURL string `json:"download_url" binding:"required,url"`
SignatureURL string `json:"signature_url" binding:"required,url"`
ClientID string `json:"client_id" binding:"required"`
}

View File

@@ -0,0 +1,76 @@
package dto
// CheckUpdateResponse 检查更新响应
// @Description 更新检查结果
type CheckUpdateResponse struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
// CheckClientUpdateQuery 检查客户端更新查询参数
// @Description 检查客户端更新的查询参数
type CheckClientUpdateQuery struct {
OS string `form:"os" binding:"omitempty,oneof=linux darwin windows"`
Arch string `form:"arch" binding:"omitempty,oneof=amd64 arm64 386 arm"`
}
// ApplyServerUpdateRequest 应用服务端更新请求
// @Description 应用服务端更新
type ApplyServerUpdateRequest struct {
DownloadURL string `json:"download_url" binding:"required,url"`
Restart bool `json:"restart"`
}
// ApplyClientUpdateRequest 应用客户端更新请求
// @Description 推送更新到客户端
type ApplyClientUpdateRequest struct {
ClientID string `json:"client_id" binding:"required"`
DownloadURL string `json:"download_url" binding:"required,url"`
}
// VersionInfo 版本信息
// @Description 当前版本信息
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
// StatusResponse 服务器状态响应
// @Description 服务器状态信息
type StatusResponse struct {
Server ServerStatus `json:"server"`
ClientCount int `json:"client_count"`
}
// ServerStatus 服务器状态
type ServerStatus struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
}
// LoginRequest 登录请求
// @Description 用户登录
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应
// @Description 登录成功返回
type LoginResponse struct {
Token string `json:"token"`
}
// TokenCheckResponse Token 检查响应
// @Description Token 验证结果
type TokenCheckResponse struct {
Valid bool `json:"valid"`
Username string `json:"username"`
}

View File

@@ -0,0 +1,106 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ValidationError 验证错误详情
type ValidationError struct {
Field string `json:"field"` // 字段名
Message string `json:"message"` // 错误消息
}
// HandleValidationError 处理验证错误并返回统一格式
func HandleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]ValidationError, len(ve))
for i, fe := range ve {
errs[i] = ValidationError{
Field: fe.Field(),
Message: getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
// 非验证错误,返回通用错误消息
BadRequest(c, err.Error())
}
// getValidationMessage 根据验证标签返回友好的错误消息
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "email":
return "invalid email format"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
case "alphanum":
return "must contain only letters and numbers"
case "alphanumunicode":
return "must contain only letters, numbers and unicode characters"
case "ip":
return "invalid IP address"
case "hostname":
return "invalid hostname"
case "clientid":
return "must be 1-64 alphanumeric characters, underscore or hyphen"
case "gte":
return "value must be greater than or equal to " + fe.Param()
case "lte":
return "value must be less than or equal to " + fe.Param()
case "gt":
return "value must be greater than " + fe.Param()
case "lt":
return "value must be less than " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}
// BindJSON 绑定 JSON 并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}
// BindURI 绑定 URI 参数并自动处理验证错误
// 返回 true 表示绑定成功false 表示已处理错误响应
func BindURI(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindUri(obj); err != nil {
HandleValidationError(c, err)
return false
}
return true
}

View File

@@ -0,0 +1,100 @@
package handler
import (
"crypto/subtle"
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/auth"
"github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
type AuthHandler struct {
username string
password string
jwtAuth *auth.JWTAuth
}
// NewAuthHandler 创建认证处理器
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
return &AuthHandler{
username: username,
password: password,
jwtAuth: jwtAuth,
}
}
// Login 用户登录
// @Summary 用户登录
// @Description 使用用户名密码登录,返回 JWT token
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "登录信息"
// @Success 200 {object} Response{data=dto.LoginResponse}
// @Failure 400 {object} Response
// @Failure 401 {object} Response
// @Router /api/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if !BindJSON(c, &req) {
return
}
// 验证用户名密码 (使用常量时间比较防止时序攻击)
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
security.LogWebLogin(c.ClientIP(), req.Username, false)
Unauthorized(c, "invalid credentials")
return
}
// 生成 token
token, err := h.jwtAuth.GenerateToken(req.Username)
if err != nil {
InternalError(c, "failed to generate token")
return
}
security.LogWebLogin(c.ClientIP(), req.Username, true)
Success(c, dto.LoginResponse{Token: token})
}
// Check 检查 token 是否有效
// @Summary 检查 Token
// @Description 验证 JWT token 是否有效
// @Tags 认证
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.TokenCheckResponse}
// @Failure 401 {object} Response
// @Router /api/auth/check [get]
func (h *AuthHandler) Check(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
Unauthorized(c, "missing authorization header")
return
}
const prefix = "Bearer "
if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
Unauthorized(c, "invalid authorization format")
return
}
tokenStr := authHeader[len(prefix):]
claims, err := h.jwtAuth.ValidateToken(tokenStr)
if err != nil {
Unauthorized(c, "invalid token")
return
}
Success(c, dto.TokenCheckResponse{
Valid: true,
Username: claims.Username,
})
}

View File

@@ -0,0 +1,393 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// ClientHandler 客户端处理器
type ClientHandler struct {
app AppInterface
}
// NewClientHandler 创建客户端处理器
func NewClientHandler(app AppInterface) *ClientHandler {
return &ClientHandler{app: app}
}
// List 获取客户端列表
// @Summary 获取所有客户端
// @Description 返回所有注册客户端的列表及其在线状态
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.ClientListItem}
// @Router /api/clients [get]
func (h *ClientHandler) List(c *gin.Context) {
clients, err := h.app.GetClientStore().GetAllClients()
if err != nil {
InternalError(c, err.Error())
return
}
statusMap := h.app.GetServer().GetAllClientStatus()
result := make([]dto.ClientListItem, 0, len(clients))
for _, client := range clients {
item := dto.ClientListItem{
ID: client.ID,
Nickname: client.Nickname,
RuleCount: len(client.Rules),
}
if status, ok := statusMap[client.ID]; ok {
item.Online = status.Online
item.LastPing = status.LastPing
item.RemoteAddr = status.RemoteAddr
}
result = append(result, item)
}
Success(c, result)
}
// Create 创建客户端
// @Summary 创建新客户端
// @Description 创建一个新的客户端配置
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.CreateClientRequest true "客户端信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 409 {object} Response
// @Router /api/clients [post]
func (h *ClientHandler) Create(c *gin.Context) {
var req dto.CreateClientRequest
if !BindJSON(c, &req) {
return
}
// 验证客户端 ID 格式
if !validateClientID(req.ID) {
BadRequest(c, "invalid client id: must be 1-64 alphanumeric characters, underscore or hyphen")
return
}
// 检查客户端是否已存在
exists, _ := h.app.GetClientStore().ClientExists(req.ID)
if exists {
Conflict(c, "client already exists")
return
}
client := &db.Client{ID: req.ID, Rules: req.Rules}
if err := h.app.GetClientStore().CreateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个客户端
// @Summary 获取客户端详情
// @Description 获取指定客户端的详细信息
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response{data=dto.ClientResponse}
// @Failure 404 {object} Response
// @Router /api/client/{id} [get]
func (h *ClientHandler) Get(c *gin.Context) {
clientID := c.Param("id")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
online, lastPing, remoteAddr := h.app.GetServer().GetClientStatus(clientID)
resp := dto.ClientResponse{
ID: client.ID,
Nickname: client.Nickname,
Rules: client.Rules,
Plugins: client.Plugins,
Online: online,
LastPing: lastPing,
RemoteAddr: remoteAddr,
}
Success(c, resp)
}
// Update 更新客户端
// @Summary 更新客户端配置
// @Description 更新指定客户端的配置信息
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.UpdateClientRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [put]
func (h *ClientHandler) Update(c *gin.Context) {
clientID := c.Param("id")
var req dto.UpdateClientRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
client.Nickname = req.Nickname
client.Rules = req.Rules
if req.Plugins != nil {
client.Plugins = req.Plugins
}
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除客户端
// @Summary 删除客户端
// @Description 删除指定的客户端配置
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 404 {object} Response
// @Router /api/client/{id} [delete]
func (h *ClientHandler) Delete(c *gin.Context) {
clientID := c.Param("id")
exists, _ := h.app.GetClientStore().ClientExists(clientID)
if !exists {
NotFound(c, "client not found")
return
}
if err := h.app.GetClientStore().DeleteClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushConfig 推送配置到客户端
// @Summary 推送配置
// @Description 将配置推送到在线客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/push [post]
func (h *ClientHandler) PushConfig(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
if err := h.app.GetServer().PushConfigToClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disconnect 断开客户端连接
// @Summary 断开连接
// @Description 强制断开客户端连接
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/disconnect [post]
func (h *ClientHandler) Disconnect(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().DisconnectClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Restart 重启客户端
// @Summary 重启客户端
// @Description 发送重启命令到客户端
// @Tags 客户端
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Success 200 {object} Response
// @Router /api/client/{id}/restart [post]
func (h *ClientHandler) Restart(c *gin.Context) {
clientID := c.Param("id")
if err := h.app.GetServer().RestartClient(clientID); err != nil {
InternalError(c, err.Error())
return
}
SuccessWithMessage(c, gin.H{"status": "ok"}, "client restart initiated")
}
// InstallPlugins 安装插件到客户端
// @Summary 安装插件
// @Description 将指定插件安装到客户端
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param request body dto.InstallPluginsRequest true "插件列表"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/install-plugins [post]
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
clientID := c.Param("id")
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
var req dto.InstallPluginsRequest
if !BindJSON(c, &req) {
return
}
if err := h.app.GetServer().InstallPluginsToClient(clientID, req.Plugins); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PluginAction 客户端插件操作
// @Summary 插件操作
// @Description 对客户端插件执行操作(stop/restart/config/delete)
// @Tags 客户端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Param action path string true "操作类型" Enums(stop, restart, config, delete)
// @Param request body dto.ClientPluginActionRequest false "操作参数"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/client/{id}/plugin/{pluginName}/{action} [post]
func (h *ClientHandler) PluginAction(c *gin.Context) {
clientID := c.Param("id")
pluginName := c.Param("pluginName")
action := c.Param("action")
var req dto.ClientPluginActionRequest
c.ShouldBindJSON(&req) // 忽略错误,使用默认值
if req.RuleName == "" {
req.RuleName = pluginName
}
var err error
switch action {
case "stop":
err = h.app.GetServer().StopClientPlugin(clientID, pluginName, req.RuleName)
case "restart":
err = h.app.GetServer().RestartClientPlugin(clientID, pluginName, req.RuleName)
case "config":
if req.Config == nil {
BadRequest(c, "config required")
return
}
err = h.app.GetServer().UpdateClientPluginConfig(clientID, pluginName, req.RuleName, req.Config, req.Restart)
case "delete":
err = h.deleteClientPlugin(clientID, pluginName)
default:
BadRequest(c, "unknown action: "+action)
return
}
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"action": action,
"plugin": pluginName,
})
}
func (h *ClientHandler) deleteClientPlugin(clientID, pluginName string) error {
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
return fmt.Errorf("client not found")
}
var newPlugins []db.ClientPlugin
found := false
for _, p := range client.Plugins {
if p.Name == pluginName {
found = true
continue
}
newPlugins = append(newPlugins, p)
}
if !found {
return fmt.Errorf("plugin %s not found", pluginName)
}
client.Plugins = newPlugins
return h.app.GetClientStore().UpdateClient(client)
}
// validateClientID 验证客户端 ID 格式
func validateClientID(id string) bool {
if len(id) < 1 || len(id) > 64 {
return false
}
for _, c := range id {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}

View File

@@ -0,0 +1,131 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// ConfigHandler 配置处理器
type ConfigHandler struct {
app AppInterface
}
// NewConfigHandler 创建配置处理器
func NewConfigHandler(app AppInterface) *ConfigHandler {
return &ConfigHandler{app: app}
}
// Get 获取服务器配置
// @Summary 获取配置
// @Description 返回服务器配置(敏感信息脱敏)
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.ServerConfigResponse}
// @Router /api/config [get]
func (h *ConfigHandler) Get(c *gin.Context) {
cfg := h.app.GetConfig()
// Token 脱敏处理只显示前4位
maskedToken := cfg.Server.Token
if len(maskedToken) > 4 {
maskedToken = maskedToken[:4] + "****"
}
resp := dto.ServerConfigResponse{
Server: dto.ServerConfigInfo{
BindAddr: cfg.Server.BindAddr,
BindPort: cfg.Server.BindPort,
Token: maskedToken,
HeartbeatSec: cfg.Server.HeartbeatSec,
HeartbeatTimeout: cfg.Server.HeartbeatTimeout,
},
Web: dto.WebConfigInfo{
Enabled: cfg.Web.Enabled,
BindAddr: cfg.Web.BindAddr,
BindPort: cfg.Web.BindPort,
Username: cfg.Web.Username,
Password: "****",
},
}
Success(c, resp)
}
// Update 更新服务器配置
// @Summary 更新配置
// @Description 更新服务器配置
// @Tags 配置
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.UpdateServerConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/config [put]
func (h *ConfigHandler) Update(c *gin.Context) {
var req dto.UpdateServerConfigRequest
if !BindJSON(c, &req) {
return
}
cfg := h.app.GetConfig()
// 更新 Server 配置
if req.Server != nil {
if req.Server.BindAddr != "" {
cfg.Server.BindAddr = req.Server.BindAddr
}
if req.Server.BindPort > 0 {
cfg.Server.BindPort = req.Server.BindPort
}
if req.Server.Token != "" {
cfg.Server.Token = req.Server.Token
}
if req.Server.HeartbeatSec > 0 {
cfg.Server.HeartbeatSec = req.Server.HeartbeatSec
}
if req.Server.HeartbeatTimeout > 0 {
cfg.Server.HeartbeatTimeout = req.Server.HeartbeatTimeout
}
}
// 更新 Web 配置
if req.Web != nil {
cfg.Web.Enabled = req.Web.Enabled
if req.Web.BindAddr != "" {
cfg.Web.BindAddr = req.Web.BindAddr
}
if req.Web.BindPort > 0 {
cfg.Web.BindPort = req.Web.BindPort
}
cfg.Web.Username = req.Web.Username
cfg.Web.Password = req.Web.Password
}
if err := h.app.SaveConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Reload 重新加载配置
// @Summary 重新加载配置
// @Description 重新加载服务器配置
// @Tags 配置
// @Produce json
// @Security Bearer
// @Success 200 {object} Response
// @Failure 500 {object} Response
// @Router /api/config/reload [post]
func (h *ConfigHandler) Reload(c *gin.Context) {
if err := h.app.GetServer().ReloadConfig(); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,230 @@
package handler
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/gotunnel/pkg/version"
)
// UpdateInfo 更新信息
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// checkUpdateForComponent 检查组件更新
func checkUpdateForComponent(component string) (*UpdateInfo, error) {
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
currentVersion := version.Version
available := version.CompareVersions(currentVersion, latestVersion) < 0
// 查找对应平台的资产
assetName := getAssetNameForPlatform(component, runtime.GOOS, runtime.GOARCH)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: available,
Current: currentVersion,
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// checkClientUpdateForPlatform 检查指定平台的客户端更新
func checkClientUpdateForPlatform(osName, arch string) (*UpdateInfo, error) {
if osName == "" {
osName = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
// 查找对应平台的资产
assetName := getAssetNameForPlatform("client", osName, arch)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: true,
Current: "",
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// getAssetNameForPlatform 获取指定平台的资产名称
func getAssetNameForPlatform(component, osName, arch string) string {
ext := ""
if osName == "windows" {
ext = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", component, osName, arch, ext)
}
// performSelfUpdate 执行自更新
func performSelfUpdate(downloadURL string, restart bool) error {
// 下载新版本
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, "gotunnel_update_"+time.Now().Format("20060102150405"))
if runtime.GOOS == "windows" {
tempFile += ".exe"
}
if err := downloadFile(downloadURL, tempFile); err != nil {
return fmt.Errorf("download update: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(tempFile, 0755); err != nil {
os.Remove(tempFile)
return fmt.Errorf("chmod: %w", err)
}
}
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
os.Remove(tempFile)
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理(运行中的文件无法直接替换)
if runtime.GOOS == "windows" {
return performWindowsUpdate(tempFile, currentPath, restart)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backup current: %w", err)
}
// 移动新文件
if err := os.Rename(tempFile, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
if restart {
restartProcess(currentPath)
}
return nil
}
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
`, currentPath, newFile, currentPath)
if restart {
batchScript += fmt.Sprintf(`start "" "%s"
`, currentPath)
}
batchScript += "del \"%~f0\"\n"
batchPath := filepath.Join(os.TempDir(), "gotunnel_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
os.Exit(0)
return nil
}
// restartProcess 重启进程
func restartProcess(path string) {
cmd := exec.Command(path, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}
// downloadFile 下载文件
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}

View File

@@ -0,0 +1,83 @@
package handler
import (
"github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db"
)
// AppInterface 应用接口
type AppInterface interface {
GetClientStore() db.ClientStore
GetServer() ServerInterface
GetConfig() *config.ServerConfig
GetConfigPath() string
SaveConfig() error
GetJSPluginStore() db.JSPluginStore
}
// ServerInterface 服务端接口
type ServerInterface interface {
GetClientStatus(clientID string) (online bool, lastPing string, remoteAddr string)
GetAllClientStatus() map[string]struct {
Online bool
LastPing string
RemoteAddr string
}
ReloadConfig() error
GetBindAddr() string
GetBindPort() int
PushConfigToClient(clientID string) error
DisconnectClient(clientID string) error
GetPluginList() []PluginInfo
EnablePlugin(name string) error
DisablePlugin(name string) error
InstallPluginsToClient(clientID string, plugins []string) error
GetPluginConfigSchema(name string) ([]ConfigField, error)
SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error
InstallJSPluginToClient(clientID string, req JSPluginInstallRequest) error
RestartClient(clientID string) error
StopClientPlugin(clientID, pluginName, ruleName string) error
RestartClientPlugin(clientID, pluginName, ruleName string) error
UpdateClientPluginConfig(clientID, pluginName, ruleName string, config map[string]string, restart bool) error
SendUpdateToClient(clientID, downloadURL string) error
}
// ConfigField 配置字段
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Description string `json:"description,omitempty"`
}
// RuleSchema 规则表单模式
type RuleSchema struct {
NeedsLocalAddr bool `json:"needs_local_addr"`
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
}
// PluginInfo 插件信息
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
}
// JSPluginInstallRequest JS 插件安装请求
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name"`
Source string `json:"source"`
Signature string `json:"signature"`
RuleName string `json:"rule_name"`
RemotePort int `json:"remote_port"`
Config map[string]string `json:"config"`
AutoStart bool `json:"auto_start"`
}

View File

@@ -0,0 +1,212 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// JSPluginHandler JS 插件处理器
type JSPluginHandler struct {
app AppInterface
}
// NewJSPluginHandler 创建 JS 插件处理器
func NewJSPluginHandler(app AppInterface) *JSPluginHandler {
return &JSPluginHandler{app: app}
}
// List 获取 JS 插件列表
// @Summary 获取所有 JS 插件
// @Description 返回所有注册的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]db.JSPlugin}
// @Router /api/js-plugins [get]
func (h *JSPluginHandler) List(c *gin.Context) {
plugins, err := h.app.GetJSPluginStore().GetAllJSPlugins()
if err != nil {
InternalError(c, err.Error())
return
}
if plugins == nil {
plugins = []db.JSPlugin{}
}
Success(c, plugins)
}
// Create 创建 JS 插件
// @Summary 创建 JS 插件
// @Description 创建新的 JS 插件
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.JSPluginCreateRequest true "插件信息"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugins [post]
func (h *JSPluginHandler) Create(c *gin.Context) {
var req dto.JSPluginCreateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: req.Name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: true,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Get 获取单个 JS 插件
// @Summary 获取 JS 插件详情
// @Description 获取指定 JS 插件的详细信息
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response{data=db.JSPlugin}
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name} [get]
func (h *JSPluginHandler) Get(c *gin.Context) {
name := c.Param("name")
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(name)
if err != nil {
NotFound(c, "plugin not found")
return
}
Success(c, plugin)
}
// Update 更新 JS 插件
// @Summary 更新 JS 插件
// @Description 更新指定 JS 插件的信息
// @Tags JS插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param request body dto.JSPluginUpdateRequest true "更新内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/js-plugin/{name} [put]
func (h *JSPluginHandler) Update(c *gin.Context) {
name := c.Param("name")
var req dto.JSPluginUpdateRequest
if !BindJSON(c, &req) {
return
}
plugin := &db.JSPlugin{
Name: name,
Source: req.Source,
Signature: req.Signature,
Description: req.Description,
Author: req.Author,
Config: req.Config,
AutoStart: req.AutoStart,
Enabled: req.Enabled,
}
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Delete 删除 JS 插件
// @Summary 删除 JS 插件
// @Description 删除指定的 JS 插件
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Router /api/js-plugin/{name} [delete]
func (h *JSPluginHandler) Delete(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetJSPluginStore().DeleteJSPlugin(name); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// PushToClient 推送 JS 插件到客户端
// @Summary 推送插件到客户端
// @Description 将 JS 插件推送到指定客户端
// @Tags JS插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Param clientID path string true "客户端ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/js-plugin/{name}/push/{clientID} [post]
func (h *JSPluginHandler) PushToClient(c *gin.Context) {
pluginName := c.Param("name")
clientID := c.Param("clientID")
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if !online {
ClientNotOnline(c)
return
}
// 获取插件
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if err != nil {
NotFound(c, "plugin not found")
return
}
if !plugin.Enabled {
Error(c, 400, CodePluginDisabled, "plugin is disabled")
return
}
// 推送到客户端
req := JSPluginInstallRequest{
PluginName: plugin.Name,
Source: plugin.Source,
Signature: plugin.Signature,
RuleName: plugin.Name,
Config: plugin.Config,
AutoStart: plugin.AutoStart,
}
if err := h.app.GetServer().InstallJSPluginToClient(clientID, req); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"status": "ok",
"plugin": pluginName,
"client": clientID,
})
}

View File

@@ -0,0 +1,285 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/plugin"
)
// PluginHandler 插件处理器
type PluginHandler struct {
app AppInterface
}
// NewPluginHandler 创建插件处理器
func NewPluginHandler(app AppInterface) *PluginHandler {
return &PluginHandler{app: app}
}
// List 获取插件列表
// @Summary 获取所有插件
// @Description 返回服务端所有注册的插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=[]dto.PluginInfo}
// @Router /api/plugins [get]
func (h *PluginHandler) List(c *gin.Context) {
plugins := h.app.GetServer().GetPluginList()
result := make([]dto.PluginInfo, len(plugins))
for i, p := range plugins {
result[i] = dto.PluginInfo{
Name: p.Name,
Version: p.Version,
Type: p.Type,
Description: p.Description,
Source: p.Source,
Icon: p.Icon,
Enabled: p.Enabled,
}
if p.RuleSchema != nil {
result[i].RuleSchema = &dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, result)
}
// Enable 启用插件
// @Summary 启用插件
// @Description 启用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/enable [post]
func (h *PluginHandler) Enable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().EnablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// Disable 禁用插件
// @Summary 禁用插件
// @Description 禁用指定插件
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param name path string true "插件名称"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/plugin/{name}/disable [post]
func (h *PluginHandler) Disable(c *gin.Context) {
name := c.Param("name")
if err := h.app.GetServer().DisablePlugin(name); err != nil {
BadRequest(c, err.Error())
return
}
Success(c, gin.H{"status": "ok"})
}
// GetRuleSchemas 获取规则配置模式
// @Summary 获取规则模式
// @Description 返回所有协议类型的配置模式
// @Tags 插件
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=map[string]dto.RuleSchema}
// @Router /api/rule-schemas [get]
func (h *PluginHandler) GetRuleSchemas(c *gin.Context) {
// 获取内置协议模式
schemas := make(map[string]dto.RuleSchema)
for name, schema := range plugin.BuiltinRuleSchemas() {
schemas[name] = dto.RuleSchema{
NeedsLocalAddr: schema.NeedsLocalAddr,
ExtraFields: convertConfigFields(schema.ExtraFields),
}
}
// 添加已注册插件的模式
plugins := h.app.GetServer().GetPluginList()
for _, p := range plugins {
if p.RuleSchema != nil {
schemas[p.Name] = dto.RuleSchema{
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
}
}
}
Success(c, schemas)
}
// GetClientConfig 获取客户端插件配置
// @Summary 获取客户端插件配置
// @Description 获取客户端上指定插件的配置
// @Tags 插件
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Success 200 {object} Response{data=dto.PluginConfigResponse}
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [get]
func (h *PluginHandler) GetClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 尝试从内置插件获取配置模式
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
var schemaFields []dto.ConfigField
if err != nil {
// 如果内置插件中找不到,尝试从 JS 插件获取
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
if jsErr != nil {
// 两者都找不到,返回空 schema
schemaFields = []dto.ConfigField{}
} else {
// 使用 JS 插件的 config 作为动态 schema
for key := range jsPlugin.Config {
schemaFields = append(schemaFields, dto.ConfigField{
Key: key,
Label: key,
Type: "string",
})
}
}
} else {
schemaFields = convertRouterConfigFields(schema)
}
// 查找客户端的插件配置
var config map[string]string
for _, p := range client.Plugins {
if p.Name == pluginName {
config = p.Config
break
}
}
if config == nil {
config = make(map[string]string)
}
Success(c, dto.PluginConfigResponse{
PluginName: pluginName,
Schema: schemaFields,
Config: config,
})
}
// UpdateClientConfig 更新客户端插件配置
// @Summary 更新客户端插件配置
// @Description 更新客户端上指定插件的配置
// @Tags 插件
// @Accept json
// @Produce json
// @Security Bearer
// @Param clientID path string true "客户端ID"
// @Param pluginName path string true "插件名称"
// @Param request body dto.PluginConfigRequest true "配置内容"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Router /api/client-plugin/{clientID}/{pluginName}/config [put]
func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
clientID := c.Param("clientID")
pluginName := c.Param("pluginName")
var req dto.PluginConfigRequest
if !BindJSON(c, &req) {
return
}
client, err := h.app.GetClientStore().GetClient(clientID)
if err != nil {
NotFound(c, "client not found")
return
}
// 更新插件配置
found := false
for i, p := range client.Plugins {
if p.Name == pluginName {
client.Plugins[i].Config = req.Config
found = true
break
}
}
if !found {
NotFound(c, "plugin not installed on client")
return
}
// 保存到数据库
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
InternalError(c, err.Error())
return
}
// 如果客户端在线,同步配置
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
if online {
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
// 配置已保存,但同步失败,返回警告
PartialSuccess(c, gin.H{"status": "partial"}, "config saved but sync failed: "+err.Error())
return
}
}
Success(c, gin.H{"status": "ok"})
}
// convertConfigFields 转换插件配置字段到 DTO
func convertConfigFields(fields []plugin.ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: string(f.Type),
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
}
}
return result
}
// convertRouterConfigFields 转换 ConfigField 到 dto.ConfigField
func convertRouterConfigFields(fields []ConfigField) []dto.ConfigField {
result := make([]dto.ConfigField, len(fields))
for i, f := range fields {
result[i] = dto.ConfigField{
Key: f.Key,
Label: f.Label,
Type: f.Type,
Default: f.Default,
Required: f.Required,
Options: f.Options,
Description: f.Description,
}
}
return result
}

View File

@@ -0,0 +1,159 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
// 业务错误码定义
const (
CodeSuccess = 0
CodeBadRequest = 400
CodeUnauthorized = 401
CodeForbidden = 403
CodeNotFound = 404
CodeConflict = 409
CodeInternalError = 500
CodeBadGateway = 502
CodeClientNotOnline = 1001
CodePluginNotFound = 1002
CodeInvalidClientID = 1003
CodePluginDisabled = 1004
CodeConfigSyncFailed = 1005
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}
// BindJSON 绑定 JSON 并自动处理验证错误
func BindJSON(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// BindQuery 绑定查询参数并自动处理验证错误
func BindQuery(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindQuery(obj); err != nil {
handleValidationError(c, err)
return false
}
return true
}
// handleValidationError 处理验证错误
func handleValidationError(c *gin.Context, err error) {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
errs := make([]map[string]string, len(ve))
for i, fe := range ve {
errs[i] = map[string]string{
"field": fe.Field(),
"message": getValidationMessage(fe),
}
}
c.JSON(http.StatusBadRequest, Response{
Code: CodeBadRequest,
Message: "validation failed",
Data: errs,
})
return
}
BadRequest(c, err.Error())
}
func getValidationMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "this field is required"
case "min":
return "value is too short or too small"
case "max":
return "value is too long or too large"
case "url":
return "invalid URL format"
case "oneof":
return "value must be one of: " + fe.Param()
default:
return "validation failed on " + fe.Tag()
}
}

View File

@@ -0,0 +1,51 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// StatusHandler 状态处理器
type StatusHandler struct {
app AppInterface
}
// NewStatusHandler 创建状态处理器
func NewStatusHandler(app AppInterface) *StatusHandler {
return &StatusHandler{app: app}
}
// GetStatus 获取服务器状态
// @Summary 获取服务器状态
// @Description 返回服务器运行状态和客户端数量
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.StatusResponse}
// @Router /api/status [get]
func (h *StatusHandler) GetStatus(c *gin.Context) {
clients, _ := h.app.GetClientStore().GetAllClients()
status := dto.StatusResponse{
Server: dto.ServerStatus{
BindAddr: h.app.GetServer().GetBindAddr(),
BindPort: h.app.GetServer().GetBindPort(),
},
ClientCount: len(clients),
}
Success(c, status)
}
// GetVersion 获取版本信息
// @Summary 获取版本信息
// @Description 返回服务器版本信息
// @Tags 状态
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.VersionInfo}
// @Router /api/update/version [get]
func (h *StatusHandler) GetVersion(c *gin.Context) {
Success(c, getVersionInfo())
}

View File

@@ -0,0 +1,169 @@
package handler
import (
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
)
// StoreHandler 插件商店处理器
type StoreHandler struct {
app AppInterface
}
// NewStoreHandler 创建插件商店处理器
func NewStoreHandler(app AppInterface) *StoreHandler {
return &StoreHandler{app: app}
}
// ListPlugins 获取商店插件列表
// @Summary 获取商店插件
// @Description 从远程插件商店获取可用插件列表
// @Tags 插件商店
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=object{plugins=[]dto.StorePluginInfo}}
// @Failure 502 {object} Response
// @Router /api/store/plugins [get]
func (h *StoreHandler) ListPlugins(c *gin.Context) {
cfg := h.app.GetConfig()
storeURL := cfg.PluginStore.GetPluginStoreURL()
// 从远程 URL 获取插件列表
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(storeURL)
if err != nil {
BadGateway(c, "Failed to fetch store: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Store returned error")
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read response")
return
}
// 直接返回原始 JSON已经是数组格式
c.Header("Content-Type", "application/json")
c.Writer.Write([]byte(`{"code":0,"data":{"plugins":`))
c.Writer.Write(body)
c.Writer.Write([]byte(`}}`))
}
// Install 从商店安装插件到客户端
// @Summary 安装商店插件
// @Description 从插件商店下载并安装插件到指定客户端
// @Tags 插件商店
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.StoreInstallRequest true "安装请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 502 {object} Response
// @Router /api/store/install [post]
func (h *StoreHandler) Install(c *gin.Context) {
var req dto.StoreInstallRequest
if !BindJSON(c, &req) {
return
}
// 检查客户端是否在线
online, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
if !online {
ClientNotOnline(c)
return
}
// 下载插件
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(req.DownloadURL)
if err != nil {
BadGateway(c, "Failed to download plugin: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
BadGateway(c, "Plugin download failed with status: "+resp.Status)
return
}
source, err := io.ReadAll(resp.Body)
if err != nil {
InternalError(c, "Failed to read plugin: "+err.Error())
return
}
// 下载签名文件
sigResp, err := client.Get(req.SignatureURL)
if err != nil {
BadGateway(c, "Failed to download signature: "+err.Error())
return
}
defer sigResp.Body.Close()
if sigResp.StatusCode != http.StatusOK {
BadGateway(c, "Signature download failed with status: "+sigResp.Status)
return
}
signature, err := io.ReadAll(sigResp.Body)
if err != nil {
InternalError(c, "Failed to read signature: "+err.Error())
return
}
// 安装到客户端
installReq := JSPluginInstallRequest{
PluginName: req.PluginName,
Source: string(source),
Signature: string(signature),
RuleName: req.PluginName,
AutoStart: true,
}
if err := h.app.GetServer().InstallJSPluginToClient(req.ClientID, installReq); err != nil {
InternalError(c, "Failed to install plugin: "+err.Error())
return
}
// 将插件信息保存到数据库
dbClient, err := h.app.GetClientStore().GetClient(req.ClientID)
if err == nil {
// 检查插件是否已存在
exists := false
for i, p := range dbClient.Plugins {
if p.Name == req.PluginName {
dbClient.Plugins[i].Enabled = true
exists = true
break
}
}
if !exists {
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
Name: req.PluginName,
Version: "1.0.0",
Enabled: true,
})
}
h.app.GetClientStore().UpdateClient(dbClient)
}
Success(c, gin.H{
"status": "ok",
"plugin": req.PluginName,
"client": req.ClientID,
})
}

View File

@@ -0,0 +1,132 @@
package handler
import (
"github.com/gin-gonic/gin"
// removed router import
"github.com/gotunnel/internal/server/router/dto"
"github.com/gotunnel/pkg/version"
)
// UpdateHandler 更新处理器
type UpdateHandler struct {
app AppInterface
}
// NewUpdateHandler 创建更新处理器
func NewUpdateHandler(app AppInterface) *UpdateHandler {
return &UpdateHandler{app: app}
}
// CheckServer 检查服务端更新
// @Summary 检查服务端更新
// @Description 检查是否有新的服务端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/server [get]
func (h *UpdateHandler) CheckServer(c *gin.Context) {
updateInfo, err := checkUpdateForComponent("server")
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// CheckClient 检查客户端更新
// @Summary 检查客户端更新
// @Description 检查是否有新的客户端版本可用
// @Tags 更新
// @Produce json
// @Security Bearer
// @Param os query string false "操作系统" Enums(linux, darwin, windows)
// @Param arch query string false "架构" Enums(amd64, arm64, 386, arm)
// @Success 200 {object} Response{data=dto.CheckUpdateResponse}
// @Router /api/update/check/client [get]
func (h *UpdateHandler) CheckClient(c *gin.Context) {
var query dto.CheckClientUpdateQuery
if !BindQuery(c, &query) {
return
}
updateInfo, err := checkClientUpdateForPlatform(query.OS, query.Arch)
if err != nil {
InternalError(c, err.Error())
return
}
Success(c, updateInfo)
}
// ApplyServer 应用服务端更新
// @Summary 应用服务端更新
// @Description 下载并应用服务端更新,服务器将自动重启
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyServerUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/server [post]
func (h *UpdateHandler) ApplyServer(c *gin.Context) {
var req dto.ApplyServerUpdateRequest
if !BindJSON(c, &req) {
return
}
// 异步执行更新
go func() {
if err := performSelfUpdate(req.DownloadURL, req.Restart); err != nil {
println("[Update] Server update failed:", err.Error())
}
}()
Success(c, gin.H{
"success": true,
"message": "Update started, server will restart shortly",
})
}
// ApplyClient 应用客户端更新
// @Summary 推送客户端更新
// @Description 向指定客户端推送更新命令
// @Tags 更新
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ApplyClientUpdateRequest true "更新请求"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Router /api/update/apply/client [post]
func (h *UpdateHandler) ApplyClient(c *gin.Context) {
var req dto.ApplyClientUpdateRequest
if !BindJSON(c, &req) {
return
}
// 发送更新命令到客户端
if err := h.app.GetServer().SendUpdateToClient(req.ClientID, req.DownloadURL); err != nil {
InternalError(c, err.Error())
return
}
Success(c, gin.H{
"success": true,
"message": "Update command sent to client",
})
}
// getVersionInfo 获取版本信息
func getVersionInfo() dto.VersionInfo {
info := version.GetInfo()
return dto.VersionInfo{
Version: info.Version,
GitCommit: info.GitCommit,
BuildTime: info.BuildTime,
GoVersion: info.GoVersion,
Platform: info.OS + "/" + info.Arch,
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = "*"
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gotunnel/pkg/auth"
)
// JWTAuth JWT 认证中间件
func JWTAuth(jwtAuth *auth.JWTAuth) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "missing authorization header",
})
c.Abort()
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid authorization format",
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := jwtAuth.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "invalid or expired token",
})
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("username", claims.Username)
c.Set("claims", claims)
c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
// Logger 请求日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
method := c.Request.Method
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
if query != "" {
path = path + "?" + query
}
log.Printf("[API] %s %s %d %v %s",
method, path, status, latency, clientIP)
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// Recovery 自定义恢复中间件(返回统一格式)
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("[PANIC] %v\n%s", err, debug.Stack())
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "internal server error",
})
c.Abort()
}
}()
c.Next()
}
}

View File

@@ -0,0 +1,116 @@
package router
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"` // 业务状态码: 0=成功, 非0=错误
Data interface{} `json:"data,omitempty"` // 响应数据
Message string `json:"message,omitempty"` // 提示信息
}
// 业务错误码定义
const (
CodeSuccess = 0 // 成功
CodeBadRequest = 400 // 请求参数错误
CodeUnauthorized = 401 // 未授权
CodeForbidden = 403 // 禁止访问
CodeNotFound = 404 // 资源不存在
CodeConflict = 409 // 资源冲突
CodeInternalError = 500 // 服务器内部错误
CodeBadGateway = 502 // 网关错误
// 业务错误码 (1000+)
CodeClientNotOnline = 1001 // 客户端不在线
CodePluginNotFound = 1002 // 插件不存在
CodeInvalidClientID = 1003 // 无效的客户端ID
CodePluginDisabled = 1004 // 插件已禁用
CodeConfigSyncFailed = 1005 // 配置同步失败
)
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
})
}
// SuccessWithMessage 成功响应带消息
func SuccessWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Data: data,
Message: message,
})
}
// Error 错误响应
func Error(c *gin.Context, httpCode int, bizCode int, message string) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
})
}
// ErrorWithData 错误响应带数据
func ErrorWithData(c *gin.Context, httpCode int, bizCode int, message string, data interface{}) {
c.JSON(httpCode, Response{
Code: bizCode,
Message: message,
Data: data,
})
}
// BadRequest 400 错误
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, CodeBadRequest, message)
}
// Unauthorized 401 错误
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, CodeUnauthorized, message)
}
// Forbidden 403 错误
func Forbidden(c *gin.Context, message string) {
Error(c, http.StatusForbidden, CodeForbidden, message)
}
// NotFound 404 错误
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, CodeNotFound, message)
}
// Conflict 409 错误
func Conflict(c *gin.Context, message string) {
Error(c, http.StatusConflict, CodeConflict, message)
}
// InternalError 500 错误
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, CodeInternalError, message)
}
// BadGateway 502 错误
func BadGateway(c *gin.Context, message string) {
Error(c, http.StatusBadGateway, CodeBadGateway, message)
}
// ClientNotOnline 客户端不在线错误
func ClientNotOnline(c *gin.Context) {
Error(c, http.StatusBadRequest, CodeClientNotOnline, "client not online")
}
// PartialSuccess 部分成功响应
func PartialSuccess(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Code: CodeConfigSyncFailed,
Data: data,
Message: message,
})
}

View File

@@ -1,123 +1,162 @@
package router
import (
"crypto/subtle"
"io"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/gotunnel/internal/server/router/handler"
"github.com/gotunnel/internal/server/router/middleware"
"github.com/gotunnel/pkg/auth"
)
// Router 路由管理器
type Router struct {
mux *http.ServeMux
// GinRouter Gin 路由管理器
type GinRouter struct {
Engine *gin.Engine
}
// AuthConfig 认证配置
type AuthConfig struct {
Username string
Password string
}
// New 创建路由管理器
func New() *Router {
return &Router{
mux: http.NewServeMux(),
}
}
// Handle 注册路由处理器
func (r *Router) Handle(pattern string, handler http.Handler) {
r.mux.Handle(pattern, handler)
}
// HandleFunc 注册路由处理函数
func (r *Router) HandleFunc(pattern string, handler http.HandlerFunc) {
r.mux.HandleFunc(pattern, handler)
}
// Group 创建路由组
func (r *Router) Group(prefix string) *RouteGroup {
return &RouteGroup{
router: r,
prefix: prefix,
}
}
// RouteGroup 路由组
type RouteGroup struct {
router *Router
prefix string
}
// HandleFunc 注册路由组处理函数
func (g *RouteGroup) HandleFunc(pattern string, handler http.HandlerFunc) {
g.router.mux.HandleFunc(g.prefix+pattern, handler)
// New 创建 Gin 路由管理器
func New() *GinRouter {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
return &GinRouter{Engine: engine}
}
// Handler 返回 http.Handler
func (r *Router) Handler() http.Handler {
return r.mux
func (r *GinRouter) Handler() http.Handler {
return r.Engine
}
// BasicAuthMiddleware 基础认证中间件
func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth == nil || (auth.Username == "" && auth.Password == "") {
next.ServeHTTP(w, r)
return
// SetupRoutes 配置所有路由
func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth, username, password string) {
engine := r.Engine
// 全局中间件
engine.Use(middleware.Recovery())
engine.Use(middleware.Logger())
engine.Use(middleware.CORS())
// Swagger 文档
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 认证路由 (无需 JWT)
authHandler := handler.NewAuthHandler(username, password, jwtAuth)
engine.POST("/api/auth/login", authHandler.Login)
engine.GET("/api/auth/check", authHandler.Check)
// API 路由 (需要 JWT)
api := engine.Group("/api")
api.Use(middleware.JWTAuth(jwtAuth))
{
// 状态
statusHandler := handler.NewStatusHandler(app)
api.GET("/status", statusHandler.GetStatus)
api.GET("/update/version", statusHandler.GetVersion)
// 客户端管理
clientHandler := handler.NewClientHandler(app)
api.GET("/clients", clientHandler.List)
api.POST("/clients", clientHandler.Create)
api.GET("/client/:id", clientHandler.Get)
api.PUT("/client/:id", clientHandler.Update)
api.DELETE("/client/:id", clientHandler.Delete)
api.POST("/client/:id/push", clientHandler.PushConfig)
api.POST("/client/:id/disconnect", clientHandler.Disconnect)
api.POST("/client/:id/restart", clientHandler.Restart)
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
api.POST("/client/:id/plugin/:pluginName/:action", clientHandler.PluginAction)
// 配置管理
configHandler := handler.NewConfigHandler(app)
api.GET("/config", configHandler.Get)
api.PUT("/config", configHandler.Update)
api.POST("/config/reload", configHandler.Reload)
// 插件管理
pluginHandler := handler.NewPluginHandler(app)
api.GET("/plugins", pluginHandler.List)
api.POST("/plugin/:name/enable", pluginHandler.Enable)
api.POST("/plugin/:name/disable", pluginHandler.Disable)
api.GET("/rule-schemas", pluginHandler.GetRuleSchemas)
api.GET("/client-plugin/:clientID/:pluginName/config", pluginHandler.GetClientConfig)
api.PUT("/client-plugin/:clientID/:pluginName/config", pluginHandler.UpdateClientConfig)
// JS 插件管理
jsPluginHandler := handler.NewJSPluginHandler(app)
api.GET("/js-plugins", jsPluginHandler.List)
api.POST("/js-plugins", jsPluginHandler.Create)
api.GET("/js-plugin/:name", jsPluginHandler.Get)
api.PUT("/js-plugin/:name", jsPluginHandler.Update)
api.DELETE("/js-plugin/:name", jsPluginHandler.Delete)
api.POST("/js-plugin/:name/push/:clientID", jsPluginHandler.PushToClient)
// 插件商店
storeHandler := handler.NewStoreHandler(app)
api.GET("/store/plugins", storeHandler.ListPlugins)
api.POST("/store/install", storeHandler.Install)
// 更新管理
updateHandler := handler.NewUpdateHandler(app)
api.GET("/update/check/server", updateHandler.CheckServer)
api.GET("/update/check/client", updateHandler.CheckClient)
api.POST("/update/apply/server", updateHandler.ApplyServer)
api.POST("/update/apply/client", updateHandler.ApplyClient)
}
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(auth.Username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(auth.Password)) == 1
if !userMatch || !passMatch {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// JWTMiddleware JWT 认证中间件
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 只对 /api/ 路径进行认证
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// 检查是否跳过认证
for _, path := range skipPaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r)
return
}
}
// 从 Header 获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := jwtAuth.ValidateToken(token); err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
// SetupStaticFiles 配置静态文件处理
func (r *GinRouter) SetupStaticFiles(staticFS fs.FS) {
// 使用 NoRoute 处理 SPA 路由
r.Engine.NoRoute(gin.WrapH(&spaHandler{fs: http.FS(staticFS)}))
}
// spaHandler SPA 路由处理器
type spaHandler struct {
fs http.FileSystem
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
f, err := h.fs.Open(path)
if err != nil {
f, err = h.fs.Open("index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if stat.IsDir() {
f.Close()
f, err = h.fs.Open(path + "/index.html")
if err != nil {
f, _ = h.fs.Open("index.html")
}
stat, _ = f.Stat()
}
if seeker, ok := f.(io.ReadSeeker); ok {
http.ServeContent(w, r, path, stat.ModTime(), seeker)
}
}
// Re-export types from handler package for backward compatibility
type (
ServerInterface = handler.ServerInterface
AppInterface = handler.AppInterface
ConfigField = handler.ConfigField
RuleSchema = handler.RuleSchema
PluginInfo = handler.PluginInfo
JSPluginInstallRequest = handler.JSPluginInstallRequest
)

View File

@@ -0,0 +1,256 @@
package router
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/gotunnel/pkg/version"
)
// UpdateInfo 更新信息
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// checkUpdateForComponent 检查组件更新
func checkUpdateForComponent(component string) (*UpdateInfo, error) {
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
currentVersion := version.Version
available := version.CompareVersions(currentVersion, latestVersion) < 0
// 查找对应平台的资产
assetName := getAssetNameForPlatform(component, runtime.GOOS, runtime.GOARCH)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: available,
Current: currentVersion,
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// checkClientUpdateForPlatform 检查指定平台的客户端更新
func checkClientUpdateForPlatform(osName, arch string) (*UpdateInfo, error) {
if osName == "" {
osName = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
release, err := version.GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
latestVersion := release.TagName
// 查找对应平台的资产
assetName := getAssetNameForPlatform("client", osName, arch)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Available: true, // 客户端版本由服务端判断
Current: "", // 客户端版本需要单独获取
Latest: latestVersion,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// getAssetNameForPlatform 获取指定平台的资产名称
func getAssetNameForPlatform(component, osName, arch string) string {
ext := ""
if osName == "windows" {
ext = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", component, osName, arch, ext)
}
// performSelfUpdate 执行自更新
func performSelfUpdate(downloadURL string, restart bool) error {
// 下载新版本
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, "gotunnel_update_"+time.Now().Format("20060102150405"))
if runtime.GOOS == "windows" {
tempFile += ".exe"
}
if err := downloadFile(downloadURL, tempFile); err != nil {
return fmt.Errorf("download update: %w", err)
}
// 设置执行权限
if runtime.GOOS != "windows" {
if err := os.Chmod(tempFile, 0755); err != nil {
os.Remove(tempFile)
return fmt.Errorf("chmod: %w", err)
}
}
// 获取当前可执行文件路径
currentPath, err := os.Executable()
if err != nil {
os.Remove(tempFile)
return fmt.Errorf("get executable: %w", err)
}
currentPath, _ = filepath.EvalSymlinks(currentPath)
// Windows 需要特殊处理(运行中的文件无法直接替换)
if runtime.GOOS == "windows" {
return performWindowsUpdate(tempFile, currentPath, restart)
}
// Linux/Mac: 直接替换
backupPath := currentPath + ".bak"
// 备份当前文件
if err := os.Rename(currentPath, backupPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("backup current: %w", err)
}
// 移动新文件
if err := os.Rename(tempFile, currentPath); err != nil {
os.Rename(backupPath, currentPath)
return fmt.Errorf("replace binary: %w", err)
}
// 删除备份
os.Remove(backupPath)
if restart {
// 重启进程
restartProcess(currentPath)
}
return nil
}
// performWindowsUpdate Windows 平台更新
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
// 创建批处理脚本来替换文件并重启
batchScript := fmt.Sprintf(`@echo off
ping 127.0.0.1 -n 2 > nul
del "%s"
move "%s" "%s"
`, currentPath, newFile, currentPath)
if restart {
batchScript += fmt.Sprintf(`start "" "%s"
`, currentPath)
}
batchScript += "del \"%~f0\"\n"
batchPath := filepath.Join(os.TempDir(), "gotunnel_update.bat")
if err := os.WriteFile(batchPath, []byte(batchScript), 0755); err != nil {
return fmt.Errorf("write batch: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "/MIN", batchPath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("start batch: %w", err)
}
// 退出当前进程
os.Exit(0)
return nil
}
// restartProcess 重启进程
func restartProcess(path string) {
cmd := exec.Command(path, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
os.Exit(0)
}
// downloadFile 下载文件
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// VersionInfo 版本信息
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// getVersionInfo 获取版本信息
func getVersionInfo() VersionInfo {
info := version.GetInfo()
return VersionInfo{
Version: info.Version,
GitCommit: info.GitCommit,
BuildTime: info.BuildTime,
GoVersion: info.GoVersion,
OS: info.OS,
Arch: info.Arch,
}
}

View File

@@ -1306,3 +1306,32 @@ func (s *Server) UpdateClientPluginConfig(clientID, pluginName, ruleName string,
return nil
}
// SendUpdateToClient 发送更新命令到客户端
func (s *Server) SendUpdateToClient(clientID, downloadURL string) error {
s.mu.RLock()
cs, ok := s.clients[clientID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("client %s not found or not online", clientID)
}
// 发送更新消息
stream, err := cs.Session.Open()
if err != nil {
return err
}
defer stream.Close()
req := protocol.UpdateDownloadRequest{
DownloadURL: downloadURL,
}
msg, _ := protocol.NewMessage(protocol.MsgTypeUpdateDownload, req)
if err := protocol.WriteMessage(stream, msg); err != nil {
return err
}
log.Printf("[Server] Update command sent to client %s: %s", clientID, downloadURL)
return nil
}

View File

@@ -52,6 +52,14 @@ const (
// 客户端控制消息
MsgTypeClientRestart uint8 = 60 // 重启客户端
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
// 更新相关消息
MsgTypeUpdateCheck uint8 = 70 // 检查更新请求
MsgTypeUpdateInfo uint8 = 71 // 更新信息响应
MsgTypeUpdateDownload uint8 = 72 // 下载更新请求
MsgTypeUpdateApply uint8 = 73 // 应用更新请求
MsgTypeUpdateProgress uint8 = 74 // 更新进度
MsgTypeUpdateResult uint8 = 75 // 更新结果
)
// Message 基础消息结构
@@ -257,6 +265,46 @@ type PluginConfigUpdateResponse struct {
Error string `json:"error,omitempty"`
}
// UpdateCheckRequest 更新检查请求
type UpdateCheckRequest struct {
Component string `json:"component"` // "server" 或 "client"
}
// UpdateInfoResponse 更新信息响应
type UpdateInfoResponse struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// UpdateDownloadRequest 下载更新请求
type UpdateDownloadRequest struct {
DownloadURL string `json:"download_url"`
}
// UpdateApplyRequest 应用更新请求
type UpdateApplyRequest struct {
Restart bool `json:"restart"` // 是否自动重启
}
// UpdateProgressResponse 更新进度响应
type UpdateProgressResponse struct {
Downloaded int64 `json:"downloaded"`
Total int64 `json:"total"`
Percent int `json:"percent"`
Status string `json:"status"` // downloading, applying, completed, failed
}
// UpdateResultResponse 更新结果响应
type UpdateResultResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// WriteMessage 写入消息到 writer
func WriteMessage(w io.Writer, msg *Message) error {
header := make([]byte, HeaderSize)

207
pkg/version/version.go Normal file
View File

@@ -0,0 +1,207 @@
package version
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strconv"
"strings"
"time"
)
// 版本信息
const Version = "1.0.0"
// 仓库信息
const (
RepoURL = "https://git.92coco.cn:8443/flik/GoTunnel"
APIBaseURL = "https://git.92coco.cn:8443/api/v1"
RepoOwner = "flik"
RepoName = "GoTunnel"
)
// Info 版本详细信息
type Info struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// GetInfo 获取版本信息
func GetInfo() Info {
return Info{
Version: Version,
GitCommit: "",
BuildTime: "",
GoVersion: runtime.Version(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
}
// ReleaseInfo Release 信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
}
// ReleaseAsset Release 资产
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// UpdateInfo 更新信息
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// GetLatestRelease 获取最新 Release
func GetLatestRelease() (*ReleaseInfo, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", APIBaseURL, RepoOwner, RepoName)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error: %s - %s", resp.Status, string(body))
}
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// CheckUpdate 检查更新(返回最新版本信息)
func CheckUpdate(component string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
assetName := getAssetName(component)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Latest: release.TagName,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// CheckUpdateForPlatform 检查指定平台的更新
func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
assetName := getAssetNameForPlatform(component, osName, arch)
var downloadURL string
var assetSize int64
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
break
}
}
return &UpdateInfo{
Latest: release.TagName,
ReleaseNote: release.Body,
DownloadURL: downloadURL,
AssetName: assetName,
AssetSize: assetSize,
}, nil
}
// getAssetName 获取当前平台的资产文件名
func getAssetName(component string) string {
return getAssetNameForPlatform(component, runtime.GOOS, runtime.GOARCH)
}
// getAssetNameForPlatform 获取指定平台的资产文件名
func getAssetNameForPlatform(component, osName, arch string) string {
ext := ""
if osName == "windows" {
ext = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", component, osName, arch, ext)
}
// CompareVersions 比较版本号
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
func CompareVersions(v1, v2 string) int {
parts1 := parseVersionParts(v1)
parts2 := parseVersionParts(v2)
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var p1, p2 int
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}
func parseVersionParts(v string) []int {
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
result := make([]int, len(parts))
for i, p := range parts {
n, _ := strconv.Atoi(p)
result[i] = n
}
return result
}

View File

@@ -1,7 +1,7 @@
{
"name": "webui",
"name": "GoTunnel",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -35,6 +35,8 @@ export const stopClientPlugin = (clientId: string, pluginName: string, ruleName:
post(`/client/${clientId}/plugin/${pluginName}/stop`, { rule_name: ruleName })
export const restartClientPlugin = (clientId: string, pluginName: string, ruleName: string) =>
post(`/client/${clientId}/plugin/${pluginName}/restart`, { rule_name: ruleName })
export const deleteClientPlugin = (clientId: string, pluginName: string) =>
del(`/client/${clientId}/plugin/${pluginName}/delete`)
export const updateClientPluginConfigWithRestart = (clientId: string, pluginName: string, ruleName: string, config: Record<string, string>, restart: boolean) =>
post(`/client/${clientId}/plugin/${pluginName}/config`, { rule_name: ruleName, config, restart })
@@ -66,3 +68,37 @@ export const updateJSPluginConfig = (name: string, config: Record<string, string
put(`/js-plugin/${name}/config`, { config })
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)
// 更新管理
export interface UpdateInfo {
available: boolean
current: string
latest: string
release_note: string
download_url: string
asset_name: string
asset_size: number
}
export interface VersionInfo {
version: string
git_commit: string
build_time: string
go_version: string
os: string
arch: string
}
export const getVersionInfo = () => get<VersionInfo>('/update/version')
export const checkServerUpdate = () => get<UpdateInfo>('/update/check/server')
export const checkClientUpdate = (os?: string, arch?: string) => {
const params = new URLSearchParams()
if (os) params.append('os', os)
if (arch) params.append('arch', arch)
const query = params.toString()
return get<UpdateInfo>(`/update/check/client${query ? '?' + query : ''}`)
}
export const applyServerUpdate = (downloadUrl: string, restart: boolean = true) =>
post('/update/apply/server', { download_url: downloadUrl, restart })
export const applyClientUpdate = (clientId: string, downloadUrl: string) =>
post('/update/apply/client', { client_id: clientId, download_url: downloadUrl })

View File

@@ -1,4 +1,4 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
// Token 管理
const TOKEN_KEY = 'gotunnel_token'
@@ -7,6 +7,30 @@ export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string): void => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = (): void => localStorage.removeItem(TOKEN_KEY)
// 统一 API 响应结构
export interface ApiResponse<T = any> {
code: number
data?: T
message?: string
}
// 业务错误码
export const ErrorCodes = {
Success: 0,
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
NotFound: 404,
Conflict: 409,
InternalError: 500,
BadGateway: 502,
ClientNotOnline: 1001,
PluginNotFound: 1002,
InvalidClientID: 1003,
PluginDisabled: 1004,
ConfigSyncFailed: 1005,
}
// 创建 axios 实例
const instance: AxiosInstance = axios.create({
baseURL: '/api',
@@ -30,10 +54,39 @@ instance.interceptors.request.use(
}
)
// 响应拦截器
// 响应拦截器 - 处理统一响应格式
instance.interceptors.response.use(
(response) => response,
(error) => {
(response: AxiosResponse<ApiResponse>) => {
const apiResponse = response.data
// 检查业务错误码
if (apiResponse.code !== undefined && apiResponse.code !== ErrorCodes.Success) {
// 处理认证错误
if (apiResponse.code === ErrorCodes.Unauthorized && !isRedirecting) {
isRedirecting = true
removeToken()
setTimeout(() => {
window.location.replace('/login')
isRedirecting = false
}, 0)
}
// 返回包含业务错误信息的 rejected promise
return Promise.reject({
code: apiResponse.code,
message: apiResponse.message || 'Unknown error',
response: response
})
}
// 成功时返回 data 字段
return {
...response,
data: apiResponse.data !== undefined ? apiResponse.data : apiResponse
} as AxiosResponse
},
(error: AxiosError<ApiResponse>) => {
// 处理 HTTP 错误
if (error.response?.status === 401 && !isRedirecting) {
isRedirecting = true
removeToken()
@@ -42,6 +95,17 @@ instance.interceptors.response.use(
isRedirecting = false
}, 0)
}
// 尝试从响应中提取业务错误信息
const apiResponse = error.response?.data
if (apiResponse?.message) {
return Promise.reject({
code: apiResponse.code || error.response?.status,
message: apiResponse.message,
response: error.response
})
}
return Promise.reject(error)
}
)

View File

@@ -25,6 +25,11 @@ const router = createRouter({
name: 'plugins',
component: () => import('../views/PluginsView.vue'),
},
{
path: '/update',
name: 'update',
component: () => import('../views/UpdateView.vue'),
},
],
})

View File

@@ -14,7 +14,7 @@ import {
import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, restartClientPlugin, stopClientPlugin
getStorePlugins, installStorePlugin, getRuleSchemas, restartClientPlugin, stopClientPlugin, deleteClientPlugin
} from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
@@ -351,6 +351,25 @@ const savePluginConfig = async () => {
message.error(e.response?.data || '保存失败')
}
}
// 删除客户端插件
const handleDeletePlugin = (plugin: ClientPlugin) => {
dialog.warning({
title: '确认删除',
content: `确定要删除插件 ${plugin.name} 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteClientPlugin(clientId, plugin.name)
message.success(`已删除 ${plugin.name}`)
await loadClient()
} catch (e: any) {
message.error(e.response?.data || '删除失败')
}
}
})
}
</script>
<template>
@@ -576,6 +595,10 @@ const savePluginConfig = async () => {
<template #icon><n-icon><StopOutline /></n-icon></template>
停止
</n-button>
<n-button size="small" quaternary type="error" @click="handleDeletePlugin(plugin)">
<template #icon><n-icon><TrashOutline /></n-icon></template>
删除
</n-button>
</n-space>
</td>
</tr>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty } from 'naive-ui'
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty, NIcon } from 'naive-ui'
import { ExtensionPuzzleOutline, CloudDownloadOutline } from '@vicons/ionicons5'
import { getClients } from '../api'
import type { ClientStatus } from '../types'
@@ -35,10 +36,22 @@ const viewClient = (id: string) => {
<template>
<div class="home">
<div style="margin-bottom: 24px;">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
<div>
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
</div>
<n-space>
<n-button @click="router.push('/plugins')">
<template #icon><n-icon><ExtensionPuzzleOutline /></n-icon></template>
扩展商店
</n-button>
<n-button @click="router.push('/update')">
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
系统更新
</n-button>
</n-space>
</n-space>
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
<n-gi>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon,
NAlert, NSelect, useMessage, useDialog
} from 'naive-ui'
import { ArrowBackOutline, CloudDownloadOutline, RefreshOutline, RocketOutline } from '@vicons/ionicons5'
import {
getVersionInfo, checkServerUpdate, checkClientUpdate, applyServerUpdate, applyClientUpdate,
getClients, type UpdateInfo, type VersionInfo
} from '../api'
import type { ClientStatus } from '../types'
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const versionInfo = ref<VersionInfo | null>(null)
const serverUpdate = ref<UpdateInfo | null>(null)
const clientUpdate = ref<UpdateInfo | null>(null)
const clients = ref<ClientStatus[]>([])
const loading = ref(true)
const checkingServer = ref(false)
const checkingClient = ref(false)
const updatingServer = ref(false)
const selectedClientId = ref('')
const onlineClients = computed(() => clients.value.filter(c => c.online))
const loadVersionInfo = async () => {
try {
const { data } = await getVersionInfo()
versionInfo.value = data
} catch (e) {
console.error('Failed to load version info', e)
}
}
const loadClients = async () => {
try {
const { data } = await getClients()
clients.value = data || []
} catch (e) {
console.error('Failed to load clients', e)
}
}
const handleCheckServerUpdate = async () => {
checkingServer.value = true
try {
const { data } = await checkServerUpdate()
serverUpdate.value = data
if (data.available) {
message.success('发现新版本: ' + data.latest)
} else {
message.info('已是最新版本')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingServer.value = false
}
}
const handleCheckClientUpdate = async () => {
checkingClient.value = true
try {
const { data } = await checkClientUpdate()
clientUpdate.value = data
if (data.download_url) {
message.success('找到客户端更新包: ' + data.latest)
} else {
message.warning('未找到对应平台的更新包')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingClient.value = false
}
}
const handleApplyServerUpdate = () => {
if (!serverUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新服务端',
content: `即将更新服务端到 ${serverUpdate.value.latest},更新后服务器将自动重启。确定要继续吗?`,
positiveText: '更新并重启',
negativeText: '取消',
onPositiveClick: async () => {
updatingServer.value = true
try {
await applyServerUpdate(serverUpdate.value!.download_url)
message.success('更新已开始,服务器将在几秒后重启')
// 显示倒计时或等待
setTimeout(() => {
window.location.reload()
}, 5000)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
updatingServer.value = false
}
}
})
}
const handleApplyClientUpdate = async () => {
if (!selectedClientId.value) {
message.warning('请选择要更新的客户端')
return
}
if (!clientUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
const clientName = onlineClients.value.find(c => c.id === selectedClientId.value)?.nickname || selectedClientId.value
dialog.warning({
title: '确认更新客户端',
content: `即将更新客户端 "${clientName}" 到 ${clientUpdate.value.latest},更新后客户端将自动重启。确定要继续吗?`,
positiveText: '更新',
negativeText: '取消',
onPositiveClick: async () => {
try {
await applyClientUpdate(selectedClientId.value, clientUpdate.value!.download_url)
message.success(`更新命令已发送到客户端 ${clientName}`)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
}
}
})
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMounted(async () => {
await Promise.all([loadVersionInfo(), loadClients()])
loading.value = false
})
</script>
<template>
<div class="update-view">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
<div>
<h2 style="margin: 0 0 8px 0;">系统更新</h2>
<p style="margin: 0; color: #666;">检查并应用服务端和客户端更新</p>
</div>
<n-button quaternary @click="router.push('/')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
返回首页
</n-button>
</n-space>
<n-spin :show="loading">
<!-- 当前版本信息 -->
<n-card title="当前版本" style="margin-bottom: 16px;">
<n-grid v-if="versionInfo" :cols="6" :x-gap="16" responsive="screen" cols-s="2" cols-m="3">
<n-gi>
<div class="info-item">
<span class="label">版本号</span>
<span class="value">{{ versionInfo.version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Git 提交</span>
<span class="value">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">构建时间</span>
<span class="value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">Go 版本</span>
<span class="value">{{ versionInfo.go_version }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">操作系统</span>
<span class="value">{{ versionInfo.os }}</span>
</div>
</n-gi>
<n-gi>
<div class="info-item">
<span class="label">架构</span>
<span class="value">{{ versionInfo.arch }}</span>
</div>
</n-gi>
</n-grid>
<n-empty v-else description="加载中..." />
</n-card>
<n-grid :cols="2" :x-gap="16" responsive="screen" cols-s="1">
<!-- 服务端更新 -->
<n-gi>
<n-card title="服务端更新">
<template #header-extra>
<n-button size="small" :loading="checkingServer" @click="handleCheckServerUpdate">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
检查更新
</n-button>
</template>
<n-empty v-if="!serverUpdate" description="点击检查更新按钮查看是否有新版本" />
<template v-else>
<n-alert v-if="serverUpdate.available" type="success" style="margin-bottom: 16px;">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</n-alert>
<n-alert v-else type="info" style="margin-bottom: 16px;">
当前已是最新版本 {{ serverUpdate.current }}
</n-alert>
<n-space vertical :size="12">
<div v-if="serverUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
下载文件: {{ serverUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
</p>
</div>
<div v-if="serverUpdate.release_note" style="max-height: 150px; overflow-y: auto;">
<p style="margin: 0 0 4px 0; color: #666; font-size: 12px;">更新日志:</p>
<pre style="margin: 0; white-space: pre-wrap; font-size: 12px; color: #333;">{{ serverUpdate.release_note }}</pre>
</div>
<n-button
v-if="serverUpdate.available && serverUpdate.download_url"
type="primary"
:loading="updatingServer"
@click="handleApplyServerUpdate"
>
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
下载并更新服务端
</n-button>
</n-space>
</template>
</n-card>
</n-gi>
<!-- 客户端更新 -->
<n-gi>
<n-card title="客户端更新">
<template #header-extra>
<n-button size="small" :loading="checkingClient" @click="handleCheckClientUpdate">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
检查更新
</n-button>
</template>
<n-empty v-if="!clientUpdate" description="点击检查更新按钮查看客户端更新" />
<template v-else>
<n-space vertical :size="12">
<div v-if="clientUpdate.download_url">
<p style="margin: 0 0 8px 0; color: #666;">
最新版本: {{ clientUpdate.latest }}
</p>
<p style="margin: 0 0 8px 0; color: #666;">
下载文件: {{ clientUpdate.asset_name }}
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(clientUpdate.asset_size) }}</n-tag>
</p>
</div>
<n-empty v-if="onlineClients.length === 0" description="没有在线的客户端" />
<template v-else>
<n-select
v-model:value="selectedClientId"
placeholder="选择要更新的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
<n-button
type="primary"
:disabled="!selectedClientId || !clientUpdate.download_url"
@click="handleApplyClientUpdate"
>
<template #icon><n-icon><RocketOutline /></n-icon></template>
推送更新到客户端
</n-button>
</template>
</n-space>
</template>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</div>
</template>
<style scoped>
.info-item {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.info-item .label {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.info-item .value {
font-size: 14px;
color: #333;
font-weight: 500;
}
</style>