diff --git a/client.go b/client.go index 56431de0b..ee3447ede 100644 --- a/client.go +++ b/client.go @@ -149,6 +149,8 @@ func WithRegistryMirrors(registryMirrors map[string]string) ClientOption { } } +const DockerAPIVersion = "1.38" + // NewClient allocates and returns a Client configured with the specified options. func NewClient(opts ...ClientOption) (*Client, error) { var client Client @@ -165,7 +167,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { var err error client.docker, err = dockerClient.NewClientWithOpts( dockerClient.FromEnv, - dockerClient.WithVersion("1.38"), + dockerClient.WithVersion(DockerAPIVersion), ) if err != nil { return nil, errors.Wrap(err, "creating docker client") diff --git a/cmd/cmd.go b/cmd/cmd.go index 2e54061c0..507e4f834 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -133,7 +133,11 @@ func initConfig() (config.Config, string, error) { } func initClient(logger logging.Logger, cfg config.Config) (pack.Client, error) { - client, err := pack.NewClient(pack.WithLogger(logger), pack.WithExperimental(cfg.Experimental), pack.WithRegistryMirrors(cfg.RegistryMirrors)) + dc, err := tryInitSSHDockerClient() + if err != nil { + return pack.Client{}, err + } + client, err := pack.NewClient(pack.WithLogger(logger), pack.WithExperimental(cfg.Experimental), pack.WithRegistryMirrors(cfg.RegistryMirrors), pack.WithDockerClient(dc)) if err != nil { return pack.Client{}, err } diff --git a/cmd/docker_init.go b/cmd/docker_init.go new file mode 100644 index 000000000..2a2f7d41a --- /dev/null +++ b/cmd/docker_init.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/buildpacks/pack" + "github.com/buildpacks/pack/internal/sshdialer" + + dockerClient "github.com/docker/docker/client" + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +func tryInitSSHDockerClient() (dockerClient.CommonAPIClient, error) { + dockerHost := os.Getenv("DOCKER_HOST") + _url, err := url.Parse(dockerHost) + isSSH := err == nil && _url.Scheme == "ssh" + + if !isSSH { + return nil, nil + } + + credentialsConfig := sshdialer.Config{ + Identity: os.Getenv("DOCKER_HOST_SSH_IDENTITY"), + PassPhrase: os.Getenv("DOCKER_HOST_SSH_IDENTITY_PASSPHRASE"), + PasswordCallback: newReadSecretCbk("please enter password:"), + PassPhraseCallback: newReadSecretCbk("please enter passphrase to private key:"), + HostKeyCallback: newHostKeyCbk(), + } + dialContext, err := sshdialer.NewDialContext(_url, credentialsConfig) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + // No tls + // No proxy + Transport: &http.Transport{ + DialContext: dialContext, + }, + } + + dockerClientOpts := []dockerClient.Opt{ + dockerClient.WithVersion(pack.DockerAPIVersion), + dockerClient.WithHTTPClient(httpClient), + dockerClient.WithHost("http://dummy/"), + dockerClient.WithDialContext(dialContext), + } + + return dockerClient.NewClientWithOpts(dockerClientOpts...) +} + +// readSecret prompts for a secret and returns value input by user from stdin +// Unlike terminal.ReadPassword(), $(echo $SECRET | podman...) is supported. +// Additionally, all input after `/n` is queued to podman command. +// +// NOTE: this code is based on "github.com/containers/podman/v3/pkg/terminal" +func readSecret(prompt string) (pw []byte, err error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + fmt.Fprint(os.Stderr, prompt) + pw, err = term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + return + } + + var b [1]byte + for { + n, err := os.Stdin.Read(b[:]) + // terminal.readSecret discards any '\r', so we do the same + if n > 0 && b[0] != '\r' { + if b[0] == '\n' { + return pw, nil + } + pw = append(pw, b[0]) + // limit size, so that a wrong input won't fill up the memory + if len(pw) > 1024 { + err = errors.New("password too long, 1024 byte limit") + } + } + if err != nil { + // terminal.readSecret accepts EOF-terminated passwords + // if non-empty, so we do the same + if err == io.EOF && len(pw) > 0 { + err = nil + } + return pw, err + } + } +} + +func newReadSecretCbk(prompt string) sshdialer.SecretCallback { + var secretSet bool + var secret string + return func() (string, error) { + if secretSet { + return secret, nil + } + + p, err := readSecret(prompt) + if err != nil { + return "", err + } + secretSet = true + secret = string(p) + + return secret, err + } +} + +func newHostKeyCbk() sshdialer.HostKeyCallback { + var trust []byte + return func(hostPort string, pubKey ssh.PublicKey) error { + if bytes.Equal(trust, pubKey.Marshal()) { + return nil + } + msg := `The authenticity of host %s cannot be established. +%s key fingerprint is %s +Are you sure you want to continue connecting (yes/no)? ` + fmt.Fprintf(os.Stderr, msg, hostPort, pubKey.Type(), ssh.FingerprintSHA256(pubKey)) + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return err + } + answer = strings.TrimRight(answer, "\r\n") + answer = strings.ToLower(answer) + + if answer == "yes" || answer == "y" { + trust = pubKey.Marshal() + fmt.Fprintf(os.Stderr, "To avoid this in future add following line into your ~/.ssh/known_hosts:\n%s %s %s\n", + hostPort, pubKey.Type(), base64.StdEncoding.EncodeToString(trust)) + return nil + } + + return errors.New("key rejected") + } +} diff --git a/go.mod b/go.mod index 31e781fb0..15e6a21d8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/apex/log v1.9.0 github.com/buildpacks/imgutil v0.0.0-20211001201950-cf7ae41c3771 github.com/buildpacks/lifecycle v0.12.0 + github.com/docker/cli v20.10.7+incompatible github.com/docker/docker v20.10.9+incompatible github.com/docker/go-connections v0.4.0 github.com/gdamore/tcell/v2 v2.4.0 @@ -14,6 +15,7 @@ require ( github.com/google/go-cmp v0.5.6 github.com/google/go-containerregistry v0.6.0 github.com/google/go-github/v30 v30.1.0 + github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95 github.com/heroku/color v0.0.6 github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e github.com/moby/sys/mount v0.2.0 // indirect diff --git a/go.sum b/go.sum index a4575d200..88e3616aa 100644 --- a/go.sum +++ b/go.sum @@ -480,6 +480,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95 h1:S4qyfL2sEm5Budr4KVMyEniCy+PbS55651I/a+Kn/NQ= +github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95/go.mod h1:QiyDdbZLaJ/mZP4Zwc9g2QsfaEA4o7XvvgZegSci5/E= github.com/heroku/color v0.0.6 h1:UTFFMrmMLFcL3OweqP1lAdp8i1y/9oHqkeHjQ/b/Ny0= github.com/heroku/color v0.0.6/go.mod h1:ZBvOcx7cTF2QKOv4LbmoBtNl5uB17qWxGuzZrsi1wLU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -993,6 +995,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190529164535-6a60838ec259/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/sshdialer/posix_test.go b/internal/sshdialer/posix_test.go new file mode 100644 index 000000000..9bb2ee314 --- /dev/null +++ b/internal/sshdialer/posix_test.go @@ -0,0 +1,12 @@ +//+build !windows + +package sshdialer_test + +import "os" + +func fixupPrivateKeyMod(path string) { + err := os.Chmod(path, 0400) + if err != nil { + panic(err) + } +} diff --git a/internal/sshdialer/ssh_dialer.go b/internal/sshdialer/ssh_dialer.go new file mode 100644 index 000000000..83c247c10 --- /dev/null +++ b/internal/sshdialer/ssh_dialer.go @@ -0,0 +1,407 @@ +// NOTE: this code is based on "github.com/containers/podman/v3/pkg/bindings" + +package sshdialer + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io/ioutil" + "net" + urlPkg "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/docker/cli/cli/connhelper" + "github.com/docker/docker/pkg/homedir" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +type SecretCallback func() (string, error) +type HostKeyCallback func(hostPort string, pubKey ssh.PublicKey) error + +type Config struct { + Identity string + PassPhrase string + PasswordCallback SecretCallback + PassPhraseCallback SecretCallback + HostKeyCallback HostKeyCallback +} + +const defaultSSHPort = "22" + +func NewDialContext(url *urlPkg.URL, config Config) (func(ctx context.Context, network, addr string) (net.Conn, error), error) { + sshConfig, err := NewSSHClientConfig(url, config) + if err != nil { + return nil, err + } + + port := url.Port() + if port == "" { + port = defaultSSHPort + } + host := url.Hostname() + + sshClient, err := ssh.Dial("tcp", net.JoinHostPort(host, port), sshConfig) + if err != nil { + return nil, fmt.Errorf("failed to dial ssh: %w", err) + } + defer func() { + if sshClient != nil { + sshClient.Close() + } + }() + + dialContext, err := tryGetStdioDialContext(url, sshClient, config.Identity) + if err != nil { + return nil, err + } + if dialContext != nil { + return dialContext, nil + } + + var addr string + var network string + + if url.Path != "" { + addr = url.Path + network = "unix" + } else { + network, addr, err = networkAndAddressFromRemoteDockerHost(sshClient) + if err != nil { + return nil, err + } + } + + d := dialer{sshClient: sshClient, addr: addr, network: network} + sshClient = nil + dialContext = d.DialContext + + runtime.SetFinalizer(&d, func(d *dialer) { + d.Close() + }) + + return dialContext, nil +} + +type dialer struct { + sshClient *ssh.Client + network string + addr string +} + +func (d *dialer) DialContext(ctx context.Context, n, a string) (net.Conn, error) { + conn, err := d.Dial(d.network, d.addr) + if err != nil { + return nil, err + } + go func() { + if ctx != nil { + <-ctx.Done() + conn.Close() + } + }() + return conn, nil +} + +func (d *dialer) Dial(n, a string) (net.Conn, error) { + return d.sshClient.Dial(d.network, d.addr) +} + +func (d *dialer) Close() error { + return d.sshClient.Close() +} + +func isWindowsMachine(sshClient *ssh.Client) (bool, error) { + session, err := sshClient.NewSession() + if err != nil { + return false, err + } + defer session.Close() + + out, err := session.CombinedOutput("systeminfo") + if err == nil && strings.Contains(string(out), "Windows") { + return true, nil + } + return false, nil +} + +func networkAndAddressFromRemoteDockerHost(sshClient *ssh.Client) (network string, addr string, err error) { + session, err := sshClient.NewSession() + if err != nil { + return + } + defer session.Close() + + out, err := session.CombinedOutput("set") + if err != nil { + return + } + + remoteDockerHost := "unix:///var/run/docker.sock" + isWin, err := isWindowsMachine(sshClient) + if err != nil { + return + } + + if isWin { + remoteDockerHost = "npipe:////./pipe/docker_engine" + } + + scanner := bufio.NewScanner(bytes.NewBuffer(out)) + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "DOCKER_HOST=") { + parts := strings.SplitN(scanner.Text(), "=", 2) + remoteDockerHost = strings.Trim(parts[1], `"'`) + break + } + } + + remoteDockerHostURL, err := urlPkg.Parse(remoteDockerHost) + if err != nil { + return + } + switch remoteDockerHostURL.Scheme { + case "unix": + addr = remoteDockerHostURL.Path + case "fd": + remoteDockerHostURL.Scheme = "tcp" // don't know why it works that way + fallthrough + case "tcp": + addr = remoteDockerHostURL.Host + default: + return "", "", errors.New("scheme is not supported") + } + network = remoteDockerHostURL.Scheme + + return network, addr, err +} + +func tryGetStdioDialContext(url *urlPkg.URL, sshClient *ssh.Client, identity string) (func(ctx context.Context, network, addr string) (net.Conn, error), error) { + session, err := sshClient.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + session.Stdin = nil + session.Stdout = nil + session.Stderr = nil + err = session.Run("docker system dial-stdio") + if err == nil { + var opts []string + + if identity != "" { + opts = append(opts, "-i", identity) + } + + connHelper, err := connhelper.GetConnectionHelperWithSSHOpts(url.String(), opts) + if err != nil { + return nil, err + } + if connHelper != nil { + return connHelper.Dialer, nil + } + } + return nil, nil +} + +func NewSSHClientConfig(url *urlPkg.URL, config Config) (*ssh.ClientConfig, error) { + var ( + authMethods []ssh.AuthMethod + signers []ssh.Signer + ) + + if pw, found := url.User.Password(); found { + authMethods = append(authMethods, ssh.Password(pw)) + } + + // add signer from explicit identity parameter + if config.Identity != "" { + signer, err := loadSignerFromFile(config.Identity, []byte(config.Identity), config.PassPhraseCallback) + if err != nil { + return nil, fmt.Errorf("failed to parse identity file: %w", err) + } + signers = append(signers, signer) + } + + // pulls signers (keys) from ssh-agent + signersFromAgent, err := getSignersFromAgent() + if err != nil { + return nil, err + } + signers = append(signers, signersFromAgent...) + + // if there is no explicit identity file nor keys from ssh-agent then + // add keys with standard name from ~/.ssh/ + if len(signers) == 0 { + defaultKeyPaths := getDefaultKeys() + if len(defaultKeyPaths) == 1 { + signer, err := loadSignerFromFile(defaultKeyPaths[0], []byte(config.PassPhrase), config.PassPhraseCallback) + if err != nil { + return nil, err + } + signers = append(signers, signer) + } + } + + authMethods = append(authMethods, signersToAuthMethods(signers)...) + + if len(authMethods) == 0 && config.PasswordCallback != nil { + authMethods = append(authMethods, ssh.PasswordCallback(config.PasswordCallback)) + } + + const sshTimeout = 5 + clientConfig := &ssh.ClientConfig{ + User: url.User.Username(), + Auth: authMethods, + HostKeyCallback: createHostKeyCallback(config.HostKeyCallback), + HostKeyAlgorithms: []string{ + ssh.KeyAlgoECDSA256, + ssh.KeyAlgoECDSA384, + ssh.KeyAlgoECDSA521, + ssh.KeyAlgoED25519, + ssh.SigAlgoRSASHA2512, + ssh.SigAlgoRSASHA2256, + ssh.KeyAlgoRSA, + ssh.KeyAlgoDSA, + }, + Timeout: sshTimeout * time.Second, + } + + return clientConfig, nil +} + +// returns signers from ssh agent +func getSignersFromAgent() ([]ssh.Signer, error) { + if sock, found := os.LookupEnv("SSH_AUTH_SOCK"); found && sock != "" { + var err error + var agentSigners []ssh.Signer + var agentConn net.Conn + agentConn, err = net.Dial("unix", sock) + if err != nil { + return nil, fmt.Errorf("failed to connect to ssh-agent's socket: %w", err) + } + agentSigners, err = agent.NewClient(agentConn).Signers() + if err != nil { + return nil, fmt.Errorf("failed to get signers from ssh-agent: %w", err) + } + return agentSigners, nil + } + return nil, nil +} + +// Default key names. +var knownKeyNames = []string{"id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk"} + +// returns paths to keys with standard name that are in the ~/.ssh/ directory +func getDefaultKeys() []string { + var defaultKeyPaths []string + if home, err := os.UserHomeDir(); err == nil { + for _, keyName := range knownKeyNames { + p := filepath.Join(home, ".ssh", keyName) + + fi, err := os.Stat(p) + if err != nil { + continue + } + if fi.Mode().IsRegular() { + defaultKeyPaths = append(defaultKeyPaths, p) + } + } + } + return defaultKeyPaths +} + +// transforms slice of singers (keys) into slice of authentication methods for ssh client +func signersToAuthMethods(signers []ssh.Signer) []ssh.AuthMethod { + if len(signers) == 0 { + return nil + } + + var authMethods []ssh.AuthMethod + dedup := make(map[string]ssh.Signer, len(signers)) + // Dedup signers based on fingerprint, ssh-agent keys override explicit identity + for _, s := range signers { + fp := ssh.FingerprintSHA256(s.PublicKey()) + dedup[fp] = s + } + + var uniq []ssh.Signer + for _, s := range dedup { + uniq = append(uniq, s) + } + authMethods = append(authMethods, ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { + return uniq, nil + })) + + return authMethods +} + +// reads key from given path +// if necessary it will decrypt it +func loadSignerFromFile(path string, passphrase []byte, passPhraseCallback SecretCallback) (ssh.Signer, error) { + key, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read key file: %w", err) + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + var missingPhraseError *ssh.PassphraseMissingError + if ok := errors.As(err, &missingPhraseError); !ok { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + if len(passphrase) == 0 && passPhraseCallback != nil { + b, err := passPhraseCallback() + if err != nil { + return nil, err + } + passphrase = []byte(b) + } + + return ssh.ParsePrivateKeyWithPassphrase(key, passphrase) + } + + return signer, nil +} + +func createHostKeyCallback(userCallback HostKeyCallback) ssh.HostKeyCallback { + return func(hostPort string, remote net.Addr, pubKey ssh.PublicKey) error { + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + fileCallback, err := knownhosts.New(knownHosts) + if err != nil { + if os.IsNotExist(err) { + err = errKeyUnknown + } + } else { + err = fileCallback(hostPort, remote, pubKey) + if err == nil { + return nil + } + } + + if userCallback != nil { + err = userCallback(hostPort, pubKey) + if err == nil { + return nil + } + } + + return err + } +} + +var ErrKeyMismatchMsg = "key mismatch" +var ErrKeyUnknownMsg = "key is unknown" + +// I would expose those but since ssh pkg doesn't do correct error wrapping it would be entirely futile +var errKeyUnknown = errors.New(ErrKeyUnknownMsg) diff --git a/internal/sshdialer/ssh_dialer_test.go b/internal/sshdialer/ssh_dialer_test.go new file mode 100644 index 000000000..3e569953c --- /dev/null +++ b/internal/sshdialer/ssh_dialer_test.go @@ -0,0 +1,1146 @@ +package sshdialer_test + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "text/template" + "time" + + "github.com/buildpacks/pack/internal/sshdialer" + th "github.com/buildpacks/pack/testhelpers" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/homedir" + "github.com/docker/go-connections/nat" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +const ( + imageName = "buildpacks/sshdialer-test-img" + containerName = "sshdialer-test-ctr" +) + +var containerIP4 = "" +var containerIP6 = "" + +// We need to set up the test container running sshd against which we will run tests. +// This will return IPv4 and IPv6 of the container, +// cleanUp procedure to remove the test container and possibly error. +func prepareSSHServer(t *testing.T) (cleanUp func(), err error) { + t.Helper() + + th.RequireDocker(t) + + containerIP4 = "127.0.0.1" + containerIP6 = "::1" + + var cleanUps []func() + cleanUp = func() { + for i := range cleanUps { + cleanUps[i]() + } + } + + ctx := context.Background() + + cli, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return + } + + info, err := cli.Info(ctx) + th.SkipIf(t, info.OSType == "windows", "These tests are not yet compatible with Windows-based containers") + + wd, err := os.Getwd() + if err != nil { + return + } + + th.CreateImageFromDir(t, cli, imageName, filepath.Join(wd, "testdata")) + + config := container.Config{ + Image: imageName, + } + + var hostConfig *container.HostConfig + if runtime.GOOS != "linux" { + hostConfig = &container.HostConfig{ + PortBindings: map[nat.Port][]nat.PortBinding{ + "22/tcp": {nat.PortBinding{HostIP: "localhost", HostPort: "22"}}, + "2222/tcp": {nat.PortBinding{HostIP: "localhost", HostPort: "2222"}}, + }, + } + } + + // just in case the container has not been cleaned up + _ = cli.ContainerRemove(ctx, containerName, types.ContainerRemoveOptions{Force: true}) + + ctr, err := cli.ContainerCreate(ctx, &config, hostConfig, nil, nil, containerName) + if err != nil { + return + } + + defer func() { + f := func() { cli.ContainerRemove(ctx, ctr.ID, types.ContainerRemoveOptions{Force: true}) } + if err != nil { + f() + } else { + cleanUps = append(cleanUps, f) + } + }() + + ctrStartOpts := types.ContainerStartOptions{} + err = cli.ContainerStart(ctx, ctr.ID, ctrStartOpts) + if err != nil { + return + } + + defer func() { + f := func() { cli.ContainerKill(ctx, ctr.ID, "SIGKILL") } + if err != nil { + f() + } else { + cleanUps = append(cleanUps, f) + } + }() + + var ctrJSON types.ContainerJSON + if runtime.GOOS == "linux" { + ctrJSON, err = cli.ContainerInspect(ctx, ctr.ID) + if err != nil { + return + } + + containerIP4 = ctrJSON.NetworkSettings.IPAddress + containerIP6 = ctrJSON.NetworkSettings.GlobalIPv6Address + } + + // wait for ssh container to start serving ssh + timeoutChan := time.After(time.Second * 10) + for { + select { + case <-timeoutChan: + err = fmt.Errorf("test container failed to start serving ssh") + return + case <-time.After(time.Millisecond * 500): + } + + conn, err := net.Dial("tcp", net.JoinHostPort(containerIP4, "2222")) + if err != nil { + continue + } + conn.Close() + + break + } + + return cleanUp, err +} + +// function that prepares testing environment and returns clean up function +// this should be used in conjunction with defer: `defer fn()()` +// e.g. sets environment variables or starts mock up services +// it returns clean up procedure that restores old values of environment variables +// or shuts down mock up services +type setUpEnvFn func(t *testing.T) func() + +// combines multiple setUp routines into one setUp routine +func all(fns ...setUpEnvFn) setUpEnvFn { + return func(t *testing.T) func() { + t.Helper() + var cleanUps []func() + for _, fn := range fns { + cleanUps = append(cleanUps, fn(t)) + } + + return func() { + for i := len(cleanUps) - 1; i >= 0; i-- { + cleanUps[i]() + } + } + } +} + +func TestCreateDialer(t *testing.T) { + for _, privateKey := range []string{"id_ed25519", "id_rsa", "id_dsa"} { + path := filepath.Join("testdata", privateKey) + fixupPrivateKeyMod(path) + } + + defer withoutSSHAgent(t)() + defer withCleanHome(t)() + + cleanUp, err := prepareSSHServer(t) + if err != nil { + t.Fatal(err) + } + defer cleanUp() + + type args struct { + connStr string + credentialConfig sshdialer.Config + } + type testParams struct { + name string + args args + setUpEnv setUpEnvFn + skipOnWin bool + CreateError string + DialError string + } + tests := []testParams{ + { + name: "read password from input", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{PasswordCallback: func() (string, error) { + return "idkfa", nil + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "password in url", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "password in url standard ssh port", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "server key is not in known_hosts (the file doesn't exists)", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome), + CreateError: sshdialer.ErrKeyUnknownMsg, + }, + { + name: "server key is not in known_hosts (the file exists)", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withEmptyKnownHosts), + CreateError: sshdialer.ErrKeyUnknownMsg, + }, + { + name: "server key is not in known_hosts (the filed doesn't exists) - user force trust", + args: args{ + connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{HostKeyCallback: func(hostPort string, pubKey ssh.PublicKey) error { + return nil + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome), + }, + { + name: "server key is not in known_hosts (the file exists) - user force trust", + args: args{ + connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{HostKeyCallback: func(hostPort string, pubKey ssh.PublicKey) error { + return nil + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withEmptyKnownHosts), + }, + { + name: "server key does not match the respective key in known_host", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withBadKnownHosts), + CreateError: sshdialer.ErrKeyMismatchMsg, + }, + { + name: "key from identity parameter", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "key at standard location with need to read passphrase", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{PassPhraseCallback: func() (string, error) { + return "idfa", nil + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKey(t, "id_rsa"), withKnowHosts), + }, + { + name: "key at standard location with explicitly set passphrase", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{PassPhrase: "idfa"}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKey(t, "id_rsa"), withKnowHosts), + }, + { + name: "key at standard location with no passphrase", + args: args{connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKey(t, "id_ed25519"), withKnowHosts), + }, + { + name: "key from ssh-agent", + args: args{connStr: fmt.Sprintf("ssh://testuser@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withGoodSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "password in url with IPv6", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@[%s]:2222/home/testuser/test.sock", containerIP6)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "password in url with IPv6 standard port", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@[%s]/home/testuser/test.sock", containerIP6)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + }, + { + name: "broken known host", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withBrokenKnownHosts), + CreateError: "missing host pattern", + }, + { + name: "inaccessible known host", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withInaccessibleKnownHosts), + skipOnWin: true, + CreateError: "permission denied", + }, + { + name: "failing pass phrase cbk", + args: args{ + connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{PassPhraseCallback: func() (string, error) { + return "", errors.New("test_error_msg") + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKey(t, "id_rsa"), withKnowHosts), + CreateError: "test_error_msg", + }, + { + name: "with broken key at default location", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKey(t, "id_dsa"), withKnowHosts), + CreateError: "failed to parse private key", + }, + { + name: "with broken key explicit", + args: args{ + connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_dsa")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + CreateError: "failed to parse private key", + }, + { + name: "with inaccessible key", + args: args{connStr: fmt.Sprintf("ssh://testuser:idkfa@%s:2222/home/testuser/test.sock", containerIP4)}, + setUpEnv: all(withoutSSHAgent, withCleanHome, withInaccessibleKey("id_rsa"), withKnowHosts), + skipOnWin: true, + CreateError: "failed to read key file", + }, + { + name: "socket doesn't exist in remote", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/does/not/exist/test.sock", containerIP4), + credentialConfig: sshdialer.Config{PasswordCallback: func() (string, error) { + return "idkfa", nil + }}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts), + DialError: "failed to dial unix socket in the remote", + }, + { + name: "ssh agent non-existent socket", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/does/not/exist/test.sock", containerIP4), + }, + setUpEnv: all(withBadSSHAgentSocket, withCleanHome, withKnowHosts), + CreateError: "failed to connect to ssh-agent's socket", + }, + { + name: "bad ssh agent", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222/does/not/exist/test.sock", containerIP4), + }, + setUpEnv: all(withBadSSHAgent, withCleanHome, withKnowHosts), + CreateError: "failed to get signers from ssh-agent", + }, + { + name: "use docker host from remote unix", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, + withRemoteDockerHost("unix:///home/testuser/test.sock")), + }, + { + name: "use docker host from remote tcp", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, + withRemoteDockerHost("tcp://localhost:1234")), + }, + { + name: "use docker host from remote fd", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, + withRemoteDockerHost("fd://localhost:1234")), + }, + { + name: "use docker host from remote npipe", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, + withRemoteDockerHost("npipe:////./pipe/docker_engine")), + CreateError: "not supported", + }, + { + name: "use emulated windows with default docker host", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, + withEmulatingWindows), + CreateError: "not supported", + }, + { + name: "use emulated windows with tcp docker host", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, withEmulatingWindows, + withRemoteDockerHost("tcp://localhost:1234")), + }, + { + name: "use docker system dial-stdio", + args: args{ + connStr: fmt.Sprintf("ssh://testuser@%s:2222", containerIP4), + credentialConfig: sshdialer.Config{Identity: filepath.Join("testdata", "id_ed25519")}, + }, + setUpEnv: all(withoutSSHAgent, withCleanHome, withKnowHosts, withEmulatedDockerSystemDialStdio, withFixedUpSSHCLI), + }, + } + + for _, ttx := range tests { + tt := ttx + t.Run(tt.name, func(t *testing.T) { + // this test cannot be parallelized as they use process wide environment variable $HOME + + u, err := url.Parse(tt.args.connStr) + if err != nil { + t.Fatal(err) + } + + if (u.Port() == "" || u.Port() == "22") && runtime.GOOS != "linux" { + t.Skip("skipping test against standard port (22) on non-linux platform") + } + + if net.ParseIP(u.Hostname()).To4() == nil && containerIP6 == "" { + t.Skip("skipping ipv6 test since test environment doesn't support ipv6 connection") + } + + if tt.skipOnWin && runtime.GOOS == "windows" { + t.Skip("skipping this test on windows") + } + + defer tt.setUpEnv(t)() + + dialContext, err := sshdialer.NewDialContext(u, tt.args.credentialConfig) + + if tt.CreateError == "" { + th.AssertEq(t, err, nil) + } else { + // I wish I could use errors.Is(), + // however foreign code is not wrapping errors thoroughly + if err != nil { + th.AssertContains(t, err.Error(), tt.CreateError) + } else { + t.Error("expected error but got nil") + } + } + if err != nil { + return + } + + transport := http.Transport{DialContext: dialContext} + httpClient := http.Client{Transport: &transport} + resp, err := httpClient.Get("http://docker/") + if tt.DialError == "" { + th.AssertEq(t, err, nil) + } else { + // I wish I could use errors.Is(), + // however foreign code is not wrapping errors thoroughly + if err != nil { + th.AssertContains(t, err.Error(), tt.CreateError) + } else { + t.Error("expected error but got nil") + } + } + if err != nil { + return + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + th.AssertTrue(t, err == nil) + if err != nil { + return + } + th.AssertEq(t, string(b), "Hello there!") + }) + } +} + +func cp(src, dest string) error { + srcFs, err := os.Stat(src) + if err != nil { + return fmt.Errorf("the cp() function failed to stat source file: %w", err) + } + + data, err := ioutil.ReadFile(src) + if err != nil { + return fmt.Errorf("the cp() function failed to read source file: %w", err) + } + + _, err = os.Stat(dest) + if err == nil { + return fmt.Errorf("destination file already exists: %w", os.ErrExist) + } + + return ioutil.WriteFile(dest, data, srcFs.Mode()) +} + +// puts key from ./testdata/{keyName} to $HOME/.ssh/{keyName} +// those keys are authorized by the testing ssh server +func withKey(t *testing.T, keyName string) func(t *testing.T) func() { + t.Helper() + + return func(t *testing.T) func() { + t.Helper() + var err error + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Join(home, ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + keySrc := filepath.Join("testdata", keyName) + keyDest := filepath.Join(home, ".ssh", keyName) + err = cp(keySrc, keyDest) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(keyDest) + } + } +} + +// withInaccessibleKey creates inaccessible key of give type (specified by keyName) +func withInaccessibleKey(keyName string) func(t *testing.T) func() { + return func(t *testing.T) func() { + t.Helper() + var err error + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Join(home, ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + keyDest := filepath.Join(home, ".ssh", keyName) + _, err = os.OpenFile(keyDest, os.O_CREATE|os.O_WRONLY, 0000) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(keyDest) + } + } +} + +// sets clean temporary $HOME for test +// this prevents interaction with actual user home which may contain .ssh/ +func withCleanHome(t *testing.T) func() { + t.Helper() + homeName := "HOME" + if runtime.GOOS == "windows" { + homeName = "USERPROFILE" + } + tmpDir, err := ioutil.TempDir("", "tmpHome") + if err != nil { + t.Fatal(err) + } + oldHome, hadHome := os.LookupEnv(homeName) + os.Setenv(homeName, tmpDir) + + return func() { + if hadHome { + os.Setenv(homeName, oldHome) + } else { + os.Unsetenv(homeName) + } + os.RemoveAll(tmpDir) + } +} + +// withKnowHosts creates $HOME/.ssh/known_hosts with correct entries +func withKnowHosts(t *testing.T) func() { + t.Helper() + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + err := os.MkdirAll(filepath.Join(homedir.Get(), ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + _, err = os.Stat(knownHosts) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatal("known_hosts already exists") + } + + f, err := os.OpenFile(knownHosts, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + // generate known_hosts + serverKeysDir := filepath.Join("testdata", "etc", "ssh") + for _, k := range []string{"ecdsa"} { + keyPath := filepath.Join(serverKeysDir, fmt.Sprintf("ssh_host_%s_key.pub", k)) + key, err := ioutil.ReadFile(keyPath) + if err != nil { + t.Fatal(t) + } + + fmt.Fprintf(f, "%s %s", containerIP4, string(key)) + fmt.Fprintf(f, "[%s]:2222 %s", containerIP4, string(key)) + + if containerIP6 != "" { + fmt.Fprintf(f, "%s %s", containerIP6, string(key)) + fmt.Fprintf(f, "[%s]:2222 %s", containerIP6, string(key)) + } + } + + return func() { + os.Remove(knownHosts) + } +} + +// withBadKnownHosts creates $HOME/.ssh/known_hosts with incorrect entries +func withBadKnownHosts(t *testing.T) func() { + t.Helper() + + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + err := os.MkdirAll(filepath.Join(homedir.Get(), ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + _, err = os.Stat(knownHosts) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatal("known_hosts already exists") + } + + f, err := os.OpenFile(knownHosts, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + knownHostTemplate := `{{range $host := .}}{{$host}} ssh-dss AAAAB3NzaC1kc3MAAACBAKH4ufS3ABVb780oTgEL1eu+pI1p6YOq/1KJn5s3zm+L3cXXq76r5OM/roGEYrXWUDGRtfVpzYTAKoMWuqcVc0AZ2zOdYkoy1fSjJ3MqDGF53QEO3TXIUt3gUzmLOewwmZWle0RgMa9GHccv7XVVIZB36RR68ZEUswLaTnlVhXQ1AAAAFQCl4t/LnY7kuUI+tL2qT2XmxmiyqwAAAIB72XaO+LfyIiqBOaTkQf+5rvH1i6y6LDO1QD9pzGWUYw3y03AEveHJMjW0EjnYBKJjK39wcZNTieRyU54lhH/HWeWABn9NcQ3duEf1WSO/s7SPsFO2R6quqVSsStkqf2Yfdy4fl24mH41olwtNA6ft5nkVfkqrIa51si4jU8fBVAAAAIB8SSvyYBcyMGLUlQjzQqhhhAHer9x/1YbknVz+y5PHJLLjHjMC4ZRfLgNEojvMKQW46Te9Pwnudcwv19ho4F+kkCOfss7xjyH70gQm6Sj76DxClmnnPoSRq3qEAOMy5Oh+7vyzxm68KHqd/aOmUaiT1LgqgViS9+kNdCoVMGAMOg== mvasek@bellatrix +{{$host}} ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLTxVVaQ93ReqHNlbjg5/nBRpuRuG6JIgNeJXWT1V4Dl+dMMrnad3uJBfyrNpvn8rv2qnn6gMTZVtTbLdo96pG0= mvasek@bellatrix +{{$host}} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOKymJNQszrxetVffPZRfZGKWK786r0mNcg/Wah4+2wn mvasek@bellatrix +{{$host}} ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/1/OCwec2Gyv5goNYYvos4iOA+a0NolOGsZA/93jmSArPY1zZS1UWeJ6dDTmxGoL/e7jm9lM6NJY7a/zM0C/GqCNRGR/aCUHBJTIgGtH+79FDKO/LWY6ClGY7Lw8qNgZpugbBw3N3HqTtyb2lELhFLT0FEb+le4WUbryooLK2zsz6DnqV4JvTYyyHcanS0h68iSXC7XbkZchvL99l5LT0gD1oDteBPKKFdNOwIjpMkk/IrbFM24xoNkaTDXN87EpQPQzYDfsoGymprc5OZZ8kzrtErQR+yfuunHfzzqDHWi7ga5pbgkuxNt10djWgCfBRsy07FTEgV0JirS0TCfwTBbqRzdjf3dgi8AP+WtkW3mcv4a1XYeqoBo2o9TbfyiA9kERs79UBN0mCe3KNX3Ns0PvutsRLaHmdJ49eaKWkJ6GgL37aqSlIwTixz2xY3eoDSkqHoZpx6Q1MdpSIl5gGVzlaobM/PNM1jqVdyUj+xpjHyiXwHQMKc3eJna7s8Jc= mvasek@bellatrix +{{end}}` + + tmpl := template.New(knownHostTemplate) + tmpl, err = tmpl.Parse(knownHostTemplate) + if err != nil { + t.Fatal(err) + } + + hosts := make([]string, 0, 4) + hosts = append(hosts, containerIP4, fmt.Sprintf("[%s]:2222", containerIP4)) + if containerIP6 != "" { + hosts = append(hosts, containerIP6, fmt.Sprintf("[%s]:2222", containerIP6)) + } + + err = tmpl.Execute(f, hosts) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(knownHosts) + } +} + +// withBrokenKnownHosts creates broken $HOME/.ssh/known_hosts +func withBrokenKnownHosts(t *testing.T) func() { + t.Helper() + + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + err := os.MkdirAll(filepath.Join(homedir.Get(), ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + _, err = os.Stat(knownHosts) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatal("known_hosts already exists") + } + + f, err := os.OpenFile(knownHosts, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + _, err = f.WriteString("somegarbage\nsome rubish\n stuff\tqwerty") + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(knownHosts) + } +} + +// withInaccessibleKnownHosts creates inaccessible $HOME/.ssh/known_hosts +func withInaccessibleKnownHosts(t *testing.T) func() { + t.Helper() + + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + err := os.MkdirAll(filepath.Join(homedir.Get(), ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + _, err = os.Stat(knownHosts) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatal("known_hosts already exists") + } + + f, err := os.OpenFile(knownHosts, os.O_CREATE|os.O_WRONLY, 0000) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + return func() { + os.Remove(knownHosts) + } +} + +// withEmptyKnownHosts creates empty $HOME/.ssh/known_hosts +func withEmptyKnownHosts(t *testing.T) func() { + t.Helper() + + knownHosts := filepath.Join(homedir.Get(), ".ssh", "known_hosts") + + err := os.MkdirAll(filepath.Join(homedir.Get(), ".ssh"), 0700) + if err != nil { + t.Fatal(err) + } + + _, err = os.Stat(knownHosts) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatal("known_hosts already exists") + } + + _, err = os.Create(knownHosts) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(knownHosts) + } +} + +// withoutSSHAgent unsets the SSH_AUTH_SOCK environment variable so ssh-agent is not used by test +func withoutSSHAgent(t *testing.T) func() { + t.Helper() + oldAuthSock, hadAuthSock := os.LookupEnv("SSH_AUTH_SOCK") + os.Unsetenv("SSH_AUTH_SOCK") + + return func() { + if hadAuthSock { + os.Setenv("SSH_AUTH_SOCK", oldAuthSock) + } else { + os.Unsetenv("SSH_AUTH_SOCK") + } + } +} + +// withBadSSHAgentSocket sets the SSH_AUTH_SOCK environment variable to non-existing file +func withBadSSHAgentSocket(t *testing.T) func() { + t.Helper() + oldAuthSock, hadAuthSock := os.LookupEnv("SSH_AUTH_SOCK") + os.Setenv("SSH_AUTH_SOCK", "/does/not/exists.sock") + + return func() { + if hadAuthSock { + os.Setenv("SSH_AUTH_SOCK", oldAuthSock) + } else { + os.Unsetenv("SSH_AUTH_SOCK") + } + } +} + +// withGoodSSHAgent starts serving ssh-agent on temporary unix socket. +// It sets the SSH_AUTH_SOCK environment variable to the temporary socket. +// The agent will return correct keys for the testing ssh server. +func withGoodSSHAgent(t *testing.T) func() { + t.Helper() + + key, err := ioutil.ReadFile(filepath.Join("testdata", "id_ed25519")) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + t.Fatal(err) + } + + return withSSHAgent(t, signerAgent{signer}) +} + +// withBadSSHAgent starts serving ssh-agent on temporary unix socket. +// It sets the SSH_AUTH_SOCK environment variable to the temporary socket. +// The agent will return incorrect keys for the testing ssh server. +func withBadSSHAgent(t *testing.T) func() { + return withSSHAgent(t, badAgent{}) +} + +func withSSHAgent(t *testing.T, ag agent.Agent) func() { + t.Helper() + tmpDirForSocket, err := ioutil.TempDir("", "forAuthSock") + if err != nil { + t.Fatal(err) + } + agentSocketPath := filepath.Join(tmpDirForSocket, "agent.sock") + unixListener, err := net.Listen("unix", agentSocketPath) + if err != nil { + t.Fatal(err) + } + os.Setenv("SSH_AUTH_SOCK", agentSocketPath) + + ctx, cancel := context.WithCancel(context.Background()) + errChan := make(chan error, 1) + var wg sync.WaitGroup + + go func() { + for { + conn, err := unixListener.Accept() + if err != nil { + errChan <- err + + return + } + + wg.Add(1) + go func(conn net.Conn) { + defer wg.Done() + go func() { + <-ctx.Done() + conn.Close() + }() + err := agent.ServeAgent(ag, conn) + if err != nil { + // we can use this once we use go 1.16 + // if !errors.Is(err, net.ErrClosed) { + if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { + fmt.Fprintf(os.Stderr, "agent.ServeAgent() failed: %v\n", err) + } + } + }(conn) + } + }() + + return func() { + os.Unsetenv("SSH_AUTH_SOCK") + + err := unixListener.Close() + if err != nil { + t.Fatal(err) + } + err = <-errChan + + // we can use this once we use go 1.16 + // if !errors.Is(err, net.ErrClosed) { + if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { + t.Fatal(err) + } + cancel() + wg.Wait() + os.RemoveAll(tmpDirForSocket) + } +} + +type signerAgent struct { + impl ssh.Signer +} + +func (a signerAgent) List() ([]*agent.Key, error) { + return []*agent.Key{{ + Format: a.impl.PublicKey().Type(), + Blob: a.impl.PublicKey().Marshal(), + }}, nil +} + +func (a signerAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.impl.Sign(nil, data) +} + +func (a signerAgent) Add(key agent.AddedKey) error { + panic("implement me") +} + +func (a signerAgent) Remove(key ssh.PublicKey) error { + panic("implement me") +} + +func (a signerAgent) RemoveAll() error { + panic("implement me") +} + +func (a signerAgent) Lock(passphrase []byte) error { + panic("implement me") +} + +func (a signerAgent) Unlock(passphrase []byte) error { + panic("implement me") +} + +func (a signerAgent) Signers() ([]ssh.Signer, error) { + panic("implement me") +} + +var errBadAgent = errors.New("bad agent error") + +type badAgent struct{} + +func (b badAgent) List() ([]*agent.Key, error) { + return nil, errBadAgent +} + +func (b badAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return nil, errBadAgent +} + +func (b badAgent) Add(key agent.AddedKey) error { + return errBadAgent +} + +func (b badAgent) Remove(key ssh.PublicKey) error { + return errBadAgent +} + +func (b badAgent) RemoveAll() error { + return errBadAgent +} + +func (b badAgent) Lock(passphrase []byte) error { + return errBadAgent +} + +func (b badAgent) Unlock(passphrase []byte) error { + return errBadAgent +} + +func (b badAgent) Signers() ([]ssh.Signer, error) { + return nil, errBadAgent +} + +// openSSH CLI doesn't take the HOME/USERPROFILE environment variable into account. +// It gets user home in different way (e.g. reading /etc/passwd). +// This means tests cannot mock home dir just by setting environment variable. +// withFixedUpSSHCLI works around the problem, it forces usage of known_hosts from HOME/USERPROFILE. +func withFixedUpSSHCLI(t *testing.T) func() { + t.Helper() + + which := "which" + if runtime.GOOS == "windows" { + which = "where" + } + + out, err := exec.Command(which, "ssh").CombinedOutput() + if err != nil { + t.Fatal(err) + } + sshAbsPath := string(out) + sshAbsPath = strings.Trim(sshAbsPath, "\r\n") + + sshScript := `#!/bin/sh +SSH_BIN -o PasswordAuthentication=no -o ConnectTimeout=3 -o UserKnownHostsFile="$HOME/.ssh/known_hosts" $@ +` + if runtime.GOOS == "windows" { + sshScript = `@echo off +SSH_BIN -o PasswordAuthentication=no -o ConnectTimeout=3 -o UserKnownHostsFile=%USERPROFILE%\.ssh\known_hosts %* +` + } + sshScript = strings.ReplaceAll(sshScript, "SSH_BIN", sshAbsPath) + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + homeBin := filepath.Join(home, "bin") + err = os.MkdirAll(homeBin, 0700) + if err != nil { + t.Fatal(err) + } + + sshScriptName := "ssh" + if runtime.GOOS == "windows" { + sshScriptName = "ssh.bat" + } + + sshScriptFullPath := filepath.Join(homeBin, sshScriptName) + err = ioutil.WriteFile(sshScriptFullPath, []byte(sshScript), 0700) + if err != nil { + t.Fatal(err) + } + + oldPath := os.Getenv("PATH") + os.Setenv("PATH", homeBin+string(os.PathListSeparator)+oldPath) + return func() { + os.Setenv("PATH", oldPath) + os.RemoveAll(homeBin) + } +} + +// withEmulatedDockerSystemDialStdio makes `docker system dial-stdio` viable in the testing ssh server. +// It does so by appending definition of shell function named `docker` into .bashrc . +func withEmulatedDockerSystemDialStdio(t *testing.T) func() { + t.Helper() + + _, err := runRemote(`echo 'docker () { + if [ "$1" = "system" ] && [ "$2" = "dial-stdio" ]; then + if [ "$3" = "--help" ]; then + echo "\nProxy the stdio stream to the daemon connection."; + else + socat - /home/testuser/test.sock; + fi + fi +}' >> ~/.bashrc`) + if err != nil { + t.Fatal(err) + } + + return func() { + _, _ = runRemote(`echo 'unset -f docker' >> ~/.bashrc`) + } +} + +// withEmulatingWindows makes changes to the testing ssh server such that +// the server appears to be Windows server for simple check done calling the `systeminfo` command +func withEmulatingWindows(t *testing.T) func() { + t.Helper() + + _, err := runRemote(`echo 'systeminfo () { + echo '\nWindows\n' +}' >> ~/.bashrc`) + if err != nil { + t.Fatal(err) + } + + return func() { + _, _ = runRemote(`echo 'unset -f systeminfo' >> ~/.bashrc`) + } +} + +// withRemoteDockerHost sets the DOCKER_HOST environment variable in the testing ssh server. +// It does so by appending export statement to .bashrc . +func withRemoteDockerHost(host string) setUpEnvFn { + return func(t *testing.T) func() { + t.Helper() + _, err := runRemote(fmt.Sprintf(`echo 'export DOCKER_HOST=%s' >> ~/.bashrc`, host)) + if err != nil { + t.Fatal(err) + } + return func() { + runRemote(`echo 'unset DOCKER_HOST' >> ~/.bashrc`) + } + } +} + +// runRemote runs command it the testing ssh server +func runRemote(cmd string) ([]byte, error) { + u, err := url.Parse(fmt.Sprintf("ssh://testuser@%s:2222", containerIP4)) + if err != nil { + return nil, err + } + sshClientConfig, err := sshdialer.NewSSHClientConfig(u, sshdialer.Config{ + HostKeyCallback: func(hostPort string, pubKey ssh.PublicKey) error { + return nil + }, + PasswordCallback: func() (string, error) { + return "idkfa", nil + }, + }) + if err != nil { + return nil, err + } + + sshClient, err := ssh.Dial("tcp", u.Host, sshClientConfig) + if err != nil { + return nil, err + } + defer sshClient.Close() + + session, err := sshClient.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + + return session.CombinedOutput(cmd) +} diff --git a/internal/sshdialer/testdata/Dockerfile b/internal/sshdialer/testdata/Dockerfile new file mode 100644 index 000000000..f125105d2 --- /dev/null +++ b/internal/sshdialer/testdata/Dockerfile @@ -0,0 +1,32 @@ +FROM docker.io/library/golang:1.16 AS builder + +RUN mkdir /workspace/ +COPY main.go go.mod /workspace/ +WORKDIR /workspace/ +ENV CGO_ENABLED=0 +RUN go build -o serve-socket + +FROM fedora:33 + +RUN dnf in -y openssh-server passwd socat + +COPY etc/ssh /etc/ssh +RUN chmod og-rwx /etc/ssh/*_key +COPY entrypoint.sh /usr/local/bin +RUN chmod a+x /usr/local/bin/entrypoint.sh + +RUN groupadd testuser && \ + useradd testuser -g testuser -m -s /bin/bash && \ + echo iddqd | passwd root --stdin && \ + echo idkfa | passwd testuser --stdin + +RUN su testuser && mkdir /home/testuser/.ssh/ +COPY --chown=testuser:testuser id_ed25519.pub id_rsa.pub /tmp/ +RUN cat /tmp/id_ed25519.pub /tmp/id_rsa.pub >> /home/testuser/.ssh/authorized_keys + +COPY --from=builder /workspace/serve-socket /usr/local/bin + +EXPOSE 22 +EXPOSE 2222 + +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/internal/sshdialer/testdata/entrypoint.sh b/internal/sshdialer/testdata/entrypoint.sh new file mode 100755 index 000000000..1396f7d74 --- /dev/null +++ b/internal/sshdialer/testdata/entrypoint.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +/usr/sbin/sshd + +su - testuser -c '/usr/local/bin/serve-socket "/home/testuser/test.sock" "1234"' diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key b/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key new file mode 100644 index 000000000..b6cfa3ba1 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key @@ -0,0 +1,21 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsgAAAAdzc2gtZH +NzAAAAgQDhtFRnJxDyn1xru4PNNnOyvaYlPzt+l8Jwu1PozLsU+2LPJmZQtYXjUl7d7w/P +MklwmwH/4xuQ+YjkaWD8I+4IA4FZfAJjEuTPXJsze48Fefcorg9TkxRxDPk6vetn3TKSuR +YeBpLDq+Dq1vo/2vBM70KahRQDxVNlYdNbiBG5dQAAABUAjg1kaaW8DHwj326XhjKpkoXR +zwEAAACAfSdptBoh5kQHp1ZAMr4EBzH+PMhRf6sdfHhpUOO+4uhRC5hqIgIm3IA1UG8H5V +2Py6xNvIvELX/UVrtzTeqjxZ8+47WKM+j6fTUp3gx+InLq23bD9U43BP+sUcXIs322LUkb +fBoQzWnXx8lkOQzaeofGc4Cg2NIIA5pWewsEACQAAACBAK2mPPqr8PSHjnW9bqv8MEuc3K +qKHuL4a5fGQA5G5upbFxXgB3MtstTtWO27LVD5wrlLOpEz7Lzwv51GUFpxYRTSv49qliKP +c31QAoEjlpST6Jv9JpERGcCTETwNwyH8TH4gx7Ku0JXaysyJH0rn2UTCUAhNmMpiO5tSb5 +PCwK94AAAB6ItbM9KLWzPSAAAAB3NzaC1kc3MAAACBAOG0VGcnEPKfXGu7g802c7K9piU/ +O36XwnC7U+jMuxT7Ys8mZlC1heNSXt3vD88ySXCbAf/jG5D5iORpYPwj7ggDgVl8AmMS5M +9cmzN7jwV59yiuD1OTFHEM+Tq962fdMpK5Fh4GksOr4OrW+j/a8EzvQpqFFAPFU2Vh01uI +Ebl1AAAAFQCODWRppbwMfCPfbpeGMqmShdHPAQAAAIB9J2m0GiHmRAenVkAyvgQHMf48yF +F/qx18eGlQ477i6FELmGoiAibcgDVQbwflXY/LrE28i8Qtf9RWu3NN6qPFnz7jtYoz6Pp9 +NSneDH4icurbdsP1TjcE/6xRxcizfbYtSRt8GhDNadfHyWQ5DNp6h8ZzgKDY0ggDmlZ7Cw +QAJAAAAIEAraY8+qvw9IeOdb1uq/wwS5zcqooe4vhrl8ZADkbm6lsXFeAHcy2y1O1Y7bst +UPnCuUs6kTPsvPC/nUZQWnFhFNK/j2qWIo9zfVACgSOWlJPom/0mkREZwJMRPA3DIfxMfi +DHsq7QldrKzIkfSufZRMJQCE2YymI7m1Jvk8LAr3gAAAAUdboNp8quoeOloagm/Or8qP1d +zwMAAAAQbXZhc2VrQGJlbGxhdHJpeAEC +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key.pub b/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key.pub new file mode 100644 index 000000000..291a3f583 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_dsa_key.pub @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAOG0VGcnEPKfXGu7g802c7K9piU/O36XwnC7U+jMuxT7Ys8mZlC1heNSXt3vD88ySXCbAf/jG5D5iORpYPwj7ggDgVl8AmMS5M9cmzN7jwV59yiuD1OTFHEM+Tq962fdMpK5Fh4GksOr4OrW+j/a8EzvQpqFFAPFU2Vh01uIEbl1AAAAFQCODWRppbwMfCPfbpeGMqmShdHPAQAAAIB9J2m0GiHmRAenVkAyvgQHMf48yFF/qx18eGlQ477i6FELmGoiAibcgDVQbwflXY/LrE28i8Qtf9RWu3NN6qPFnz7jtYoz6Pp9NSneDH4icurbdsP1TjcE/6xRxcizfbYtSRt8GhDNadfHyWQ5DNp6h8ZzgKDY0ggDmlZ7CwQAJAAAAIEAraY8+qvw9IeOdb1uq/wwS5zcqooe4vhrl8ZADkbm6lsXFeAHcy2y1O1Y7bstUPnCuUs6kTPsvPC/nUZQWnFhFNK/j2qWIo9zfVACgSOWlJPom/0mkREZwJMRPA3DIfxMfiDHsq7QldrKzIkfSufZRMJQCE2YymI7m1Jvk8LAr3g= mvasek@bellatrix diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key b/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key new file mode 100644 index 000000000..08a9d8c0f --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRDB1fPXY58fSwaqyoj5lCfLtQ/NcIs +grKTA11vypVy9MUCWtdAQIXczmtRMTFCVozk3lwt9M4iKc79nCkkkfyrAAAAsPgat2v4Gr +drAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEMHV89djnx9LBqr +KiPmUJ8u1D81wiyCspMDXW/KlXL0xQJa10BAhdzOa1ExMUJWjOTeXC30ziIpzv2cKSSR/K +sAAAAhAIcs2smJGAEKOvzL8Rfz5b1IpQqB8GzxycT3/53XOzaSAAAAEG12YXNla0BiZWxs +YXRyaXgBAgMEBQYH +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key.pub b/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key.pub new file mode 100644 index 000000000..922ed1926 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_ecdsa_key.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEMHV89djnx9LBqrKiPmUJ8u1D81wiyCspMDXW/KlXL0xQJa10BAhdzOa1ExMUJWjOTeXC30ziIpzv2cKSSR/Ks= mvasek@bellatrix diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key b/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key new file mode 100644 index 000000000..914ede587 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAhhd7wuRBaf9R7Q/HQi7lEWoukb/HrYDg394NpeOgsbAAAAJjz02VI89Nl +SAAAAAtzc2gtZWQyNTUxOQAAACAhhd7wuRBaf9R7Q/HQi7lEWoukb/HrYDg394NpeOgsbA +AAAEC9lvHqAASWXcZSm/Rih3V78uMejs+6sc6SOVhaogLwHyGF3vC5EFp/1HtD8dCLuURa +i6Rv8etgODf3g2l46CxsAAAAEG12YXNla0BiZWxsYXRyaXgBAgMEBQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key.pub b/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key.pub new file mode 100644 index 000000000..328d03fc7 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICGF3vC5EFp/1HtD8dCLuURai6Rv8etgODf3g2l46Cxs mvasek@bellatrix diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key b/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key new file mode 100644 index 000000000..8958a0e2a --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key.pub b/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key.pub new file mode 100644 index 000000000..6276d2d9a --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/ssh_host_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDVUeZF20fM6EJkKZytgl0X1WN09Ndz0eJ30HN7GTRB+cboLXmA7JtuZflhXGBWIfOuulqh0sMJgFa7j6XYM94bmBohrxrVYCGC8RBF8mHT0lxWRVZ3P0DtSaC1vqTd3RDCVhpmstLVJkZiD6KjGiV5vMCz/52bXJ+RJFc1/d8+6HK5s7oAgEwF1ycjPdzC7c1m/qjcuyEVjKbsNpcD8t0fCYxl7YlqSypJtp9trGpnqQiO9ZR46sjpH/e4tYzUFMyKv2KefyJGtzGcC1JUb5DleC9qQ+5kNe/PI8/rqQ5ydPXHwMMdzLg8PRLLO0MiMdZen9cHCoOpZBuGJQFo6N7Jgnqqj1kmesahqKnkDxOUq1oMVLppfWhsfFh635K8DmwqNr9zHfc0uHQVF9ddmaSeAS+uY/2gZf8B6AxNvMT63aQoNTzQzJ7UubFY397xG52JvjWFVNnRTw9lweaAA4xyYZ8DiJWZZz68Rzmhh5jRZKT/NAoFNg2VCw3Dr47upPc= mvasek@bellatrix diff --git a/internal/sshdialer/testdata/etc/ssh/sshd_config b/internal/sshdialer/testdata/etc/ssh/sshd_config new file mode 100644 index 000000000..95740bbc8 --- /dev/null +++ b/internal/sshdialer/testdata/etc/ssh/sshd_config @@ -0,0 +1,119 @@ +# $OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $ + +# This is the sshd server system-wide configuration file. See +# sshd_config(5) for more information. + +# This sshd was compiled with PATH=/bin:/usr/bin:/sbin:/usr/sbin + +# The strategy used for options in the default sshd_config shipped with +# OpenSSH is to specify options with their default value where +# possible, but leave them commented. Uncommented options override the +# default value. + +Port 22 +Port 2222 +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +HostKey /etc/ssh/ssh_host_ecdsa_key +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_dsa_key + +# Ciphers and keying +#RekeyLimit default none + +# Logging +#SyslogFacility AUTH +#LogLevel INFO + +# Authentication: + +#LoginGraceTime 2m +PermitRootLogin yes +#StrictModes yes +#MaxAuthTries 6 +#MaxSessions 10 + +#PubkeyAuthentication yes + +# The default is to check both .ssh/authorized_keys and .ssh/authorized_keys2 +# but this is overridden so installations will only check .ssh/authorized_keys +AuthorizedKeysFile .ssh/authorized_keys + +#AuthorizedPrincipalsFile none + +#AuthorizedKeysCommand none +#AuthorizedKeysCommandUser nobody + +# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts +#HostbasedAuthentication no +# Change to yes if you don't trust ~/.ssh/known_hosts for +# HostbasedAuthentication +#IgnoreUserKnownHosts no +# Don't read the user's ~/.rhosts and ~/.shosts files +#IgnoreRhosts yes + +# To disable tunneled clear text passwords, change to no here! +PasswordAuthentication yes +#PermitEmptyPasswords no + +# Change to no to disable s/key passwords +#ChallengeResponseAuthentication yes + +# Kerberos options +#KerberosAuthentication no +#KerberosOrLocalPasswd yes +#KerberosTicketCleanup yes +#KerberosGetAFSToken no + +# GSSAPI options +#GSSAPIAuthentication no +#GSSAPICleanupCredentials yes + +# Set this to 'yes' to enable PAM authentication, account processing, +# and session processing. If this is enabled, PAM authentication will +# be allowed through the ChallengeResponseAuthentication and +# PasswordAuthentication. Depending on your PAM configuration, +# PAM authentication via ChallengeResponseAuthentication may bypass +# the setting of "PermitRootLogin without-password". +# If you just want the PAM account and session checks to run without +# PAM authentication, then enable this but set PasswordAuthentication +# and ChallengeResponseAuthentication to 'no'. +#UsePAM no + +#AllowAgentForwarding yes +# Feel free to re-enable these if your use case requires them. +AllowTcpForwarding yes +GatewayPorts no +X11Forwarding no +#X11DisplayOffset 10 +#X11UseLocalhost yes +#PermitTTY yes +#PrintMotd yes +#PrintLastLog yes +#TCPKeepAlive yes +#PermitUserEnvironment no +#Compression delayed +#ClientAliveInterval 0 +#ClientAliveCountMax 3 +#UseDNS no +#PidFile /run/sshd.pid +#MaxStartups 10:30:100 +#PermitTunnel no +#ChrootDirectory none +#VersionAddendum none + +# no default banner path +#Banner none + +# override default of no subsystems +Subsystem sftp /usr/lib/ssh/sftp-server + +# Example of overriding settings on a per-user basis +#Match User anoncvs +# X11Forwarding no +# AllowTcpForwarding no +# PermitTTY no +# ForceCommand cvs server diff --git a/internal/sshdialer/testdata/go.mod b/internal/sshdialer/testdata/go.mod new file mode 100644 index 000000000..63aed47b8 --- /dev/null +++ b/internal/sshdialer/testdata/go.mod @@ -0,0 +1,3 @@ +module serve-socket + +go 1.16 diff --git a/internal/sshdialer/testdata/id_dsa b/internal/sshdialer/testdata/id_dsa new file mode 100644 index 000000000..5678a77ee --- /dev/null +++ b/internal/sshdialer/testdata/id_dsa @@ -0,0 +1,2 @@ +somegarbage +somerugish \ No newline at end of file diff --git a/internal/sshdialer/testdata/id_dsa.pub b/internal/sshdialer/testdata/id_dsa.pub new file mode 100644 index 000000000..5678a77ee --- /dev/null +++ b/internal/sshdialer/testdata/id_dsa.pub @@ -0,0 +1,2 @@ +somegarbage +somerugish \ No newline at end of file diff --git a/internal/sshdialer/testdata/id_ed25519 b/internal/sshdialer/testdata/id_ed25519 new file mode 100644 index 000000000..f04bad68d --- /dev/null +++ b/internal/sshdialer/testdata/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCVbPGj8U+Zjq5NEgyP2996RFk+lIrXNMYLqLLikeJRJQAAAJjDwa3Yw8Gt +2AAAAAtzc2gtZWQyNTUxOQAAACCVbPGj8U+Zjq5NEgyP2996RFk+lIrXNMYLqLLikeJRJQ +AAAEDti7Y7pPMfJq3Cwztd3ZiM1orRTIibsTH2Y/NQPPFiHpVs8aPxT5mOrk0SDI/b33pE +WT6Uitc0xguosuKR4lElAAAAFHRlc3R1c2VyQGV4YW1wbGUuY29tAQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/id_ed25519.pub b/internal/sshdialer/testdata/id_ed25519.pub new file mode 100644 index 000000000..446c7f4bc --- /dev/null +++ b/internal/sshdialer/testdata/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJVs8aPxT5mOrk0SDI/b33pEWT6Uitc0xguosuKR4lEl testuser@example.com diff --git a/internal/sshdialer/testdata/id_rsa b/internal/sshdialer/testdata/id_rsa new file mode 100644 index 000000000..a194f218c --- /dev/null +++ b/internal/sshdialer/testdata/id_rsa @@ -0,0 +1,50 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +MUnDXSP1KUo0fm3Ojh6J25+2w= +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/sshdialer/testdata/id_rsa.pub b/internal/sshdialer/testdata/id_rsa.pub new file mode 100644 index 000000000..383c92340 --- /dev/null +++ b/internal/sshdialer/testdata/id_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe2RqNezOI3cRJq+PQYlcASjYRaJgFd/AjAYtB+u8C7C8OkuelIaiYavjUn1+Sx3VOkqSVwA7J7zUzUPcx/83BjCffvHtXLAmYfxojd9Z7Bh05kV1Ayx7Pn8xEbEkZpffLGrIy98Vs2MYUU7K7JhLOInkEfD1qolV5IpoIKazkulJEpsdTalrSn53IO0Afa+aayZIG+Fc4RiTHUqk6YcLZQnAcUAoa6WCC31cIOhbllv83hVwjx9b5ZgX509o/WrBsR2cnL7qemBDJmq4RkL6RVcM0lyaGyMn+PfYCXbx4pn3bxdw7An3BS3/O/hTEFLh2tCJc55u7xbKJX265RpKMt/BdU8+VLuA8assVBkNhDwlLI+jvmC8fzqE7H/PLWO2XgTkMpDybEy2n4+8nRElnj4RB2J+vagiJPZFrr5SWQHMUD8fHAYvWo0AHPLly+NgkEu2ZRoAV3gRwZm4C8u9tXMNf4UNVcyxpgaylZwxnaiLNjwx6UChcA0TAAGPygJ3UJ8D/Gakj3iekOd7Zcl7oE9dzByjIolVaPqXChAjfB8wWSiJ2LZRhVklLwHm+wKLEpIBhzai4OnF6k4NTyo1wxlfHb90rbyfn6/arUTJX7cU1NFd7cUw0lh+fL/Q15nJZsd5oCNt3yCTS9fpo9rPPPjCkL+XqUzVVOgbRK5Iknw== testuser@example.com diff --git a/internal/sshdialer/testdata/main.go b/internal/sshdialer/testdata/main.go new file mode 100644 index 000000000..8badbb478 --- /dev/null +++ b/internal/sshdialer/testdata/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +// simple HTTP server to verify that tunneling works +func main() { + if len(os.Args) != 3 { + panic("exactly two positional parameters expected: path to unix socket and tcp port.") + } + + ctx, cancel := context.WithCancel(context.Background()) + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigs + cancel() + <-sigs + os.Exit(1) + }() + + var handler http.HandlerFunc = func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(200) + _, err := w.Write([]byte("Hello there!")) + if err != nil { + panic(err) + } + } + + serverUnix := http.Server{Handler: handler} + go func() { + <-ctx.Done() + shutdownCtx, _ := context.WithTimeout(context.Background(), time.Second*5) + _ = serverUnix.Shutdown(shutdownCtx) + }() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + unixListener, err := net.Listen("unix", os.Args[1]) + if err != nil { + panic(err) + } + defer wg.Done() + err = serverUnix.Serve(unixListener) + if err != nil { + panic(err) + } + }() + + serverTcp := http.Server{Handler: handler} + go func() { + <-ctx.Done() + shutdownCtx, _ := context.WithTimeout(context.Background(), time.Second*5) + _ = serverTcp.Shutdown(shutdownCtx) + }() + + wg.Add(1) + go func() { + defer wg.Done() + tcpListener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%s", os.Args[2])) + if err != nil { + panic(err) + } + err = serverTcp.Serve(tcpListener) + if err != nil { + panic(err) + } + }() + + wg.Wait() +} diff --git a/internal/sshdialer/windows_test.go b/internal/sshdialer/windows_test.go new file mode 100644 index 000000000..dd76adfd3 --- /dev/null +++ b/internal/sshdialer/windows_test.go @@ -0,0 +1,24 @@ +//+build windows + +package sshdialer_test + +import ( + "os/user" + + "github.com/hectane/go-acl" +) + +func fixupPrivateKeyMod(path string) { + usr, err := user.Current() + if err != nil { + panic(err) + } + mode := uint32(0400) + err = acl.Apply(path, + true, + false, + acl.GrantName(((mode&0700)<<23)|((mode&0200)<<9), usr.Name)) + if err != nil { + panic(err) + } +}