commit 905c6363ff5156256eaf504ec53d8d5da4d3775f Author: ginuerzh Date: Thu Dec 14 15:38:33 2023 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fcf3ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +*.syso +*.apk +*.idsig +gost-plus \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31a912c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GOST + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fca6149 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: linux + +linux: + GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build + +win: + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-H windowsgui" + +arm: + GOOS=linux GOARCH=arm64 CGO_ENABLED=1 go build + +android: + gogio -x -work -target android -minsdk 22 -version 1 -appid gost.plus github.com/go-gost/gost-plus + +clean: + rm gost-plus.exe gost-plus gost-plus.apk diff --git a/appicon.png b/appicon.png new file mode 100644 index 0000000..05aa1af Binary files /dev/null and b/appicon.png differ diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..10e13d9 --- /dev/null +++ b/config/config.go @@ -0,0 +1,136 @@ +package config + +import ( + "bytes" + "log" + "os" + "path/filepath" + "sync" + + "gioui.org/app" + "github.com/go-gost/core/logger" + "github.com/go-gost/x/config" + xconfig "github.com/go-gost/x/config" + logger_parser "github.com/go-gost/x/config/parsing/logger" + "gopkg.in/yaml.v3" +) + +const ( + configFile = "config.yml" +) + +var ( + configDir string +) + +func Init() { + log.SetFlags(log.Lshortfile | log.LstdFlags | log.Lmicroseconds) + + dir, err := app.DataDir() + if err != nil { + log.Println(err) + } + if dir == "" { + dir, _ = os.Getwd() + } + configDir = filepath.Join(dir, "gost.plus") + os.MkdirAll(configDir, 0755) + + log.Println("config dir:", configDir) + + if err := global.Load(); err != nil { + log.Println(err) + if _, ok := err.(*os.PathError); ok { + global.Write() + } + } + if global.Log == nil { + logDir := filepath.Join(configDir, "logs") + os.MkdirAll(logDir, 0755) + log.Println("log dir:", logDir) + + global.Log = &xconfig.LogConfig{ + Output: filepath.Join(logDir, "gost-plus.log"), + Level: string(logger.InfoLevel), + Format: string(logger.JSONFormat), + Rotation: &xconfig.LogRotationConfig{ + MaxSize: 10, + MaxAge: 7, + MaxBackups: 10, + LocalTime: true, + Compress: true, + }, + } + } + + logger.SetDefault(logger_parser.ParseLogger(&xconfig.LoggerConfig{Log: global.Log})) +} + +var ( + global = &Config{} + globalMux sync.RWMutex +) + +func Global() *Config { + globalMux.RLock() + defer globalMux.RUnlock() + + cfg := &Config{} + *cfg = *global + return cfg +} + +func Set(c *Config) { + globalMux.Lock() + defer globalMux.Unlock() + + global = c +} + +type Settings struct { + Lang string + Theme string +} + +type Tunnel struct { + ID string + Name string + Type string + Endpoint string + Hostname string `yaml:",omitempty"` + Username string `yaml:",omitempty"` + Password string `yaml:",omitempty"` + EnableTLS bool `yaml:"enableTLS,omitempty"` + + Favorite bool + Closed bool +} + +type Config struct { + Settings *Settings + Tunnels []*Tunnel + Log *config.LogConfig +} + +func (c *Config) Load() error { + f, err := os.Open(filepath.Join(configDir, configFile)) + if err != nil { + return err + } + defer f.Close() + + return yaml.NewDecoder(f).Decode(c) +} + +func (c *Config) Write() error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + defer enc.Close() + + enc.SetIndent(2) + if err := enc.Encode(c); err != nil { + return err + } + + return os.WriteFile(filepath.Join(configDir, configFile), buf.Bytes(), 0644) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8aa5de1 --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module github.com/go-gost/gost-plus + +go 1.21.1 + +require ( + gioui.org v0.4.1 + gioui.org/x v0.4.0 + github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7 + github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8 + github.com/google/uuid v1.4.0 + github.com/spf13/viper v1.18.1 + golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb +) + +require ( + gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect + gioui.org/shader v1.0.8 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-gost/gosocks5 v0.4.0 // indirect + github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b // indirect + github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7 // indirect + github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pires/go-proxyproto v0.7.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/rs/xid v1.3.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xtaci/smux v1.5.24 // indirect + github.com/yl2chen/cidranger v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect + golang.org/x/image v0.7.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4db3a8c --- /dev/null +++ b/go.sum @@ -0,0 +1,197 @@ +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.4.1 h1:sCTw5Fexg0xg9CxYmbrkKtHXcobf0JMbl5XpF2TC/zc= +gioui.org v0.4.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +gioui.org/x v0.4.0 h1:H6DofC86KoG51wgzeeA4ujZDDfcIa8vbL+jD9SpF/D8= +gioui.org/x v0.4.0/go.mod h1:YAoFl2lbeARk4LopDXHK1N7fBQJupPYDSm9maf6tFlM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7 h1:fxVUlZANqPApygO7lT8bYySyajiCFA62bDiNorral1w= +github.com/go-gost/core v0.0.0-20231119081403-abc73f2ca2b7/go.mod h1:ndkgWVYRLwupVaFFWv8ML1Nr8tD3xhHK245PLpUDg4E= +github.com/go-gost/gosocks5 v0.4.0 h1:EIrOEkpJez4gwHrMa33frA+hHXJyevjp47thpMQsJzI= +github.com/go-gost/gosocks5 v0.4.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4= +github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b h1:ZmnYutflq+KOZK+Px5RDckorDSxTYlkT4aQbjTC8/C4= +github.com/go-gost/plugin v0.0.0-20231119084331-d49a1cb23b3b/go.mod h1:qXr2Zm9Ex2ATqnWuNUzVZqySPMnuIihvblYZt4MlZLw= +github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7 h1:qAG1OyjvdA5h221CfFSS3J359V3d2E7dJWyP29QoDSI= +github.com/go-gost/relay v0.4.1-0.20230916134211-828f314ddfe7/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= +github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 h1:xj8gUZGYO3nb5+6Bjw9+tsFkA9sYynrOvDvvC4uDV2I= +github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs= +github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8 h1:aM2tAfTg4+oBiB161GrYbRBhUHQM1CYNiDNYsqoVAlE= +github.com/go-gost/x v0.0.0-20231130113937-b1390dda1cc8/go.mod h1:YaeMQsu+I8Q3bxPFeo5MbnmLsAdbGUxxG3A4mvUt2YE= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= +github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= +github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= +github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= +github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM= +github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= +github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +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/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb h1:t3SA1mKG2eyhChDjOL0n0VLKZQmqU4J8UlCoQ0+nqSk= +golang.org/x/exp/shiny v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= +golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= +golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..df016fc --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "log" + _ "net" + "os" + + "gioui.org/app" + _ "gioui.org/app/permission/storage" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op" + "github.com/go-gost/gost-plus/config" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui" +) + +func main() { + config.Init() + tunnel.LoadConfig() + + go func() { + w := app.NewWindow( + app.Title("GOST.PLUS"), + app.MinSize(800, 600), + ) + err := run(w) + if err != nil { + log.Fatal(err) + } + os.Exit(0) + }() + app.Main() +} + +func run(w *app.Window) error { + ui := ui.NewUI() + var ops op.Ops + for { + switch e := w.NextEvent().(type) { + case system.DestroyEvent: + return e.Err + case system.FrameEvent: + gtx := layout.NewContext(&ops, e) + ui.Layout(gtx) + e.Frame(gtx.Ops) + } + } +} diff --git a/tunnel/file.go b/tunnel/file.go new file mode 100644 index 0000000..c50dd16 --- /dev/null +++ b/tunnel/file.go @@ -0,0 +1,244 @@ +package tunnel + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "os" + "sync/atomic" + + "github.com/go-gost/core/auth" + "github.com/go-gost/core/chain" + "github.com/go-gost/core/handler" + "github.com/go-gost/core/listener" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/service" + xauth "github.com/go-gost/x/auth" + "github.com/go-gost/x/config" + chain_parser "github.com/go-gost/x/config/parsing/chain" + "github.com/go-gost/x/handler/file" + "github.com/go-gost/x/handler/forward/remote" + "github.com/go-gost/x/hop" + "github.com/go-gost/x/listener/rtcp" + "github.com/go-gost/x/listener/tcp" + mdx "github.com/go-gost/x/metadata" + xservice "github.com/go-gost/x/service" + "github.com/google/uuid" +) + +type fileTunnel struct { + endpoint string + opts Options + config *config.Config + file service.Service + forward service.Service + favorite atomic.Bool + + cclose chan struct{} +} + +func NewFileTunnel(opts ...Option) Tunnel { + var options Options + for _, opt := range opts { + opt(&options) + } + + if options.ID == "" { + options.ID = uuid.NewString() + } + + v := md5.Sum([]byte(options.ID)) + endpoint := hex.EncodeToString(v[:8]) + + if options.Endpoint == "" { + options.Endpoint, _ = os.Getwd() + } + + if options.Name == "" { + options.Name = fmt.Sprintf("FILE-%s", endpoint) + } + + s := &fileTunnel{ + endpoint: endpoint, + opts: options, + cclose: make(chan struct{}), + } + + return s +} + +func (s *fileTunnel) ID() string { + return s.opts.ID +} + +func (s *fileTunnel) Type() string { + return FileTunnel +} + +func (s *fileTunnel) Name() string { + return s.opts.Name +} + +func (s *fileTunnel) Endpoint() string { + return s.opts.Endpoint +} + +func (f *fileTunnel) Entrypoint() string { + return fmt.Sprintf("https://%s.%s", f.endpoint, endpointAddr) +} + +func (s *fileTunnel) Options() Options { + return s.opts +} + +func (s *fileTunnel) Favorite(b bool) { + s.favorite.Store(b) +} + +func (s *fileTunnel) IsFavorite() bool { + return s.favorite.Load() +} + +func (s *fileTunnel) init() error { + file := &config.ServiceConfig{ + Name: s.opts.Name, + Addr: ":0", + Handler: &config.HandlerConfig{ + Type: "file", + Metadata: map[string]any{"file.dir": s.opts.Endpoint}, + }, + Listener: &config.ListenerConfig{ + Type: "tcp", + }, + } + if s.opts.Username != "" { + file.Handler.Auth = &config.AuthConfig{ + Username: s.opts.Username, + Password: s.opts.Password, + } + } + + rtcp := &config.ServiceConfig{ + Name: s.opts.Name, + Addr: s.opts.Hostname, + Handler: &config.HandlerConfig{ + Type: "rtcp", + }, + Listener: &config.ListenerConfig{ + Type: "rtcp", + Chain: s.opts.Name, + }, + } + + s.config = &config.Config{ + Services: []*config.ServiceConfig{file, rtcp}, + Chains: []*config.ChainConfig{chainConfig(s.opts.ID, s.opts.Name)}, + } + + return nil +} + +func (s *fileTunnel) Run() error { + if s.IsClosed() { + return ErrTunnelClosed + } + + if err := s.init(); err != nil { + return err + } + + log := logger.Default().WithFields(map[string]any{ + "kind": "service", + "service": s.opts.Name, + }) + + { + cfg := s.config.Services[0] + ln := tcp.NewListener( + listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "tcp"})), + ) + if err := ln.Init(nil); err != nil { + return err + } + log.Infof("listen on %s", ln.Addr()) + + var auther auth.Authenticator + if auth := cfg.Handler.Auth; auth != nil { + auther = xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{auth.Username: auth.Password})) + } + h := file.NewHandler( + handler.LoggerOption(log.WithFields(map[string]any{"kind": "handler", "handler": "file"})), + handler.AutherOption(auther), + ) + if err := h.Init(mdx.NewMetadata(cfg.Handler.Metadata)); err != nil { + return err + } + s.file = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log)) + } + + { + ch, err := chain_parser.ParseChain(s.config.Chains[0], log) + if err != nil { + log.Error(err) + return err + } + + cfg := s.config.Services[1] + ln := rtcp.NewListener( + listener.AddrOption(cfg.Addr), + listener.ChainOption(ch), + listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})), + ) + if err := ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil { + return err + } + + h := remote.NewHandler( + handler.LoggerOption(log.WithFields(map[string]any{"kind": "handler", "handler": "rtcp"})), + ) + if err := h.Init(mdx.NewMetadata(cfg.Handler.Metadata)); err != nil { + return err + } + if forwarder, ok := h.(handler.Forwarder); ok { + forwarder.Forward(hop.NewHop( + hop.NodeOption(chain.NewNode(s.opts.Name, s.file.Addr().String())), + hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})), + )) + } + s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log)) + log.Infof("service listen on %s", s.file.Addr()) + } + + go s.file.Serve() + go s.forward.Serve() + + log.Infof("file service run at %s", s.file.Addr()) + return nil +} + +func (s *fileTunnel) Close() error { + defer func() { + select { + case <-s.cclose: + default: + close(s.cclose) + } + }() + + if s.forward != nil { + s.forward.Close() + } + if s.file != nil { + return s.file.Close() + } + return nil +} + +func (s *fileTunnel) IsClosed() bool { + select { + case <-s.cclose: + return true + default: + return false + } +} diff --git a/tunnel/http.go b/tunnel/http.go new file mode 100644 index 0000000..ac384ef --- /dev/null +++ b/tunnel/http.go @@ -0,0 +1,233 @@ +package tunnel + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "sync/atomic" + + "github.com/go-gost/core/chain" + "github.com/go-gost/core/handler" + "github.com/go-gost/core/listener" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/service" + xauth "github.com/go-gost/x/auth" + "github.com/go-gost/x/config" + chain_parser "github.com/go-gost/x/config/parsing/chain" + "github.com/go-gost/x/handler/forward/remote" + "github.com/go-gost/x/hop" + "github.com/go-gost/x/listener/rtcp" + mdx "github.com/go-gost/x/metadata" + xservice "github.com/go-gost/x/service" + "github.com/google/uuid" +) + +type httpTunnel struct { + endpoint string + opts Options + config *config.Config + forward service.Service + favorite atomic.Bool + + cclose chan struct{} +} + +func NewHTTPTunnel(opts ...Option) Tunnel { + var options Options + for _, opt := range opts { + opt(&options) + } + + if options.ID == "" { + options.ID = uuid.NewString() + } + + v := md5.Sum([]byte(options.ID)) + endpoint := hex.EncodeToString(v[:8]) + + if options.Endpoint == "" { + options.Endpoint = "localhost:8080" + } + + if options.Name == "" { + options.Name = fmt.Sprintf("HTTP-%s", endpoint) + } + + s := &httpTunnel{ + endpoint: endpoint, + opts: options, + cclose: make(chan struct{}), + } + + return s +} + +func (s *httpTunnel) ID() string { + return s.opts.ID +} + +func (s *httpTunnel) Type() string { + return HTTPTunnel +} + +func (s *httpTunnel) Name() string { + return s.opts.Name +} + +func (s *httpTunnel) Endpoint() string { + return s.opts.Endpoint +} + +func (f *httpTunnel) Entrypoint() string { + return fmt.Sprintf("https://%s.%s", f.endpoint, endpointAddr) +} + +func (s *httpTunnel) Options() Options { + return s.opts +} + +func (s *httpTunnel) Favorite(b bool) { + s.favorite.Store(b) +} + +func (s *httpTunnel) IsFavorite() bool { + return s.favorite.Load() +} + +func (s *httpTunnel) init() error { + node := &config.ForwardNodeConfig{ + Name: s.opts.Name, + Addr: s.opts.Endpoint, + } + if s.opts.Username != "" { + node.Auth = &config.AuthConfig{ + Username: s.opts.Username, + Password: s.opts.Password, + } + } + if s.opts.Hostname != "" { + node.HTTP = &config.HTTPNodeConfig{ + Host: s.opts.Hostname, + } + } + if s.opts.EnableTLS { + node.TLS = &config.TLSNodeConfig{} + } + + rtcp := &config.ServiceConfig{ + Name: s.opts.Name, + Addr: "", + Handler: &config.HandlerConfig{ + Type: "rtcp", + Metadata: map[string]any{ + "sniffing": true, + }, + }, + Listener: &config.ListenerConfig{ + Type: "rtcp", + Chain: s.opts.Name, + }, + Forwarder: &config.ForwarderConfig{ + Nodes: []*config.ForwardNodeConfig{node}, + }, + } + + s.config = &config.Config{ + Services: []*config.ServiceConfig{rtcp}, + Chains: []*config.ChainConfig{chainConfig(s.opts.ID, s.opts.Name)}, + } + return nil +} + +func (s *httpTunnel) Run() error { + if s.IsClosed() { + return ErrTunnelClosed + } + + if err := s.init(); err != nil { + return err + } + + log := logger.Default().WithFields(map[string]any{ + "kind": "service", + "service": s.opts.Name, + }) + + { + ch, err := chain_parser.ParseChain(s.config.Chains[0], log) + if err != nil { + log.Error(err) + return err + } + + cfg := s.config.Services[0] + ln := rtcp.NewListener( + listener.AddrOption(cfg.Addr), + listener.ChainOption(ch), + listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})), + ) + if err := ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil { + return err + } + + h := remote.NewHandler( + handler.LoggerOption(log.WithFields(map[string]any{"kind": "handler", "handler": "rtcp"})), + ) + if err := h.Init(mdx.NewMetadata(cfg.Handler.Metadata)); err != nil { + return err + } + + node := cfg.Forwarder.Nodes[0] + var nodeOpts []chain.NodeOption + if node.Auth != nil { + auther := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{node.Auth.Username: node.Auth.Password})) + nodeOpts = append(nodeOpts, chain.AutherNodeOption(auther)) + } + if node.HTTP != nil { + nodeOpts = append(nodeOpts, chain.HTTPNodeOption(&chain.HTTPNodeSettings{ + Host: node.HTTP.Host, + Header: node.HTTP.Header, + })) + } + if node.TLS != nil { + nodeOpts = append(nodeOpts, chain.TLSNodeOption(&chain.TLSNodeSettings{ + ServerName: node.TLS.ServerName, + Secure: node.TLS.Secure, + })) + } + if forwarder, ok := h.(handler.Forwarder); ok { + forwarder.Forward(hop.NewHop(hop.NodeOption(chain.NewNode(node.Name, node.Addr, nodeOpts...)), + hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})), + )) + } + s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log)) + } + + go s.forward.Serve() + + return nil +} + +func (s *httpTunnel) Close() error { + defer func() { + select { + case <-s.cclose: + default: + close(s.cclose) + } + }() + + if s.forward != nil { + return s.forward.Close() + } + return nil +} + +func (s *httpTunnel) IsClosed() bool { + select { + case <-s.cclose: + return true + default: + return false + } +} diff --git a/tunnel/tcp.go b/tunnel/tcp.go new file mode 100644 index 0000000..a4ce61c --- /dev/null +++ b/tunnel/tcp.go @@ -0,0 +1,199 @@ +package tunnel + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "sync/atomic" + + "github.com/go-gost/core/chain" + "github.com/go-gost/core/handler" + "github.com/go-gost/core/listener" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/service" + "github.com/go-gost/x/config" + chain_parser "github.com/go-gost/x/config/parsing/chain" + "github.com/go-gost/x/handler/forward/remote" + "github.com/go-gost/x/hop" + "github.com/go-gost/x/listener/rtcp" + mdx "github.com/go-gost/x/metadata" + xservice "github.com/go-gost/x/service" + "github.com/google/uuid" +) + +type tcpTunnel struct { + endpoint string + opts Options + config *config.Config + forward service.Service + favorite atomic.Bool + + cclose chan struct{} +} + +func NewTCPTunnel(opts ...Option) Tunnel { + var options Options + for _, opt := range opts { + opt(&options) + } + + if options.ID == "" { + options.ID = uuid.NewString() + } + + v := md5.Sum([]byte(options.ID)) + endpoint := hex.EncodeToString(v[:8]) + + if options.Endpoint == "" { + options.Endpoint = "localhost:8080" + } + + if options.Name == "" { + options.Name = fmt.Sprintf("TCP-%s", endpoint) + } + + s := &tcpTunnel{ + endpoint: endpoint, + opts: options, + cclose: make(chan struct{}), + } + + return s +} + +func (s *tcpTunnel) ID() string { + return s.opts.ID +} + +func (s *tcpTunnel) Type() string { + return TCPTunnel +} + +func (s *tcpTunnel) Name() string { + return s.opts.Name +} + +func (s *tcpTunnel) Endpoint() string { + return s.opts.Endpoint +} + +func (f *tcpTunnel) Entrypoint() string { + return fmt.Sprintf("%s.%s", f.endpoint, endpointAddr) +} + +func (s *tcpTunnel) Options() Options { + return s.opts +} + +func (s *tcpTunnel) Favorite(b bool) { + s.favorite.Store(b) +} + +func (s *tcpTunnel) IsFavorite() bool { + return s.favorite.Load() +} + +func (s *tcpTunnel) init() error { + rtcp := &config.ServiceConfig{ + Name: s.opts.Name, + Addr: s.opts.Hostname, + Handler: &config.HandlerConfig{ + Type: "rtcp", + }, + Listener: &config.ListenerConfig{ + Type: "rtcp", + Chain: s.opts.Name, + }, + Forwarder: &config.ForwarderConfig{ + Nodes: []*config.ForwardNodeConfig{ + { + Name: s.opts.Name, + Addr: s.opts.Endpoint, + }, + }, + }, + } + + s.config = &config.Config{ + Services: []*config.ServiceConfig{rtcp}, + Chains: []*config.ChainConfig{chainConfig(s.opts.ID, s.opts.Name)}, + } + return nil +} + +func (s *tcpTunnel) Run() error { + if s.IsClosed() { + return ErrTunnelClosed + } + + if err := s.init(); err != nil { + return err + } + + log := logger.Default().WithFields(map[string]any{ + "kind": "service", + "service": s.opts.Name, + }) + + { + ch, err := chain_parser.ParseChain(s.config.Chains[0], log) + if err != nil { + log.Error(err) + return err + } + + cfg := s.config.Services[0] + ln := rtcp.NewListener( + listener.AddrOption(cfg.Addr), + listener.ChainOption(ch), + listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rtcp"})), + ) + if err := ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil { + return err + } + + h := remote.NewHandler( + handler.LoggerOption(log.WithFields(map[string]any{"kind": "handler", "handler": "rtcp"})), + ) + if err := h.Init(mdx.NewMetadata(cfg.Handler.Metadata)); err != nil { + return err + } + + node := cfg.Forwarder.Nodes[0] + if forwarder, ok := h.(handler.Forwarder); ok { + forwarder.Forward(hop.NewHop( + hop.NodeOption(chain.NewNode(node.Name, node.Addr)), + hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})), + )) + } + s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log)) + } + + go s.forward.Serve() + + return nil +} + +func (s *tcpTunnel) Close() error { + defer func() { + select { + case <-s.cclose: + default: + close(s.cclose) + } + }() + + if s.forward != nil { + return s.forward.Close() + } + return nil +} + +func (s *tcpTunnel) IsClosed() bool { + select { + case <-s.cclose: + return true + default: + return false + } +} diff --git a/tunnel/tunnel.go b/tunnel/tunnel.go new file mode 100644 index 0000000..ead7482 --- /dev/null +++ b/tunnel/tunnel.go @@ -0,0 +1,313 @@ +package tunnel + +import ( + "errors" + "sync" + + "github.com/go-gost/core/logger" + "github.com/go-gost/gost-plus/config" + xconfig "github.com/go-gost/x/config" + _ "github.com/go-gost/x/connector/tunnel" + _ "github.com/go-gost/x/dialer/ws" +) + +const ( + endpointAddr = "gost.plus" + serverName = "tunnel.gost.plus" + serverAddr = serverName + ":443" +) + +const ( + FileTunnel = "file" + HTTPTunnel = "http" + TCPTunnel = "tcp" + UDPTunnel = "udp" +) + +var ( + ErrTunnelClosed = errors.New("tunnel closed") +) + +type Options struct { + ID string + Name string + Endpoint string + Hostname string + Username string + Password string + EnableTLS bool +} + +type Option func(opts *Options) + +func IDOption(id string) Option { + return func(opts *Options) { + opts.ID = id + } +} + +func NameOption(name string) Option { + return func(opts *Options) { + opts.Name = name + } +} + +func EndpointOption(endpoint string) Option { + return func(opts *Options) { + opts.Endpoint = endpoint + } +} + +func HostnameOption(hostname string) Option { + return func(opts *Options) { + opts.Hostname = hostname + } +} + +func UsernameOption(username string) Option { + return func(opts *Options) { + opts.Username = username + } +} + +func PasswordOption(password string) Option { + return func(opts *Options) { + opts.Password = password + } +} + +func EnableTLSOption(b bool) Option { + return func(opts *Options) { + opts.EnableTLS = b + } +} + +type Tunnel interface { + ID() string + Type() string + Name() string + Endpoint() string + Entrypoint() string + Options() Options + Run() error + Favorite(b bool) + IsFavorite() bool + Close() error + IsClosed() bool +} + +type tunnelList struct { + tunnels []Tunnel + mux sync.RWMutex +} + +func (sl *tunnelList) Count() int { + sl.mux.RLock() + defer sl.mux.RUnlock() + return len(sl.tunnels) +} + +func (sl *tunnelList) Add(s Tunnel) { + sl.mux.Lock() + defer sl.mux.Unlock() + sl.tunnels = append(sl.tunnels, s) +} + +func (sl *tunnelList) Set(s Tunnel) { + if s == nil { + return + } + + sl.mux.Lock() + defer sl.mux.Unlock() + + for i, sv := range sl.tunnels { + if sv != nil && sv.ID() == s.ID() { + sl.tunnels[i] = s + } + } +} + +func (sl *tunnelList) Get(index int) Tunnel { + sl.mux.RLock() + defer sl.mux.RUnlock() + if index < 0 || index >= len(sl.tunnels) { + return nil + } + return sl.tunnels[index] +} + +func (sl *tunnelList) GetID(id string) Tunnel { + sl.mux.RLock() + defer sl.mux.RUnlock() + + for _, s := range sl.tunnels { + if s != nil && s.ID() == id { + return s + } + } + return nil +} + +func (sl *tunnelList) DeleteID(id string) { + sl.mux.Lock() + defer sl.mux.Unlock() + + for i, s := range sl.tunnels { + if s != nil && s.ID() == id { + s.Close() + sl.tunnels[i] = nil + return + } + } +} + +var ( + tunnels tunnelList +) + +func TunnelCount() int { + return tunnels.Count() +} + +func AddTunnel(s Tunnel) { + tunnels.Add(s) +} + +func SetTunnel(s Tunnel) { + t := tunnels.GetID(s.ID()) + if t == nil { + return + } + s.Favorite(t.IsFavorite()) + tunnels.Set(s) +} + +func GetTunnel(index int) Tunnel { + return tunnels.Get(index) +} + +func GetTunnelID(id string) Tunnel { + return tunnels.GetID(id) +} + +func DeleteTunnel(id string) { + tunnels.DeleteID(id) +} + +func chainConfig(id string, name string) *xconfig.ChainConfig { + return &xconfig.ChainConfig{ + Name: name, + Hops: []*xconfig.HopConfig{ + { + Name: name, + Nodes: []*xconfig.NodeConfig{ + { + Name: name, + Addr: serverAddr, + Connector: &xconfig.ConnectorConfig{ + Type: "tunnel", + Metadata: map[string]any{"tunnel.id": id}, + }, + Dialer: &xconfig.DialerConfig{ + Type: "wss", + TLS: &xconfig.TLSConfig{ + Secure: true, + ServerName: serverName, + }, + }, + }, + }, + }, + }, + } +} + +func LoadConfig() { + for _, tun := range config.Global().Tunnels { + if tun == nil { + continue + } + + s := createTunnel(tun.Type, Options{ + ID: tun.ID, + Name: tun.Name, + Endpoint: tun.Endpoint, + Hostname: tun.Hostname, + Username: tun.Username, + Password: tun.Password, + EnableTLS: tun.EnableTLS, + }) + if s == nil { + continue + } + + if tun.Closed { + s.Close() + } else { + s.Run() + } + + s.Favorite(tun.Favorite) + tunnels.Add(s) + } +} + +func SaveTunnel() error { + cfg := config.Global() + cfg.Tunnels = nil + + for i := 0; i < tunnels.Count(); i++ { + tun := tunnels.Get(i) + if tun == nil { + continue + } + + opts := tun.Options() + + cfg.Tunnels = append(cfg.Tunnels, &config.Tunnel{ + ID: tun.ID(), + Name: tun.Name(), + Type: tun.Type(), + Endpoint: tun.Endpoint(), + Hostname: opts.Hostname, + Username: opts.Username, + Password: opts.Password, + EnableTLS: opts.EnableTLS, + Favorite: tun.IsFavorite(), + Closed: tun.IsClosed(), + }) + } + + config.Set(cfg) + + if err := cfg.Write(); err != nil { + logger.Default().Error(err) + return err + } + return nil +} + +func createTunnel(st string, opts Options) Tunnel { + options := []Option{ + IDOption(opts.ID), + NameOption(opts.Name), + EndpointOption(opts.Endpoint), + HostnameOption(opts.Hostname), + UsernameOption(opts.Username), + PasswordOption(opts.Password), + EnableTLSOption(opts.EnableTLS), + } + switch st { + case FileTunnel: + return NewFileTunnel(options...) + case HTTPTunnel: + return NewHTTPTunnel(options...) + case TCPTunnel: + return NewTCPTunnel(options...) + case UDPTunnel: + return NewUDPTunnel(options...) + default: + return nil + } +} diff --git a/tunnel/udp.go b/tunnel/udp.go new file mode 100644 index 0000000..cf3911e --- /dev/null +++ b/tunnel/udp.go @@ -0,0 +1,199 @@ +package tunnel + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "sync/atomic" + + "github.com/go-gost/core/chain" + "github.com/go-gost/core/handler" + "github.com/go-gost/core/listener" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/service" + "github.com/go-gost/x/config" + chain_parser "github.com/go-gost/x/config/parsing/chain" + "github.com/go-gost/x/handler/forward/remote" + "github.com/go-gost/x/hop" + "github.com/go-gost/x/listener/rudp" + mdx "github.com/go-gost/x/metadata" + xservice "github.com/go-gost/x/service" + "github.com/google/uuid" +) + +type udpTunnel struct { + endpoint string + opts Options + config *config.Config + forward service.Service + favorite atomic.Bool + + cclose chan struct{} +} + +func NewUDPTunnel(opts ...Option) Tunnel { + var options Options + for _, opt := range opts { + opt(&options) + } + + if options.ID == "" { + options.ID = uuid.NewString() + } + + v := md5.Sum([]byte(options.ID)) + endpoint := hex.EncodeToString(v[:8]) + + if options.Endpoint == "" { + options.Endpoint = "localhost:8080" + } + + if options.Name == "" { + options.Name = fmt.Sprintf("UDP-%s", endpoint) + } + + s := &udpTunnel{ + endpoint: endpoint, + opts: options, + cclose: make(chan struct{}), + } + + return s +} + +func (s *udpTunnel) ID() string { + return s.opts.ID +} + +func (s *udpTunnel) Type() string { + return UDPTunnel +} + +func (s *udpTunnel) Name() string { + return s.opts.Name +} + +func (s *udpTunnel) Endpoint() string { + return s.opts.Endpoint +} + +func (f *udpTunnel) Entrypoint() string { + return fmt.Sprintf("%s.%s", f.endpoint, endpointAddr) +} + +func (s *udpTunnel) Options() Options { + return s.opts +} + +func (s *udpTunnel) Favorite(b bool) { + s.favorite.Store(b) +} + +func (s *udpTunnel) IsFavorite() bool { + return s.favorite.Load() +} + +func (s *udpTunnel) init() error { + rudp := &config.ServiceConfig{ + Name: s.opts.Name, + Addr: s.opts.Hostname, + Handler: &config.HandlerConfig{ + Type: "rudp", + }, + Listener: &config.ListenerConfig{ + Type: "rudp", + Chain: s.opts.Name, + }, + Forwarder: &config.ForwarderConfig{ + Nodes: []*config.ForwardNodeConfig{ + { + Name: s.opts.Name, + Addr: s.opts.Endpoint, + }, + }, + }, + } + + s.config = &config.Config{ + Services: []*config.ServiceConfig{rudp}, + Chains: []*config.ChainConfig{chainConfig(s.opts.ID, s.opts.Name)}, + } + return nil +} + +func (s *udpTunnel) Run() error { + if s.IsClosed() { + return ErrTunnelClosed + } + + if err := s.init(); err != nil { + return err + } + + log := logger.Default().WithFields(map[string]any{ + "kind": "service", + "service": s.opts.Name, + }) + + { + ch, err := chain_parser.ParseChain(s.config.Chains[0], log) + if err != nil { + log.Error(err) + return err + } + + cfg := s.config.Services[0] + ln := rudp.NewListener( + listener.AddrOption(cfg.Addr), + listener.ChainOption(ch), + listener.LoggerOption(log.WithFields(map[string]any{"kind": "listener", "listener": "rudp"})), + ) + if err := ln.Init(mdx.NewMetadata(cfg.Listener.Metadata)); err != nil { + return err + } + + h := remote.NewHandler( + handler.LoggerOption(log.WithFields(map[string]any{"kind": "handler", "handler": "rudp"})), + ) + if err := h.Init(mdx.NewMetadata(cfg.Handler.Metadata)); err != nil { + return err + } + + node := cfg.Forwarder.Nodes[0] + if forwarder, ok := h.(handler.Forwarder); ok { + forwarder.Forward(hop.NewHop( + hop.NodeOption(chain.NewNode(node.Name, node.Addr)), + hop.LoggerOption(log.WithFields(map[string]any{"kind": "hop"})), + )) + } + s.forward = xservice.NewService(s.opts.Name, ln, h, xservice.LoggerOption(log)) + } + + go s.forward.Serve() + + return nil +} + +func (s *udpTunnel) Close() error { + defer func() { + select { + case <-s.cclose: + default: + close(s.cclose) + } + }() + + if s.forward != nil { + return s.forward.Close() + } + return nil +} + +func (s *udpTunnel) IsClosed() bool { + select { + case <-s.cclose: + return true + default: + return false + } +} diff --git a/ui/icons/icon.png b/ui/icons/icon.png new file mode 100644 index 0000000..80fb820 Binary files /dev/null and b/ui/icons/icon.png differ diff --git a/ui/icons/icons.go b/ui/icons/icons.go new file mode 100644 index 0000000..7f1ab36 --- /dev/null +++ b/ui/icons/icons.go @@ -0,0 +1,56 @@ +package icons + +import ( + "bytes" + _ "embed" + "image" + _ "image/jpeg" + _ "image/png" + "log" + + "gioui.org/op/paint" + "gioui.org/widget" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +//go:embed icon.png +var iconAppData []byte + +func init() { + img, _, err := image.Decode(bytes.NewReader(iconAppData)) + if err != nil { + log.Println("err") + return + } + + IconApp = &widget.Image{ + Src: paint.NewImageOp(img), + Fit: widget.Unscaled, + } +} + +var ( + IconApp *widget.Image + + IconHome = mustIcon(icons.ActionHome) + IconFavorite = mustIcon(icons.ActionFavorite) + IconAdd = mustIcon(icons.ContentAdd) + IconSettings = mustIcon(icons.ActionSettings) + IconDone = mustIcon(icons.ActionDone) + IconTunnelState = mustIcon(icons.ToggleRadioButtonChecked) + IconForward = mustIcon(icons.NavigationChevronRight) + IconEdit = mustIcon(icons.EditorModeEdit) + IconDelete = mustIcon(icons.ActionDelete) + IconStart = mustIcon(icons.AVPlayArrow) + IconStop = mustIcon(icons.AVStop) + IconBack = mustIcon(icons.NavigationArrowBack) + IconClose = mustIcon(icons.ContentClear) +) + +func mustIcon(data []byte) *widget.Icon { + icon, err := widget.NewIcon(data) + if err != nil { + panic(err) + } + return icon +} diff --git a/ui/page/about.go b/ui/page/about.go new file mode 100644 index 0000000..15ca48d --- /dev/null +++ b/ui/page/about.go @@ -0,0 +1,57 @@ +package page + +import ( + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget/material" + "github.com/go-gost/gost-plus/ui/icons" + "github.com/go-gost/gost-plus/version" +) + +type aboutPage struct { + router *Router + + list layout.List +} + +func NewAboutPage(r *Router) Page { + return &aboutPage{ + router: r, + } +} + +func (p *aboutPage) Init(opts ...PageOption) { + p.router.bar.SetActions(nil, nil) + p.router.bar.Title = "About" + p.router.bar.NavigationIcon = icons.IconBack +} + +func (p *aboutPage) Layout(gtx C, th *material.Theme) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + if icons.IconApp == nil { + return D{} + } + return icons.IconApp.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + label := material.H6(th, "GOST.PLUS") + label.Font.Weight = font.Bold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, version.Version).Layout(gtx) + }), + ) + }) + }) + }) +} diff --git a/ui/page/file.go b/ui/page/file.go new file mode 100644 index 0000000..901c34b --- /dev/null +++ b/ui/page/file.go @@ -0,0 +1,467 @@ +package page + +import ( + "fmt" + "image/color" + "log" + "os" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type fileAddPage struct { + router *Router + + list layout.List + wgDone widget.Clickable + + name component.TextField + path component.TextField + cbBasicAuth widget.Bool + username component.TextField + password component.TextField +} + +func NewFileAddPage(r *Router) Page { + return &fileAddPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + path: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *fileAddPage) Init(opts ...PageOption) { + p.name.SetText("") + p.path.SetText("") + p.cbBasicAuth.Value = false + p.username.SetText("") + p.password.SetText("") + + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Create", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + p.createTunnel() + tunnel.SaveTunnel() + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + }, nil) + + p.router.bar.Title = "File" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *fileAddPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *fileAddPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, "Expose local files to public network.").Layout), + layout.Rigid(layout.Spacer{Height: 15}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Service name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Root directory, default to the current working directory").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + dir := strings.TrimSpace(p.path.Text()) + if dir == "" { + return nil + } + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + fs, err := f.Stat() + if err != nil { + return err + } + if !fs.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + return nil + }(); err != nil { + p.path.SetError(err.Error()) + } else { + p.path.ClearError() + } + + return p.path.Layout(gtx, th, "Path") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), + layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.username.SetText("") + return layout.Dimensions{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.password.SetText("") + return layout.Dimensions{} + } + return p.password.Layout(gtx, th, "Password") + }), + ) +} + +func (p *fileAddPage) createTunnel() error { + var username, password string + if p.cbBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + tun := tunnel.NewFileTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.path.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + ) + + if err := tun.Run(); err != nil { + return err + } + + tunnel.AddTunnel(tun) + return nil +} + +type fileEditPage struct { + router *Router + + id string + + list layout.List + wgFavorite widget.Clickable + wgState widget.Clickable + wgDelete widget.Clickable + wgDone widget.Clickable + + wgID widget.Clickable + wgEntrypoint widget.Clickable + + name component.TextField + path component.TextField + cbBasicAuth widget.Bool + username component.TextField + password component.TextField +} + +func NewFileEditPage(r *Router) Page { + return &fileEditPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + path: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *fileEditPage) Init(opts ...PageOption) { + var options PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + s := tunnel.GetTunnelID(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.path.SetText(sopts.Endpoint) + if sopts.Username != "" { + p.cbBasicAuth.Value = true + p.username.SetText(sopts.Username) + p.password.SetText(sopts.Password) + } + } + + actions := []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if s == nil { + return D{} + } + + if p.wgFavorite.Clicked(gtx) { + s.Favorite(!s.IsFavorite()) + tunnel.SaveTunnel() + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if s.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Start/Stop", + Tag: &p.wgState, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if s == nil { + return D{} + } + + if p.wgState.Clicked(gtx) { + if s.IsClosed() { + s = p.createTunnel() + } else { + s.Close() + } + tunnel.SaveTunnel() + } + + if s != nil && !s.IsClosed() { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStop).Layout(gtx) + } else { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStart).Layout(gtx) + } + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Delete", + Tag: &p.wgDelete, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDelete.Clicked(gtx) { + tunnel.DeleteTunnel(p.id) + tunnel.SaveTunnel() + p.router.SwitchTo(Route{Path: PageHome}) + } + return component.SimpleIconButton(bg, fg, &p.wgDelete, icons.IconDelete).Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Save", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + + if s := tunnel.GetTunnelID(p.id); s != nil { + s.Close() + p.createTunnel() + tunnel.SaveTunnel() + } + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + } + p.router.bar.SetActions(actions, nil) + p.router.bar.Title = "File" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *fileEditPage) createTunnel() tunnel.Tunnel { + var username, password string + if p.cbBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + s := tunnel.NewFileTunnel( + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.path.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + ) + + if err := s.Run(); err != nil { + log.Println(err) + } + tunnel.SetTunnel(s) + + return s +} + +func (p *fileEditPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *fileEditPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layoutHeader(gtx, th, tunnel.GetTunnelID(p.id), &p.wgID, &p.wgEntrypoint) + }), + layout.Rigid(func(gtx C) D { + div := component.Divider(th) + return div.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Root directory, default to the current working directory").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + dir := strings.TrimSpace(p.path.Text()) + if dir == "" { + return nil + } + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + fs, err := f.Stat() + if err != nil { + return err + } + if !fs.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + return nil + }(); err != nil { + p.path.SetError(err.Error()) + } else { + p.path.ClearError() + } + + return p.path.Layout(gtx, th, "Path") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), + layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), + ) + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.username.SetText("") + return layout.Dimensions{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.password.SetText("") + return layout.Dimensions{} + } + return p.password.Layout(gtx, th, "Password") + }), + ) +} diff --git a/ui/page/http.go b/ui/page/http.go new file mode 100644 index 0000000..9f4afe9 --- /dev/null +++ b/ui/page/http.go @@ -0,0 +1,534 @@ +package page + +import ( + "fmt" + "image/color" + "log" + "net" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type httpAddPage struct { + router *Router + + list layout.List + wgDone widget.Clickable + + name component.TextField + addr component.TextField + + bHost widget.Bool + hostname component.TextField + + bBasicAuth widget.Bool + username component.TextField + password component.TextField + + bTLS widget.Bool +} + +func NewHTTPAddPage(r *Router) Page { + return &httpAddPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + hostname: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *httpAddPage) Init(opts ...PageOption) { + p.name.SetText("") + p.addr.SetText("") + p.hostname.SetText("") + p.bBasicAuth.Value = false + p.username.SetText("") + p.password.SetText("") + p.bTLS.Value = false + + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Create", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + p.createTunnel() + tunnel.SaveTunnel() + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + }, nil) + + p.router.bar.Title = "HTTP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *httpAddPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *httpAddPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, "Expose local http tunnel to public network.").Layout), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use custom hostname (rewrite HTTP Host header)").Layout), + layout.Rigid(material.Switch(th, &p.bHost, "custom hostname").Layout), + ) + }), + layout.Rigid(func(gtx C) D { + if !p.bHost.Value { + p.hostname.SetText("") + return layout.Dimensions{} + } + return p.hostname.Layout(gtx, th, "Hostname") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), + layout.Rigid(material.Switch(th, &p.bBasicAuth, "use basic auth").Layout), + ) + }), + layout.Rigid(func(gtx C) D { + if !p.bBasicAuth.Value { + p.username.SetText("") + return layout.Dimensions{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.bBasicAuth.Value { + p.password.SetText("") + return layout.Dimensions{} + } + return p.password.Layout(gtx, th, "Password") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Enable TLS").Layout), + layout.Rigid(material.Switch(th, &p.bTLS, "enable TLS").Layout), + ) + }), + ) +} + +func (p *httpAddPage) createTunnel() error { + var username, password string + if p.bBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + var hostname string + if p.bHost.Value { + hostname = strings.TrimSpace(p.hostname.Text()) + } + srv := tunnel.NewHTTPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + tunnel.HostnameOption(hostname), + tunnel.EnableTLSOption(p.bTLS.Value), + ) + + if err := srv.Run(); err != nil { + return err + } + + tunnel.AddTunnel(srv) + return nil +} + +type httpEditPage struct { + router *Router + + id string + + list layout.List + wgFavorite widget.Clickable + wgState widget.Clickable + wgDelete widget.Clickable + wgDone widget.Clickable + + name component.TextField + addr component.TextField + + bHost widget.Bool + hostname component.TextField + + cbBasicAuth widget.Bool + username component.TextField + password component.TextField + + bTLS widget.Bool + + wgID widget.Clickable + wgEntrypoint widget.Clickable +} + +func NewHTTPEditPage(r *Router) Page { + return &httpEditPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + hostname: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + username: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + password: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *httpEditPage) Init(opts ...PageOption) { + var options PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + s := tunnel.GetTunnelID(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.addr.SetText(sopts.Endpoint) + if sopts.Hostname != "" { + p.bHost.Value = true + p.hostname.SetText(sopts.Hostname) + } + if sopts.Username != "" { + p.cbBasicAuth.Value = true + p.username.SetText(sopts.Username) + p.password.SetText(sopts.Password) + } + p.bTLS.Value = sopts.EnableTLS + } + + actions := []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if s == nil { + return D{} + } + + if p.wgFavorite.Clicked(gtx) { + s.Favorite(!s.IsFavorite()) + tunnel.SaveTunnel() + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if s.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Start/Stop", + Tag: &p.wgState, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if p.wgState.Clicked(gtx) && s != nil { + if s.IsClosed() { + s = p.createTunnel() + } else { + s.Close() + } + tunnel.SaveTunnel() + } + + if s != nil && !s.IsClosed() { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStop).Layout(gtx) + } else { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStart).Layout(gtx) + } + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Delete", + Tag: &p.wgDelete, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDelete.Clicked(gtx) { + tunnel.DeleteTunnel(p.id) + tunnel.SaveTunnel() + p.router.SwitchTo(Route{Path: PageHome}) + } + return component.SimpleIconButton(bg, fg, &p.wgDelete, icons.IconDelete).Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Save", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + + if s := tunnel.GetTunnelID(p.id); s != nil { + s.Close() + p.createTunnel() + tunnel.SaveTunnel() + } + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + } + p.router.bar.SetActions(actions, nil) + p.router.bar.Title = "HTTP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *httpEditPage) createTunnel() tunnel.Tunnel { + var username, password string + if p.cbBasicAuth.Value { + username = strings.TrimSpace(p.username.Text()) + password = strings.TrimSpace(p.password.Text()) + } + var hostname string + if p.bHost.Value { + hostname = strings.TrimSpace(p.hostname.Text()) + } + s := tunnel.NewHTTPTunnel( + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + tunnel.UsernameOption(username), + tunnel.PasswordOption(password), + tunnel.HostnameOption(hostname), + tunnel.EnableTLSOption(p.bTLS.Value), + ) + + if err := s.Run(); err != nil { + log.Println(err) + } + tunnel.SetTunnel(s) + + return s +} + +func (p *httpEditPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *httpEditPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layoutHeader(gtx, th, tunnel.GetTunnelID(p.id), &p.wgID, &p.wgEntrypoint) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use custom hostname (rewrite HTTP Host header)").Layout), + layout.Rigid(material.Switch(th, &p.bHost, "custom hostname").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.bHost.Value { + p.hostname.SetText("") + return layout.Dimensions{} + } + return p.hostname.Layout(gtx, th, "Hostname") + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Use basic auth").Layout), + layout.Rigid(material.Switch(th, &p.cbBasicAuth, "use basic auth").Layout), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.username.SetText("") + return layout.Dimensions{} + } + return p.username.Layout(gtx, th, "Username") + }), + layout.Rigid(func(gtx C) D { + if !p.cbBasicAuth.Value { + p.password.SetText("") + return layout.Dimensions{} + } + return p.password.Layout(gtx, th, "Password") + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 10, Bottom: 10}.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, material.Body1(th, "Enable TLS").Layout), + layout.Rigid(material.Switch(th, &p.bTLS, "enable TLS").Layout), + ) + }) + }), + ) +} diff --git a/ui/page/menu.go b/ui/page/menu.go new file mode 100644 index 0000000..464e600 --- /dev/null +++ b/ui/page/menu.go @@ -0,0 +1,156 @@ +package page + +import ( + "image/color" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type menuPage struct { + router *Router + + list widget.List + + wgFile widget.Clickable + wgHTTP widget.Clickable + wgTCP widget.Clickable + wgUDP widget.Clickable +} + +func NewMenuPage(r *Router) Page { + return &menuPage{ + router: r, + list: widget.List{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + } +} + +func (p *menuPage) Init(opts ...PageOption) { + p.router.bar.SetActions(nil, nil) + p.router.bar.Title = "Add" + p.router.bar.NavigationIcon = icons.IconBack +} + +func (p *menuPage) Layout(gtx C, th *material.Theme) D { + if clicked := func() bool { + if p.wgFile.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageNewFile}) + return true + } + if p.wgHTTP.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageNewHTTP}) + return true + } + if p.wgTCP.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageNewTCP}) + return true + } + if p.wgUDP.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageNewUDP}) + return true + } + + return false + }(); clicked { + op.InvalidateOp{}.Add(gtx.Ops) + } + + return p.list.List.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.H6(th, "Services") + label.Font.Weight = font.Bold + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, label.Layout) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return p.wgFile.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutCard(gtx, th, "File", "Expose local files to public network") + }) + }) + }) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return p.wgHTTP.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutCard(gtx, th, "HTTP", "Expose local HTTP service to public network") + }) + }) + }) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return p.wgTCP.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutCard(gtx, th, "TCP", "Expose local TCP service to public network") + }) + }) + }) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return p.wgUDP.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutCard(gtx, th, "UDP", "Expose local UDP service to public network") + }) + }) + }) + }) + }), + ) + }) + }) + }) +} + +func (p *menuPage) layoutCard(gtx C, th *material.Theme, name, desc string) D { + return layout.Flex{ + Axis: layout.Horizontal, + Spacing: layout.SpaceBetween, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + title := material.Body1(th, name) + title.Font.Weight = font.Bold + return title.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + title := material.Body1(th, desc) + return title.Layout(gtx) + }), + ) + }), + layout.Rigid(layout.Spacer{Width: 10}.Layout), + layout.Rigid(func(gtx C) D { + return icons.IconForward.Layout(gtx, color.NRGBA(colornames.Grey500)) + }), + ) +} diff --git a/ui/page/page.go b/ui/page/page.go new file mode 100644 index 0000000..7d756ff --- /dev/null +++ b/ui/page/page.go @@ -0,0 +1,125 @@ +package page + +import ( + "image/color" + + "gioui.org/font" + "gioui.org/io/clipboard" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/go-gost/gost-plus/tunnel" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +const ( + PageHome = "/" + PageMenu = "/menu" + PageNewFile = "/tunnel/file/create" + PageNewHTTP = "/tunnel/http/create" + PageNewTCP = "/tunnel/tcp/create" + PageNewUDP = "/tunnel/udp/create" + PageEditFile = "/tunnel/file/edit" + PageEditHTTP = "/tunnel/http/edit" + PageEditTCP = "/tunnel/tcp/edit" + PageEditUDP = "/tunnel/udp/edit" + + PageAbout = "/about" +) + +type OverflowAction string + +const ( + OverflowActionAbout OverflowAction = "about" +) + +type PageOptions struct { + ID string +} + +type PageOption func(*PageOptions) + +func IDPageOption(id string) PageOption { + return func(opts *PageOptions) { + opts.ID = id + } +} + +type Page interface { + Init(opts ...PageOption) + Layout(gtx layout.Context, th *material.Theme) layout.Dimensions +} + +func layoutHeader(gtx C, th *material.Theme, tun tunnel.Tunnel, wgID, wgEntrypoint *widget.Clickable) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + if tun == nil { + return D{} + } + + copied := false + if wgID.Clicked(gtx) { + copied = true + clipboard.WriteOp{ + Text: tun.ID(), + }.Add(gtx.Ops) + } + + return wgID.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.Body1(th, tun.ID()) + label.Font.Weight = font.Bold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 10}.Layout), + layout.Rigid(func(gtx C) D { + label := material.Body1(th, "Copy") + label.Color = color.NRGBA(colornames.Blue500) + if copied { + label = material.Body1(th, "Copied") + label.Color = color.NRGBA(colornames.Green500) + } + return label.Layout(gtx) + }), + ) + }) + }), + layout.Rigid(func(gtx C) D { + if tun == nil { + return D{} + } + + copied := false + if wgEntrypoint.Clicked(gtx) { + copied = true + clipboard.WriteOp{ + Text: tun.Entrypoint(), + }.Add(gtx.Ops) + } + + return wgEntrypoint.Layout(gtx, func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.Body1(th, tun.Entrypoint()) + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 10}.Layout), + layout.Rigid(func(gtx C) D { + label := material.Body1(th, "Copy") + label.Color = color.NRGBA(colornames.Blue500) + if copied { + label = material.Body1(th, "Copied") + label.Color = color.NRGBA(colornames.Green500) + } + return label.Layout(gtx) + }), + ) + }) + }) + }), + ) +} diff --git a/ui/page/router.go b/ui/page/router.go new file mode 100644 index 0000000..6167348 --- /dev/null +++ b/ui/page/router.go @@ -0,0 +1,95 @@ +package page + +import ( + "gioui.org/layout" + "gioui.org/widget/material" + "gioui.org/x/component" +) + +type C = layout.Context +type D = layout.Dimensions + +type Route struct { + Path string + ID string +} + +type Router struct { + modal *component.ModalLayer + bar *component.AppBar + + pages map[string]Page + current Route +} + +func NewRouter() *Router { + modal := component.NewModal() + bar := component.NewAppBar(modal) + + r := &Router{ + modal: modal, + bar: bar, + pages: make(map[string]Page), + } + + r.Register(PageHome, NewTunnelPage(r)) + r.Register(PageMenu, NewMenuPage(r)) + r.Register(PageNewFile, NewFileAddPage(r)) + r.Register(PageEditFile, NewFileEditPage(r)) + r.Register(PageNewHTTP, NewHTTPAddPage(r)) + r.Register(PageEditHTTP, NewHTTPEditPage(r)) + r.Register(PageNewTCP, NewTCPAddPage(r)) + r.Register(PageEditTCP, NewTCPEditPage(r)) + r.Register(PageNewUDP, NewUDPAddPage(r)) + r.Register(PageEditUDP, NewUDPEditPage(r)) + r.Register(PageAbout, NewAboutPage(r)) + + r.SwitchTo(Route{Path: PageHome}) + + return r +} + +func (r *Router) Register(path string, page Page) { + if page != nil { + r.pages[path] = page + } +} + +func (r *Router) SwitchTo(route Route) { + p := r.pages[route.Path] + if p == nil { + return + } + + p.Init(IDPageOption(route.ID)) + + r.current = route +} + +func (r *Router) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + for _, event := range r.bar.Events(gtx) { + switch event := event.(type) { + case component.AppBarNavigationClicked: + // log.Printf("navigation clicked: %+v", event) + r.SwitchTo(Route{Path: PageHome}) + case component.AppBarContextMenuDismissed: + // log.Printf("Context menu dismissed: %+v", event) + r.SwitchTo(Route{Path: PageHome}) + case component.AppBarOverflowActionClicked: + if event.Tag == OverflowActionAbout { + r.SwitchTo(Route{Path: PageAbout}) + } + } + } + + defer r.modal.Layout(gtx, th) + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return r.bar.Layout(gtx, th, "Menu", "Actions") + }), + layout.Flexed(1, func(gtx C) D { + return r.pages[r.current.Path].Layout(gtx, th) + }), + ) +} diff --git a/ui/page/tcp.go b/ui/page/tcp.go new file mode 100644 index 0000000..5a677c0 --- /dev/null +++ b/ui/page/tcp.go @@ -0,0 +1,345 @@ +package page + +import ( + "fmt" + "image/color" + "log" + "net" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type tcpAddPage struct { + router *Router + + list layout.List + wgDone widget.Clickable + + name component.TextField + addr component.TextField +} + +func NewTCPAddPage(r *Router) Page { + return &tcpAddPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *tcpAddPage) Init(opts ...PageOption) { + p.name.SetText("") + p.addr.SetText("") + + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Create", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + p.createTunnel() + tunnel.SaveTunnel() + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + }, nil) + + p.router.bar.Title = "TCP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *tcpAddPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *tcpAddPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, "Expose local TCP tunnel to public network.").Layout), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + ) +} + +func (p *tcpAddPage) createTunnel() error { + srv := tunnel.NewTCPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + ) + + if err := srv.Run(); err != nil { + return err + } + + tunnel.AddTunnel(srv) + return nil +} + +type tcpEditPage struct { + router *Router + + id string + + list layout.List + wgFavorite widget.Clickable + wgState widget.Clickable + wgDelete widget.Clickable + wgDone widget.Clickable + + name component.TextField + addr component.TextField + + wgID widget.Clickable + wgEntrypoint widget.Clickable +} + +func NewTCPEditPage(r *Router) Page { + return &tcpEditPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *tcpEditPage) Init(opts ...PageOption) { + var options PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + s := tunnel.GetTunnelID(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.addr.SetText(sopts.Endpoint) + } + + actions := []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if s == nil { + return D{} + } + + if p.wgFavorite.Clicked(gtx) { + s.Favorite(!s.IsFavorite()) + tunnel.SaveTunnel() + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if s.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Start/Stop", + Tag: &p.wgState, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if p.wgState.Clicked(gtx) && s != nil { + if s.IsClosed() { + s = p.createTunnel() + } else { + s.Close() + } + tunnel.SaveTunnel() + } + + if s != nil && !s.IsClosed() { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStop).Layout(gtx) + } else { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStart).Layout(gtx) + } + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Delete", + Tag: &p.wgDelete, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDelete.Clicked(gtx) { + tunnel.DeleteTunnel(p.id) + tunnel.SaveTunnel() + p.router.SwitchTo(Route{Path: PageHome}) + } + return component.SimpleIconButton(bg, fg, &p.wgDelete, icons.IconDelete).Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Save", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + + if s := tunnel.GetTunnelID(p.id); s != nil { + s.Close() + p.createTunnel() + tunnel.SaveTunnel() + } + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + } + p.router.bar.SetActions(actions, nil) + p.router.bar.Title = "TCP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *tcpEditPage) createTunnel() tunnel.Tunnel { + s := tunnel.NewTCPTunnel( + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + ) + + if err := s.Run(); err != nil { + log.Println(err) + } + tunnel.SetTunnel(s) + + return s +} + +func (p *tcpEditPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *tcpEditPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layoutHeader(gtx, th, tunnel.GetTunnelID(p.id), &p.wgID, &p.wgEntrypoint) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + ) +} diff --git a/ui/page/tunnel.go b/ui/page/tunnel.go new file mode 100644 index 0000000..7865933 --- /dev/null +++ b/ui/page/tunnel.go @@ -0,0 +1,172 @@ +package page + +import ( + "fmt" + "image/color" + "strings" + "sync/atomic" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type tunnelState struct { + editor widget.Clickable +} + +type tunnelPage struct { + router *Router + + list layout.List + + wgFavorite widget.Clickable + wgAdd widget.Clickable + + tunnels map[int]*tunnelState + favorite atomic.Bool +} + +func NewTunnelPage(r *Router) Page { + return &tunnelPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + tunnels: make(map[int]*tunnelState), + } +} + +func (p *tunnelPage) Init(opts ...PageOption) { + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgFavorite.Clicked(gtx) { + p.favorite.Store(!p.favorite.Load()) + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if p.favorite.Load() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Add", + Tag: &p.wgAdd, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgAdd.Clicked(gtx) { + p.router.SwitchTo(Route{Path: PageMenu}) + } + return component.SimpleIconButton(bg, fg, &p.wgAdd, icons.IconAdd).Layout(gtx) + }, + }, + }, + []component.OverflowAction{ + { + Name: "About", + Tag: OverflowActionAbout, + }, + }, + ) + + p.router.bar.Title = "Tunnels" + p.router.bar.NavigationIcon = icons.IconHome +} + +func (p *tunnelPage) Layout(gtx C, th *material.Theme) D { + favorite := p.favorite.Load() + // gtx.Constraints.Min.X = gtx.Constraints.Max.X + return p.list.Layout(gtx, tunnel.TunnelCount(), func(gtx C, index int) D { + s := tunnel.GetTunnel(index) + if s == nil { + delete(p.tunnels, index) + return D{} + } + + if p.tunnels[index] == nil { + p.tunnels[index] = &tunnelState{} + } + + if favorite && !s.IsFavorite() { + return D{} + } + + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + surface := component.Surface(th) + return surface.Layout(gtx, func(gtx C) D { + state := p.tunnels[index] + if state.editor.Clicked(gtx) { + switch s.Type() { + case tunnel.FileTunnel: + p.router.SwitchTo(Route{Path: PageEditFile, ID: s.ID()}) + case tunnel.HTTPTunnel: + p.router.SwitchTo(Route{Path: PageEditHTTP, ID: s.ID()}) + case tunnel.TCPTunnel: + p.router.SwitchTo(Route{Path: PageEditTCP, ID: s.ID()}) + case tunnel.UDPTunnel: + p.router.SwitchTo(Route{Path: PageEditUDP, ID: s.ID()}) + } + op.InvalidateOp{}.Add(gtx.Ops) + } + return state.editor.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layoutTunnel(gtx, th, s) + }) + }) + }) + }) + }) + }) +} + +func (p *tunnelPage) layoutTunnel(gtx C, th *material.Theme, s tunnel.Tunnel) D { + return layout.Flex{ + Alignment: layout.Middle, + Spacing: layout.SpaceBetween, + }.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + label := material.Body1(th, s.ID()) + label.Font.Weight = font.Bold + return label.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: 5}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Type: %s", strings.ToUpper(s.Type()))).Layout), + layout.Rigid(layout.Spacer{Height: 5}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Name: %s", s.Name())).Layout), + layout.Rigid(layout.Spacer{Height: 5}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Endpoint: %s", s.Endpoint())).Layout), + layout.Rigid(layout.Spacer{Height: 5}.Layout), + layout.Rigid(material.Body2(th, fmt.Sprintf("Entrypoint: %s", s.Entrypoint())).Layout), + ) + }), + layout.Rigid(layout.Spacer{Width: 10}.Layout), + layout.Rigid(func(gtx C) D { + c := colornames.Green500 + if s.IsClosed() { + c = colornames.Grey500 + } + return icons.IconTunnelState.Layout(gtx, color.NRGBA(c)) + }), + ) +} diff --git a/ui/page/udp.go b/ui/page/udp.go new file mode 100644 index 0000000..5e565d7 --- /dev/null +++ b/ui/page/udp.go @@ -0,0 +1,346 @@ +package page + +import ( + "fmt" + "image/color" + "log" + "net" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" + "github.com/go-gost/gost-plus/tunnel" + "github.com/go-gost/gost-plus/ui/icons" + "golang.org/x/exp/shiny/materialdesign/colornames" +) + +type udpAddPage struct { + router *Router + + list layout.List + wgDone widget.Clickable + + name component.TextField + addr component.TextField +} + +func NewUDPAddPage(r *Router) Page { + return &udpAddPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *udpAddPage) Init(opts ...PageOption) { + p.name.SetText("") + p.addr.SetText("") + + p.router.bar.SetActions( + []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Create", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + p.createTunnel() + tunnel.SaveTunnel() + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + }, nil) + + p.router.bar.Title = "UDP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *udpAddPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *udpAddPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(material.Body1(th, "Expose local UDP tunnel to public network.").Layout), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + ) +} + +func (p *udpAddPage) createTunnel() error { + srv := tunnel.NewUDPTunnel( + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + ) + + if err := srv.Run(); err != nil { + return err + } + + tunnel.AddTunnel(srv) + return nil +} + +type udpEditPage struct { + router *Router + + id string + + list layout.List + wgFavorite widget.Clickable + wgState widget.Clickable + wgDelete widget.Clickable + wgDone widget.Clickable + + name component.TextField + addr component.TextField + + wgID widget.Clickable + wgEntrypoint widget.Clickable +} + +func NewUDPEditPage(r *Router) Page { + return &udpEditPage{ + router: r, + list: layout.List{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }, + name: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + addr: component.TextField{ + Editor: widget.Editor{ + SingleLine: true, + }, + }, + } +} + +func (p *udpEditPage) Init(opts ...PageOption) { + var options PageOptions + for _, opt := range opts { + opt(&options) + } + + p.id = options.ID + s := tunnel.GetTunnelID(p.id) + if s != nil { + sopts := s.Options() + p.name.SetText(sopts.Name) + p.addr.SetText(sopts.Endpoint) + } + + actions := []component.AppBarAction{ + { + OverflowAction: component.OverflowAction{ + Name: "Favorite", + Tag: &p.wgFavorite, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if s == nil { + return D{} + } + + if p.wgFavorite.Clicked(gtx) { + s.Favorite(!s.IsFavorite()) + tunnel.SaveTunnel() + } + + btn := component.SimpleIconButton(bg, fg, &p.wgFavorite, icons.IconFavorite) + if s.IsFavorite() { + btn.Color = color.NRGBA(colornames.Red500) + } else { + btn.Color = fg + } + return btn.Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Start/Stop", + Tag: &p.wgState, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + s := tunnel.GetTunnelID(p.id) + if p.wgState.Clicked(gtx) && s != nil { + if s.IsClosed() { + s = p.createTunnel() + } else { + s.Close() + } + tunnel.SaveTunnel() + } + + if s != nil && !s.IsClosed() { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStop).Layout(gtx) + } else { + return component.SimpleIconButton(bg, fg, &p.wgState, icons.IconStart).Layout(gtx) + } + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Delete", + Tag: &p.wgDelete, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDelete.Clicked(gtx) { + tunnel.DeleteTunnel(p.id) + tunnel.SaveTunnel() + p.router.SwitchTo(Route{Path: PageHome}) + } + return component.SimpleIconButton(bg, fg, &p.wgDelete, icons.IconDelete).Layout(gtx) + }, + }, + { + OverflowAction: component.OverflowAction{ + Name: "Save", + Tag: &p.wgDone, + }, + Layout: func(gtx C, bg, fg color.NRGBA) D { + if p.wgDone.Clicked(gtx) { + defer p.router.SwitchTo(Route{Path: PageHome}) + + p.createTunnel() + if s := tunnel.GetTunnelID(p.id); s != nil { + s.Close() + p.createTunnel() + tunnel.SaveTunnel() + } + } + return component.SimpleIconButton(bg, fg, &p.wgDone, icons.IconDone).Layout(gtx) + }, + }, + } + p.router.bar.SetActions(actions, nil) + p.router.bar.Title = "UDP" + p.router.bar.NavigationIcon = icons.IconClose +} + +func (p *udpEditPage) createTunnel() tunnel.Tunnel { + s := tunnel.NewUDPTunnel( + tunnel.IDOption(p.id), + tunnel.NameOption(strings.TrimSpace(p.name.Text())), + tunnel.EndpointOption(strings.TrimSpace(p.addr.Text())), + ) + + if err := s.Run(); err != nil { + log.Println(err) + } + tunnel.SetTunnel(s) + + return s +} + +func (p *udpEditPage) Layout(gtx C, th *material.Theme) D { + return p.list.Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return component.Surface(th).Layout(gtx, func(gtx C) D { + return layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return p.layout(gtx, th) + }) + }) + }) + }) + }) +} + +func (p *udpEditPage) layout(gtx C, th *material.Theme) D { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layoutHeader(gtx, th, tunnel.GetTunnelID(p.id), &p.wgID, &p.wgEntrypoint) + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Tunnel name").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return p.name.Layout(gtx, th, "Name") + }), + layout.Rigid(layout.Spacer{Height: 10}.Layout), + layout.Rigid(func(gtx C) D { + return material.Body1(th, "Endpoint address").Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if err := func() error { + addr := strings.TrimSpace(p.addr.Text()) + if addr == "" { + return nil + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("invalid address format, should be [IP]:PORT or [HOST]:PORT") + } + return nil + }(); err != nil { + p.addr.SetError(err.Error()) + } else { + p.addr.ClearError() + } + + return p.addr.Layout(gtx, th, "Address") + }), + ) +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..c212658 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,35 @@ +package ui + +import ( + "gioui.org/font/gofont" + "gioui.org/layout" + "gioui.org/text" + "gioui.org/widget/material" + "github.com/go-gost/gost-plus/ui/page" +) + +type C = layout.Context +type D = layout.Dimensions + +type UI struct { + th *material.Theme + router *page.Router +} + +func NewUI() *UI { + th := material.NewTheme() + th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + // th.Bg = color.NRGBA(colornames.Brown800) + // th.Fg = color.NRGBA(colornames.Grey50) + + ui := &UI{ + th: th, + router: page.NewRouter(), + } + + return ui +} + +func (ui *UI) Layout(gtx C) D { + return ui.router.Layout(gtx, ui.th) +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..c103455 --- /dev/null +++ b/version/version.go @@ -0,0 +1,3 @@ +package version + +var Version = "0.1.0" diff --git a/winres/icon.png b/winres/icon.png new file mode 100644 index 0000000..3267b8c Binary files /dev/null and b/winres/icon.png differ diff --git a/winres/winres.json b/winres/winres.json new file mode 100644 index 0000000..79a021c --- /dev/null +++ b/winres/winres.json @@ -0,0 +1,60 @@ +{ + "RT_GROUP_ICON": { + "OTHER": { + "0000": [ + "icon.png" + ] + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "Simple GUI client for GOST.PLUS", + "minimum-os": "win7", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "0.1.0.0", + "product_version": "0.1.0.0" + }, + "info": { + "0409": { + "Comments": "", + "CompanyName": "GOST.PLUS", + "FileDescription": "A simple GUI client for GOST.PLUS", + "FileVersion": "0.1.0", + "InternalName": "gost.plus", + "LegalCopyright": "Copyright © 2024 GOST.PLUS", + "LegalTrademarks": "GOST.PLUS", + "OriginalFilename": "gost-plus", + "PrivateBuild": "", + "ProductName": "GOST.PLUS", + "ProductVersion": "0.1.0", + "SpecialBuild": "" + } + } + } + } + } +} \ No newline at end of file