Add SOCKS5/HTTP proxy support for SSH connections

Allow SSH connections to be routed through SOCKS5 or HTTP CONNECT proxies.

Backend:
- Add ProxyType, ProxyHost, ProxyPort, ProxyUsername, ProxyPassword to SSHConfig
- dialTarget() dispatches to direct TCP, SOCKS5, or HTTP CONNECT based on config
- SOCKS5: uses golang.org/x/net/proxy with optional username/password auth
- HTTP CONNECT: sends CONNECT request with optional Basic auth, validates 200 OK
- Proxy dialling replaces ssh.Dial for non-jump-host connections; the resulting
  net.Conn is passed to ssh.NewClientConn for the SSH handshake

Frontend:
- Add Proxy section to SSH profile editor (collapsed by default)
- Proxy type selector: None / SOCKS5 / HTTP CONNECT
- Host, port, username, password fields
- Collect proxy values on save
This commit is contained in:
2026-04-17 00:00:00 +01:00
parent 2446e037ce
commit 9d3b3ff507
5 changed files with 148 additions and 6 deletions
+28
View File
@@ -408,6 +408,27 @@ class ProfileEditor {
// TOTP / 2FA // TOTP / 2FA
sections.push(this._renderTOTPSection(profile)); 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 // Advanced
sections.push(this._renderSection('Advanced', [ sections.push(this._renderSection('Advanced', [
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), 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.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5;
p.ssh.startupCommand = val('ssh.startupCommand') || ''; 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'); const authStr = val('ssh.authMethods');
if (authStr !== undefined) { if (authStr !== undefined) {
p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean); p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean);
+5 -5
View File
@@ -1,6 +1,6 @@
module github.com/copilot-ssh-client/app module github.com/copilot-ssh-client/app
go 1.23.0 go 1.25.0
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -8,8 +8,8 @@ require (
github.com/pkg/sftp v1.13.10 github.com/pkg/sftp v1.13.10
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.5 github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.50.0
golang.org/x/text v0.28.0 golang.org/x/text v0.36.0
) )
require ( require (
@@ -41,6 +41,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.43.0 // indirect
) )
+9
View File
@@ -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= 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 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 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.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 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.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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 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.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.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 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 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.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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5
View File
@@ -99,6 +99,11 @@ type SSHConfig struct {
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted) 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"` JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"` PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"` StartupCommand string `json:"startupCommand"`
+101 -1
View File
@@ -1,10 +1,13 @@
package sshclient package sshclient
import ( import (
"bufio"
"context" "context"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -13,6 +16,7 @@ import (
"github.com/copilot-ssh-client/app/internal/models" "github.com/copilot-ssh-client/app/internal/models"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
) )
// Client manages a single SSH session lifecycle. // Client manages a single SSH session lifecycle.
@@ -127,7 +131,18 @@ func (c *Client) connectInternal(
port = 22 port = 22
} }
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port)) 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 { if err != nil {
return fmt.Errorf("SSH connection failed: %w", err) return fmt.Errorf("SSH connection failed: %w", err)
@@ -420,6 +435,91 @@ func isTOTPPrompt(question string) bool {
return false 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 // connectViaJumpHosts establishes the SSH connection through a chain of
// jump hosts. Each hop opens a new net.Conn through the previous hop's // jump hosts. Each hop opens a new net.Conn through the previous hop's
// SSH client. // SSH client.