feat: support HTTP/SOCKS5 proxy for connections #159

This commit is contained in:
Lykin 2024-02-22 17:54:00 +08:00
parent 64ae79f565
commit 13e80da978
7 changed files with 268 additions and 9 deletions

View File

@ -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

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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 () => {
<!-- label-placement="top">-->
<!-- </n-form>-->
</n-tab-pane>
<!-- Proxy pane -->
<n-tab-pane :tab="$t('dialogue.connection.proxy.title')" display-directive="show:lazy" name="proxy">
<n-radio-group v-model:value="generalForm.proxy.type" name="radiogroup">
<n-space size="large" vertical>
<n-radio :label="$t('dialogue.connection.proxy.type_none')" :value="0" />
<n-radio :label="$t('dialogue.connection.proxy.type_system')" :value="1" />
<n-radio :label="$t('dialogue.connection.proxy.type_custom')" :value="2" />
<n-form
:disabled="generalForm.proxy.type !== 2"
:model="generalForm.proxy"
:show-require-mark="false"
label-placement="top">
<n-grid :x-gap="10">
<n-form-item-gi :show-label="false" :span="24" path="addr" required>
<n-input-group>
<n-select
v-model:value="generalForm.proxy.schema"
:consistent-menu-width="false"
:options="[
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'socks5h', label: 'SOCKS5H' },
]"
default-value="http"
style="max-width: 100px" />
<n-input
v-model:value="generalForm.proxy.addr"
:placeholder="$t('dialogue.connection.proxy.host')" />
<n-text style="width: 40px; text-align: center">:</n-text>
<n-input-number
v-model:value="generalForm.proxy.port"
:max="65535"
:min="0"
:show-button="false"
style="width: 200px" />
</n-input-group>
</n-form-item-gi>
<n-form-item-gi :show-label="false" :span="24" path="auth">
<n-checkbox v-model:checked="generalForm.proxy.auth" size="medium">
{{ $t('dialogue.connection.proxy.auth') }}
</n-checkbox>
</n-form-item-gi>
<n-form-item-gi :label="$t('dialogue.connection.usr')" :span="12" path="username">
<n-input
v-model:value="generalForm.proxy.username"
:disabled="!!!generalForm.proxy.auth"
:placeholder="$t('dialogue.connection.proxy.usr_tip')" />
</n-form-item-gi>
<n-form-item-gi :label="$t('dialogue.connection.pwd')" :span="12" path="password">
<n-input
v-model:value="generalForm.proxy.password"
:disabled="!!!generalForm.proxy.auth"
:placeholder="$t('dialogue.connection.proxy.pwd_tip')"
show-password-on="click"
type="password" />
</n-form-item-gi>
</n-grid>
</n-form>
</n-space>
</n-radio-group>
</n-tab-pane>
</n-tabs>
<!-- test result alert-->

View File

@ -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": {

View File

@ -269,6 +269,16 @@
"cluster": {
"title": "集群模式",
"enable": "当前为集群节点"
},
"proxy": {
"title": "网络代理",
"type_none": "不使用代理",
"type_system": "使用系统代理设置",
"type_custom": "手动配置代理",
"host": "主机名",
"auth": "使用身份验证",
"usr_tip": "代理授权用户名",
"pwd_tip": "代理授权密码"
}
},
"group": {

View File

@ -196,6 +196,15 @@ const useConnectionStore = defineStore('connections', {
cluster: {
enable: false,
},
proxy: {
type: 0,
schema: 'http',
addr: '',
port: 0,
auth: false,
username: '',
password: '',
},
}
},