From 47df424138a2529fb228610d54633399e0b35090 Mon Sep 17 00:00:00 2001
From: tiny-craft <137850705+tiny-craft@users.noreply.github.com>
Date: Thu, 28 Sep 2023 01:41:18 +0800
Subject: [PATCH] feat: add ssh tunnel support #7 #28 #38
---
backend/services/connection_service.go | 103 ++++++++++++++---
backend/types/connection.go | 34 ++++--
frontend/src/AppContent.vue | 13 ++-
.../components/dialogs/ConnectionDialog.vue | 109 +++++++++++++++++-
frontend/src/langs/en.json | 13 ++-
frontend/src/langs/zh-cn.json | 12 +-
frontend/src/stores/connections.js | 10 ++
go.mod | 2 +-
go.sum | 2 +
9 files changed, 259 insertions(+), 39 deletions(-)
diff --git a/backend/services/connection_service.go b/backend/services/connection_service.go
index e283249..d7ebe4b 100644
--- a/backend/services/connection_service.go
+++ b/backend/services/connection_service.go
@@ -5,6 +5,10 @@ import (
"errors"
"fmt"
"github.com/redis/go-redis/v9"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+ "golang.org/x/crypto/ssh"
+ "net"
+ "os"
"strconv"
"strings"
"sync"
@@ -68,12 +72,69 @@ func (c *connectionService) Stop(ctx context.Context) {
c.connMap = map[string]connectionItem{}
}
-func (c *connectionService) TestConnection(host string, port int, username, password string) (resp types.JSResp) {
- rdb := redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%d", host, port),
- Username: username,
- Password: password,
- })
+func (c *connectionService) createRedisClient(config types.ConnectionConfig) (*redis.Client, error) {
+ var sshClient *ssh.Client
+ if config.SSH.Enable {
+ sshConfig := &ssh.ClientConfig{
+ User: config.SSH.Username,
+ Auth: []ssh.AuthMethod{ssh.Password(config.SSH.Password)},
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: time.Duration(config.ConnTimeout) * time.Second,
+ }
+ switch config.SSH.LoginType {
+ case "pwd":
+ sshConfig.Auth = []ssh.AuthMethod{ssh.Password(config.SSH.Password)}
+ case "pkfile":
+ key, err := os.ReadFile(config.SSH.PKFile)
+ if err != nil {
+ return nil, err
+ }
+ var signer ssh.Signer
+ if len(config.SSH.Passphrase) > 0 {
+ signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.SSH.Passphrase))
+ } else {
+ signer, err = ssh.ParsePrivateKey(key)
+ }
+ if err != nil {
+ return nil, err
+ }
+ sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
+ default:
+ 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
+ }
+ }
+
+ option := &redis.Options{
+ Addr: fmt.Sprintf("%s:%d", config.Addr, config.Port),
+ Username: config.Username,
+ Password: config.Password,
+ DialTimeout: time.Duration(config.ConnTimeout) * time.Second,
+ ReadTimeout: time.Duration(config.ExecTimeout) * time.Second,
+ WriteTimeout: time.Duration(config.ExecTimeout) * time.Second,
+ }
+ if sshClient != nil {
+ option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ return sshClient.Dial(network, addr)
+ }
+ option.ReadTimeout = -2
+ option.WriteTimeout = -2
+ }
+ rdb := redis.NewClient(option)
+ return rdb, nil
+}
+
+func (c *connectionService) TestConnection(config types.ConnectionConfig) (resp types.JSResp) {
+ rdb, err := c.createRedisClient(config)
+ if err != nil {
+ resp.Msg = err.Error()
+ return
+ }
defer rdb.Close()
if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil {
resp.Msg = err.Error()
@@ -141,6 +202,23 @@ func (c *connectionService) SaveSortedConnection(sortedConns types.Connections)
return
}
+// SelectKeyFile open file dialog to select a private key file
+func (c *connectionService) SelectKeyFile(title string) (resp types.JSResp) {
+ filepath, err := runtime.OpenFileDialog(c.ctx, runtime.OpenDialogOptions{
+ Title: title,
+ ShowHiddenFiles: true,
+ })
+ if err != nil {
+ resp.Msg = err.Error()
+ return
+ }
+ resp.Success = true
+ resp.Data = map[string]any{
+ "path": filepath,
+ }
+ return
+}
+
// CreateGroup create a new group
func (c *connectionService) CreateGroup(name string) (resp types.JSResp) {
err := c.conns.CreateGroup(name)
@@ -252,14 +330,11 @@ func (c *connectionService) getRedisClient(connName string, db int) (*redis.Clie
return nil, nil, fmt.Errorf("no match connection \"%s\"", connName)
}
- rdb = redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%d", selConn.Addr, selConn.Port),
- Username: selConn.Username,
- Password: selConn.Password,
- DialTimeout: time.Duration(selConn.ConnTimeout) * time.Second,
- ReadTimeout: time.Duration(selConn.ExecTimeout) * time.Second,
- WriteTimeout: time.Duration(selConn.ExecTimeout) * time.Second,
- })
+ var err error
+ rdb, err = c.createRedisClient(selConn.ConnectionConfig)
+ if err != nil {
+ return nil, nil, fmt.Errorf("create conenction error: %s", err.Error())
+ }
rdb.AddHook(redis2.NewHook(connName, func(cmd string, cost int64) {
now := time.Now()
//last := strings.LastIndex(cmd, ":")
diff --git a/backend/types/connection.go b/backend/types/connection.go
index 2999629..c384d19 100644
--- a/backend/types/connection.go
+++ b/backend/types/connection.go
@@ -3,17 +3,18 @@ package types
type ConnectionCategory int
type ConnectionConfig struct {
- Name string `json:"name" yaml:"name"`
- Group string `json:"group,omitempty" yaml:"-"`
- 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"`
- DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"`
- KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"`
- ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"`
- ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"`
- MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
+ Name string `json:"name" yaml:"name"`
+ Group string `json:"group,omitempty" yaml:"-"`
+ 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"`
+ DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"`
+ KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"`
+ ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"`
+ ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"`
+ MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
+ SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"`
}
type Connection struct {
@@ -35,3 +36,14 @@ type ConnectionDB struct {
Expires int `json:"expires,omitempty"`
AvgTTL int `json:"avgTtl,omitempty"`
}
+
+type ConnectionSSH struct {
+ Enable bool `json:"enable" yaml:"enable"`
+ Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
+ Port int `json:"port,omitempty" yaml:"port,omitempty"`
+ LoginType string `json:"loginType" yaml:"login_type"`
+ Username string `json:"username" yaml:"username"`
+ Password string `json:"password,omitempty" yaml:"password,omitempty"`
+ PKFile string `json:"pkFile,omitempty" yaml:"pk_file,omitempty"`
+ Passphrase string `json:"passphrase,omitempty" yaml:"passphrase,omitempty"`
+}
diff --git a/frontend/src/AppContent.vue b/frontend/src/AppContent.vue
index 7b48ded..020ebe2 100644
--- a/frontend/src/AppContent.vue
+++ b/frontend/src/AppContent.vue
@@ -76,16 +76,17 @@ watch(
)
const borderRadius = computed(() => {
- if (isMacOS()) {
- return WindowIsFullscreen().then((full) => {
- return full ? '0' : '10px'
- })
- }
+ // FIXME: cannot get full screen status sync?
+ // if (isMacOS()) {
+ // return WindowIsFullscreen().then((full) => {
+ // return full ? '0' : '10px'
+ // })
+ // }
return '10px'
})
const border = computed(() => {
- const color = isMacOS() ? '#0000' : themeVars.value.borderColor
+ const color = isMacOS() && false ? '#0000' : themeVars.value.borderColor
return `1px solid ${color}`
})
diff --git a/frontend/src/components/dialogs/ConnectionDialog.vue b/frontend/src/components/dialogs/ConnectionDialog.vue
index f492d16..525e527 100644
--- a/frontend/src/components/dialogs/ConnectionDialog.vue
+++ b/frontend/src/components/dialogs/ConnectionDialog.vue
@@ -2,7 +2,7 @@
import { every, get, includes, isEmpty, map } from 'lodash'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
-import { TestConnection } from 'wailsjs/go/services/connectionService.js'
+import { SelectKeyFile, TestConnection } from 'wailsjs/go/services/connectionService.js'
import useDialog, { ConnDialogType } from 'stores/dialog'
import Close from '@/components/icons/Close.vue'
import useConnectionStore from 'stores/connections.js'
@@ -56,6 +56,19 @@ const groupOptions = computed(() => {
return options
})
+const sshLoginType = computed(() => {
+ return get(generalForm.value, 'ssh.loginType', 'pwd')
+})
+
+const onChoosePKFile = async () => {
+ const { success, data } = await SelectKeyFile(i18n.t('dialogue.connection.pkfile_selection_title'))
+ if (!success) {
+ generalForm.value.ssh.pkFile = ''
+ } else {
+ generalForm.value.ssh.pkFile = get(data, 'path', '')
+ }
+}
+
const tab = ref('general')
const testing = ref(false)
const showTestResult = ref(false)
@@ -79,6 +92,23 @@ const onSaveConnection = async () => {
}
})
+ // trim ssh login data
+ if (generalForm.value.ssh.enable) {
+ switch (generalForm.value.ssh.loginType) {
+ case 'pkfile':
+ generalForm.value.ssh.password = ''
+ break
+ default:
+ generalForm.value.ssh.pkFile = ''
+ generalForm.value.ssh.passphrase = ''
+ break
+ }
+ } else {
+ // ssh disabled, reset to default value
+ const { ssh } = connectionStore.newDefaultConnection()
+ generalForm.value.ssh = ssh
+ }
+
// store new connection
const { success, msg } = await connectionStore.saveConnection(
isEditMode.value ? editName.value : null,
@@ -107,6 +137,7 @@ watch(
if (visible) {
editName.value = get(dialogStore.connParam, 'name', '')
generalForm.value = dialogStore.connParam || connectionStore.newDefaultConnection()
+ generalForm.value.ssh.loginType = generalForm.value.ssh.loginType || 'pwd'
}
},
)
@@ -116,8 +147,7 @@ const onTestConnection = async () => {
testing.value = true
let result = ''
try {
- const { addr, port, username, password } = generalForm.value
- const { success = false, msg } = await TestConnection(addr, port, username, password)
+ const { success = false, msg } = await TestConnection(generalForm.value)
if (!success) {
result = msg
}
@@ -187,7 +217,9 @@ const onClose = () => {
type="password" />
-
+
@@ -238,13 +270,80 @@ const onClose = () => {
+
+
+
+
+ {{ $t('dialogue.connection.ssh_enable') }}
+
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+
+
+
+
+
+ :type="isEmpty(testResult) ? 'success' : 'error'"
+ closable
+ :on-close="() => (showTestResult = false)">
{{ $t('dialogue.connection.test_succ') }}
{{ testResult }}
diff --git a/frontend/src/langs/en.json b/frontend/src/langs/en.json
index 2d6ecc4..3c17a73 100644
--- a/frontend/src/langs/en.json
+++ b/frontend/src/langs/en.json
@@ -48,6 +48,7 @@
"status": "Status",
"filter": "Filter",
"sort_conn": "Sort Connections",
+ "new_conn_title": "New Connection",
"open_db": "Open Database",
"close_db": "Close Database",
"filter_key": "Filter Key",
@@ -131,7 +132,17 @@
"advn_separator_tip": "Separator used for key path item",
"advn_conn_timeout": "Connection Timeout",
"advn_exec_timeout": "Execution Timeout",
- "advn_mark_color": "Mark Color"
+ "advn_mark_color": "Mark Color",
+ "ssh_enable": "Enable SSH Tuntel",
+ "ssh_tunnel": "SSH Tunnel",
+ "login_type": "Login Type",
+ "pkfile": "Private Key File",
+ "passphrase": "Passphrase",
+ "ssh_usr_tip": "SSH Username",
+ "ssh_pwd_tip": "SSH Password",
+ "pkfile_tip": "SSH Private Key File Path",
+ "passphrase_tip": "(Optional) Passphrase for Private Key",
+ "pkfile_selection_title": "Please Select Private Key File"
},
"group": {
"name": "Group Name",
diff --git a/frontend/src/langs/zh-cn.json b/frontend/src/langs/zh-cn.json
index dd4adae..64cd8a7 100644
--- a/frontend/src/langs/zh-cn.json
+++ b/frontend/src/langs/zh-cn.json
@@ -132,7 +132,17 @@
"advn_separator_tip": "键名路径分隔符",
"advn_conn_timeout": "连接超时",
"advn_exec_timeout": "执行超时",
- "advn_mark_color": "标记颜色"
+ "advn_mark_color": "标记颜色",
+ "ssh_enable": "启用SSH隧道",
+ "ssh_tunnel": "SSH隧道",
+ "login_type": "登录类型",
+ "pkfile": "私钥文件",
+ "passphrase": "私钥密码",
+ "ssh_usr_tip": "SSH登录用户名",
+ "ssh_pwd_tip": "SSH登录密码",
+ "pkfile_tip": "SSH私钥文件路径",
+ "passphrase_tip": "(可选)SSH私钥密码",
+ "pkfile_selection_title": "请选择私钥文件"
},
"group": {
"name": "分组名",
diff --git a/frontend/src/stores/connections.js b/frontend/src/stores/connections.js
index ba4acb1..45e5792 100644
--- a/frontend/src/stores/connections.js
+++ b/frontend/src/stores/connections.js
@@ -209,6 +209,16 @@ const useConnectionStore = defineStore('connections', {
connTimeout: 60,
execTimeout: 60,
markColor: '',
+ ssh: {
+ enable: false,
+ addr: '',
+ port: 22,
+ loginType: 'pwd',
+ username: '',
+ password: '',
+ pkFile: '',
+ passphrase: '',
+ },
}
},
diff --git a/go.mod b/go.mod
index 348c3bb..b3b86d6 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/redis/go-redis/v9 v9.2.0
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68
github.com/wailsapp/wails/v2 v2.6.0
+ golang.org/x/crypto v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -37,7 +38,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.5 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
- golang.org/x/crypto v0.12.0 // indirect
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
diff --git a/go.sum b/go.sum
index 5414d9d..91028c0 100644
--- a/go.sum
+++ b/go.sum
@@ -107,6 +107,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
+golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=