11package cli
22
33import (
4+ "bufio"
5+ "bytes"
6+ "context"
47 "fmt"
8+ "io"
59 "os"
610 "os/exec"
11+ "os/signal"
12+ "path/filepath"
713 "strings"
814
915 "github.com/spf13/cobra"
@@ -13,16 +19,30 @@ import (
1319)
1420
1521func gitssh () * cobra.Command {
16- return & cobra.Command {
22+ cmd := & cobra.Command {
1723 Use : "gitssh" ,
1824 Hidden : true ,
1925 Short : `Wraps the "ssh" command and uses the coder gitssh key for authentication` ,
2026 RunE : func (cmd * cobra.Command , args []string ) error {
27+ ctx := cmd .Context ()
28+ env := os .Environ ()
29+
30+ // Catch interrupt signals to ensure the temporary private
31+ // key file is cleaned up on most cases.
32+ ctx , stop := signal .NotifyContext (ctx , interruptSignals ... )
33+ defer stop ()
34+
35+ // Early check so errors are reported immediately.
36+ identityFiles , err := parseIdentityFilesForHost (ctx , args , env )
37+ if err != nil {
38+ return err
39+ }
40+
2141 client , err := createAgentClient (cmd )
2242 if err != nil {
2343 return xerrors .Errorf ("create agent client: %w" , err )
2444 }
25- key , err := client .AgentGitSSHKey (cmd . Context () )
45+ key , err := client .AgentGitSSHKey (ctx )
2646 if err != nil {
2747 return xerrors .Errorf ("get agent git ssh token: %w" , err )
2848 }
@@ -44,8 +64,23 @@ func gitssh() *cobra.Command {
4464 return xerrors .Errorf ("close temp gitsshkey file: %w" , err )
4565 }
4666
47- args = append ([]string {"-i" , privateKeyFile .Name ()}, args ... )
48- c := exec .CommandContext (cmd .Context (), "ssh" , args ... )
67+ // Append our key, giving precedence to user keys. Note that
68+ // OpenSSH server are typically configured with MaxAuthTries
69+ // set to the default value of 6. This means that only the 6
70+ // first keys can be tried. However, we will assume that if
71+ // a user has configured 6+ keys for a host, they know what
72+ // they're doing. This behavior is critical if a server has
73+ // been configured with MaxAuthTries set to 1.
74+ identityFiles = append (identityFiles , privateKeyFile .Name ())
75+
76+ var identityArgs []string
77+ for _ , id := range identityFiles {
78+ identityArgs = append (identityArgs , "-i" , id )
79+ }
80+
81+ args = append (identityArgs , args ... )
82+ c := exec .CommandContext (ctx , "ssh" , args ... )
83+ c .Env = append (c .Env , env ... )
4984 c .Stderr = cmd .ErrOrStderr ()
5085 c .Stdout = cmd .OutOrStdout ()
5186 c .Stdin = cmd .InOrStdin ()
@@ -69,4 +104,86 @@ func gitssh() *cobra.Command {
69104 return nil
70105 },
71106 }
107+
108+ return cmd
109+ }
110+
111+ // fallbackIdentityFiles is the list of identity files SSH tries when
112+ // none have been defined for a host.
113+ var fallbackIdentityFiles = strings .Join ([]string {
114+ "identityfile ~/.ssh/id_rsa" ,
115+ "identityfile ~/.ssh/id_dsa" ,
116+ "identityfile ~/.ssh/id_ecdsa" ,
117+ "identityfile ~/.ssh/id_ecdsa_sk" ,
118+ "identityfile ~/.ssh/id_ed25519" ,
119+ "identityfile ~/.ssh/id_ed25519_sk" ,
120+ "identityfile ~/.ssh/id_xmss" ,
121+ }, "\n " )
122+
123+ // parseIdentityFilesForHost uses ssh -G to discern what SSH keys have
124+ // been enabled for the host (via the users SSH config) and returns a
125+ // list of existing identity files.
126+ //
127+ // We do this because when no keys are defined for a host, SSH uses
128+ // fallback keys (see above). However, by passing `-i` to attach our
129+ // private key, we're effectively disabling the fallback keys.
130+ //
131+ // Example invocation:
132+ //
133+ // ssh -G -o SendEnv=GIT_PROTOCOL [email protected] git-upload-pack 'coder/coder' 134+ //
135+ // The extra arguments work without issue and lets us run the command
136+ // as-is without stripping out the excess (git-upload-pack 'coder/coder').
137+ func parseIdentityFilesForHost (ctx context.Context , args , env []string ) (identityFiles []string , error error ) {
138+ home , err := os .UserHomeDir ()
139+ if err != nil {
140+ return nil , xerrors .Errorf ("get user home dir failed: %w" , err )
141+ }
142+
143+ var outBuf bytes.Buffer
144+ var r io.Reader = & outBuf
145+
146+ args = append ([]string {"-G" }, args ... )
147+ cmd := exec .CommandContext (ctx , "ssh" , args ... )
148+ cmd .Env = append (cmd .Env , env ... )
149+ cmd .Stdout = & outBuf
150+ cmd .Stderr = io .Discard
151+ err = cmd .Run ()
152+ if err != nil {
153+ // If ssh -G failed, the SSH version is likely too old, fallback
154+ // to using the default identity files.
155+ r = strings .NewReader (fallbackIdentityFiles )
156+ }
157+
158+ s := bufio .NewScanner (r )
159+ for s .Scan () {
160+ line := s .Text ()
161+ if strings .HasPrefix (line , "identityfile " ) {
162+ id := strings .TrimPrefix (line , "identityfile " )
163+ if strings .HasPrefix (id , "~/" ) {
164+ id = home + id [1 :]
165+ }
166+ // OpenSSH on Windows is weird, it supports using (and does
167+ // use) mixed \ and / in paths.
168+ //
169+ // Example: C:\Users\ZeroCool/.ssh/known_hosts
170+ //
171+ // To check the file existence in Go, though, we want to use
172+ // proper Windows paths.
173+ // OpenSSH is amazing, this will work on Windows too:
174+ // C:\Users\ZeroCool/.ssh/id_rsa
175+ id = filepath .FromSlash (id )
176+
177+ // Only include the identity file if it exists.
178+ if _ , err := os .Stat (id ); err == nil {
179+ identityFiles = append (identityFiles , id )
180+ }
181+ }
182+ }
183+ if err := s .Err (); err != nil {
184+ // This should never happen, the check is for completeness.
185+ return nil , xerrors .Errorf ("scan ssh output: %w" , err )
186+ }
187+
188+ return identityFiles , nil
72189}
0 commit comments