From 4d08e7be20962b15fc10e8bc9e37ce7aea0901f3 Mon Sep 17 00:00:00 2001
From: Guilherme Lepsch <lepsch@gmail.com>
Date: Wed, 29 Sep 2021 18:31:45 +0100
Subject: [PATCH] Initial commit

---
 .github/workflows/ci.yaml |  63 +++++++++++++++++++++++
 .gitignore                |  24 +++++++++
 LICENSE                   |  21 ++++++++
 README.md                 |   2 +
 build.sh                  |   7 +++
 cmd/grun/grun.go          |  60 ++++++++++++++++++++++
 cmd/main.go               |  70 ++++++++++++++++++++++++++
 cmd/scp/scp.go            |  21 ++++++++
 cmd/ssh/ssh.go            |  21 ++++++++
 go.mod                    |   5 ++
 go.sum                    |   2 +
 pkg/process/find.go       |  39 +++++++++++++++
 pkg/process/name.go       |  13 +++++
 pkg/ssh/ssh.go            | 102 ++++++++++++++++++++++++++++++++++++++
 pkg/ssh/ssh_posix.go      |  17 +++++++
 pkg/ssh/ssh_windows.go    |  30 +++++++++++
 16 files changed, 497 insertions(+)
 create mode 100644 .github/workflows/ci.yaml
 create mode 100644 .gitignore
 create mode 100644 LICENSE
 create mode 100644 README.md
 create mode 100755 build.sh
 create mode 100644 cmd/grun/grun.go
 create mode 100644 cmd/main.go
 create mode 100644 cmd/scp/scp.go
 create mode 100644 cmd/ssh/ssh.go
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 pkg/process/find.go
 create mode 100644 pkg/process/name.go
 create mode 100644 pkg/ssh/ssh.go
 create mode 100644 pkg/ssh/ssh_posix.go
 create mode 100644 pkg/ssh/ssh_windows.go

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..20e60a1
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,63 @@
+# Set the workflow name.
+name: CI
+
+# Execute the workflow on pushes and pull requests.
+on: [push, pull_request]
+
+jobs:
+  linux:
+    name: Linux
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-go@v2
+        with:
+          go-version: '^1.17'
+      - run: docker version
+      - run: go version
+      - run: ./build.sh
+      - uses: actions/upload-artifact@v2
+        with:
+          name: binaries
+          path: mutagen-ssh-wrapper.tar.gz
+  versioning:
+    name: Versioning
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-go@v2
+        with:
+          go-version: '^1.17'
+      - run: go version
+      - name: "Analyze version information and release status"
+        id: analyze
+        run: |
+          # Determine whether or not this is a release build.
+          RELEASE="${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }}"
+          # Determine version target information for Go. If this is a release,
+          # then we'll use the tag, otherwise we'll use the raw commit identifier.
+          if [ "${RELEASE}" = "true" ]; then
+            TARGET="${GITHUB_REF#refs/tags/}"
+          else
+            TARGET="${GITHUB_SHA}"
+          fi
+          # Set outputs.
+          echo ::set-output name=release::${RELEASE}
+          echo ::set-output name=target::${TARGET}
+    outputs:
+      release: ${{ steps.analyze.outputs.release }}
+      target: ${{ steps.analyze.outputs.target }}
+  release:
+    name: Release
+    runs-on: ubuntu-latest
+    needs: [linux, versioning]
+    if: ${{ needs.versioning.outputs.release == 'true' }}
+    steps:
+      - uses: actions/download-artifact@v2
+        with:
+          name: binaries
+          path: .
+      - uses: softprops/action-gh-release@v1
+        with:
+          files: |
+            mutagen-ssh-wrapper.tar.gz
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ef97e01
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# IDE specific
+/.vscode
+
+# Output dir
+/out
+
+# Bundle
+*.tar.gz
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..70d3f03
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Duckly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..be71851
--- /dev/null
+++ b/README.md
@@ -0,0 +1,2 @@
+# mutagen-ssh-wrapper
+Mutagen SSH wrapper for Windows
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..156e9e6
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -e
+
+GOOS=windows GOARCH=386 go build -o ./out/ ./cmd/ssh
+GOOS=windows GOARCH=386 go build -o ./out/ ./cmd/scp
+GOOS=windows GOARCH=386 go build -o ./out/ ./cmd/grun
+tar czf mutagen-ssh-wrapper.tar.gz -C ./out scp.exe ssh.exe grun.exe
diff --git a/cmd/grun/grun.go b/cmd/grun/grun.go
new file mode 100644
index 0000000..09c6230
--- /dev/null
+++ b/cmd/grun/grun.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"strconv"
+
+	"golang.org/x/sync/errgroup"
+	"ssh-wrapper/cmd"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		if len(os.Args) == 1 {
+			fmt.Fprintln(os.Stderr, "Error: missing arguments: parent PID and command")
+		} else {
+			fmt.Fprintln(os.Stderr, "Error: missing command argument")
+		}
+		os.Exit(1)
+	}
+
+	commandName, args := os.Args[2], os.Args[3:]
+
+	parentPid, err := strconv.Atoi(os.Args[1])
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "Error: parent PID is not a number:", parentPid)
+		os.Exit(1)
+	}
+
+	parentProcess, err := os.FindProcess(parentPid)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error: cannot find process PID %d: %v", parentPid, err)
+		os.Exit(1)
+	}
+
+	g, ctx := errgroup.WithContext(context.Background())
+
+	command := exec.CommandContext(ctx, commandName, args...)
+	command.Stdin = os.Stdin
+	command.Stdout = os.Stdout
+	command.Stderr = os.Stderr
+
+	// Wait for the child process to finish
+	g.Go(func() error {
+		cmd.WrapRunAndExit(command)
+		return nil
+	})
+
+	// Wait for the parent process to finish. If so, kill the child process
+	g.Go(func() error {
+		_, err := parentProcess.Wait()
+		command.Process.Kill()
+		return err
+	})
+
+	g.Wait()
+	os.Exit(1)
+}
diff --git a/cmd/main.go b/cmd/main.go
new file mode 100644
index 0000000..c25cab4
--- /dev/null
+++ b/cmd/main.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"path"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"syscall"
+
+	"ssh-wrapper/pkg/process"
+)
+
+const (
+	MUTAGEN_SSH_CONFIG                 = "MUTAGEN_SSH_CONFIG"
+	MUTAGEN_CONNECT_TIMEOUT_IN_SECONDS = 20
+)
+
+func ProcessArgs() []string {
+	sshConfig := os.Getenv(MUTAGEN_SSH_CONFIG)
+	if sshConfig == "" {
+		fmt.Fprintln(os.Stderr, "Error: MUTAGEN_SSH_CONFIG environment variable is not set")
+		os.Exit(1)
+	}
+
+	// Increase the default timeout
+	args := os.Args[1:]
+	for i, arg := range args {
+		if strings.HasPrefix(arg, "-oConnectTimeout=") {
+			args[i] = fmt.Sprintf("-oConnectTimeout=%d", MUTAGEN_CONNECT_TIMEOUT_IN_SECONDS)
+			break
+		}
+	}
+
+	return append([]string{fmt.Sprintf("-F%s", sshConfig)}, args...)
+}
+
+func WrapRunAndExit(command *exec.Cmd) {
+	command.Stdin = os.Stdin
+	command.Stdout = os.Stdout
+	command.Stderr = os.Stderr
+
+	if err := command.Run(); err != nil {
+		if exiterr, ok := err.(*exec.ExitError); ok {
+			if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
+				os.Exit(status.ExitStatus())
+			}
+		}
+		os.Exit(1)
+	}
+	os.Exit(0)
+}
+
+func Grun(commandName string, args ...string) {
+	args = append([]string{strconv.Itoa(os.Getpid()), commandName}, args...)
+
+	self, err := os.Executable()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "Error: cannot get current executable path")
+		os.Exit(1)
+	}
+
+	grun := path.Join(filepath.Dir(self), process.ExecutableName("grun", runtime.GOOS))
+	command := exec.CommandContext(context.Background(), grun, args...)
+	WrapRunAndExit(command)
+}
diff --git a/cmd/scp/scp.go b/cmd/scp/scp.go
new file mode 100644
index 0000000..ddd02b4
--- /dev/null
+++ b/cmd/scp/scp.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"ssh-wrapper/pkg/ssh"
+	"ssh-wrapper/cmd"
+)
+
+func main() {
+	args := cmd.ProcessArgs()
+
+	commandName, err := ssh.ScpCommandPath()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "Error: unable to set up SCP invocation:", err)
+		os.Exit(1)
+	}
+
+	cmd.Grun(commandName, args...)
+}
diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go
new file mode 100644
index 0000000..45916c8
--- /dev/null
+++ b/cmd/ssh/ssh.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"ssh-wrapper/cmd"
+	"ssh-wrapper/pkg/ssh"
+)
+
+func main() {
+	args := cmd.ProcessArgs()
+
+	commandName, err := ssh.SshCommandPath()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "Error: unable to set up SSH invocation:", err)
+		os.Exit(1)
+	}
+
+	cmd.Grun(commandName, args...)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..cad926d
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module ssh-wrapper
+
+go 1.17
+
+require golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5c00efd
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/pkg/process/find.go b/pkg/process/find.go
new file mode 100644
index 0000000..a614963
--- /dev/null
+++ b/pkg/process/find.go
@@ -0,0 +1,39 @@
+package process
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// FindCommand searches for a command with the specified name within the
+// specified list of directories. It's similar to os/exec.LookPath, except that
+// it allows one to manually specify paths, and it uses a slightly simpler
+// lookup mechanism.
+func FindCommand(name string, paths []string) (string, error) {
+	// Iterate through the directories.
+	for _, path := range paths {
+		// Compute the target name.
+		target := filepath.Join(path, ExecutableName(name, runtime.GOOS))
+
+		// Check if the target exists and has the correct type.
+		// TODO: Should we do more extensive (and platform-specific) testing on
+		// the resulting metadata? See, e.g., the implementation of
+		// os/exec.LookPath.
+		if metadata, err := os.Stat(target); err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return "", fmt.Errorf("unable to query file metadata: %w", err)
+		} else if metadata.Mode()&os.ModeType != 0 {
+			continue
+		} else {
+			return target, nil
+		}
+	}
+
+	// Failure.
+	return "", errors.New("unable to locate command")
+}
diff --git a/pkg/process/name.go b/pkg/process/name.go
new file mode 100644
index 0000000..45b5a01
--- /dev/null
+++ b/pkg/process/name.go
@@ -0,0 +1,13 @@
+package process
+
+// ExecutableName computes the name for an executable for a given base name on a
+// specified operating system.
+func ExecutableName(base, goos string) string {
+	// If we're on Windows, append ".exe".
+	if goos == "windows" {
+		return base + ".exe"
+	}
+
+	// Otherwise return the base name unmodified.
+	return base
+}
diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go
new file mode 100644
index 0000000..b0218b9
--- /dev/null
+++ b/pkg/ssh/ssh.go
@@ -0,0 +1,102 @@
+package ssh
+
+import (
+	"context"
+	"fmt"
+	// "os"
+	"os/exec"
+
+	// "ssh-wrapper/pkg/process"
+)
+
+// CompressionFlag returns a flag that can be passed to scp or ssh to enable
+// compression. Note that while SSH does have a CompressionLevel configuration
+// option, this only applies to SSHv1. SSHv2 defaults to a DEFLATE level of 6,
+// which is what we want anyway.
+func CompressionFlag() string {
+	return "-C"
+}
+
+// ConnectTimeoutFlag returns a flag that can be passed to scp or ssh to limit
+// connection time. The provided timeout is in seconds. The timeout must be
+// greater than 0, otherwise this function will panic.
+func ConnectTimeoutFlag(timeout int) string {
+	// Validate the timeout.
+	if timeout < 1 {
+		panic("invalid timeout value")
+	}
+
+	// Format the flag.
+	return fmt.Sprintf("-oConnectTimeout=%d", timeout)
+}
+
+// ServerAliveFlags returns a set of flags that can be passed to scp or ssh to
+// enable use of server alive messages. The provided interval is in seconds.
+// Both the interval and count must be greater than 0, otherwise this function
+// will panic.
+func ServerAliveFlags(interval, countMax int) []string {
+	// Validate the interval and count.
+	if interval < 1 {
+		panic("invalid interval value")
+	} else if countMax < 1 {
+		panic("invalid count value")
+	}
+
+	// Format the flags.
+	return []string{
+		fmt.Sprintf("-oServerAliveInterval=%d", interval),
+		fmt.Sprintf("-oServerAliveCountMax=%d", countMax),
+	}
+}
+
+// SshCommandPath returns the full path to use for invoking ssh. It will use the
+// MUTAGEN_SSH_PATH environment variable if provided, otherwise falling back to
+// a platform-specific implementation.
+func SshCommandPath() (string, error) {
+	// If MUTAGEN_SSH_PATH is specified, then use it to perform the lookup.
+	// if searchPath := os.Getenv("MUTAGEN_SSH_PATH"); searchPath != "" {
+	// 	return process.FindCommand("ssh", []string{searchPath})
+	// }
+
+	// Otherwise fall back to the platform-specific implementation.
+	return sshCommandPathForPlatform()
+}
+
+// SSHCommand prepares (but does not start) an SSH command with the specified
+// arguments and scoped to lifetime of the provided context.
+func SSHCommand(context context.Context, args ...string) (*exec.Cmd, error) {
+	// Identify the command name or path.
+	nameOrPath, err := SshCommandPath()
+	if err != nil {
+		return nil, fmt.Errorf("unable to identify 'ssh' command: %w", err)
+	}
+
+	// Create the command.
+	return exec.CommandContext(context, nameOrPath, args...), nil
+}
+
+// ScpCommandPath returns the full path to use for invoking scp. It will use the
+// MUTAGEN_SSH_PATH environment variable if provided, otherwise falling back to
+// a platform-specific implementation.
+func ScpCommandPath() (string, error) {
+	// If MUTAGEN_SSH_PATH is specified, then use it to perform the lookup.
+	// if searchPath := os.Getenv("MUTAGEN_SSH_PATH"); searchPath != "" {
+	// 	return process.FindCommand("scp", []string{searchPath})
+	// }
+
+	// Otherwise fall back to the platform-specific implementation.
+	return scpCommandPathForPlatform()
+}
+
+// SCPCommand prepares (but does not start) an SCP command with the specified
+// arguments and scoped to lifetime of the provided context.
+func SCPCommand(context context.Context, args ...string) (*exec.Cmd, error) {
+	// Identify the command name or path.
+	nameOrPath, err := ScpCommandPath()
+	if err != nil {
+		return nil, fmt.Errorf("unable to identify 'scp' command: %w", err)
+	}
+
+	// Create the command.
+	return exec.CommandContext(context, nameOrPath, args...), nil
+}
diff --git a/pkg/ssh/ssh_posix.go b/pkg/ssh/ssh_posix.go
new file mode 100644
index 0000000..71b4eb1
--- /dev/null
+++ b/pkg/ssh/ssh_posix.go
@@ -0,0 +1,17 @@
+//go:build !windows
+
+package ssh
+
+import (
+	"os/exec"
+)
+
+// sshCommandPathForPlatform searches for the ssh command in the user's path.
+func sshCommandPathForPlatform() (string, error) {
+	return exec.LookPath("ssh")
+}
+
+// scpCommandPathForPlatform searches for the scp command in the user's path.
+func scpCommandPathForPlatform() (string, error) {
+	return exec.LookPath("scp")
+}
diff --git a/pkg/ssh/ssh_windows.go b/pkg/ssh/ssh_windows.go
new file mode 100644
index 0000000..0862d22
--- /dev/null
+++ b/pkg/ssh/ssh_windows.go
@@ -0,0 +1,30 @@
+//go:build windows
+
+package ssh
+
+import (
+	"ssh-wrapper/pkg/process"
+)
+
+// commandSearchPaths specifies locations on Windows where we might find ssh.exe
+// and scp.exe binaries.
+var commandSearchPaths = []string{
+	// TODO: Add the PowerShell OpenSSH paths at the top of this list once
+	// there's a usable release.
+	`C:\Program Files\Git\usr\bin`,
+	`C:\Program Files (x86)\Git\usr\bin`,
+	`C:\msys32\usr\bin`,
+	`C:\msys64\usr\bin`,
+	`C:\cygwin\bin`,
+	`C:\cygwin64\bin`,
+}
+
+// sshCommandPathForPlatform will search for a suitable ssh command on Windows.
+func sshCommandPathForPlatform() (string, error) {
+	return process.FindCommand("ssh", commandSearchPaths)
+}
+
+// scpCommandPathForPlatform will search for a suitable scp command on Windows.
+func scpCommandPathForPlatform() (string, error) {
+	return process.FindCommand("scp", commandSearchPaths)
+}