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) +}