diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index ad52870..4b1555d 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -408,6 +408,27 @@ class ProfileEditor { // TOTP / 2FA sections.push(this._renderTOTPSection(profile)); + // Proxy + sections.push(this._renderSection('Proxy', [ + this._field('select', 'ssh.proxyType', 'Proxy Type', ssh.proxyType || 'none', { + options: [ + { value: 'none', label: 'None (direct connection)' }, + { value: 'socks5', label: 'SOCKS5' }, + { value: 'http', label: 'HTTP CONNECT' }, + ], + }), + this._field('text', 'ssh.proxyHost', 'Proxy Host', ssh.proxyHost || '', { + placeholder: 'proxy.example.com', + }), + this._field('number', 'ssh.proxyPort', 'Proxy Port', ssh.proxyPort || 1080, { min: 1, max: 65535 }), + this._field('text', 'ssh.proxyUsername', 'Proxy Username', ssh.proxyUsername || '', { + placeholder: 'Optional', + }), + this._field('text', 'ssh.proxyPassword', 'Proxy Password', ssh.proxyPassword || '', { + placeholder: 'Optional', + }), + ], true)); + // Advanced sections.push(this._renderSection('Advanced', [ this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), @@ -1264,6 +1285,13 @@ class ProfileEditor { p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5; p.ssh.startupCommand = val('ssh.startupCommand') || ''; + // Proxy settings + p.ssh.proxyType = val('ssh.proxyType') || 'none'; + p.ssh.proxyHost = val('ssh.proxyHost') || ''; + p.ssh.proxyPort = val('ssh.proxyPort') || 1080; + p.ssh.proxyUsername = val('ssh.proxyUsername') || ''; + p.ssh.proxyPassword = val('ssh.proxyPassword') || ''; + const authStr = val('ssh.authMethods'); if (authStr !== undefined) { p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean); diff --git a/go.mod b/go.mod index 45a705a..1d6de19 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/copilot-ssh-client/app -go 1.23.0 +go 1.25.0 require ( github.com/google/uuid v1.6.0 @@ -8,8 +8,8 @@ require ( github.com/pkg/sftp v1.13.10 github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.41.0 - golang.org/x/text v0.28.0 + golang.org/x/crypto v0.50.0 + golang.org/x/text v0.36.0 ) require ( @@ -41,6 +41,6 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 7900eef..8207a21 100644 --- a/go.sum +++ b/go.sum @@ -84,9 +84,13 @@ github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8L github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -95,12 +99,17 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/models/models.go b/internal/models/models.go index 3a07059..76b5dd6 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -99,6 +99,11 @@ type SSHConfig struct { MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted) + ProxyType string `json:"proxyType"` // "none", "socks5", "http" + ProxyHost string `json:"proxyHost"` + ProxyPort int `json:"proxyPort"` + ProxyUsername string `json:"proxyUsername"` + ProxyPassword string `json:"proxyPassword"` // stored in vault for production; inline for simplicity JumpHosts []JumpHost `json:"jumpHosts"` PortForwards []PortForward `json:"portForwards"` StartupCommand string `json:"startupCommand"` diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index 12896fd..7fee8ac 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -1,10 +1,13 @@ package sshclient import ( + "bufio" "context" + "encoding/base64" "fmt" "io" "net" + "net/http" "os" "strings" "sync" @@ -13,6 +16,7 @@ import ( "github.com/copilot-ssh-client/app/internal/models" "github.com/pquerna/otp/totp" "golang.org/x/crypto/ssh" + "golang.org/x/net/proxy" ) // Client manages a single SSH session lifecycle. @@ -127,7 +131,18 @@ func (c *Client) connectInternal( port = 22 } addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port)) - sshConn, err = ssh.Dial("tcp", addr, sshConfig) + + // Dial through proxy if configured, otherwise use direct TCP. + conn, dialErr := c.dialTarget(sc, addr, timeout) + if dialErr != nil { + return fmt.Errorf("SSH connection failed: %w", dialErr) + } + ncc, chans, reqs, sshErr := ssh.NewClientConn(conn, addr, sshConfig) + if sshErr != nil { + conn.Close() + return fmt.Errorf("SSH handshake failed: %w", sshErr) + } + sshConn = ssh.NewClient(ncc, chans, reqs) } if err != nil { return fmt.Errorf("SSH connection failed: %w", err) @@ -420,6 +435,91 @@ func isTOTPPrompt(question string) bool { return false } +// dialTarget dials the target address, optionally through a SOCKS5 or HTTP +// CONNECT proxy as configured in the profile's SSH settings. +func (c *Client) dialTarget(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { + switch strings.ToLower(sc.ProxyType) { + case "socks5": + return c.dialSOCKS5(sc, addr, timeout) + case "http": + return c.dialHTTPProxy(sc, addr, timeout) + default: + // No proxy — direct TCP dial. + return net.DialTimeout("tcp", addr, timeout) + } +} + +// dialSOCKS5 connects to the target via a SOCKS5 proxy. +func (c *Client) dialSOCKS5(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { + proxyPort := sc.ProxyPort + if proxyPort == 0 { + proxyPort = 1080 + } + proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort)) + + var auth *proxy.Auth + if sc.ProxyUsername != "" { + auth = &proxy.Auth{ + User: sc.ProxyUsername, + Password: sc.ProxyPassword, + } + } + + dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: timeout}) + if err != nil { + return nil, fmt.Errorf("SOCKS5 proxy setup failed: %w", err) + } + + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("SOCKS5 proxy connection to %s failed: %w", addr, err) + } + return conn, nil +} + +// dialHTTPProxy connects to the target via an HTTP CONNECT proxy. +func (c *Client) dialHTTPProxy(sc models.SSHConfig, addr string, timeout time.Duration) (net.Conn, error) { + proxyPort := sc.ProxyPort + if proxyPort == 0 { + proxyPort = 8080 + } + proxyAddr := net.JoinHostPort(sc.ProxyHost, fmt.Sprintf("%d", proxyPort)) + + conn, err := net.DialTimeout("tcp", proxyAddr, timeout) + if err != nil { + return nil, fmt.Errorf("HTTP proxy connection failed: %w", err) + } + + // Send CONNECT request. + connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr) + if sc.ProxyUsername != "" { + cred := base64.StdEncoding.EncodeToString([]byte(sc.ProxyUsername + ":" + sc.ProxyPassword)) + connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", cred) + } + connectReq += "\r\n" + + if _, err := conn.Write([]byte(connectReq)); err != nil { + conn.Close() + return nil, fmt.Errorf("HTTP CONNECT write failed: %w", err) + } + + // Read response. + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"}) + if err != nil { + conn.Close() + return nil, fmt.Errorf("HTTP CONNECT response read failed: %w", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("HTTP CONNECT proxy returned %d %s", resp.StatusCode, resp.Status) + } + + return conn, nil +} + // connectViaJumpHosts establishes the SSH connection through a chain of // jump hosts. Each hop opens a new net.Conn through the previous hop's // SSH client.