feat: add ssh tunnel support #7 #28 #38

This commit is contained in:
tiny-craft 2023-09-28 01:41:18 +08:00
parent ebacf2bd57
commit 47df424138
9 changed files with 259 additions and 39 deletions

View File

@ -5,6 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/crypto/ssh"
"net"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -68,12 +72,69 @@ func (c *connectionService) Stop(ctx context.Context) {
c.connMap = map[string]connectionItem{} c.connMap = map[string]connectionItem{}
} }
func (c *connectionService) TestConnection(host string, port int, username, password string) (resp types.JSResp) { func (c *connectionService) createRedisClient(config types.ConnectionConfig) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{ var sshClient *ssh.Client
Addr: fmt.Sprintf("%s:%d", host, port), if config.SSH.Enable {
Username: username, sshConfig := &ssh.ClientConfig{
Password: password, 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() defer rdb.Close()
if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil { if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil {
resp.Msg = err.Error() resp.Msg = err.Error()
@ -141,6 +202,23 @@ func (c *connectionService) SaveSortedConnection(sortedConns types.Connections)
return 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 // CreateGroup create a new group
func (c *connectionService) CreateGroup(name string) (resp types.JSResp) { func (c *connectionService) CreateGroup(name string) (resp types.JSResp) {
err := c.conns.CreateGroup(name) 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) return nil, nil, fmt.Errorf("no match connection \"%s\"", connName)
} }
rdb = redis.NewClient(&redis.Options{ var err error
Addr: fmt.Sprintf("%s:%d", selConn.Addr, selConn.Port), rdb, err = c.createRedisClient(selConn.ConnectionConfig)
Username: selConn.Username, if err != nil {
Password: selConn.Password, return nil, nil, fmt.Errorf("create conenction error: %s", err.Error())
DialTimeout: time.Duration(selConn.ConnTimeout) * time.Second, }
ReadTimeout: time.Duration(selConn.ExecTimeout) * time.Second,
WriteTimeout: time.Duration(selConn.ExecTimeout) * time.Second,
})
rdb.AddHook(redis2.NewHook(connName, func(cmd string, cost int64) { rdb.AddHook(redis2.NewHook(connName, func(cmd string, cost int64) {
now := time.Now() now := time.Now()
//last := strings.LastIndex(cmd, ":") //last := strings.LastIndex(cmd, ":")

View File

@ -3,17 +3,18 @@ package types
type ConnectionCategory int type ConnectionCategory int
type ConnectionConfig struct { type ConnectionConfig struct {
Name string `json:"name" yaml:"name"` Name string `json:"name" yaml:"name"`
Group string `json:"group,omitempty" yaml:"-"` Group string `json:"group,omitempty" yaml:"-"`
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
Port int `json:"port,omitempty" yaml:"port,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"`
Username string `json:"username,omitempty" yaml:"username,omitempty"` Username string `json:"username,omitempty" yaml:"username,omitempty"`
Password string `json:"password,omitempty" yaml:"password,omitempty"` Password string `json:"password,omitempty" yaml:"password,omitempty"`
DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"` DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"`
KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"` KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"`
ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"` ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"`
ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"` ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"`
MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"` MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"`
SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"`
} }
type Connection struct { type Connection struct {
@ -35,3 +36,14 @@ type ConnectionDB struct {
Expires int `json:"expires,omitempty"` Expires int `json:"expires,omitempty"`
AvgTTL int `json:"avgTtl,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"`
}

View File

@ -76,16 +76,17 @@ watch(
) )
const borderRadius = computed(() => { const borderRadius = computed(() => {
if (isMacOS()) { // FIXME: cannot get full screen status sync?
return WindowIsFullscreen().then((full) => { // if (isMacOS()) {
return full ? '0' : '10px' // return WindowIsFullscreen().then((full) => {
}) // return full ? '0' : '10px'
} // })
// }
return '10px' return '10px'
}) })
const border = computed(() => { const border = computed(() => {
const color = isMacOS() ? '#0000' : themeVars.value.borderColor const color = isMacOS() && false ? '#0000' : themeVars.value.borderColor
return `1px solid ${color}` return `1px solid ${color}`
}) })
</script> </script>

View File

@ -2,7 +2,7 @@
import { every, get, includes, isEmpty, map } from 'lodash' import { every, get, includes, isEmpty, map } from 'lodash'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' 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 useDialog, { ConnDialogType } from 'stores/dialog'
import Close from '@/components/icons/Close.vue' import Close from '@/components/icons/Close.vue'
import useConnectionStore from 'stores/connections.js' import useConnectionStore from 'stores/connections.js'
@ -56,6 +56,19 @@ const groupOptions = computed(() => {
return options 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 tab = ref('general')
const testing = ref(false) const testing = ref(false)
const showTestResult = 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 // store new connection
const { success, msg } = await connectionStore.saveConnection( const { success, msg } = await connectionStore.saveConnection(
isEditMode.value ? editName.value : null, isEditMode.value ? editName.value : null,
@ -107,6 +137,7 @@ watch(
if (visible) { if (visible) {
editName.value = get(dialogStore.connParam, 'name', '') editName.value = get(dialogStore.connParam, 'name', '')
generalForm.value = dialogStore.connParam || connectionStore.newDefaultConnection() 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 testing.value = true
let result = '' let result = ''
try { try {
const { addr, port, username, password } = generalForm.value const { success = false, msg } = await TestConnection(generalForm.value)
const { success = false, msg } = await TestConnection(addr, port, username, password)
if (!success) { if (!success) {
result = msg result = msg
} }
@ -187,7 +217,9 @@ const onClose = () => {
type="password" /> type="password" />
</n-form-item> </n-form-item>
<n-form-item :label="$t('dialogue.connection.usr')" path="username"> <n-form-item :label="$t('dialogue.connection.usr')" path="username">
<n-input v-model="generalForm.username" :placeholder="$t('dialogue.connection.usr_tip')" /> <n-input
v-model:value="generalForm.username"
:placeholder="$t('dialogue.connection.usr_tip')" />
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
@ -238,13 +270,80 @@ const onClose = () => {
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane :tab="$t('dialogue.connection.ssh_tunnel')" display-directive="show" name="ssh">
<n-form-item label-placement="left">
<n-checkbox v-model:checked="generalForm.ssh.enable" size="medium">
{{ $t('dialogue.connection.ssh_enable') }}
</n-checkbox>
</n-form-item>
<n-form
ref="sshFormRef"
:model="generalForm.ssh"
:show-require-mark="false"
:disabled="!generalForm.ssh.enable"
label-placement="top">
<n-form-item :label="$t('dialogue.connection.addr')" required>
<n-input
v-model:value="generalForm.ssh.addr"
:placeholder="$t('dialogue.connection.addr_tip')" />
<n-text style="width: 40px; text-align: center">:</n-text>
<n-input-number
v-model:value="generalForm.ssh.port"
:max="65535"
:min="1"
style="width: 200px" />
</n-form-item>
<n-form-item :label="$t('dialogue.connection.login_type')">
<n-radio-group v-model:value="generalForm.ssh.loginType">
<n-radio-button :label="$t('dialogue.connection.pwd')" value="pwd" />
<n-radio-button :label="$t('dialogue.connection.pkfile')" value="pkfile" />
</n-radio-group>
</n-form-item>
<n-form-item
v-if="sshLoginType === 'pwd' || sshLoginType === 'pkfile'"
:label="$t('dialogue.connection.usr')">
<n-input
v-model:value="generalForm.ssh.username"
:placeholder="$t('dialogue.connection.ssh_usr_tip')" />
</n-form-item>
<n-form-item v-if="sshLoginType === 'pwd'" :label="$t('dialogue.connection.pwd')">
<n-input
v-model:value="generalForm.ssh.password"
:placeholder="$t('dialogue.connection.ssh_pwd_tip')"
show-password-on="click"
type="password" />
</n-form-item>
<n-form-item v-if="sshLoginType === 'pkfile'" :label="$t('dialogue.connection.pkfile')">
<n-input-group>
<n-input
v-model:value="generalForm.ssh.pkFile"
:placeholder="$t('dialogue.connection.pkfile_tip')" />
<n-button :focusable="false" @click="onChoosePKFile">...</n-button>
</n-input-group>
</n-form-item>
<n-form-item v-if="sshLoginType === 'pkfile'" :label="$t('dialogue.connection.passphrase')">
<n-input
v-model:value="generalForm.ssh.passphrase"
:placeholder="$t('dialogue.connection.passphrase_tip')"
show-password-on="click"
type="password" />
</n-form-item>
</n-form>
</n-tab-pane>
<!-- TODO: SSL tab pane -->
<!-- TODO: Sentinel tab pane -->
<!-- TODO: Cluster tab pane -->
</n-tabs> </n-tabs>
<!-- test result alert--> <!-- test result alert-->
<n-alert <n-alert
v-if="showTestResult" v-if="showTestResult"
:title="isEmpty(testResult) ? '' : $t('dialogue.connection.test_fail')" :title="isEmpty(testResult) ? '' : $t('dialogue.connection.test_fail')"
:type="isEmpty(testResult) ? 'success' : 'error'"> :type="isEmpty(testResult) ? 'success' : 'error'"
closable
:on-close="() => (showTestResult = false)">
<template v-if="isEmpty(testResult)">{{ $t('dialogue.connection.test_succ') }}</template> <template v-if="isEmpty(testResult)">{{ $t('dialogue.connection.test_succ') }}</template>
<template v-else>{{ testResult }}</template> <template v-else>{{ testResult }}</template>
</n-alert> </n-alert>

View File

@ -48,6 +48,7 @@
"status": "Status", "status": "Status",
"filter": "Filter", "filter": "Filter",
"sort_conn": "Sort Connections", "sort_conn": "Sort Connections",
"new_conn_title": "New Connection",
"open_db": "Open Database", "open_db": "Open Database",
"close_db": "Close Database", "close_db": "Close Database",
"filter_key": "Filter Key", "filter_key": "Filter Key",
@ -131,7 +132,17 @@
"advn_separator_tip": "Separator used for key path item", "advn_separator_tip": "Separator used for key path item",
"advn_conn_timeout": "Connection Timeout", "advn_conn_timeout": "Connection Timeout",
"advn_exec_timeout": "Execution 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": { "group": {
"name": "Group Name", "name": "Group Name",

View File

@ -132,7 +132,17 @@
"advn_separator_tip": "键名路径分隔符", "advn_separator_tip": "键名路径分隔符",
"advn_conn_timeout": "连接超时", "advn_conn_timeout": "连接超时",
"advn_exec_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": { "group": {
"name": "分组名", "name": "分组名",

View File

@ -209,6 +209,16 @@ const useConnectionStore = defineStore('connections', {
connTimeout: 60, connTimeout: 60,
execTimeout: 60, execTimeout: 60,
markColor: '', markColor: '',
ssh: {
enable: false,
addr: '',
port: 22,
loginType: 'pwd',
username: '',
password: '',
pkFile: '',
passphrase: '',
},
} }
}, },

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/redis/go-redis/v9 v9.2.0 github.com/redis/go-redis/v9 v9.2.0
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68
github.com/wailsapp/wails/v2 v2.6.0 github.com/wailsapp/wails/v2 v2.6.0
golang.org/x/crypto v0.12.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -37,7 +38,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.5 // indirect github.com/wailsapp/go-webview2 v1.0.5 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/net v0.14.0 // indirect golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/sys v0.11.0 // indirect

2
go.sum
View File

@ -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 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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.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 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=