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)"> 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=