diff --git a/README.md b/README.md index 38a30ef..5f2a458 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ You can find the docs at [go docs](https://pkg.go.dev/github.com/melbahja/goph). - Supports adding new hosts to **known_hosts file**. - Supports **file system operations** like: `Open, Create, Chmod...` - Supports **context.Context** for command cancellation. +- Supports **TCP proxy** connections (SOCKS5 and HTTP CONNECT). ## 📄  Usage @@ -105,6 +106,7 @@ if err != nil { client, err := goph.New("root", "192.1.1.3", auth) ``` + #### ⤴️ Upload Local File to Remote: ```go err := client.Upload("/path/to/local/file", "/path/to/remote/file") diff --git a/client.go b/client.go index a1165b2..b381711 100644 --- a/client.go +++ b/client.go @@ -5,14 +5,17 @@ package goph import ( "context" + "encoding/base64" "fmt" "io" "net" "os" + "strings" "time" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" + "golang.org/x/net/proxy" ) // Client represents Goph client. @@ -30,8 +33,30 @@ type Config struct { Timeout time.Duration Callback ssh.HostKeyCallback BannerCallback ssh.BannerCallback + Proxy *ProxyConfig } +// ProxyConfig holds proxy configuration. +type ProxyConfig struct { + Type ProxyType + Addr string + Port uint + User string + Password string +} + +// ProxyType represents the type of proxy. +type ProxyType int + +const ( + // ProxyTypeNone indicates no proxy + ProxyTypeNone ProxyType = iota + // ProxyTypeSOCKS5 indicates SOCKS5 proxy + ProxyTypeSOCKS5 + // ProxyTypeHTTP indicates HTTP CONNECT proxy + ProxyTypeHTTP +) + // DefaultTimeout is the timeout of ssh client connection. var DefaultTimeout = 20 * time.Second @@ -81,15 +106,182 @@ func NewConn(config *Config) (c *Client, err error) { return } +// NewProxyConn returns new client with proxy configuration and error if any. +func NewProxyConn(config *Config, proxyType ProxyType, proxyAddr string, proxyPort uint, proxyUser, proxyPass string) (*Client, error) { + config.Proxy = &ProxyConfig{ + Type: proxyType, + Addr: proxyAddr, + Port: proxyPort, + User: proxyUser, + Password: proxyPass, + } + return NewConn(config) +} + +// NewSOCKS5ProxyConn returns new client with SOCKS5 proxy configuration. +func NewSOCKS5ProxyConn(user, addr string, auth Auth, proxyAddr string, proxyPort uint, proxyUser, proxyPass string) (*Client, error) { + callback, _ := DefaultKnownHosts() + return NewProxyConn(&Config{ + User: user, + Addr: addr, + Port: 22, + Auth: auth, + Timeout: DefaultTimeout, + Callback: callback, + }, ProxyTypeSOCKS5, proxyAddr, proxyPort, proxyUser, proxyPass) +} + +// NewHTTPProxyConn returns new client with HTTP CONNECT proxy configuration. +func NewHTTPProxyConn(user, addr string, auth Auth, proxyAddr string, proxyPort uint, proxyUser, proxyPass string) (*Client, error) { + callback, _ := DefaultKnownHosts() + return NewProxyConn(&Config{ + User: user, + Addr: addr, + Port: 22, + Auth: auth, + Timeout: DefaultTimeout, + Callback: callback, + }, ProxyTypeHTTP, proxyAddr, proxyPort, proxyUser, proxyPass) +} + +// NewSOCKS5ProxyUnknown returns new client with SOCKS5 proxy and insecure host key checking. +func NewSOCKS5ProxyUnknown(user, addr string, auth Auth, proxyAddr string, proxyPort uint, proxyUser, proxyPass string) (*Client, error) { + return NewProxyConn(&Config{ + User: user, + Addr: addr, + Port: 22, + Auth: auth, + Timeout: DefaultTimeout, + Callback: ssh.InsecureIgnoreHostKey(), + }, ProxyTypeSOCKS5, proxyAddr, proxyPort, proxyUser, proxyPass) +} + +// NewHTTPProxyUnknown returns new client with HTTP CONNECT proxy and insecure host key checking. +func NewHTTPProxyUnknown(user, addr string, auth Auth, proxyAddr string, proxyPort uint, proxyUser, proxyPass string) (*Client, error) { + return NewProxyConn(&Config{ + User: user, + Addr: addr, + Port: 22, + Auth: auth, + Timeout: DefaultTimeout, + Callback: ssh.InsecureIgnoreHostKey(), + }, ProxyTypeHTTP, proxyAddr, proxyPort, proxyUser, proxyPass) +} + // Dial starts a client connection to SSH server based on config. func Dial(proto string, c *Config) (*ssh.Client, error) { - return ssh.Dial(proto, net.JoinHostPort(c.Addr, fmt.Sprint(c.Port)), &ssh.ClientConfig{ + var conn net.Conn + var err error + + if c.Proxy != nil && c.Proxy.Type != ProxyTypeNone { + conn, err = dialThroughProxy(c) + if err != nil { + return nil, err + } + } else { + conn, err = net.DialTimeout(proto, net.JoinHostPort(c.Addr, fmt.Sprint(c.Port)), c.Timeout) + if err != nil { + return nil, err + } + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, net.JoinHostPort(c.Addr, fmt.Sprint(c.Port)), &ssh.ClientConfig{ User: c.User, Auth: c.Auth, Timeout: c.Timeout, HostKeyCallback: c.Callback, BannerCallback: c.BannerCallback, }) + if err != nil { + conn.Close() + return nil, err + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} + +// dialThroughProxy establishes a connection through the configured proxy. +func dialThroughProxy(c *Config) (net.Conn, error) { + proxyAddr := net.JoinHostPort(c.Proxy.Addr, fmt.Sprint(c.Proxy.Port)) + + switch c.Proxy.Type { + case ProxyTypeSOCKS5: + return dialSOCKS5Proxy(proxyAddr, c.Proxy.User, c.Proxy.Password, c.Addr, c.Port, c.Timeout) + case ProxyTypeHTTP: + return dialHTTPProxy(proxyAddr, c.Proxy.User, c.Proxy.Password, c.Addr, c.Port, c.Timeout) + default: + return nil, fmt.Errorf("unsupported proxy type: %v", c.Proxy.Type) + } +} + +// dialSOCKS5Proxy establishes a connection through a SOCKS5 proxy. +func dialSOCKS5Proxy(proxyAddr, proxyUser, proxyPass, targetAddr string, targetPort uint, timeout time.Duration) (net.Conn, error) { + var auth *proxy.Auth + if proxyUser != "" { + auth = &proxy.Auth{ + User: proxyUser, + Password: proxyPass, + } + } + + dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct) + if err != nil { + return nil, fmt.Errorf("failed to create SOCKS5 dialer: %w", err) + } + + target := net.JoinHostPort(targetAddr, fmt.Sprint(targetPort)) + + // Set timeout on the dialer if it's supported + if timeoutDialer, ok := dialer.(interface{ SetTimeout(time.Duration) }); ok { + timeoutDialer.SetTimeout(timeout) + } + + return dialer.Dial("tcp", target) +} + +// dialHTTPProxy establishes a connection through an HTTP CONNECT proxy. +func dialHTTPProxy(proxyAddr, proxyUser, proxyPass, targetAddr string, targetPort uint, timeout time.Duration) (net.Conn, error) { + // First connect to the proxy + conn, err := net.DialTimeout("tcp", proxyAddr, timeout) + if err != nil { + return nil, fmt.Errorf("failed to connect to HTTP proxy: %w", err) + } + + target := net.JoinHostPort(targetAddr, fmt.Sprint(targetPort)) + + // Send CONNECT request + connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\n", target) + connectReq += fmt.Sprintf("Host: %s\r\n", target) + + if proxyUser != "" { + // Basic auth for proxy + auth := proxyUser + ":" + proxyPass + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", encodedAuth) + } + + connectReq += "\r\n" + + if _, err := conn.Write([]byte(connectReq)); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to send CONNECT request: %w", err) + } + + // Read response + buffer := make([]byte, 1024) + n, err := conn.Read(buffer) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to read proxy response: %w", err) + } + + response := string(buffer[:n]) + if !strings.Contains(response, "200") { + conn.Close() + return nil, fmt.Errorf("proxy CONNECT failed: %s", response) + } + + return conn, nil } // Run starts a new SSH session and runs the cmd, it returns CombinedOutput and err if any. diff --git a/go.mod b/go.mod index f7e98dc..48fcbce 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.5 golang.org/x/crypto v0.6.0 + golang.org/x/net v0.7.0 ) diff --git a/go.sum b/go.sum index eea2896..593ecde 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/goph_test.go b/goph_test.go index b99e061..d3968dc 100644 --- a/goph_test.go +++ b/goph_test.go @@ -57,6 +57,7 @@ func TestGoph(t *testing.T) { t.Run("gophRunTest", gophRunTest) t.Run("gophAuthTest", gophAuthTest) t.Run("gophWrongPassTest", gophWrongPassTest) + t.Run("gophProxyConfigTest", gophProxyConfigTest) } func gophAuthTest(t *testing.T) { @@ -198,3 +199,45 @@ func newServer(port string) { } }() } + +func gophProxyConfigTest(t *testing.T) { + // Test proxy config creation + proxyConfig := &goph.ProxyConfig{ + Type: goph.ProxyTypeSOCKS5, + Addr: "127.0.0.1", + Port: 1080, + User: "proxyuser", + Password: "proxypass", + } + + config := &goph.Config{ + User: "testuser", + Addr: "127.0.0.1", + Port: 22, + Auth: goph.Password("testpass"), + Timeout: goph.DefaultTimeout, + Callback: ssh.InsecureIgnoreHostKey(), + Proxy: proxyConfig, + } + + if config.Proxy.Type != goph.ProxyTypeSOCKS5 { + t.Errorf("expected proxy type SOCKS5, got %v", config.Proxy.Type) + } + + if config.Proxy.Addr != "127.0.0.1" { + t.Errorf("expected proxy addr 127.0.0.1, got %s", config.Proxy.Addr) + } + + if config.Proxy.Port != 1080 { + t.Errorf("expected proxy port 1080, got %d", config.Proxy.Port) + } + + if config.Proxy.User != "proxyuser" { + t.Errorf("expected proxy user proxyuser, got %s", config.Proxy.User) + } + + if config.Proxy.Password != "proxypass" { + t.Errorf("expected proxy password proxypass, got %s", config.Proxy.Password) + } +} +