parent
ebacf2bd57
commit
47df424138
|
@ -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, ":")
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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}`
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -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" />
|
||||
</n-form-item>
|
||||
<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>
|
||||
</n-tab-pane>
|
||||
|
@ -238,13 +270,80 @@ const onClose = () => {
|
|||
</n-form-item>
|
||||
</n-form>
|
||||
</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>
|
||||
|
||||
<!-- test result alert-->
|
||||
<n-alert
|
||||
v-if="showTestResult"
|
||||
: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-else>{{ testResult }}</template>
|
||||
</n-alert>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "分组名",
|
||||
|
|
|
@ -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: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
2
go.mod
2
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
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
Loading…
Reference in New Issue