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