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 }