diff --git a/ssh/go.mod b/ssh/go.mod index 1de05f393..a1606acc1 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/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 ) 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..7276cbb19 100644 --- a/ssh/go.sum +++ b/ssh/go.sum @@ -1,4 +1,6 @@ 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.go b/ssh/host_key.go index e3b2e8d34..e603d1680 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,16 +47,33 @@ 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() - } + 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() + // this reads the ALL_PROXY environment varaible + conn, err := proxy.Dial(ctx, "tcp", host) + if err != nil { + return err + } + c, chans, reqs, err := ssh.NewClientConn(conn, host, config) + if err != nil { + return err + } + client := ssh.NewClient(c, chans, reqs) + defer client.Close() + + return nil +} + // HostKeyCollector offers a StoreKey method which provides an // HostKeyCallBack to collect public keys from an SSH server. type HostKeyCollector struct { diff --git a/ssh/host_key_test.go b/ssh/host_key_test.go index 4b90cff0f..5cc4d6a62 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,36 @@ limitations under the License. package ssh import ( + "context" + "fmt" + "io" "net" + "os" + "os/exec" "testing" "time" + socks "github.com/firefart/gosocks" . "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()) +func startSSH(listener net.Listener, cfg *ssh.ServerConfig, g *WithT) { + 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 - } - - 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 +89,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 +106,77 @@ 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) + + 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()) + socksProxy.Start(context.TODO()) + + // 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://%s", socksAddress), + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Child process failed with %v. Output: %s", err, string(output)) + } + + g.Expect(handler.proxiedRequests).Should(BeNumerically(">", 0)) + listener.Close() + socksProxy.Stop() +} + +type CustomHandler struct { + socks.DefaultHandler + proxiedRequests int +} + +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) +} 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")) +}