From 13e80da9782f31486461292c8cecf88d82506f8a Mon Sep 17 00:00:00 2001 From: Lykin <137850705+tiny-craft@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:54:00 +0800 Subject: [PATCH] feat: support HTTP/SOCKS5 proxy for connections #159 --- backend/services/connection_service.go | 64 +++++++++++-- backend/types/connection.go | 10 ++ backend/utils/proxy/http.go | 96 +++++++++++++++++++ .../components/dialogs/ConnectionDialog.vue | 78 +++++++++++++++ frontend/src/langs/en-us.json | 10 ++ frontend/src/langs/zh-cn.json | 10 ++ frontend/src/stores/connections.js | 9 ++ 7 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 backend/utils/proxy/http.go diff --git a/backend/services/connection_service.go b/backend/services/connection_service.go index 659f0bb..51d3d1e 100644 --- a/backend/services/connection_service.go +++ b/backend/services/connection_service.go @@ -11,8 +11,10 @@ import ( "github.com/vrischmann/userdir" "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/crypto/ssh" + "golang.org/x/net/proxy" "io" "net" + "net/url" "os" "path" "strconv" @@ -21,6 +23,7 @@ import ( "time" . "tinyrdm/backend/storage" "tinyrdm/backend/types" + _ "tinyrdm/backend/utils/proxy" ) type cmdHistoryItem struct { @@ -54,9 +57,34 @@ func (c *connectionService) Start(ctx context.Context) { } func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.Options, error) { - var sshClient *ssh.Client + var dialer proxy.Dialer + var dialerErr error + if config.Proxy.Type == 1 { + // use system proxy + dialer = proxy.FromEnvironment() + } else if config.Proxy.Type == 2 { + // use custom proxy + proxyUrl := url.URL{ + Host: fmt.Sprintf("%s:%d", config.Proxy.Addr, config.Proxy.Port), + } + if len(config.Proxy.Username) > 0 { + proxyUrl.User = url.UserPassword(config.Proxy.Username, config.Proxy.Password) + } + switch config.Proxy.Schema { + case "socks5", "socks5h", "http", "https": + proxyUrl.Scheme = config.Proxy.Schema + default: + proxyUrl.Scheme = "http" + } + if dialer, dialerErr = proxy.FromURL(&proxyUrl, proxy.Direct); dialerErr != nil { + return nil, dialerErr + } + } + + var sshConfig *ssh.ClientConfig + var sshAddr string if config.SSH.Enable { - sshConfig := &ssh.ClientConfig{ + sshConfig = &ssh.ClientConfig{ User: config.SSH.Username, Auth: []ssh.AuthMethod{ssh.Password(config.SSH.Password)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), @@ -84,11 +112,7 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O return nil, errors.New("invalid login type") } - var err error - sshClient, err = ssh.Dial("tcp", fmt.Sprintf("%s:%d", config.SSH.Addr, config.SSH.Port), sshConfig) - if err != nil { - return nil, err - } + sshAddr = fmt.Sprintf("%s:%d", config.SSH.Addr, config.SSH.Port) } var tlsConfig *tls.Config @@ -150,9 +174,31 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O option.Addr = fmt.Sprintf("%s:%d", config.Addr, port) } } - if sshClient != nil { + + if len(sshAddr) > 0 { + if dialer != nil { + // ssh with proxy + conn, err := dialer.Dial("tcp", sshAddr) + if err != nil { + return nil, err + } + sc, chans, reqs, err := ssh.NewClientConn(conn, sshAddr, sshConfig) + if err != nil { + return nil, err + } + dialer = ssh.NewClient(sc, chans, reqs) + } else { + // ssh without proxy + sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig) + if err != nil { + return nil, err + } + dialer = sshClient + } + } + if dialer != nil { option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) { - return sshClient.Dial(network, addr) + return dialer.Dial(network, addr) } option.ReadTimeout = -2 option.WriteTimeout = -2 diff --git a/backend/types/connection.go b/backend/types/connection.go index 7125fb2..f936199 100644 --- a/backend/types/connection.go +++ b/backend/types/connection.go @@ -27,6 +27,7 @@ type ConnectionConfig struct { SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"` Sentinel ConnectionSentinel `json:"sentinel,omitempty" yaml:"sentinel,omitempty"` Cluster ConnectionCluster `json:"cluster,omitempty" yaml:"cluster,omitempty"` + Proxy ConnectionProxy `json:"proxy,omitempty" yaml:"proxy,omitempty"` } type Connection struct { @@ -76,3 +77,12 @@ type ConnectionSentinel struct { type ConnectionCluster struct { Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"` } + +type ConnectionProxy struct { + Type int `json:"type,omitempty" yaml:"type,omitempty"` + Schema string `json:"schema,omitempty" yaml:"schema,omitempty"` + Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` + Port int `json:"port,omitempty" yaml:"port,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` +} diff --git a/backend/utils/proxy/http.go b/backend/utils/proxy/http.go new file mode 100644 index 0000000..fb4c959 --- /dev/null +++ b/backend/utils/proxy/http.go @@ -0,0 +1,96 @@ +package proxy + +import ( + "bufio" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "golang.org/x/net/proxy" +) + +type HttpProxy struct { + scheme string // HTTP Proxy scheme + host string // HTTP Proxy host or host:port + auth *proxy.Auth // authentication + forward proxy.Dialer // forwarding Dialer +} + +func (p *HttpProxy) Dial(network, addr string) (net.Conn, error) { + c, err := p.forward.Dial(network, p.host) + if err != nil { + return nil, err + } + + err = c.SetDeadline(time.Now().Add(15 * time.Second)) + if err != nil { + return nil, err + } + + reqUrl := &url.URL{ + Scheme: "", + Host: addr, + } + + // create with CONNECT method + req, err := http.NewRequest("CONNECT", reqUrl.String(), nil) + if err != nil { + c.Close() + return nil, err + } + req.Close = false + + // authentication + if p.auth != nil { + req.SetBasicAuth(p.auth.User, p.auth.Password) + req.Header.Add("Proxy-Authorization", req.Header.Get("Authorization")) + } + + // send request + err = req.Write(c) + if err != nil { + c.Close() + return nil, err + } + + res, err := http.ReadResponse(bufio.NewReader(c), req) + if err != nil { + res.Body.Close() + c.Close() + return nil, err + } + res.Body.Close() + + if res.StatusCode != http.StatusOK { + c.Close() + return nil, fmt.Errorf("proxy connection error: StatusCode[%d]", res.StatusCode) + } + + return c, nil +} + +func NewHttpProxyDialer(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) { + var auth *proxy.Auth + if u.User != nil { + pwd, _ := u.User.Password() + auth = &proxy.Auth{ + User: u.User.Username(), + Password: pwd, + } + } + + hp := &HttpProxy{ + scheme: u.Scheme, + host: u.Host, + auth: auth, + forward: forward, + } + return hp, nil +} + +func init() { + proxy.RegisterDialerType("http", NewHttpProxyDialer) + proxy.RegisterDialerType("https", NewHttpProxyDialer) +} diff --git a/frontend/src/components/dialogs/ConnectionDialog.vue b/frontend/src/components/dialogs/ConnectionDialog.vue index f38b192..1844aec 100644 --- a/frontend/src/components/dialogs/ConnectionDialog.vue +++ b/frontend/src/components/dialogs/ConnectionDialog.vue @@ -205,10 +205,24 @@ const onSaveConnection = async () => { generalForm.value.sentinel = {} } + // trim cluster data if (!!!generalForm.value.cluster.enable) { generalForm.value.cluster = {} } + // trim proxy data + if (generalForm.value.proxy.type !== 2) { + generalForm.value.proxy.schema = '' + generalForm.value.proxy.addr = '' + generalForm.value.proxy.port = 0 + generalForm.value.proxy.auth = false + generalForm.value.proxy.username = '' + generalForm.value.proxy.password = '' + } else if (!generalForm.value.proxy.auth) { + generalForm.value.proxy.username = '' + generalForm.value.proxy.password = '' + } + // store new connection const { success, msg } = await connectionStore.saveConnection( isEditMode.value ? editName.value : null, @@ -248,6 +262,7 @@ watch( pairs.push({ db: parseInt(db), alias: alias[db] }) } aliasPair.value = pairs + generalForm.value.proxy.auth = !isEmpty(generalForm.value.proxy.username) } }, ) @@ -723,6 +738,69 @@ const pasteFromClipboard = async () => { + + + + + + + + + + + + + + + : + + + + + + {{ $t('dialogue.connection.proxy.auth') }} + + + + + + + + + + + + + diff --git a/frontend/src/langs/en-us.json b/frontend/src/langs/en-us.json index 2e44367..fdea643 100644 --- a/frontend/src/langs/en-us.json +++ b/frontend/src/langs/en-us.json @@ -269,6 +269,16 @@ "cluster": { "title": "Cluster", "enable": "Serve as Cluster Node" + }, + "proxy": { + "title": "Proxy", + "type_none": "No Proxy", + "type_system": "System Proxy Configuration", + "type_custom": "Manual Proxy Configuration", + "host": "Host name", + "auth": "Proxy authentication", + "usr_tip": "Username for proxy authentication", + "pwd_tip": "Password for proxy authentication" } }, "group": { diff --git a/frontend/src/langs/zh-cn.json b/frontend/src/langs/zh-cn.json index 3617b94..6af5ae9 100644 --- a/frontend/src/langs/zh-cn.json +++ b/frontend/src/langs/zh-cn.json @@ -269,6 +269,16 @@ "cluster": { "title": "集群模式", "enable": "当前为集群节点" + }, + "proxy": { + "title": "网络代理", + "type_none": "不使用代理", + "type_system": "使用系统代理设置", + "type_custom": "手动配置代理", + "host": "主机名", + "auth": "使用身份验证", + "usr_tip": "代理授权用户名", + "pwd_tip": "代理授权密码" } }, "group": { diff --git a/frontend/src/stores/connections.js b/frontend/src/stores/connections.js index f1b3601..07bb8d7 100644 --- a/frontend/src/stores/connections.js +++ b/frontend/src/stores/connections.js @@ -196,6 +196,15 @@ const useConnectionStore = defineStore('connections', { cluster: { enable: false, }, + proxy: { + type: 0, + schema: 'http', + addr: '', + port: 0, + auth: false, + username: '', + password: '', + }, } },