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
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:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go build:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(go list:*)",
|
||||||
|
"Bash(go get:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
package main
|
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 (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +19,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/gotunnel/docs" // Swagger docs
|
||||||
|
|
||||||
"github.com/gotunnel/internal/server/app"
|
"github.com/gotunnel/internal/server/app"
|
||||||
"github.com/gotunnel/internal/server/config"
|
"github.com/gotunnel/internal/server/config"
|
||||||
"github.com/gotunnel/internal/server/db"
|
"github.com/gotunnel/internal/server/db"
|
||||||
|
|||||||
2398
docs/docs.go
Normal file
2398
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2374
docs/swagger.json
Normal file
2374
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1500
docs/swagger.yaml
Normal file
1500
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
62
go.mod
62
go.mod
@@ -10,19 +10,77 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 // indirect
|
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/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/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/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/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/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/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // 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/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
195
go.sum
195
go.sum
@@ -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 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:3uSSOd6mVlwcX3k5OYOpiDqFgRmaE2dBfLvVIFWWHrw=
|
||||||
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||||
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 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/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 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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.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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
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/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
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=
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package tunnel
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -231,6 +235,8 @@ func (c *Client) handleStream(stream net.Conn) {
|
|||||||
c.handleClientRestart(stream, msg)
|
c.handleClientRestart(stream, msg)
|
||||||
case protocol.MsgTypePluginConfigUpdate:
|
case protocol.MsgTypePluginConfigUpdate:
|
||||||
c.handlePluginConfigUpdate(stream, msg)
|
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)
|
msg, _ := protocol.NewMessage(protocol.MsgTypePluginConfigUpdate, result)
|
||||||
protocol.WriteMessage(stream, msg)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gotunnel/internal/server/config"
|
"github.com/gotunnel/internal/server/config"
|
||||||
"github.com/gotunnel/internal/server/db"
|
"github.com/gotunnel/internal/server/db"
|
||||||
@@ -16,33 +14,6 @@ import (
|
|||||||
//go:embed dist/*
|
//go:embed dist/*
|
||||||
var staticFiles embed.FS
|
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控制台服务
|
// WebServer Web控制台服务
|
||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
ClientStore db.ClientStore
|
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 {
|
func (w *WebServer) Run(addr string) error {
|
||||||
r := router.New()
|
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")
|
staticFS, err := fs.Sub(staticFiles, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
r.SetupStaticFiles(staticFS)
|
||||||
|
|
||||||
log.Printf("[Web] Console listening on %s", addr)
|
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 {
|
func (w *WebServer) RunWithAuth(addr, username, password string) error {
|
||||||
r := router.New()
|
// 转发到 JWT 认证
|
||||||
router.RegisterRoutes(r, w)
|
return w.RunWithJWT(addr, username, password, "auto-generated-secret")
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithJWT 启动带 JWT 认证的 Web 服务
|
// RunWithJWT 启动带 JWT 认证的 Web 服务
|
||||||
@@ -102,26 +66,18 @@ func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error
|
|||||||
// JWT 认证器
|
// JWT 认证器
|
||||||
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
|
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
|
||||||
|
|
||||||
// 注册认证路由(不需要认证)
|
// 设置所有路由
|
||||||
authHandler := router.NewAuthHandler(username, password, jwtAuth)
|
r.SetupRoutes(w, jwtAuth, username, password)
|
||||||
router.RegisterAuthRoutes(r, authHandler)
|
|
||||||
|
|
||||||
// 注册业务路由
|
|
||||||
router.RegisterRoutes(r, w)
|
|
||||||
|
|
||||||
// 静态文件
|
// 静态文件
|
||||||
staticFS, err := fs.Sub(staticFiles, "dist")
|
staticFS, err := fs.Sub(staticFiles, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
r.SetupStaticFiles(staticFS)
|
||||||
|
|
||||||
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/)
|
|
||||||
skipPaths := []string{"/api/auth/"}
|
|
||||||
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
|
|
||||||
|
|
||||||
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
|
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
|
||||||
return http.ListenAndServe(addr, handler)
|
return r.Engine.Run(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientStore 获取客户端存储
|
// GetClientStore 获取客户端存储
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
58
internal/server/router/dto/client.go
Normal file
58
internal/server/router/dto/client.go
Normal 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"`
|
||||||
|
}
|
||||||
53
internal/server/router/dto/config.go
Normal file
53
internal/server/router/dto/config.go
Normal 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"` // 显示为 ****
|
||||||
|
}
|
||||||
105
internal/server/router/dto/plugin.go
Normal file
105
internal/server/router/dto/plugin.go
Normal 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"`
|
||||||
|
}
|
||||||
76
internal/server/router/dto/update.go
Normal file
76
internal/server/router/dto/update.go
Normal 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"`
|
||||||
|
}
|
||||||
106
internal/server/router/errors.go
Normal file
106
internal/server/router/errors.go
Normal 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
|
||||||
|
}
|
||||||
100
internal/server/router/handler/auth.go
Normal file
100
internal/server/router/handler/auth.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
393
internal/server/router/handler/client.go
Normal file
393
internal/server/router/handler/client.go
Normal 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
|
||||||
|
}
|
||||||
131
internal/server/router/handler/config.go
Normal file
131
internal/server/router/handler/config.go
Normal 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"})
|
||||||
|
}
|
||||||
230
internal/server/router/handler/helpers.go
Normal file
230
internal/server/router/handler/helpers.go
Normal 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
|
||||||
|
}
|
||||||
83
internal/server/router/handler/interfaces.go
Normal file
83
internal/server/router/handler/interfaces.go
Normal 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"`
|
||||||
|
}
|
||||||
212
internal/server/router/handler/js_plugin.go
Normal file
212
internal/server/router/handler/js_plugin.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
285
internal/server/router/handler/plugin.go
Normal file
285
internal/server/router/handler/plugin.go
Normal 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
|
||||||
|
}
|
||||||
159
internal/server/router/handler/response.go
Normal file
159
internal/server/router/handler/response.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
51
internal/server/router/handler/status.go
Normal file
51
internal/server/router/handler/status.go
Normal 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())
|
||||||
|
}
|
||||||
169
internal/server/router/handler/store.go
Normal file
169
internal/server/router/handler/store.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
132
internal/server/router/handler/update.go
Normal file
132
internal/server/router/handler/update.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/server/router/middleware/cors.go
Normal file
28
internal/server/router/middleware/cors.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/server/router/middleware/jwt.go
Normal file
49
internal/server/router/middleware/jwt.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/server/router/middleware/logger.go
Normal file
31
internal/server/router/middleware/logger.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/server/router/middleware/recovery.go
Normal file
26
internal/server/router/middleware/recovery.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
116
internal/server/router/response.go
Normal file
116
internal/server/router/response.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,123 +1,162 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"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"
|
"github.com/gotunnel/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router 路由管理器
|
// GinRouter Gin 路由管理器
|
||||||
type Router struct {
|
type GinRouter struct {
|
||||||
mux *http.ServeMux
|
Engine *gin.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthConfig 认证配置
|
// New 创建 Gin 路由管理器
|
||||||
type AuthConfig struct {
|
func New() *GinRouter {
|
||||||
Username string
|
gin.SetMode(gin.ReleaseMode)
|
||||||
Password string
|
engine := gin.New()
|
||||||
}
|
return &GinRouter{Engine: engine}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler 返回 http.Handler
|
// Handler 返回 http.Handler
|
||||||
func (r *Router) Handler() http.Handler {
|
func (r *GinRouter) Handler() http.Handler {
|
||||||
return r.mux
|
return r.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// BasicAuthMiddleware 基础认证中间件
|
// SetupRoutes 配置所有路由
|
||||||
func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
|
func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth, username, password string) {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
engine := r.Engine
|
||||||
if auth == nil || (auth.Username == "" && auth.Password == "") {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, pass, ok := r.BasicAuth()
|
// 全局中间件
|
||||||
if !ok {
|
engine.Use(middleware.Recovery())
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
|
engine.Use(middleware.Logger())
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
engine.Use(middleware.CORS())
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(auth.Username)) == 1
|
// Swagger 文档
|
||||||
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(auth.Password)) == 1
|
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
if !userMatch || !passMatch {
|
// 认证路由 (无需 JWT)
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="GoTunnel"`)
|
authHandler := handler.NewAuthHandler(username, password, jwtAuth)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
engine.POST("/api/auth/login", authHandler.Login)
|
||||||
return
|
engine.GET("/api/auth/check", authHandler.Check)
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTMiddleware JWT 认证中间件
|
// SetupStaticFiles 配置静态文件处理
|
||||||
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
|
func (r *GinRouter) SetupStaticFiles(staticFS fs.FS) {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// 使用 NoRoute 处理 SPA 路由
|
||||||
// 只对 /api/ 路径进行认证
|
r.Engine.NoRoute(gin.WrapH(&spaHandler{fs: http.FS(staticFS)}))
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|||||||
256
internal/server/router/update_helpers.go
Normal file
256
internal/server/router/update_helpers.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1306,3 +1306,32 @@ func (s *Server) UpdateClientPluginConfig(clientID, pluginName, ruleName string,
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ const (
|
|||||||
// 客户端控制消息
|
// 客户端控制消息
|
||||||
MsgTypeClientRestart uint8 = 60 // 重启客户端
|
MsgTypeClientRestart uint8 = 60 // 重启客户端
|
||||||
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
|
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
|
||||||
|
|
||||||
|
// 更新相关消息
|
||||||
|
MsgTypeUpdateCheck uint8 = 70 // 检查更新请求
|
||||||
|
MsgTypeUpdateInfo uint8 = 71 // 更新信息响应
|
||||||
|
MsgTypeUpdateDownload uint8 = 72 // 下载更新请求
|
||||||
|
MsgTypeUpdateApply uint8 = 73 // 应用更新请求
|
||||||
|
MsgTypeUpdateProgress uint8 = 74 // 更新进度
|
||||||
|
MsgTypeUpdateResult uint8 = 75 // 更新结果
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message 基础消息结构
|
// Message 基础消息结构
|
||||||
@@ -257,6 +265,46 @@ type PluginConfigUpdateResponse struct {
|
|||||||
Error string `json:"error,omitempty"`
|
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
|
// WriteMessage 写入消息到 writer
|
||||||
func WriteMessage(w io.Writer, msg *Message) error {
|
func WriteMessage(w io.Writer, msg *Message) error {
|
||||||
header := make([]byte, HeaderSize)
|
header := make([]byte, HeaderSize)
|
||||||
|
|||||||
207
pkg/version/version.go
Normal file
207
pkg/version/version.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "GoTunnel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const stopClientPlugin = (clientId: string, pluginName: string, ruleName:
|
|||||||
post(`/client/${clientId}/plugin/${pluginName}/stop`, { rule_name: ruleName })
|
post(`/client/${clientId}/plugin/${pluginName}/stop`, { rule_name: ruleName })
|
||||||
export const restartClientPlugin = (clientId: string, pluginName: string, ruleName: string) =>
|
export const restartClientPlugin = (clientId: string, pluginName: string, ruleName: string) =>
|
||||||
post(`/client/${clientId}/plugin/${pluginName}/restart`, { rule_name: ruleName })
|
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) =>
|
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 })
|
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 })
|
put(`/js-plugin/${name}/config`, { config })
|
||||||
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
|
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
|
||||||
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)
|
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 })
|
||||||
|
|||||||
@@ -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 管理
|
// Token 管理
|
||||||
const TOKEN_KEY = 'gotunnel_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 setToken = (token: string): void => localStorage.setItem(TOKEN_KEY, token)
|
||||||
export const removeToken = (): void => localStorage.removeItem(TOKEN_KEY)
|
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 实例
|
// 创建 axios 实例
|
||||||
const instance: AxiosInstance = axios.create({
|
const instance: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -30,10 +54,39 @@ instance.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器 - 处理统一响应格式
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response) => response,
|
(response: AxiosResponse<ApiResponse>) => {
|
||||||
(error) => {
|
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) {
|
if (error.response?.status === 401 && !isRedirecting) {
|
||||||
isRedirecting = true
|
isRedirecting = true
|
||||||
removeToken()
|
removeToken()
|
||||||
@@ -42,6 +95,17 @@ instance.interceptors.response.use(
|
|||||||
isRedirecting = false
|
isRedirecting = false
|
||||||
}, 0)
|
}, 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)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const router = createRouter({
|
|||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('../views/PluginsView.vue'),
|
component: () => import('../views/PluginsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/update',
|
||||||
|
name: 'update',
|
||||||
|
component: () => import('../views/UpdateView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
|
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
|
||||||
getClientPluginConfig, updateClientPluginConfig,
|
getClientPluginConfig, updateClientPluginConfig,
|
||||||
getStorePlugins, installStorePlugin, getRuleSchemas, restartClientPlugin, stopClientPlugin
|
getStorePlugins, installStorePlugin, getRuleSchemas, restartClientPlugin, stopClientPlugin, deleteClientPlugin
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
|
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
|
||||||
|
|
||||||
@@ -351,6 +351,25 @@ const savePluginConfig = async () => {
|
|||||||
message.error(e.response?.data || '保存失败')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -576,6 +595,10 @@ const savePluginConfig = async () => {
|
|||||||
<template #icon><n-icon><StopOutline /></n-icon></template>
|
<template #icon><n-icon><StopOutline /></n-icon></template>
|
||||||
停止
|
停止
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button size="small" quaternary type="error" @click="handleDeletePlugin(plugin)">
|
||||||
|
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { getClients } from '../api'
|
||||||
import type { ClientStatus } from '../types'
|
import type { ClientStatus } from '../types'
|
||||||
|
|
||||||
@@ -35,10 +36,22 @@ const viewClient = (id: string) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<div style="margin-bottom: 24px;">
|
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||||
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
|
<div>
|
||||||
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
|
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
|
||||||
</div>
|
<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-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||||
<n-gi>
|
<n-gi>
|
||||||
|
|||||||
328
web/src/views/UpdateView.vue
Normal file
328
web/src/views/UpdateView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user