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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user