package lfshttp import ( "bytes" "encoding/json" "fmt" "path/filepath" "regexp" "strings" "time" "github.com/git-lfs/git-lfs/config" "github.com/git-lfs/git-lfs/subprocess" "github.com/git-lfs/git-lfs/tools" "github.com/rubyist/tracerx" ) type SSHResolver interface { Resolve(Endpoint, string) (sshAuthResponse, error) } func withSSHCache(ssh SSHResolver) SSHResolver { return &sshCache{ endpoints: make(map[string]*sshAuthResponse), ssh: ssh, } } type sshCache struct { endpoints map[string]*sshAuthResponse ssh SSHResolver } func (c *sshCache) Resolve(e Endpoint, method string) (sshAuthResponse, error) { if len(e.SSHMetadata.UserAndHost) == 0 { return sshAuthResponse{}, nil } key := strings.Join([]string{e.SSHMetadata.UserAndHost, e.SSHMetadata.Port, e.SSHMetadata.Path, method}, "//") if res, ok := c.endpoints[key]; ok { if _, expired := res.IsExpiredWithin(5 * time.Second); !expired { tracerx.Printf("ssh cache: %s git-lfs-authenticate %s %s", e.SSHMetadata.UserAndHost, e.SSHMetadata.Path, endpointOperation(e, method)) return *res, nil } else { tracerx.Printf("ssh cache expired: %s git-lfs-authenticate %s %s", e.SSHMetadata.UserAndHost, e.SSHMetadata.Path, endpointOperation(e, method)) } } res, err := c.ssh.Resolve(e, method) if err == nil { c.endpoints[key] = &res } return res, err } type sshAuthResponse struct { Message string `json:"-"` Href string `json:"href"` Header map[string]string `json:"header"` ExpiresAt time.Time `json:"expires_at"` ExpiresIn int `json:"expires_in"` createdAt time.Time } func (r *sshAuthResponse) IsExpiredWithin(d time.Duration) (time.Time, bool) { return tools.IsExpiredAtOrIn(r.createdAt, d, r.ExpiresAt, time.Duration(r.ExpiresIn)*time.Second) } type sshAuthClient struct { os config.Environment git config.Environment } func (c *sshAuthClient) Resolve(e Endpoint, method string) (sshAuthResponse, error) { res := sshAuthResponse{} if len(e.SSHMetadata.UserAndHost) == 0 { return res, nil } exe, args := sshGetLFSExeAndArgs(c.os, c.git, &e.SSHMetadata, endpointOperation(e, method), method) cmd := subprocess.ExecCommand(exe, args...) // Save stdout and stderr in separate buffers var outbuf, errbuf bytes.Buffer cmd.Stdout = &outbuf cmd.Stderr = &errbuf now := time.Now() // Execute command err := cmd.Start() if err == nil { err = cmd.Wait() } // Processing result if err != nil { res.Message = strings.TrimSpace(errbuf.String()) } else { err = json.Unmarshal(outbuf.Bytes(), &res) if res.ExpiresIn == 0 && res.ExpiresAt.IsZero() { ttl := c.git.Int("lfs.defaulttokenttl", 0) if ttl < 0 { ttl = 0 } res.ExpiresIn = ttl } res.createdAt = now } return res, err } type SSHMetadata struct { UserAndHost string Port string Path string } func sshFormatArgs(cmd string, args []string, needShell bool) (string, []string) { if !needShell { return cmd, args } return subprocess.FormatForShellQuotedArgs(cmd, args) } func sshGetLFSExeAndArgs(osEnv config.Environment, gitEnv config.Environment, meta *SSHMetadata, operation, method string) (string, []string) { exe, args, needShell := sshGetExeAndArgs(osEnv, gitEnv, meta) args = append(args, fmt.Sprintf("git-lfs-authenticate %s %s", meta.Path, operation)) exe, args = sshFormatArgs(exe, args, needShell) tracerx.Printf("run_command: %s %s", exe, strings.Join(args, " ")) return exe, args } // Parse command, and if it looks like a valid command, return the ssh binary // name, the command to run, and whether we need a shell. If not, return // existing as the ssh binary name. func sshParseShellCommand(command string, existing string) (ssh string, cmd string, needShell bool) { ssh = existing if cmdArgs := tools.QuotedFields(command); len(cmdArgs) > 0 { needShell = true ssh = cmdArgs[0] cmd = command } return } // Return the executable name for ssh on this machine and the base args // Base args includes port settings, user/host, everything pre the command to execute func sshGetExeAndArgs(osEnv config.Environment, gitEnv config.Environment, meta *SSHMetadata) (exe string, baseargs []string, needShell bool) { var cmd string isPlink := false isTortoise := false ssh, _ := osEnv.Get("GIT_SSH") sshCmd, _ := osEnv.Get("GIT_SSH_COMMAND") ssh, cmd, needShell = sshParseShellCommand(sshCmd, ssh) if ssh == "" { sshCmd, _ := gitEnv.Get("core.sshcommand") ssh, cmd, needShell = sshParseShellCommand(sshCmd, defaultSSHCmd) } if cmd == "" { cmd = ssh } basessh := filepath.Base(ssh) if basessh != defaultSSHCmd { // Strip extension for easier comparison if ext := filepath.Ext(basessh); len(ext) > 0 { basessh = basessh[:len(basessh)-len(ext)] } isPlink = strings.EqualFold(basessh, "plink") isTortoise = strings.EqualFold(basessh, "tortoiseplink") } args := make([]string, 0, 7) if isTortoise { // TortoisePlink requires the -batch argument to behave like ssh/plink args = append(args, "-batch") } if len(meta.Port) > 0 { if isPlink || isTortoise { args = append(args, "-P") } else { args = append(args, "-p") } args = append(args, meta.Port) } if sep, ok := sshSeparators[basessh]; ok { // inserts a separator between cli -options and host/cmd commands // example: $ ssh -p 12345 -- user@host.com git-lfs-authenticate ... args = append(args, sep, meta.UserAndHost) } else { // no prefix supported, strip leading - off host to prevent cmd like: // $ git config lfs.url ssh://-proxycmd=whatever // $ plink -P 12345 -proxycmd=foo git-lfs-authenticate ... // // Instead, it'll attempt this, and eventually return an error // $ plink -P 12345 proxycmd=foo git-lfs-authenticate ... args = append(args, sshOptPrefixRE.ReplaceAllString(meta.UserAndHost, "")) } return cmd, args, needShell } const defaultSSHCmd = "ssh" var ( sshOptPrefixRE = regexp.MustCompile(`\A\-+`) sshSeparators = map[string]string{ "ssh": "--", "lfs-ssh-echo": "--", // used in lfs integration tests only } )