github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/internal/codespaces/ssh.go (about)

     1  package codespaces
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"os/exec"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"github.com/cli/safeexec"
    12  )
    13  
    14  type printer interface {
    15  	Printf(fmt string, v ...interface{})
    16  }
    17  
    18  // Shell runs an interactive secure shell over an existing
    19  // port-forwarding session. It runs until the shell is terminated
    20  // (including by cancellation of the context).
    21  func Shell(ctx context.Context, p printer, sshArgs []string, port int, destination string, usingCustomPort bool) error {
    22  	cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs)
    23  	if err != nil {
    24  		return fmt.Errorf("failed to create ssh command: %w", err)
    25  	}
    26  
    27  	if usingCustomPort {
    28  		p.Printf("Connection Details: ssh %s %s", destination, connArgs)
    29  	}
    30  
    31  	return cmd.Run()
    32  }
    33  
    34  // Copy runs an scp command over the specified port. scpArgs should contain both scp flags
    35  // as well as the list of files to copy, with the flags first.
    36  //
    37  // Remote files indicated by a "remote:" prefix are resolved relative
    38  // to the remote user's home directory, and are subject to shell expansion
    39  // on the remote host; see https://lwn.net/Articles/835962/.
    40  func Copy(ctx context.Context, scpArgs []string, port int, destination string) error {
    41  	cmd, err := newSCPCommand(ctx, port, destination, scpArgs)
    42  	if err != nil {
    43  		return fmt.Errorf("failed to create scp command: %w", err)
    44  	}
    45  
    46  	return cmd.Run()
    47  }
    48  
    49  // NewRemoteCommand returns an exec.Cmd that will securely run a shell
    50  // command on the remote machine.
    51  func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) {
    52  	cmd, _, err := newSSHCommand(ctx, tunnelPort, destination, sshArgs)
    53  	return cmd, err
    54  }
    55  
    56  // newSSHCommand populates an exec.Cmd to run a command (or if blank,
    57  // an interactive shell) over ssh.
    58  func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) {
    59  	connArgs := []string{
    60  		"-p", strconv.Itoa(port),
    61  		"-o", "NoHostAuthenticationForLocalhost=yes",
    62  		"-o", "PasswordAuthentication=no",
    63  	}
    64  
    65  	// The ssh command syntax is: ssh [flags] user@host command [args...]
    66  	// There is no way to specify the user@host destination as a flag.
    67  	// Unfortunately, that means we need to know which user-provided words are
    68  	// SSH flags and which are command arguments so that we can place
    69  	// them before or after the destination, and that means we need to know all
    70  	// the flags and their arities.
    71  	cmdArgs, command, err := parseSSHArgs(cmdArgs)
    72  	if err != nil {
    73  		return nil, nil, err
    74  	}
    75  
    76  	cmdArgs = append(cmdArgs, connArgs...)
    77  	cmdArgs = append(cmdArgs, "-C") // Compression
    78  	cmdArgs = append(cmdArgs, dst)  // user@host
    79  
    80  	if command != nil {
    81  		cmdArgs = append(cmdArgs, command...)
    82  	}
    83  
    84  	exe, err := safeexec.LookPath("ssh")
    85  	if err != nil {
    86  		return nil, nil, fmt.Errorf("failed to execute ssh: %w", err)
    87  	}
    88  
    89  	cmd := exec.CommandContext(ctx, exe, cmdArgs...)
    90  	cmd.Stdout = os.Stdout
    91  	cmd.Stdin = os.Stdin
    92  	cmd.Stderr = os.Stderr
    93  
    94  	return cmd, connArgs, nil
    95  }
    96  
    97  func parseSSHArgs(args []string) (cmdArgs, command []string, err error) {
    98  	return parseArgs(args, "bcDeFIiLlmOopRSWw")
    99  }
   100  
   101  // newSCPCommand populates an exec.Cmd to run an scp command for the files specified in cmdArgs.
   102  // cmdArgs is parsed such that scp flags precede the files to copy in the command.
   103  // For example: scp -F ./config local/file remote:file
   104  func newSCPCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, error) {
   105  	connArgs := []string{
   106  		"-P", strconv.Itoa(port),
   107  		"-o", "NoHostAuthenticationForLocalhost=yes",
   108  		"-o", "PasswordAuthentication=no",
   109  		"-C", // compression
   110  	}
   111  
   112  	cmdArgs, command, err := parseSCPArgs(cmdArgs)
   113  	if err != nil {
   114  		return nil, err
   115  	}
   116  
   117  	cmdArgs = append(cmdArgs, connArgs...)
   118  
   119  	for _, arg := range command {
   120  		// Replace "remote:" prefix with (e.g.) "root@localhost:".
   121  		if rest := strings.TrimPrefix(arg, "remote:"); rest != arg {
   122  			arg = dst + ":" + rest
   123  		}
   124  		cmdArgs = append(cmdArgs, arg)
   125  	}
   126  
   127  	exe, err := safeexec.LookPath("scp")
   128  	if err != nil {
   129  		return nil, fmt.Errorf("failed to execute scp: %w", err)
   130  	}
   131  
   132  	// Beware: invalid syntax causes scp to exit 1 with
   133  	// no error message, so don't let that happen.
   134  	cmd := exec.CommandContext(ctx, exe, cmdArgs...)
   135  
   136  	cmd.Stdin = nil
   137  	cmd.Stdout = os.Stderr
   138  	cmd.Stderr = os.Stderr
   139  
   140  	return cmd, nil
   141  }
   142  
   143  func parseSCPArgs(args []string) (cmdArgs, command []string, err error) {
   144  	return parseArgs(args, "cFiJloPS")
   145  }
   146  
   147  // parseArgs parses arguments into two distinct slices of flags and command. Parsing stops
   148  // as soon as a non-flag argument is found assuming the remaining arguments are the command.
   149  // It returns an error if a unary flag is provided without an argument.
   150  func parseArgs(args []string, unaryFlags string) (cmdArgs, command []string, err error) {
   151  	for i := 0; i < len(args); i++ {
   152  		arg := args[i]
   153  
   154  		// if we've started parsing the command, set it to the rest of the args
   155  		if !strings.HasPrefix(arg, "-") {
   156  			command = args[i:]
   157  			break
   158  		}
   159  
   160  		cmdArgs = append(cmdArgs, arg)
   161  		if len(arg) == 2 && strings.Contains(unaryFlags, arg[1:2]) {
   162  			if i++; i == len(args) {
   163  				return nil, nil, fmt.Errorf("flag: %s requires an argument", arg)
   164  			}
   165  
   166  			cmdArgs = append(cmdArgs, args[i])
   167  		}
   168  	}
   169  
   170  	return cmdArgs, command, nil
   171  }