From a04bacf60c7ea286b55fa2f4b8be0d66f2c4893f Mon Sep 17 00:00:00 2001 From: Artem Nistratov Date: Sun, 19 Jul 2026 20:42:37 +0300 Subject: [PATCH 1/4] use proxy.Dial instead of net.Dial for ScanHostKey ssh.Dial uses net.DialTimeout under the hood and there is no possibility to use a proxy when running command like `flux create source git` so we use almost all internal implementation of ssh.Dial except net.DialTimeout is replaced with proxy.Dial like it is done in go-git Signed-off-by: Artem Nistratov --- ssh/host_key.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ssh/host_key.go b/ssh/host_key.go index e3b2e8d34..cde1be88b 100644 --- a/ssh/host_key.go +++ b/ssh/host_key.go @@ -17,6 +17,7 @@ limitations under the License. package ssh import ( + "context" "encoding/base64" "fmt" "net" @@ -24,6 +25,7 @@ import ( "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" + "golang.org/x/net/proxy" ) // ScanHostKey collects the given host's preferred public key for the @@ -45,10 +47,20 @@ func ScanHostKey(host string, timeout time.Duration, clientHostKeyAlgos []string config.HostKeyAlgorithms = clientHostKeyAlgos } - client, err := ssh.Dial("tcp", host, config) - if err == nil { - defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + // support for ALL_PROXY ENV varaible + conn, err := proxy.Dial(ctx, "tcp", host) + if err != nil { + return nil, err } + c, chans, reqs, err := ssh.NewClientConn(conn, host, config) + if err != nil { + return nil, err + } + client := ssh.NewClient(c, chans, reqs) + defer client.Close() + if len(col.knownKeys) > 0 { return col.knownKeys, nil } From 3a4bd8f0fd290b9808b09eb5de2215d30e2c7c36 Mon Sep 17 00:00:00 2001 From: Artem Nistratov Date: Sun, 19 Jul 2026 20:42:46 +0300 Subject: [PATCH 2/4] imitate ssh.Dial func for simplicity previously ScanHostKey ignored any SSH/network errors in case it managed to get host keys to make it more obvious we imitate `ssh.Dial` with `sshDial` func Signed-off-by: Artem Nistratov --- ssh/host_key.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ssh/host_key.go b/ssh/host_key.go index cde1be88b..e603d1680 100644 --- a/ssh/host_key.go +++ b/ssh/host_key.go @@ -47,24 +47,31 @@ func ScanHostKey(host string, timeout time.Duration, clientHostKeyAlgos []string config.HostKeyAlgorithms = clientHostKeyAlgos } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + err := sshDial(host, config) + + if len(col.knownKeys) > 0 { + return col.knownKeys, nil + } + + return col.knownKeys, err +} + +func sshDial(host string, config *ssh.ClientConfig) error { + ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) defer cancel() - // support for ALL_PROXY ENV varaible + // this reads the ALL_PROXY environment varaible conn, err := proxy.Dial(ctx, "tcp", host) if err != nil { - return nil, err + return err } c, chans, reqs, err := ssh.NewClientConn(conn, host, config) if err != nil { - return nil, err + return err } client := ssh.NewClient(c, chans, reqs) defer client.Close() - if len(col.knownKeys) > 0 { - return col.knownKeys, nil - } - return col.knownKeys, err + return nil } // HostKeyCollector offers a StoreKey method which provides an From 03966948cc87372fec1d3fbca391a15b2e3660e8 Mon Sep 17 00:00:00 2001 From: Artem Nistratov Date: Sun, 19 Jul 2026 20:37:05 +0300 Subject: [PATCH 3/4] add test for ALL_PROXY ENV usage Signed-off-by: Artem Nistratov --- ssh/go.mod | 3 +- ssh/go.sum | 2 + ssh/host_key_test.go | 105 ++++++++++++++++++++++++++++++++++++------- ssh/proxy_test.go | 18 ++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 ssh/proxy_test.go diff --git a/ssh/go.mod b/ssh/go.mod index 1de05f393..3b7125546 100644 --- a/ssh/go.mod +++ b/ssh/go.mod @@ -3,15 +3,16 @@ module github.com/fluxcd/pkg/ssh go 1.26.0 require ( + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/onsi/gomega v1.40.0 golang.org/x/crypto v0.47.0 + golang.org/x/net v0.49.0 ) require ( github.com/google/go-cmp v0.7.0 // indirect github.com/kr/text v0.2.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect ) diff --git a/ssh/go.sum b/ssh/go.sum index d2fcf5a36..525802e5a 100644 --- a/ssh/go.sum +++ b/ssh/go.sum @@ -1,3 +1,5 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/ssh/host_key_test.go b/ssh/host_key_test.go index 4b90cff0f..b3781cc03 100644 --- a/ssh/host_key_test.go +++ b/ssh/host_key_test.go @@ -1,3 +1,5 @@ +//go:build !proxy + /* Copyright 2022 The Flux authors @@ -17,32 +19,35 @@ limitations under the License. package ssh import ( + "context" + "fmt" "net" + "os" + "os/exec" "testing" "time" + "github.com/armon/go-socks5" . "github.com/onsi/gomega" "golang.org/x/crypto/ssh" ) -func TestScanHost(t *testing.T) { - g := NewWithT(t) - - startSSH := func(listener net.Listener, cfg *ssh.ServerConfig) { - conn, err := listener.Accept() - g.Expect(err).ToNot(HaveOccurred()) - - sConn, _, _, err := ssh.NewServerConn(conn, cfg) - if err != nil { - // the only expected error - g.Expect(err.Error()).To(ContainSubstring("no common algorithm for host key")) - return - } +func startSSH(listener net.Listener, cfg *ssh.ServerConfig, g *WithT) { + conn, err := listener.Accept() + g.Expect(err).ToNot(HaveOccurred()) - sConn.Close() - listener.Close() + sConn, _, _, err := ssh.NewServerConn(conn, cfg) + if err != nil { + // the only expected error + g.Expect(err.Error()).To(ContainSubstring("no common algorithm for host key")) + return } + sConn.Close() + listener.Close() +} + +func TestScanHost(t *testing.T) { tests := []struct { keyType KeyPairType sshKeyTypeName string @@ -83,7 +88,7 @@ func TestScanHost(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) sshConfig.AddHostKey(signer) - go startSSH(listener, sshConfig) + go startSSH(listener, sshConfig, g) kh, err := ScanHostKey(serverAddr, 5*time.Second, []string{tt.sshKeyTypeName}, false) if tt.wantErr == "" { @@ -100,3 +105,71 @@ func TestScanHost(t *testing.T) { }) } } + +// this test is partially based on a go-git's TestSOCKS5Proxy +// see https://github.com/go-git/go-git/blob/5f90b841aef24f235002e2fc71bfb1e142f804cf/plumbing/transport/ssh/proxy_test.go#L21 +func TestScanHostWithProxy(t *testing.T) { + g := NewWithT(t) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + g.Expect(err).ToNot(HaveOccurred()) + + serverAddr := listener.Addr().String() + g.Expect(serverAddr).ToNot(BeEmpty()) + + sshConfig := &ssh.ServerConfig{ + NoClientAuth: true, + } + + // Generate new keypair for the server to use for HostKeys. + hkp, err := GenerateKeyPair(RSA_4096) + g.Expect(err).NotTo(HaveOccurred()) + p, err := ssh.ParseRawPrivateKey(hkp.PrivateKey) + g.Expect(err).NotTo(HaveOccurred()) + + // Add key to server. + signer, err := ssh.NewSignerFromKey(p) + g.Expect(err).NotTo(HaveOccurred()) + sshConfig.AddHostKey(signer) + + go startSSH(listener, sshConfig, g) + + rule := new(testProxyRule) + socksServer, err := socks5.New(&socks5.Config{ + Rules: rule, + }) + g.Expect(err).NotTo(HaveOccurred()) + socksListener, err := net.Listen("tcp", "127.0.0.1:0") + g.Expect(err).ToNot(HaveOccurred()) + go socksServer.Serve(socksListener) + + // we can't set ENV only for this test + // because Golang proxy package caches ENV checks + // and there is no method to reset this cache outside of proxy package + // https://cs.opensource.google/go/x/net/+/refs/tags/v0.57.0:proxy/proxy.go;l=132 + // so we have to run an additional process + // and then check the request counter in our socks server + cmd := exec.Command("go", "test", "-tags=proxy") + cmd.Env = append(os.Environ(), + fmt.Sprintf("SSH_HOST=%s", serverAddr), + fmt.Sprintf("ALL_PROXY=socks5://127.0.0.1:%d", socksListener.Addr().(*net.TCPAddr).Port), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Child process failed with %v. Output: %s", err, string(output)) + } + + g.Expect(rule.proxiedRequests).Should(BeNumerically(">", 0)) + listener.Close() + socksListener.Close() +} + +type testProxyRule struct { + proxiedRequests int +} + +func (r *testProxyRule) Allow(_ context.Context, _ *socks5.Request) (context.Context, bool) { + r.proxiedRequests++ + return context.Background(), true +} diff --git a/ssh/proxy_test.go b/ssh/proxy_test.go new file mode 100644 index 000000000..1cfd8a076 --- /dev/null +++ b/ssh/proxy_test.go @@ -0,0 +1,18 @@ +//go:build proxy + +package ssh + +import ( + "os" + "testing" + "time" + + . "github.com/onsi/gomega" +) + +func TestWithProxy(t *testing.T) { + g := NewWithT(t) + kh, err := ScanHostKey(os.Getenv("SSH_HOST"), 5*time.Second, []string{"ssh-rsa"}, false) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(kh)).To(ContainSubstring("ssh-rsa")) +} From 467540b954ee0928d084ce8d3e06a0fa2f997132 Mon Sep 17 00:00:00 2001 From: Artem Nistratov Date: Sat, 1 Aug 2026 21:52:35 +0300 Subject: [PATCH 4/4] replace outdated go-socks5 --- ssh/go.mod | 2 +- ssh/go.sum | 4 ++-- ssh/host_key_test.go | 37 ++++++++++++++++++++++--------------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/ssh/go.mod b/ssh/go.mod index 3b7125546..a1606acc1 100644 --- a/ssh/go.mod +++ b/ssh/go.mod @@ -3,7 +3,7 @@ module github.com/fluxcd/pkg/ssh go 1.26.0 require ( - github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 + github.com/firefart/gosocks v0.4.2 github.com/onsi/gomega v1.40.0 golang.org/x/crypto v0.47.0 golang.org/x/net v0.49.0 diff --git a/ssh/go.sum b/ssh/go.sum index 525802e5a..7276cbb19 100644 --- a/ssh/go.sum +++ b/ssh/go.sum @@ -1,6 +1,6 @@ -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/firefart/gosocks v0.4.2 h1:HduMZGxEVsBFEHa57rNwqg8S8M878Pe2Og1TfQKaIR8= +github.com/firefart/gosocks v0.4.2/go.mod h1:9k5AYic+qFxo1W9hxw3vFbRTBEVIT3nWepdFvqv0uc4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/ssh/host_key_test.go b/ssh/host_key_test.go index b3781cc03..5cc4d6a62 100644 --- a/ssh/host_key_test.go +++ b/ssh/host_key_test.go @@ -21,13 +21,14 @@ package ssh import ( "context" "fmt" + "io" "net" "os" "os/exec" "testing" "time" - "github.com/armon/go-socks5" + socks "github.com/firefart/gosocks" . "github.com/onsi/gomega" "golang.org/x/crypto/ssh" ) @@ -134,14 +135,19 @@ func TestScanHostWithProxy(t *testing.T) { go startSSH(listener, sshConfig, g) - rule := new(testProxyRule) - socksServer, err := socks5.New(&socks5.Config{ - Rules: rule, - }) + handler := &CustomHandler{ + DefaultHandler: socks.DefaultHandler{ + Timeout: 1 * time.Second, + }, + } + socksAddress := "127.0.0.1:1080" + socksProxy := socks.Proxy{ + ServerAddr: socksAddress, + Proxyhandler: handler, + Timeout: 1 * time.Second, + } g.Expect(err).NotTo(HaveOccurred()) - socksListener, err := net.Listen("tcp", "127.0.0.1:0") - g.Expect(err).ToNot(HaveOccurred()) - go socksServer.Serve(socksListener) + socksProxy.Start(context.TODO()) // we can't set ENV only for this test // because Golang proxy package caches ENV checks @@ -152,7 +158,7 @@ func TestScanHostWithProxy(t *testing.T) { cmd := exec.Command("go", "test", "-tags=proxy") cmd.Env = append(os.Environ(), fmt.Sprintf("SSH_HOST=%s", serverAddr), - fmt.Sprintf("ALL_PROXY=socks5://127.0.0.1:%d", socksListener.Addr().(*net.TCPAddr).Port), + fmt.Sprintf("ALL_PROXY=socks5://%s", socksAddress), ) output, err := cmd.CombinedOutput() @@ -160,16 +166,17 @@ func TestScanHostWithProxy(t *testing.T) { t.Fatalf("Child process failed with %v. Output: %s", err, string(output)) } - g.Expect(rule.proxiedRequests).Should(BeNumerically(">", 0)) + g.Expect(handler.proxiedRequests).Should(BeNumerically(">", 0)) listener.Close() - socksListener.Close() + socksProxy.Stop() } -type testProxyRule struct { +type CustomHandler struct { + socks.DefaultHandler proxiedRequests int } -func (r *testProxyRule) Allow(_ context.Context, _ *socks5.Request) (context.Context, bool) { - r.proxiedRequests++ - return context.Background(), true +func (h *CustomHandler) Init(ctx context.Context, request socks.Request) (context.Context, io.ReadWriteCloser, *socks.Error) { + h.proxiedRequests += 1 + return h.DefaultHandler.Init(ctx, request) }